Skip to content

fix(native): identity-stable, spy-able turboStubs#70

Open
danfry1 wants to merge 1 commit into
mainfrom
fix/spyable-turbostubs
Open

fix(native): identity-stable, spy-able turboStubs#70
danfry1 wants to merge 1 commit into
mainfrom
fix/spyable-turbostubs

Conversation

@danfry1

@danfry1 danfry1 commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Problem

Unmocked native modules under the native engine are served by turboStub Proxies that minted a fresh stub object on every property access, and whose get trap never consulted the proxy target. Two consequences:

  1. NativeModules.Foo !== NativeModules.Foo — identity-unstable across reads (breaks instanceof-style assertions and captured-reference comparisons).
  2. vi.spyOn(NativeModules.Foo, 'method') silently recorded nothing — the spy landed on a throwaway object while callers kept receiving fresh closures. This is a real migrated-Jest-suite pattern, and the failure mode is "assertion never fires", not an error.

Change

  • Stubs memoized per module name in the shared boundary state: NativeModules.Foo === TurboModuleRegistry.get('Foo'), matching bridgeless RN where NativeModules proxies TurboModuleRegistry.
  • Methods memoized as own target properties on first read (via defineProperty — sloppy-mode assignment of __proto__ would invoke the inherited setter and swap the target's prototype), with explicitly-set properties winning, so vi.spyOn records, mockRestore restores, and repeated reads are identity-stable.
  • has: () => true — consistent with the get trap serving a callable for every key, and required by vi.spyOn's in existence check. RN 0.86's source was audited for in-operator feature detection against native modules: none found.
  • Hot runtime: each stub registers a surgical-reset callback clearing per-file overrides (spies, direct assignments) between files while preserving stub identity for resident libraries. The reset registry now runs before the Dimensions/Appearance value-restores, because those restores route through the very stubs being cleared — restore-first would send the restore through the previous file's dead override and leak state across files. That ordering leak was demonstrated in pre-merge review (its expression is environment-dependent; the clear-then-restore order is strictly safer either way), and the hot-isolation suite now carries a dead-override regression scenario. The registry stays bounded (one callback per distinct module name; verified ~32 after full RN preload).

Under the default engine each test file gets a fresh process, so the reset path never fires there.

Review

Independently adversarially reviewed pre-PR (vitest 4.1.8 spy mechanics traced against @vitest/spy source; RN 0.86 grepped for in-detection; Proxy invariants checked; await stub thenable behavior confirmed unchanged). Review findings (reset ordering, __proto__ memoization, stale comment, test-shape gap) are incorporated.

Tests

  • tests/native-unit.test.ts: stub identity across module reads and method reads, spy + restore round-trip, NativeModules ↔ TurboModuleRegistry shared identity, reset-registry clears overrides while preserving identity.
  • tests-native/native-module-spies.test.ts: same guarantees end-to-end under the real native engine (spy on a never-before-accessed method — fails without the has trap; callback/promise conventions preserved after memoization).
  • tests-native/hot-isolation/: file 1 leaves a dead override on the Appearance stub; file 2 asserts both the boundary value and the override are gone (direct NativeModules reads bypassing RN's JS-side cache).

Full gate: mock 1280, native suites (stock/hot/isolation/android/navigation/soak) green, crosscheck 75/75, lint/format/typecheck clean.

…ride clearing

Unmocked native modules were served by a Proxy that minted a fresh stub
object on every property access and whose get trap never consulted the
target. Consequences: NativeModules.Foo !== NativeModules.Foo, and
vi.spyOn(NativeModules.Foo, 'method') silently recorded nothing — the spy
landed on a throwaway object (a real pattern in migrated Jest suites).

- Stubs are memoized per module name in the shared boundary state, so
  NativeModules.Foo === TurboModuleRegistry.get('Foo'), matching bridgeless
  RN where NativeModules proxies TurboModuleRegistry.
- Methods are memoized as own properties of the proxy target on first read
  (via defineProperty — sloppy-mode assignment of '__proto__' would invoke
  the inherited setter and silently swap the target's prototype), and
  explicitly-set properties win, so spies record and restore correctly.
- A has trap reports all properties present, consistent with the get trap's
  serve-anything behavior; vi.spyOn's existence check requires it. RN 0.86
  was audited for 'in'-operator feature detection against native modules
  (none found).
- Hot runtime: each stub registers a callback in the surgical-reset registry
  that clears per-file overrides between files while preserving stub
  identity for resident libraries. The registry now runs BEFORE the
  Dimensions/Appearance value-restores — those restores route through the
  very stubs being cleared, so restoring first would send the restore
  through a previous file's dead override and leak state (demonstrated in
  pre-merge review; the hot-isolation suite now carries a dead-override
  regression scenario). The leak's expression is environment-dependent, but
  the clear-then-restore order is strictly safer.

Under the default engine each file gets a fresh process, so the reset
registry never fires there.
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.

1 participant