Skip to content

perf(core): overhaul css matching, cascade, and application - #11361

Merged
NathanWalker merged 7 commits into
mainfrom
feat/nativescript-css-perf-bpe1py
Aug 26, 2026
Merged

NathanWalker merged 7 commits into
mainfrom
feat/nativescript-css-perf-bpe1py

Conversation

@edusperoni

Copy link
Copy Markdown
Contributor

PR Checklist

What is the current behavior?

The CSS engine does a large amount of redundant work on every dynamic restyle, and has two correctness bugs in the cascade:

  • The "skip unchanged values" diff in CssState.setPropertyValues is dead code. The previously applied entry is deleted before it is compared against, so every matching declaration is re-applied (colors and lengths re-parsed, converters re-run) on every dynamic update — tapping a button re-applies the view's entire computed style.
  • Attribute selectors always answer mayMatch with true and unconditionally subscribe to <attribute>Change. With Angular's emulated encapsulation ([_ngcontent-cN] markers), every view keeps N−1 selectors it can never match and holds change subscriptions for events that are never raised — plain expando assignment does not notify. This is the main reason component-level CSS performs badly enough that it is currently discouraged.
  • Every StyleScope builds its own index over the merged application rule list, so registering a stylesheet (as Angular does once per component at first use) re-sorts every application rule into fresh lookup maps for every scope in the app — O(components × rules × scopes) on navigation. Multiple scopes also write pos onto the same shared RuleSet selectors, and whichever built last wins.
  • Shorthand expansion never runs. ShorthandProperty.register defines the expanding setter on cls.prototype.PropertyBag — the bag's constructor rather than its prototype — so shorthands reach the apply step unexpanded and the cascade breaks: label { margin: 1 } .a { margin-top: 8 } #x { margin: 4 } leaves marginTop at 8, ignoring the most specific rule.
  • CssState.onChange tears down and rebuilds every dynamic-selector subscription on every re-match, even when the dependencies are identical.
  • CSSSource.parseCSSAst is async, so parse failures escape the try/catch in parse() as unhandled rejections and leave the stylesheet silently empty.

What is the new behavior?

Six commits, each self-contained, aligning the engine with how browsers and the CSS specs handle the same problems:

  • The unchanged-value diff actually works: values are compared before the old entry is consumed, css expressions diff against their previously evaluated result, and !important stripping/scoped-tag lookups move out of the per-view hot path.
  • AttributeSelector.mayMatch tests whether the view knows the attribute, and change subscriptions are only registered for attributes that can actually raise <attribute>Change (i.e. real properties with accessors). Angular's [_ngcontent-*] selectors no longer cost anything on views they can't match.
  • Shorthands are expanded into their longhands at parse time, as the spec defines the cascade (a shorthand declaration is equivalent to declaring each longhand in its place). This fixes the specificity bug above, runs each converter once per declaration instead of once per view per update, and keeps values shared so the diff can compare by identity.
  • var()/calc() shorthands, which cannot be expanded lexically, get pending-substitution values per the CSS Variables spec (css-variables-1 §3.2): each longhand cascades a shared placeholder, resolved once per view at apply time. Property bags are now longhand-only, so shorthand/longhand overlap is structurally impossible.
  • The application stylesheets are indexed once, globally, instead of once per StyleScope — mirroring Blink's per-TreeScope ScopedStyleResolver + merged-collection architecture. Selectors sort by (specificity, tier, position) so application and scope-local rules cascade correctly from separate indexes, and component registration appends to the shared index instead of rebuilding it.
  • CssState.onChange re-matches first and only rebuilds subscriptions when the change map actually differs.
  • parseCSSAst is synchronous, so parse errors are reported through the existing error path again.

Benchmarks (included in packages/core/__tests__/benchmarks/; measured per-process against main, median, best of 3, node 22 — jitless approximates iOS JavaScriptCore):

Scenario JIT jitless
re-apply 200 views, nothing changed 2.90x 3.00x
toggle a class on 200 loaded views 2.12x 2.74x
add 30 stylesheets + restyle 200 views 1.84x 2.28x
toggle a pseudo class on 200 views 1.76x 2.32x
style 400 views (angular attribute css) 1.31x 1.34x
load 30 components 1.28x 1.31x
style 400 views (plain css) 1.00x 1.00x
build a style scope 0.89x 0.87x

The style-scope build is ~10% slower by design: !important stripping and shorthand expansion moved into parsing, paid once per stylesheet instead of once per view per update.

380 unit tests pass, including new suites for the cascade (style-scope.spec.ts, 28 tests) and selector behavior (css-selector.spec.ts). The apps/automated STYLE suite on a device is the remaining end-to-end check reviewers may want before merging, as it exercises the cascade against real native views.

