Skip to content

perf(core): hot-path optimizations across events, properties, CSS matching and iOS layout#11307

Merged
NathanWalker merged 3 commits into
mainfrom
perf/core-optimizations
Jul 18, 2026
Merged

perf(core): hot-path optimizations across events, properties, CSS matching and iOS layout#11307
NathanWalker merged 3 commits into
mainfrom
perf/core-optimizations

Conversation

@NathanWalker

Copy link
Copy Markdown
Contributor

Event system (data/observable)

  • notify() now skips the deprecated static/global event-handler machinery entirely (including the this.constructor.name resolution and 4 hash lookups per event) when no global handlers are registered, tracked via a simple module-level counter. Global handler semantics are unchanged when they are used.
  • _handleListenerEntry uses callback.call(thisArg, data) instead of callback.apply(thisArg, [data]), removing a throwaway array allocation on every listener invocation.
  • hasListeners() uses a direct property read instead of the in operator.
  • _indexOfListener uses an indexed loop instead of findIndex + closure (runs on every addEventListener).

Property system (ui/core/properties)

  • isResetValue() / isCssWideKeyword() — these run at the top of every property setter. They now bail out with a single typeof check for the overwhelmingly common case of non-string values (numbers, booleans, Colors, objects) instead of comparing against every CSS reset keyword.

CSS selector matching (ui/styling)

  • SelectorScope.getSelectorCandidates() replaced an O(n·buckets) reduce+concat (which re-copied the accumulator on every bucket) with a single push-based accumulation. This runs for every view on load and on every class/id change.
  • AttributeSelector with ignoreCase normalizes its value once at construction instead of lowercasing (and reassigning) it on every match.
  • matchMediaQueryString() caches media-query string splits (they come from parsed stylesheets, so the distinct set is small and stable) and avoids per-call closures.
  • SimpleSelectorSequence.match/mayMatch/trackChanges, ComplexSelector.match/trackChanges and Selector.ChildGroup.getMatchingNode use indexed loops instead of every/forEach closures; getMatchingNode also drops the string-keyed method dispatch (sel[funcName]).
  • CssState.updateDynamicState / setPropertyValues avoid per-call closure allocations (hoisted kebab-case replacement func, indexed filter loop). These run on every pseudo-class change (every tap).

View base (ui/core/view-base)

  • addPseudoClass/deletePseudoClass no longer allocate an aliased-states array on every visual-state transition (every tap toggles :highlighted/:active/:pressed).
  • classNameProperty.valueChanged uses indexed loops instead of forEach closures (hot in list-item recycling).
  • Fixed a latent bug in getViewByDomId(): the descendants callback compared view._domId (the root argument) instead of child._domId, so descendant lookups never matched and always walked the entire subtree.

iOS layout (utils/layout-helper)

  • toDevicePixels / toDeviceIndependentPixels / getDisplayDensity now cache the screen scale instead of resolving window → screen → scale through the ObjC bridge on every dp/px conversion (these are called several times per view per measure/layout pass). The device main-screen scale never changes at runtime — Screen.mainScreen already relies on the same invariant, and the Android implementation already caches density this way.

Measured results

Micro-benchmarks (vitest bench, committed under packages/core/__tests__/benchmarks/)

Node v25, darwin arm64, same machine/settings for both runs:

Benchmark Before (ops/s) After (ops/s) Δ
StyleSheetSelectorScope.query — button w/ id + 4 classes 282,946 392,871 +38.9%
StyleSheetSelectorScope.query — plain label 434,778 518,608 +19.3%
Observable.notify — 1 listener (thisArg) 17,503,308 23,439,545 +33.9%
Observable.notify — 3 listeners (thisArg) 11,750,316 14,388,805 +22.5%
Observable.notify — no listeners ~21.3M 27,112,421 +27%
notifyPropertyChange ~18.3M 22,350,543 +22%
AttributeSelector.match (ignoreCase) 22,247,452 26,514,154 +19.2%
ComplexSelector.match (child combinator) 13,961,654 14,904,038 +6.7%
hasListeners / on/off cycles flat (no regression)

Reproduce with: cd packages/core && npx vitest bench --run

