Skip to content

fix(core): css lost to a local value or a late attribute assignment - #11371

Merged
NathanWalker merged 4 commits into
mainfrom
fix/css-local-shadow-and-attribute-match
Aug 28, 2026
Merged

NathanWalker merged 4 commits into
mainfrom
fix/css-local-shadow-and-attribute-match

Conversation

@edusperoni

@edusperoni edusperoni commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

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.mayMatch was narrowed to this.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>Change event and does not invalidate the match, so the selector is never reconsidered and the rule never applies.

label[ngcontent] { color: red; }
const view = new Label();
root.addChild(view); // parent is loaded -> the css state is computed here
view.ngcontent = ''; // marker assigned after insertion
before #11361 on main
after onLoaded() #FF0000 undefined
after updateDynamicState() #FF0000 undefined

This 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

CssProperty keeps 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. CssState recorded the value as applied either way, and the unchanged-value diff then skipped it for good — a full re-match included.

// label { margin: 4 }
view.style.marginTop = 20;         // local value wins, as designed
view.style.marginTop = unsetValue; // released
// -> marginTop is 0, and stays 0
before #11361 on main
final style.marginTop 4 0

Before #11361 the diff never fired (the delete ran ahead of the comparison), so css was re-asserted on every update and this was invisible. Verified for color, margin and padding (via the shorthand and via the longhand), for inherited properties, for a local value set before css ever ran, and for applyInlineStyle. Real triggers are Angular [style.x] / ngStyle bindings going null (the renderer calls removeStyle, which sets unsetValue), a style="..." attribute later cleared, and any code doing view.style.X = … then resetting it.

What is the new behavior?

1. AttributeSelector.mayMatch returns true again. The selector stays a candidate and match() decides at apply time, as it already does for pseudo classes.

The pruning is not salvageable: this.attribute in node is 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. _isCssValueStillApplied reads the property's sourceKey — collected per CssProperty at 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 Style counts them and CssState records 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-state benchmarks, run on node 24 under --max-opt=0 — interpreter only, which is how iOS executes — against 9228826b4, the commit before #11361. Higher is better:

benchmark before #11361 this PR
re-apply 200 views, nothing changed 544.25 hz 1581.79 hz 2.91x
toggle a class on 200 loaded views 12.04 hz 27.40 hz 2.28x
toggle a pseudo class on 200 views 194.59 hz 420.14 hz 2.16x
add 30 stylesheets + restyle 200 views 4.25 hz 6.63 hz 1.56x
load 30 components 22.07 hz 27.29 hz 1.24x
style 400 views (angular attribute css) 25.10 hz 27.55 hz 1.10x
style 400 views (plain css) 41.38 hz 40.56 hz 0.98x
build a style scope 345.06 hz 318.71 hz 0.92x

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 mayMatch no longer pruning.

Against main the two fixes cost very little, but only because the check is gated. Ungated it took back a third of the re-apply win:

check ungated gated, as shipped
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%

Restoring mayMatch is what the attribute-scoped paths pay for (about 20% against main under 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 --jitless does to javascript without also disabling the WebAssembly vite needs; plain --no-opt only drops turbofan and measures almost nothing on current node.

Tests

css-attribute-match.spec.ts and css-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 longhand margin/padding, for a local value set before css ran, for applyInlineStyle, 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.ts keeps its assertion that a node without the attribute is not styled, now against match() rather than the removed candidate pruning.

455 core unit tests pass and nx run core:build is clean.

`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.)
@nx-cloud

nx-cloud Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 594b265

Command Status Duration Result
nx test apps-automated -c=android ✅ Succeeded 3m 15s 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-08-28 17:57:54 UTC

@pkg-pr-new

pkg-pr-new Bot commented Aug 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@nativescript/core@11371
npm i https://pkg.pr.new/@nativescript/vite@11371
npm i https://pkg.pr.new/@nativescript/webpack@11371

commit: 594b265

@NathanWalker
NathanWalker merged commit 2b2842e into main Aug 28, 2026
10 checks passed
@NathanWalker
NathanWalker deleted the fix/css-local-shadow-and-attribute-match branch August 28, 2026 22: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.

2 participants