fix(core): css lost to a local value or a late attribute assignment - #11371
Merged
Merged
Conversation
`AttributeSelector.mayMatch` dropped a selector whose attribute the node did not carry yet. Assigning a plain instance value raises no change event and does not invalidate the match, so the selector was never reconsidered and the rule never applied - the shape Angular's emulated view encapsulation produces when the `_ngcontent-*` marker lands after the view is inserted into a loaded tree.
A `CssProperty` keeps a single value, so a local value both suppresses the css write and takes the slot: clearing it leaves the property at its default with the cascaded value gone. The css state recorded the value as applied either way, and the unchanged-value diff then skipped it for good - a full re-match included. It now records a value as applied only while the style still carries it, so the cascade writes it again on the next update. Values nothing disturbed are still skipped.
iOS runs V8 jitless, where these benchmarks read very differently - the css work is dominated by lookups and megamorphic reads that only the optimizing tiers hide. `--max-opt=0` is what `--jitless` does to javascript without also turning off the WebAssembly vite needs; plain `--no-opt` only drops turbofan and measures almost nothing on current node.
Checking the style for every recorded value on every update cost 30% of the re-apply benchmark without the optimizing compilers - a name lookup plus a megamorphic symbol read per property. A local write is the only thing that can drop a value the cascade applied, so the style now counts them and the css state records the count it applied against. The check runs only when the two differ, which for a view nothing writes to locally is never. re-apply 200 views, nothing changed -30% -> -4% toggle a class on 200 views -24% -> -6% toggle a pseudo class on 200 views -23% -> -1% (node 24, --max-opt=0, against the same benchmarks before the fix.)
|
View your CI Pipeline Execution ↗ for commit 594b265
💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗ ☁️ Nx Cloud last updated this comment at |
commit: |
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?
Two regressions from #11361, both of which leave the wrong css applied in a real app. Each was confirmed by running the same scenario against the commit before that PR.
1. An attribute selector stops matching when the attribute is assigned after the view is inserted
AttributeSelector.mayMatchwas narrowed tothis.attribute in node, dropping the selector from the candidate set for a node that does not carry the attribute yet. Assigning a plain instance value raises no<attribute>Changeevent and does not invalidate the match, so the selector is never reconsidered and the rule never applies.onLoaded()#FF0000undefinedupdateDynamicState()#FF0000undefinedThis is the shape Angular's emulated view encapsulation produces (
.foo[_ngcontent-ng-c123]), so it can take out a component's styles wholesale. It also reproduces with the attribute on an ancestor (stacklayout[foo] label) and combined with a class (label.mid[foo]).2. A local value permanently destroys the css value it shadows
CssPropertykeeps a single value, so a local write both suppresses the css write and takes the slot: clearing it leaves the property at its default with the cascaded value gone.CssStaterecorded the value as applied either way, and the unchanged-value diff then skipped it for good — a full re-match included.style.marginTop40Before #11361 the diff never fired (the
deleteran ahead of the comparison), so css was re-asserted on every update and this was invisible. Verified forcolor,marginandpadding(via the shorthand and via the longhand), for inherited properties, for a local value set before css ever ran, and forapplyInlineStyle. Real triggers are Angular[style.x]/ngStylebindings going null (the renderer callsremoveStyle, which setsunsetValue), astyle="..."attribute later cleared, and any code doingview.style.X = …then resetting it.What is the new behavior?
1.
AttributeSelector.mayMatchreturnstrueagain. The selector stays a candidate andmatch()decides at apply time, as it already does for pseudo classes.The pruning is not salvageable:
this.attribute in nodeis false only when the attribute is neither a prototype accessor nor an own value, which is exactly the ad-hoc, non-notifying attribute that can silently start matching later. It was only ever active in the case where it is wrong.2. A value is recorded as applied only while the style still carries it.
_isCssValueStillAppliedreads the property'ssourceKey— collected perCssPropertyat registration — so the cascade writes the value again on the next update instead of skipping it. Values nothing disturbed are still skipped.3. That check is gated on a local-write counter, so it costs nothing on the hot path. A local write is the only thing that can drop a value the cascade applied, so
Stylecounts them andCssStaterecords the count it applied against; the check runs only when the two differ, which for a view nothing writes to locally is never.Performance
The
css-statebenchmarks, run on node 24 under--max-opt=0— interpreter only, which is how iOS executes — against9228826b4, the commit before #11361. Higher is better:So #11361's win survives the fixes: every figure lands within noise of what that PR measured, and no path ends up slower than it was before it — the attribute-scoped one included, despite
mayMatchno longer pruning.Against
mainthe two fixes cost very little, but only because the check is gated. Ungated it took back a third of the re-apply win:Restoring
mayMatchis what the attribute-scoped paths pay for (about 20% againstmainunder the same conditions, nothing measurable with the optimizing compilers on). That is the price of those rules applying at all, and as the first table shows it still leaves them ahead of where they started.This PR also adds
VITEST_NO_OPT=1, which runs the vitest worker with--max-opt=0. That is what--jitlessdoes to javascript without also disabling the WebAssembly vite needs; plain--no-optonly drops turbofan and measures almost nothing on current node.Tests
css-attribute-match.spec.tsandcss-state-recovery.spec.ts, 12 tests — 10 of them fail without these changes. They cover the attribute landing on the view, on an ancestor, and arriving before a dynamic update; and the local-value release for a longhand, for both shorthand and longhandmargin/padding, for a local value set before css ran, forapplyInlineStyle, and across a full re-match. One test pins that idle updates still write nothing, inherited values and shorthand-unset longhands included.css-selector.spec.tskeeps its assertion that a node without the attribute is not styled, now againstmatch()rather than the removed candidate pruning.455 core unit tests pass and
nx run core:buildis clean.