Skip to content

perf: resolve iOS routing on the first client-only render (useSyncExternalStore) - #44

Merged
joeblau merged 1 commit into
mainfrom
perf/39-sync-ios-routing
Oct 21, 2026
Merged

joeblau merged 1 commit into
mainfrom
perf/39-sync-ios-routing

Conversation

@joeblau

@joeblau joeblau commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Closes #39

What

useIOSNativeHaptics() now uses useSyncExternalStore(noopSubscribe, isIOSWebKitWithoutVibration, () => false) instead of useState(false) + useEffect.

  • Client-only mounts (createRoot, client navigation, lists added later): React reads the client snapshot directly, so iOS routing resolves on the first render and each HapticTarget skips the post-mount routing update.
  • Server rendering and hydration: the server snapshot stays false. renderToString output is byte-for-byte unchanged, and hydration renders false first, then resolves after hydration with no mismatch.

Visible API change

On iOS, useIOSNativeHaptics() now returns true on the first client-only render instead of after an effect. Hydrated renders still return false first. This is documented as a Behavior change in CHANGELOG.md, in the package README, and in the docs (api.mdx, index.mdx). Because of it, the next release should be a pre-1.0 minor (0.3.0).

Implemented with the Codex CLI on the shared box and verified by hand.

Benchmark (npm run bench, same box, 3 interleaved pairs)

Baseline is main at 1ea44a1 (already includes #42 and #43).

Metric (iOS path, simulated) main 1ea44a1 this PR
Commits per mount, 100 / 1,000 instances 3 / 3 2 / 2
HapticTarget renders per instance 3 2
iOS client mount, 1,000 (median ms, 3 runs) 283 / 303 / 305 252 / 270 / 436
iOS client mount, 100 (median ms, 3 runs) 48.0 / 48.7 / 56.5 38.8 / 45.3 / 60.8
Full ESM gzip bytes 4,583 4,590

The commit and render counts are deterministic. The timing rows aren't conclusive. Another agent's build was saturating the shared 8-core box during these runs (load average 13–18; main's iOS 1,000-instance mount measured 120–150 ms on a quieter box earlier today). The issue's 1-commit target assumed the old observer path. With #43's guarantee that no overlay is committed before the host is checked, the floor is 2 commits: render, then layout-phase host sync, before paint.

Verification

  • Locally: npm run lint, typecheck (ESM+CJS+DTS build), coverage (141 tests, 100% statements/branches/functions/lines on React 19 and React 18.3.1), size, pack:check, and docs:check all pass.
  • New tests:
    • A client-only createRoot render on iOS stubs returns true on the first render (renders === [true]).
    • renderToString never reads navigator (a throwing getter proves it) and its markup is byte-identical.
    • hydrateRoot on iOS stubs renders [false, true] with no recoverable errors and no console.error, and the switch appears after hydration.
  • SSR: there's no module-level navigator access, and the server snapshot is a constant false.

Device-testing notes

These are simulated-browser numbers from Node 24 with production React and jsdom, using a stubbed iPhone/AppleWebKit navigator. None were measured on physical devices, and no physical device was used. Physical iPhone/Android/iPadOS/VoiceOver certification is still tracked in #9.

@joeblau
joeblau merged commit a59d23f into main Oct 21, 2026
7 checks passed
@joeblau
joeblau deleted the perf/39-sync-ios-routing branch October 21, 2026 01:02
@joeblau joeblau mentioned this pull request Oct 21, 2026
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.

Resolve iOS routing with useSyncExternalStore to cut iOS mount renders from 3 to 1

1 participant