Skip to content

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

Draft
sammoore wants to merge 4 commits into
nativewind:mainfrom
sammoore:expo-57-perf-regression
Draft

sammoore wants to merge 4 commits into
nativewind:mainfrom
sammoore:expo-57-perf-regression

Conversation

@sammoore

@sammoore sammoore commented Oct 2, 2026

Copy link
Copy Markdown

Overview

After the Expo 57 updates in #451, I witnessed a ~33% increase in Uniwind's mass-render benchmark. It turns out that to satisfy Strict Mode guarantees (i.e. effects getting destroyed and then recreated in dev-only mode, which is to support use cases that e.g. <Activity /> provide), on the first mount, we were now running styles and rules updates twice -- even if we weren't in dev mode (which makes sense to have done to properly fix the issue, because <Activity /> exists).

Fix

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

  • When the commit effect is initially mounted, resubscribe to the last known calculated rules (i.e. from the state initializer) if they are available / without running updateRules. The effect still re-runs and re-calculates if the styleEffect or ruleEffect's change (i.e. previous/existing behavior).
  • If anything the component's rules looks at changes while the effect was detached -- i.e. if the component styles care about color scheme, device orientation, etc, and they change under slow mount/render (unlikely), or more realistically, while within a <Activity mode="hidden" /> -- also recalculate the rules.

More Details

LLM summary (TODO benchmark values need fixing -- 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)

@danstepanov

Copy link
Copy Markdown
Member

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

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