Profiling a themed app tree (and an Angular-shaped one) showed most of the
per-view css cost was work that could not change anything.

`CssState.setPropertyValues` deleted the entry from the previously applied
values *before* testing whether the new value equalled it, so the
"skip unchanged values" branch was unreachable and every matching
declaration was re-applied on every dynamic update - re-parsing colors and
lengths, re-running value converters and allocating on each one. The
comparison now happens before the entry is consumed. Css variables are
still always re-registered (the scope resets them first), and css
expressions are compared against their previously evaluated result, which
is now what gets recorded.

Attribute selectors always answered `mayMatch` with `true`, on the grounds
that the attribute might be assigned later. That only holds when the view
knows about the attribute: a registered property lives on the prototype,
and anything a framework already assigned is an own value. Angular's
emulated encapsulation gives every element an `_ngcontent-cN` marker and
scopes each component's rules with `[_ngcontent-cN]`, so with N components
every view kept N-1 selectors it could never match, plus a change
subscription per selector - for events that are never raised, since a
plain assignment does not notify. `mayMatch` now tests whether the view
knows the attribute at all, and a change subscription is only registered
for attributes that can actually raise `<attribute>Change`.

Also:
- merge the application selector/keyframe lists lazily, so registering N
  component stylesheets no longer copies the whole list N times, and
  replace `push(...arr)` (which passes every rule as an argument) with a
  plain copy
- look scoped tags up in a Set instead of scanning the css file array for
  every rule in the scope
- strip the unsupported `!important` flag once while parsing, instead of
  scanning every declaration again for every view it applies to
- drop the per-query intermediate arrays in `StyleSheetSelectorScope.query`
  and only materialize a change map when something is actually tracked
- make `CSSSource.parseCSSAst` synchronous - as an `async` method its
  failures escaped the `try/catch` in `parse()` as unhandled rejections and
  left the stylesheet silently empty

Measured with the added benchmark (median, node 22, best of 5 rounds):

                                              JIT     jitless
  style 400 views (plain css)                1.00x     1.00x
  style 400 views (angular attribute css)    1.30x     1.29x
  re-apply 200 views, nothing changed        4.12x     4.62x
  toggle a pseudo class on 200 views         1.90x     3.04x
  add 30 stylesheets + restyle 200 views     1.65x     2.43x

Building a style scope is ~5% slower because `!important` stripping moved
into parsing; it is paid once per stylesheet instead of once per view per
update.
Skipping unchanged values (96bea36) is unsound when a property that
stopped matching overlaps one that is being skipped. A shorthand and its
longhands write the same style properties, so unsetting `margin-top`
clears what `margin` had set - and `margin`, being unchanged, was no
longer re-applied afterwards.

    label { margin: 4; }
    label:highlighted { margin-top: 8; }

left `marginTop` at 0 instead of 4 once the pseudo class went away.

Shorthand css names are now registered, and the diff falls back to
applying every matched value whenever something was removed and either
side of the removal is a shorthand. Removals of properties that cannot
overlap - the common case - still take the fast path.
`ShorthandProperty.register` defined the expanding setter on
`cls.prototype.PropertyBag` - the bag's constructor rather than its
prototype - so bag instances never got it and shorthands reached the
apply step unexpanded. Bag keys keep the position of their first
declaration, so an early `margin` was written after longhands that
should have lost to it:

    label { margin: 1; }
    .a    { margin-top: 8; }
    #x    { margin: 4; }

left `marginTop` at 8 rather than 4, ignoring the most specific rule.

The cascade is defined on longhands - a shorthand declaration is
equivalent to declaring each of its longhands in its place - so the
expansion belongs in the parser, next to the other per-declaration work.
`fromAstNode` now expands there. Source order stays meaningful, the
converter runs once per declaration instead of once per view per update,
and the resulting values are shared, so the unchanged-value diff still
compares by identity.

Shorthands whose value is a `var()` or `calc()` expression are left
alone: they can only be resolved per view, and converting them early
throws on the unevaluated text. They keep the existing evaluate-then-
apply path, and are now the only case the shorthand overlap guard has to
cover.

The dead `propertyBagDescriptor` goes with it.

Measured against upstream with the added benchmark (median, best of
three, each run in its own process):

                                              JIT
  re-apply 200 views, nothing changed        2.70x
  add 30 stylesheets + restyle 200 views     1.79x
  toggle a pseudo class on 200 views         1.66x
  style 400 views (angular attribute css)    1.25x
  load 30 components                         1.20x
  style 400 views (plain css)                1.01x
  build a style scope                        0.92x

Re-apply drops from 4.13x and the toggle rises from 1.14x: expanding
turns two shorthand declarations into eight longhands, so there is more
to diff per view, but a removal no longer forces everything to be
written again.
…e scope

Every `StyleScope` built its own `StyleSheetSelectorScope` over the whole
merged rule list, so a version bump re-sorted every application rule into
fresh lookup maps for every scope in the app. Angular registers one
stylesheet per component the first time it is used, which made a
navigation cost O(components x all rules x scopes). It also meant several
scopes wrote `pos` onto the same shared `RuleSet` selectors, and whichever
built last won.

Browsers do not keep one merged index either: in Blink each `TreeScope`
has a `ScopedStyleResolver` holding that scope's rules partitioned into
`RuleSet`s, and `ElementRuleCollector` walks several of them and merges
the hits. So the application rules are now indexed once, globally, and a
`StyleScope` only indexes its own stylesheets. `matchSelectors` gathers
candidates from both into one array and resolves them together.

Ordering follows the cascade rather than a single running counter: a
specificity tie breaks on source order, and source order across
stylesheets is (stylesheet, position within it). Selectors carry the tier
they were indexed in and sort by `(specificity, tier, pos)`, so positions
only have to be comparable within a tier - which is what makes the shared
index possible.

Two details worth knowing:

- Rules registered on behalf of a particular stylesheet (`scopedTag`) stay
  in the shared index and are filtered at match time for scopes that never
  loaded that stylesheet. The filter is skipped entirely - the common case
  - when no such rules exist.
- The application rules are only ever appended to outside of a livesync or
  a `removeTaggedAdditionalCSS`, so those two bump a reset counter and
  everything else appends to the existing index instead of rebuilding it.

  add 30 stylesheets + restyle 200 views     1.78x
  load 30 components                         1.34x   (was 1.20x)
  toggle a pseudo class on 200 views         1.65x
  style 400 views (angular attribute css)    1.31x
  re-apply 200 views, nothing changed        2.45x
…atch

`CssState.onChange` unsubscribed from every dynamic dependency, re-matched,
then subscribed to whatever came back - unconditionally. A re-match usually
lands on the same dependencies, so that removed and re-added the identical
listeners, and toggled the widget-level pseudo class handlers off and back
on for nothing.

Matching does not read the subscriptions, so the match now runs first and
its change map is compared against the one already applied. Only a real
difference touches the listeners. Both empty maps compare equal even though
they are distinct objects.

Toggling a class on 200 loaded views: 27.2ms -> 23.5ms on its own, and
46.6ms -> 22.1ms against upstream with the rest of the branch. A benchmark
scenario for that path came with it - the existing ones drove
`updateDynamicState` directly and never went through `onChange`.
Shorthands are expanded while parsing, but one holding a `var()` cannot
be: a single `var()` may substitute the value of several longhands at
once, so there is no way to tell what belongs where until substitution
has happened. Those declarations stayed keyed by the shorthand, which
left the one case where two entries in a view's property bag write the
same style property - and so the one case where unsetting a property
that stopped matching could clear a value being skipped as unchanged.
That is what the shorthand overlap guard was covering.

CSS answers this with pending-substitution values: the longhands are
filled with a placeholder, cascaded like any other value, and the
shorthand is parsed at computed-value time once the substitution is
done. Doing the same here means a declaration is only ever keyed by a
longhand, whatever its value looks like.

So `fromAstNode` now emits one placeholder per longhand, discovering the
longhand names by asking the shorthand's converter for an unset value -
deferred to first use, because the longhand properties a converter closes
over are not initialized yet while the shorthand is being registered.
`setPropertyValues` resolves each placeholder once per view no matter how
many longhands point at it, after the css variables are up to date, and
diffs the resolved values so an unchanged shorthand is still skipped.
Inline styles resolve them the same way.

With that the overlap is structurally impossible, so the guard and
`isCssShorthandProperty` are gone: a removal no longer forces every
matched value to be written again.

  re-apply 200 views, nothing changed        2.90x   (was 2.54x)
  add 30 stylesheets + restyle 200 views     1.84x   (was 1.62x)
  toggle a class on 200 loaded views         2.12x
  toggle a pseudo class on 200 views         1.76x

Ref: https://drafts.csswg.org/css-variables/#variables-in-shorthands
Comment thread packages/core/ui/styling/style-scope.ts Resolved
Comment thread packages/core/ui/styling/style-scope.ts Resolved
Comment thread packages/core/ui/styling/style-scope.ts Resolved
Comment thread packages/core/ui/styling/style-scope.ts Resolved
Comment thread packages/core/ui/styling/style-scope.ts Resolved
Review feedback: keep comments to the constraint or non-obvious why, at
most a few lines, and drop the ones that restate the code.
@NathanWalker
NathanWalker merged commit 75b0e7e into main Aug 26, 2026
2 of 3 checks passed
@NathanWalker
NathanWalker deleted the feat/nativescript-css-perf-bpe1py branch August 26, 2026 16:31
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.

3 participants