Repository navigation
perf(core): overhaul css matching, cascade, and application - #11361
Merged
Merged
Conversation
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
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
approved these changes
Aug 26, 2026
4 of 5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
CssState.setPropertyValuesis 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.mayMatchwithtrueand 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.StyleScopebuilds 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 writeposonto the same sharedRuleSetselectors, and whichever built last wins.ShorthandProperty.registerdefines the expanding setter oncls.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 }leavesmarginTopat 8, ignoring the most specific rule.CssState.onChangetears down and rebuilds every dynamic-selector subscription on every re-match, even when the dependencies are identical.CSSSource.parseCSSAstisasync, so parse failures escape thetry/catchinparse()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:
!importantstripping/scoped-tag lookups move out of the per-view hot path.AttributeSelector.mayMatchtests 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.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.StyleScope— mirroring Blink's per-TreeScopeScopedStyleResolver+ 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.onChangere-matches first and only rebuilds subscriptions when the change map actually differs.parseCSSAstis synchronous, so parse errors are reported through the existing error path again.Benchmarks (included in
packages/core/__tests__/benchmarks/; measured per-process againstmain, median, best of 3, node 22 — jitless approximates iOS JavaScriptCore):The style-scope build is ~10% slower by design:
!importantstripping 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). Theapps/automatedSTYLE 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.