Visitar URL original
perf(core): skip CSS work whose result cannot change by triniwiz · Pull Request #11506 · NativeScript/NativeScript · GitHub
Skip to content

perf(core): skip CSS work whose result cannot change - #11506

Open
triniwiz wants to merge 2 commits into
perf/css-media-query-cachefrom
perf/css-cascade-skip-work
Open

triniwiz wants to merge 2 commits into
perf/css-media-query-cachefrom
perf/css-cascade-skip-work

Conversation

@triniwiz

@triniwiz triniwiz commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

PR Checklist

What is the current behavior?

  • Every matched view collects its selector candidates from the universal, id, type and class buckets (plus every media scope), then sorts them, even when it has the same type and classes as the view before it.
  • Every restyle rebuilds the property bag and diffs it, even when the view matched exactly the rules it already applied.
  • A className change re-matches the view and every descendant.
  • Each view styled with color: #1F2330 parses a new Color (and on iOS creates its own UIColor).

What is the new behavior?

  • Candidate cache. Sorted candidates are cached per style scope, keyed by type, id and classes, and cleared when the selectors version or the media epoch from perf(core): cache CSS media query results until their inputs change #11505 changes. Each view gets a copy, so matching still filters per view.
  • Skip unchanged applies. When the matched rules are the ones last applied, with no var() or calc() values, no keyframe animations and no local writes since, the property pass is skipped.
  • Scoped className invalidation. Each selector scope records which classes selectors read outside their subject (ancestor and sibling compounds, functional pseudo-class arguments), and which subjects they gate. A className change restyles the view, then only descendants that are subjects of a selector reading a changed class. Scoped CSS variables reach descendants through var(), so the whole subtree is restyled below the changed view, and below any restyled descendant, that has or had scoped variables. It also falls back to the whole subtree when a [class...] attribute selector reads ancestors.
  • Shared colors. Color converters reuse one Color per string; Color is immutable.
  • The css: key string for each property is built once.

Behavior change

Descendants that are no longer restyled on a class change no longer restart their CSS keyframe animations. Before, every className change stopped and replayed the animations of the whole subtree; now only restyled views do.

Numbers

On its own the gain on these benchmarks is 1 to 4%, within run-to-run noise: their class toggle styles most of the views it affects, so scoped invalidation rarely skips work here. The gains are larger on top of #11507 and #11510 (all of the series together at size M: attach 0.62 to 0.76x, restyle 0.90x).

Scenario (M) Painted before Painted after Attach before Attach after
flex-wrap-tiles 913.1 908.0 437.7 424.1
grid-dashboard 236.4 234.2 118.0 114.7
insert-remove 916.5 899.8 438.4 421.6
nested-chain 31.3 31.1 16.7 16.3
relayout-resize 266.0 264.1 99.8 97.5
relayout-style 267.5 262.5 99.7 97.1
styled-cards 308.4 307.8 158.3 155.9
text-flow 167.6 166.5 44.9 43.2
tree-fanout 265.5 264.6 98.8 97.5
Mutation (M) Before After
restyle (relayout-style) 145.9 143.6
restore (relayout-style) 145.7 145.6
insert (insert-remove) 372.6 372.2
remove (insert-remove) 240.3 245.7
shrink (relayout-resize) 115.1 117.6
grow (relayout-resize) 115.3 118.6

Resize only lays out and runs no CSS; its 2% difference is within run-to-run noise.

How this was measured

xplat-benchmarks ns-core app (plain core, no plugins), iPhone 17 Pro Max simulator, iOS 26.4. Each scenario mounts about 1,000 views at size M. Base and change were installed alternately for 3 rounds of 10 measured iterations, median ms, lower is better. "Attach" is assigning the built tree to a loaded parent (native view creation, CSS, native setters); "painted" ends one frame after layout. A GC is forced before each mutation, as it already is after each unmount, so a collection pause lands in the same phase for both builds.

Stacked on #11505. Part of a series: #11505, #11506 (this), #11507, #11510.

- Sorted selector candidates are cached per style scope, keyed by type,
  id and classes, so views that share them stop re-collecting and
  re-sorting the same rules.
- A view whose matched rules are the ones it last applied (no var() or
  calc() values, no animations, no local writes since) skips the
  property pass.
- A className change re-matches only the descendants whose selectors
  read a changed class outside their subject. Scoped CSS variables and
  class attribute selectors still restyle the whole subtree.
- Views styled with the same color string share one Color instance.
- Each property's `css:` key string is built once.
@nx-cloud

nx-cloud Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 1315833

Command Status Duration Result
nx test apps-automated -c=android ✅ Succeeded 3m 19s 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-10-07 00:05:05 UTC

@pkg-pr-new

pkg-pr-new Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 1315833

@NathanWalker NathanWalker added this to the 9.2 milestone Oct 6, 2026
@NathanWalker

Copy link
Copy Markdown
Contributor

Thanks @triniwiz. We're targeting 9.2 for this one rather than the 9.1.3 patch: it changes when views restyle, the review found a regression, and the benchmark gain (1–3%) is within run-to-run noise.

Regression (fails on this PR, passes on main)

After a className change, only the view itself and class-dependent descendants are restyled. The subtree is fully restyled only when the changed view itself has scoped CSS variables. A restyled descendant that defines variables isn't followed down, so views further down that read them through var() keep stale values:

  • .dark .card { --bg: blue } .card Label { color: var(--bg, red) }: setting className = 'dark' on the root leaves the Label red.
  • .dark StackLayout { --c: blue } Label { color: var(--c, red) }: stale when the class is added and when it's removed.
  • .dark > StackLayout { --c: blue } StackLayout > Label { color: var(--c, red) }: stale.
  • .dark .card { --w: 10 } Label { width: calc(var(--w, 5) * 2) }: width stays 10 instead of 20.

A theme class on a page with variables defined on cards inside it is a realistic trigger. One fix that passed all of these plus the full suite: walk the children manually, and when a restyled child has (or had) scoped variables, restyle everything below it.

Behavior change: descendants that are no longer restyled on a class change also no longer restart their CSS keyframe animations. Arguably more correct, but visible.

What checked out: skipping an identical rule re-apply, the sorted candidate cache (selectors version + media epoch), shared Color per string, and the class index (including :not/:is/:where, media-scoped rules, and system classes going through the full restyle).

Note this branch includes #11505's commit, so the two land together.

A className change re-matched only class-dependent descendants and fell
back to the whole subtree only when the changed view itself held scoped
CSS variables. A restyled descendant that defined variables was not
followed down, so views below it reading them through var() or calc()
kept stale values, e.g. `.dark .card { --bg: blue }` with
`.card Label { color: var(--bg, red) }`.

The walk now restyles the whole subtree below any restyled descendant
that has, or had, scoped variables.

This branch has not been deployed

No deployments
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