Automated iOS test suite (npx nx debug apps-automated ios, iPhone 17 Pro simulator)

Before After (run 1) After (run 2)
Tests 1808 OK, 0 failed 1808 OK, 0 failed 1808 OK, 0 failed
Duration 49,891.65 ms 45,468.59 ms (-8.9%) 43,018.74 ms (-13.8%)

NathanWalker and others added 2 commits July 17, 2026 13:31
…ching and iOS layout

- Observable.notify skips deprecated global-handler machinery (constructor
  name resolution + 4 hash lookups per event) when no global handlers are
  registered; listener dispatch uses .call instead of .apply([data]);
  hasListeners uses a direct read; _indexOfListener drops the closure.
- isResetValue/isCssWideKeyword bail out with a single typeof check for
  non-string values (they run at the top of every property setter).
- getSelectorCandidates replaces O(n*buckets) reduce+concat with push-based
  accumulation; AttributeSelector normalizes ignoreCase value once at
  construction; media-query string splits are cached; selector match paths
  use indexed loops instead of every/forEach closures and drop string-keyed
  method dispatch.
- addPseudoClass/deletePseudoClass no longer allocate an aliased-states
  array per visual-state transition; classNameProperty.valueChanged drops
  per-change closures; fix latent getViewByDomId bug comparing the root's
  _domId instead of each child's.
- iOS toDevicePixels/toDeviceIndependentPixels/getDisplayDensity cache the
  screen scale instead of resolving window->screen->scale through the ObjC
  bridge on every conversion (mirrors the Android density cache).
- Add vitest micro-benchmarks (packages/core/__tests__/benchmarks) and unit
  tests locking dispatch order, thisArg binding, self-removal during
  dispatch, global-handler semantics, ignoreCase attribute matching and
  query result ordering.

Measured: automated iOS suite 1808 OK / 0 failed before and after;
duration 49,891ms -> 45,469ms / 43,019ms (two runs). Micro-benchmarks:
selector query +19-39%, Observable.notify +22-34%, attribute match +19%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re pass

The config referenced @typescript-eslint/no-extra-semi, which was removed in
typescript-eslint v8, causing a rule-not-found error on every linted file.
Removing it surfaced pre-existing issues, addressed as follows:

- Disable the v8 successors of the already-disabled ban-types rule
  (no-unsafe-function-type, no-wrapper-object-types, no-empty-object-type)
  plus no-unsafe-declaration-merging (interface+class merging is a core
  NativeScript pattern), and allow short-circuit/ternary expression
  statements, matching existing code style.
- Stop linting generated iOS typings (platforms/) and the vendored css
  parser (css/lib/) in packages/core.
- Drop parserOptions.project from packages/core lint config: no type-aware
  rules are in use, and it caused parsing errors for ~20 handwritten d.ts
  files not included in any tsconfig.
- Code fixes: do..while(true) -> while(true) in CSS3Parser, prefer-const in
  inspector_modules, module -> namespace in file-system/trace d.ts files,
  eslint-disable for the required triple-slash reference in index.d.ts.

nx lint core, nx test core (192 passed) and nx build core all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NathanWalker NathanWalker added this to the 9.1 milestone Jul 17, 2026
@nx-cloud

nx-cloud Bot commented Jul 17, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit ce4ef5a

Command Status Duration Result
nx test apps-automated -c=android ✅ Succeeded 3m 51s View ↗
nx run-many --target=test --configuration=ci --... ✅ Succeeded <1s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-07-17 21:07:10 UTC

@NathanWalker
NathanWalker requested a review from CatchABus July 17, 2026 20:55
// (window resolution + screen + scale) on every dp/px conversion during
// measure and layout passes. The scale of the device's main screen never
// changes at runtime, and `Screen.mainScreen` relies on the same invariant.
let mainScreenScale = 0;

@CatchABus CatchABus Jul 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the android counterpart for screen scale caching.
Actually, I just noticed android used to cache density before iOS started doing so but it still seems strange that only one of them has the internal screen scale helper helper.

@NathanWalker
NathanWalker merged commit 1eb3706 into main Jul 18, 2026
8 checks passed
@NathanWalker
NathanWalker deleted the perf/core-optimizations branch July 18, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants