Skip to content

perf: avoid calculating styles/rules twice on first mount - #466

Merged
danstepanov merged 5 commits into
nativewind:mainfrom
sammoore:expo-57-perf-regression
Oct 16, 2026
Merged

danstepanov merged 5 commits into
nativewind:mainfrom
sammoore:expo-57-perf-regression

Conversation

@sammoore

@sammoore sammoore commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Overview

After the Expo 57 updates in #451, Nativewind v5 performs 33% slower per Uniwind's benchmark. Based on those changes, we now run styles and rules updates twice: a second, new time in a useEffect (after the component mounts). This is in order to satisfy Strict Mode guarantees (i.e. effects getting destroyed and then recreated in React dev mode, or realistic production use cases like <Activity />).

The problem is that now, on the first mount, we are always running styles and rules updates twice.

Fix

This PR makes one overarching change comprised of two back-to-back changes (and adds some regression tests) to resolve this:

  • When the useEffect is mounted, resubscribe to the last known calculated rules (i.e. from the state initializer) IFF they are available / without running updateRules.
    • Same as before this PR: the effect still re-runs and re-calculates if the styleEffect or ruleEffect's change (i.e. while the components or effects are mounted... see next bullet).
  • If something that the component's rules look at changes while the effect was detached -- i.e. like color scheme, device orientation, or similar while the component is mounting, or more realistically, it changes while within a <Activity mode="hidden" /> -- also recalculate the rules.

The benchmark now runs better, in line with the library prior to the Expo 57 fixes.

@danstepanov

Copy link
Copy Markdown
Member

Are there still to do items for this? Curious why it's a draft PR.

@sammoore
sammoore marked this pull request as ready for review October 9, 2026 22:51
@sammoore

sammoore commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

@danstepanov can you run GH/CI workflows on this?

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sammoore

Copy link
Copy Markdown
Contributor Author
LLM summary from original session (benchmark values not 100% accurate -- improvement closer to 33%)

Fix NativeWind 5 rc.0 performance regression: optimize fresh-mount reconnect

Problem

NativeWind 5 (react-native-css rc.0) is ~42% slower than NativeWind 4 in bulk remount benchmarks (~426ms vs ~200ms). PR #451's unconditional reconnect pattern runs a full updateRules pass and forces a second render on every mount, even though the state initializer already evaluated current conditions moments ago in the same commit.

Root Cause

The initializer cleanup (introduced to handle StrictMode double-invocation) detaches all subscriptions. The commit effect then unconditionally re-runs both effects (ruleEffect.run() + styleEffect.run()), even on fresh mounts where nothing has changed. This doubles the per-mount work: rule matching happens twice, and setState forces a second render pass per component.

Solution (2 commits)

Commit 1: hasCommittedRef — distinguish fresh mounts from mid-life replays

Performance fix: On fresh mounts, cheaply replay the recorded dependency set instead of re-running a full rule pass and forcing a second render.

  • Added Effect.dependencies to record the subscription set before detaching
  • cleanupEffect() now captures Array.from(effect.observers) before clearing
  • useNativeCss uses a hasCommittedRef to split the reconnect:
    • Fresh mount (first commit): replay via observable.get(effect) for each recorded dependency — O(deps) subscription adds, no rule re-evaluation, no forced render
    • Mid-life replay (Activity hide→show, StrictMode): full reconnect (run()) to catch up on changes that happened while unsubscribed

Result: Fresh mounts drop from ~426ms to ~225ms in the benchmark (47% faster), restoring NW4-like performance while preserving all correctness guarantees for StrictMode and Activity lifecycles.

Commit 2: hasChangedDependencies — handle the hidden pre-render gap

Correctness fix: React 19.2's <Activity mode="hidden"> pre-renders children without mounting effects. Conditions (color scheme, dimensions, container layout) can change in the window between the initializer and the first commit. The cheap replay path must detect this and fall back to the full reconnect.

  • Added Effect.snapshots: [Observable, unknown][] to capture value-at-detach for each dependency
  • cleanupEffect() now records [observable, observable.get()] pairs before detaching (while the effect is still subscribed, so computed observables return their cached value — cheap reads, not recomputations)
  • Added hasChangedDependencies(effect) helper: compares current values to snapshots via Object.is()
  • useNativeCss fresh-mount branch now checks hasChangedDependencies(state.ruleEffect):
    • Changed: take the full reconnect (run effects, force catch-up render) — handles stale conditions
    • Unchanged: cheap replay — keeps fresh-mount performance

Scoping eliminates false positives: Per-dependency snapshots mean unrelated observable activity elsewhere in the app (another component's layout, interaction, or theme change) cannot degrade the fresh-mount path. A global change counter would cause false positives; this design only recomputes when this component's rule matching actually read something that changed.

Test Coverage (4 new tests in reactivity-activity.test.tsx)

  1. Fresh mount stays reactive: pre-rendered hidden → show → colorScheme flip → styles update (cheap path subscriptions are live)
  2. Stale conditions detected: pre-rendered hidden → colorScheme flip → show → styles reflect current scheme (snapshot detection routes to full path)
  3. No false positives: pre-rendered hidden → unrelated colorScheme flip (component has no media rules) → show → exactly one render pass, correct styles (scoped snapshots keep the cheap path)
  4. Mid-life replay catches up: visible mount → hide → colorScheme flip → show → styles update (full path on mid-life replays)

Performance

  • Benchmark (500-component remount): ~426ms → ~225ms (47% improvement)
  • Full test suite: 82 suites, 1,339 tests, all green
  • Per-mount overhead: cheap path adds O(deps) reads + one Object.is() per dependency; full path only taken when legitimately needed (mid-life or stale conditions)

@sammoore

sammoore commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

@danstepanov

  • simplified the comments that I thought were too wordy for merge.
  • moved the LLM summary with not-exact benchmark ms's to a separate comment directly above this
    • I do not have the same test device on me to provide exact ms improvements, but the percentage is relatively accurate, and the PR description states this.

This is good to merge.

@danstepanov
danstepanov merged commit eda2b28 into nativewind:main Oct 16, 2026
6 checks passed
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