Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spyable-turbostubs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"vitest-native": patch
---

Make native-engine turboStubs identity-stable and spy-able. 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 — so `NativeModules.Foo !== NativeModules.Foo`, and `vi.spyOn(NativeModules.Foo, 'method')` silently recorded nothing (the spy landed on a throwaway object). Stubs are now memoized per module name in the shared boundary state (`NativeModules.Foo === TurboModuleRegistry.get('Foo')`, matching bridgeless RN), methods are memoized on first read, 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, which `vi.spyOn`'s existence check requires. Under the hot runtime, per-file overrides (spies, memoized methods) are cleared between files via the surgical-reset registry while stub identity is preserved for resident libraries holding references.
95 changes: 66 additions & 29 deletions packages/vitest-native/src/native/boundary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -143,36 +143,73 @@ function turboStubSource(platform, version) {
// (e.g. showShareActionSheetWithOptions(options, error, success)). For the latter,
// the success callback is the LAST function arg.
const __SUCCESS_LAST = new Set(["showShareActionSheetWithOptions"]);
const turboStub = (name) => new Proxy({}, {
get: (_t, p) => {
if (p === "getConstants") return () => (__C[name] || {});
if (p === "getColorScheme") {
return () => getBoundaryState(name).colorScheme ?? "light";
}
if (p === "setColorScheme") {
return (colorScheme) => {
getBoundaryState(name).colorScheme =
colorScheme === "unspecified" || colorScheme == null ? "light" : colorScheme;
};
}
if (p === "addListener") return () => ({ remove: () => {} });
if (p === "removeListeners") return () => {};
return (...args) => {
// Callback-style native methods resolve via a callback argument. Invoke the
// success callback so JS Promises that wrap these settle instead of hanging.
const fns = args.filter((a) => typeof a === "function");
if (fns.length) {
const cb = typeof p === "string" && __SUCCESS_LAST.has(p) ? fns[fns.length - 1] : fns[0];
return cb(false);
}
// Promise-returning native methods must yield a Promise, not undefined.
if (typeof p === "string" && Object.prototype.hasOwnProperty.call(__ASYNC, p)) {
return Promise.resolve(__ASYNC[p]);
// Stubs are memoized per module name in the shared boundary state, so
// NativeModules.Foo === 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 —
// identity-stable across reads, and explicit writes win, so
// vi.spyOn(NativeModules.Foo, 'method') records calls instead of silently
// landing on a throwaway object.
const turboStub = (name) => {
const state = getBoundaryState(name);
if (state.__stub) return state.__stub;
const target = {};
state.__stub = new Proxy(target, {
get: (t, p) => {
// Explicitly-set properties win (spies, manual overrides, memoized methods).
if (Object.prototype.hasOwnProperty.call(t, p)) return t[p];
let v;
if (p === "getConstants") v = () => (__C[name] || {});
else if (p === "getColorScheme") {
v = () => getBoundaryState(name).colorScheme ?? "light";
} else if (p === "setColorScheme") {
v = (colorScheme) => {
getBoundaryState(name).colorScheme =
colorScheme === "unspecified" || colorScheme == null ? "light" : colorScheme;
};
} else if (p === "addListener") v = () => ({ remove: () => {} });
else if (p === "removeListeners") v = () => {};
else {
v = (...args) => {
// Callback-style native methods resolve via a callback argument. Invoke the
// success callback so JS Promises that wrap these settle instead of hanging.
const fns = args.filter((a) => typeof a === "function");
if (fns.length) {
const cb = typeof p === "string" && __SUCCESS_LAST.has(p) ? fns[fns.length - 1] : fns[0];
return cb(false);
}
// Promise-returning native methods must yield a Promise, not undefined.
if (typeof p === "string" && Object.prototype.hasOwnProperty.call(__ASYNC, p)) {
return Promise.resolve(__ASYNC[p]);
}
return undefined;
};
}
return undefined;
};
},
});
// defineProperty, not assignment: sloppy-mode \`t["__proto__"] = fn\`
// would invoke the inherited __proto__ SETTER and silently swap the
// target's prototype instead of memoizing.
Object.defineProperty(t, p, {
value: v,
writable: true,
enumerable: true,
configurable: true,
});
return v;
},
// Every property reads as a callable stub, so report them all as present —
// vi.spyOn refuses to spy on a property its \`in\` check can't see.
has: () => true,
});
// Hot runtime: clear per-file state (spies, memoized methods) between files
// via the surgical-reset registry, while keeping the stub's identity for
// resident libraries that captured a reference at import time. Under the
// default engine each file gets a fresh process, so this never fires.
const resets = globalThis.__vitest_native_resets || (globalThis.__vitest_native_resets = []);
resets.push(() => {
for (const k of Reflect.ownKeys(target)) delete target[k];
});
return state.__stub;
};
`;
}

Expand Down
30 changes: 18 additions & 12 deletions packages/vitest-native/src/native/reset.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@
// 2b. process.env — restored to the worker boot snapshot.
// 3. Vitest timers/global/env stubs — restored by setup.mjs before this reset.
// 4. Boundary/preset mocks that registered callbacks in
// globalThis.__vitest_native_resets (none are stateful today; the
// registry is the extension point).
// globalThis.__vitest_native_resets (the turboStubs register one per
// native-module name to clear per-file overrides; runs before the
// value-restores, which route through those stubs).
// 5. globalThis keys added by a file — deleted. The baseline starts at the
// first per-file call (after Vitest injected its per-batch globals) and
// never grows implicitly. Mutations of pre-existing keys are not restored
Expand Down Expand Up @@ -147,6 +148,21 @@ export function installHotReset({ projectRoot, diagnostics, preserveGlobals = []
// act/auto-cleanup machinery corrupted rendering for every later file
// when the consumer's graph inlines RNTL (found via Rocket.Chat).

// (4) Boundary/preset mock reset callbacks — BEFORE the value-restore:
// these clear per-file overrides on the boundary stubs (spies, direct
// method assignments), and the value-restore below routes through those
// very stubs (Appearance.setColorScheme → NativeAppearance). Restoring
// first would send the restore through a previous file's dead override
// and then clear it one step too late.
const resets = globalThis.__vitest_native_resets;
if (Array.isArray(resets)) {
for (const fn of resets) {
try {
fn();
} catch {}
}
}

// (2) Restore mutable resident RN state (value-restore, always safe).
if (dims) {
try {
Expand All @@ -157,16 +173,6 @@ export function installHotReset({ projectRoot, diagnostics, preserveGlobals = []
RN.Appearance.setColorScheme?.(colorScheme);
} catch {}

// (4) Boundary/preset mock reset callbacks.
const resets = globalThis.__vitest_native_resets;
if (Array.isArray(resets)) {
for (const fn of resets) {
try {
fn();
} catch {}
}
}

if (!armed) return; // no bless yet → cannot attribute; fail open

// (1) Remove the previous file's test-phase RN event listeners.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Appearance, DeviceEventEmitter } from "react-native";
import { Appearance, DeviceEventEmitter, NativeModules } from "react-native";
import { expect, it } from "vitest";

const globalKey = "__VN_HOT_ISOLATION_OWNER__";
Expand All @@ -14,6 +14,11 @@ const inheritedColorScheme = Appearance.getColorScheme();
process.env[envKey] = "first";
DeviceEventEmitter.addListener(eventName, () => {});
Appearance.setColorScheme("dark");
// Leave a DEAD override on the boundary stub, un-restored. The next file's
// hot reset must clear stub overrides BEFORE the colorScheme value-restore —
// restoring first would route the restore through this no-op and leak "dark"
// into file 2 (demonstrated in review of the spy-able-turboStubs change).
(NativeModules.Appearance as Record<string, unknown>).setColorScheme = () => {};

it("starts without state from another test file", () => {
expect(inheritedGlobal).toBeUndefined();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Appearance, DeviceEventEmitter } from "react-native";
import { Appearance, DeviceEventEmitter, NativeModules } from "react-native";
import { expect, it } from "vitest";

const globalKey = "__VN_HOT_ISOLATION_OWNER__";
Expand All @@ -9,6 +9,11 @@ const inheritedGlobal = (globalThis as Record<string, unknown>)[globalKey];
const inheritedEnv = process.env[envKey];
const inheritedListeners = DeviceEventEmitter.listenerCount(eventName);
const inheritedColorScheme = Appearance.getColorScheme();
// Read the boundary stub DIRECTLY, bypassing RN's JS-side appearance cache:
// file 1 left a dead override on this stub's setColorScheme, so if the hot
// reset value-restores BEFORE clearing stub overrides, the restore is
// swallowed and this read leaks "dark" while the JS cache says "light".
const inheritedNativeColorScheme = NativeModules.Appearance.getColorScheme();

(globalThis as Record<string, unknown>)[globalKey] = "second";
process.env[envKey] = "second";
Expand All @@ -19,6 +24,16 @@ it("starts without state from another test file", () => {
expect(inheritedGlobal).toBeUndefined();
expect(inheritedEnv).toBeUndefined();
expect(inheritedListeners).toBe(0);
// File 1 set "dark" AND left a dead override on the Appearance stub; both
// must be gone — the reset clears stub overrides before the value-restore.
expect(inheritedColorScheme).toBe("light");
expect(inheritedNativeColorScheme).toBe("light");
expect(Appearance.getColorScheme()).toBe("dark");
});

it("does not inherit the previous file's boundary-stub override", () => {
// File 1 replaced NativeModules.Appearance.setColorScheme with a no-op. If
// that override survived, this write would be swallowed.
NativeModules.Appearance.setColorScheme("dark");
expect(NativeModules.Appearance.getColorScheme()).toBe("dark");
});
39 changes: 39 additions & 0 deletions packages/vitest-native/tests-native/native-module-spies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Unmocked native modules are served by identity-stable, spy-able turboStubs.
* Previously every property access minted a fresh Proxy and the get trap never
* consulted the target, so `vi.spyOn(NativeModules.X, 'method')` silently
* recorded nothing — a real pattern in migrated Jest suites.
*/
import { describe, it, expect, vi } from "vitest";
import { NativeModules, TurboModuleRegistry } from "react-native";

describe("NativeModules stubs under the native engine", () => {
it("are identity-stable across property accesses", () => {
expect(NativeModules.VNIdentityProbe).toBe(NativeModules.VNIdentityProbe);
expect(NativeModules.VNIdentityProbe.someMethod).toBe(NativeModules.VNIdentityProbe.someMethod);
});

it("share identity with TurboModuleRegistry", () => {
expect(TurboModuleRegistry.get("VNSharedProbe")).toBe(NativeModules.VNSharedProbe);
});

it("support vi.spyOn on stub methods", () => {
const spy = vi.spyOn(NativeModules.VNSpyProbe, "doThing");
NativeModules.VNSpyProbe.doThing("payload", 42);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith("payload", 42);
spy.mockRestore();
expect(NativeModules.VNSpyProbe.doThing("x")).toBeUndefined();
});

it("keeps callback/promise conventions after memoization", async () => {
// Callback-style: success callback is invoked so wrapping Promises settle.
let called: unknown = "not called";
NativeModules.VNCallbackProbe.fetchState((v: unknown) => {
called = v;
});
expect(called).toBe(false);
// getConstants stays functional on the memoized stub.
expect(typeof NativeModules.VNCallbackProbe.getConstants()).toBe("object");
});
});
43 changes: 43 additions & 0 deletions packages/vitest-native/tests/native-unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,49 @@ describe("native boundary", () => {
const mod = evalCjs(src!);
expect(typeof mod.default).toBe("function");
});

it("turboStubs are identity-stable and spy-able", () => {
const src = boundarySourceFor("/x/react-native/Libraries/TurboModule/TurboModuleRegistry.js");
const mod = evalCjs(src!);
const stub = mod.get("IdentityTarget");
// Repeated module and method reads return the same objects — a prerequisite
// for vi.spyOn and instanceof-style assertions in migrated Jest suites.
expect(mod.get("IdentityTarget")).toBe(stub);
expect(stub.someMethod).toBe(stub.someMethod);
const spy = vi.spyOn(stub, "someMethod");
stub.someMethod("a", 1);
expect(spy).toHaveBeenCalledWith("a", 1);
spy.mockRestore();
// Restored method keeps the generic stub behavior.
expect(stub.someMethod()).toBeUndefined();
});

it("NativeModules and TurboModuleRegistry share stub identity", () => {
const tm = evalCjs(
boundarySourceFor("/x/react-native/Libraries/TurboModule/TurboModuleRegistry.js")!,
);
const nm = evalCjs(
boundarySourceFor("/x/react-native/Libraries/BatchedBridge/NativeModules.js")!,
).default;
expect(nm.SharedIdentityTarget).toBe(tm.get("SharedIdentityTarget"));
});

it("the hot-reset registry clears overrides but keeps stub identity", () => {
const src = boundarySourceFor("/x/react-native/Libraries/TurboModule/TurboModuleRegistry.js");
const mod = evalCjs(src!);
const stub = mod.get("ResetTarget");
stub.overridden = "test-phase value";
const resets = (globalThis as any).__vitest_native_resets;
expect(Array.isArray(resets)).toBe(true);
for (const fn of resets) fn();
// Same stub object survives (resident libs hold references), but the
// per-file override is gone — the slot regenerates as a generic stub
// method like any other unknown property.
expect(mod.get("ResetTarget")).toBe(stub);
expect(stub.overridden).not.toBe("test-phase value");
expect(typeof stub.overridden).toBe("function");
expect(typeof stub.someMethod).toBe("function");
});
});

// @ts-expect-error — runtime .mjs
Expand Down
Loading