Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/subpath-resolution-fidelity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"vitest-native": patch
---

Fix three silent resolution-fidelity gaps around deep (subpath) imports.

- **`react-native` subpath default exports are now the leaf module.** `import Platform from 'react-native/Libraries/Utilities/Platform'` previously received the entire mock object as `Platform`, so `Platform.OS` was silently `undefined`. The virtual subpath modules (ESM) and the CJS bridge now derive the intended export from the subpath's basename and serve it as the default — CJS requires get Babel-interop shape (`{ __esModule, default }` via a live wrapper) so both `require('.../Platform').OS` and `_interopRequireDefault(...)` consumers work. Unknown leaves keep the previous whole-mock fallback.
- **`react-native/package.json` (and preset `pkg/package.json`) resolve to the real manifest.** Version gates like `require('react-native/package.json').version` previously read the mock and got `undefined`. Both the Vite-graph and CJS-bridge interception now exempt the manifest; when the package is not installed, the previous mock fallback is kept rather than erroring.
- **Preset shadowing now covers subpath imports.** `import Swipeable from 'react-native-gesture-handler/Swipeable'` (and CJS equivalents, including requires nested inside externalized third-party libraries) previously bypassed the preset mock entirely and loaded the package's real native-runtime code — or failed resolution outright on package versions that no longer ship the deep file. All three redirect layers (Vite plugin, ESM loader hook, CJS require hook) now match subpaths of preset packages and serve the mock export named by the subpath's leaf, falling back to the root mock. JSON and asset-extension subpaths are exempt so manifests and font/image files keep resolving from disk. CJS interop wrappers are memoized per specifier (keyed by the live mock set, so hot-runtime per-file rebuilds stay correct) to keep module identity stable across repeated requires.
121 changes: 118 additions & 3 deletions packages/vitest-native/src/cjs-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,70 @@ const VIRTUAL_RN_PATH = "\0vitest-native:react-native";

let originalResolveFilename: Function | null = null;
let installed = false;
// The mock object handed to installCjsBridge — consulted for leaf-aware
// subpath resolution (react-native/Libraries/Utilities/Platform → mock.Platform).
let rnMock: Record<string, any> | null = null;

// Single lookup table for all preset module redirections.
// Maps exact module names to their virtual paths. Avoids wrapping
// _resolveFilename in a new closure per preset (O(n) chain → O(1) Map).
const presetRedirects = new Map<string, string>();
// The live mock objects behind presetRedirects, for leaf-aware subpath lookups.
const presetMocks = new Map<string, Record<string, any>>();
// Synthetic leaf modules created on demand for subpath requires; tracked so
// uninstallCjsBridge can remove them from Module._cache.
const leafModulePaths = new Set<string>();

/**
* The leaf module name a subpath require points at ("pkg/lib/Swipeable" or
* ".../Platform.ios.js" → "Platform"). Mirrors native/match.mjs.
*/
function subpathLeafOf(request: string): string | null {
const base = request.split("/").pop();
if (!base) return null;
return base.split(".")[0] || null;
}

/**
* Wrap a leaf export in Babel-CJS interop shape: the real compiled deep entry
* exports `{ __esModule: true, default: X }`. A live Proxy keeps
* direct-property consumers (`require('pkg/Sub').OS`) working too.
*/
function withDefaultInterop(value: any): any {
if (value === null || (typeof value !== "object" && typeof value !== "function")) {
return { __esModule: true, default: value };
}
return new Proxy(value, {
get: (t, p, r) => (p === "default" ? t : p === "__esModule" ? true : Reflect.get(t, p, r)),
has: (t, p) => p === "default" || p === "__esModule" || Reflect.has(t, p),
});
}

/**
* Get or create a synthetic Module._cache entry for a subpath require whose
* leaf name matches an export on the mock. Returns its virtual path, or null
* when the leaf has no matching export (caller falls back to the root mock).
*/
function leafModulePath(
Module: any,
keyPrefix: string,
mock: Record<string, any>,
request: string,
): string | null {
const leaf = subpathLeafOf(request);
if (!leaf || !Object.prototype.hasOwnProperty.call(mock, leaf)) return null;
const virtualPath = `${keyPrefix}:${leaf}`;
if (!Module._cache[virtualPath]) {
const syntheticModule = new Module(virtualPath);
syntheticModule.id = virtualPath;
syntheticModule.filename = virtualPath;
syntheticModule.loaded = true;
syntheticModule.exports = withDefaultInterop(mock[leaf]);
Module._cache[virtualPath] = syntheticModule;
leafModulePaths.add(virtualPath);
}
return virtualPath;
}

export function installCjsBridge(mockObject: Record<string, any>): void {
if (installed) return;
Expand All @@ -39,6 +98,8 @@ export function installCjsBridge(mockObject: Record<string, any>): void {
syntheticModule.exports = mockObject;
Module._cache[VIRTUAL_RN_PATH] = syntheticModule;

rnMock = mockObject;

// 2. Patch _resolveFilename once — handles react-native and all presets
originalResolveFilename = Module._resolveFilename;
Module._resolveFilename = function (
Expand All @@ -47,8 +108,28 @@ export function installCjsBridge(mockObject: Record<string, any>): void {
isMain: boolean,
options: any,
) {
// Root react-native import or subpath (react-native/Libraries/*, etc.)
// Root react-native import or subpath (react-native/Libraries/*, etc.).
// The package manifest is exempt: `require('react-native/package.json')
// .version` is a common version gate and must read the real file. Subpaths
// whose leaf matches a mock export (…/Utilities/Platform → mock.Platform)
// get a per-leaf module so the require yields Platform, not the whole mock.
if (request === "react-native" || request.startsWith("react-native/")) {
if (request === "react-native/package.json") {
try {
return (originalResolveFilename as Function).call(
this,
request,
parent,
isMain,
options,
);
} catch {
// RN not installed (mock engine works without it) — keep the mock.
}
} else if (request !== "react-native" && rnMock) {
const leafPath = leafModulePath(Module, VIRTUAL_RN_PATH, rnMock, request);
if (leafPath) return leafPath;
}
return VIRTUAL_RN_PATH;
}

Expand All @@ -58,12 +139,37 @@ export function installCjsBridge(mockObject: Record<string, any>): void {

// Subpath imports (e.g. @react-navigation/native/lib/...) — find the
// longest matching prefix. Since preset names contain slashes (scoped
// packages), we check if any registered preset is a prefix.
// packages), we check if any registered preset is a prefix. JSON subpaths
// (package.json version gates) pass through to the real inert file; leaf
// matches get a per-leaf module (see the react-native branch above).
// Unlike the native-engine hooks, asset and utility subpaths are NOT
// passed through here: this CJS path has no asset-extension handlers and
// no Flow/TS transform, so the real file would throw — the root-mock
// fallback (long-standing behavior) is the lenient option.
const slashIdx = request.indexOf("/", request.startsWith("@") ? request.indexOf("/") + 1 : 0);
if (slashIdx !== -1) {
const pkg = request.slice(0, slashIdx);
const subpathMatch = presetRedirects.get(pkg);
if (subpathMatch) return subpathMatch;
if (subpathMatch) {
if (request.endsWith(".json")) {
try {
return (originalResolveFilename as Function).call(
this,
request,
parent,
isMain,
options,
);
} catch {
// Package not on disk — fall back to the root mock.
}
} else {
const mock = presetMocks.get(pkg);
const leafPath = mock && leafModulePath(Module, subpathMatch, mock, request);
if (leafPath) return leafPath;
}
return subpathMatch;
}
}

return (originalResolveFilename as Function).call(this, request, parent, isMain, options);
Expand Down Expand Up @@ -98,6 +204,14 @@ export function uninstallCjsBridge(): void {
delete Module._cache[virtualPath];
}
presetRedirects.clear();
presetMocks.clear();

// Remove per-leaf subpath modules created on demand
for (const virtualPath of leafModulePaths) {
delete Module._cache[virtualPath];
}
leafModulePaths.clear();
rnMock = null;

installed = false;
} catch (e) {
Expand All @@ -122,6 +236,7 @@ export function installPresetCjsBridge(moduleName: string, mockObject: Record<st

// Register in the flat lookup table — no _resolveFilename wrapping needed.
presetRedirects.set(moduleName, virtualPath);
presetMocks.set(moduleName, mockObject);
} catch (e) {
if (process.env.VITEST_NATIVE_DIAGNOSTICS === "true") {
console.warn(
Expand Down
57 changes: 55 additions & 2 deletions packages/vitest-native/src/native/hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import fs from "node:fs";
import { transformRN, isFlow } from "./transform.mjs";
import { boundarySourceFor } from "./boundary.mjs";
import { resolvePlatformFile } from "./resolve.mjs";
import { buildPkgMatcher } from "./match.mjs";
import { buildPkgMatcher, packageNameOf, subpathLeafOf, isUtilitySubpath } from "./match.mjs";

const RN_PATH = /[\\/]node_modules[\\/](react-native|@react-native)[\\/]/;
// Any file under a node_modules directory. Platform-extension resolution
Expand Down Expand Up @@ -38,6 +38,7 @@ export function installRequireHooks(
// @react-native-vector-icons are shadowed by their preset, so they never inspect
// the stubbed font require.)
const NON_ASSET = new Set([".js", ".cjs", ".mjs", ".ts", ".tsx", ".json", ".node"]);
const assetExtSet = new Set(assetExts.map((e) => String(e).replace(/^\./, "").toLowerCase()));
for (const raw of assetExts) {
const ext = "." + String(raw).replace(/^\./, "");
if (NON_ASSET.has(ext) || Module._extensions[ext]) continue;
Expand All @@ -59,10 +60,62 @@ export function installRequireHooks(
// the real package's native runtime. The lookup is dynamic (no preset-name list
// captured at install time) so the hooks can install at hot-worker boot, before
// the setup file has built the preset mocks.
// Subpath requires of a preset package (pkg/Swipeable) get the mock export
// matching the leaf name, wrapped in Babel-CJS interop shape ({ __esModule,
// default }) like the real compiled deep entry — served via a live Proxy so
// direct-property consumers (`require('pkg/Sub').X`) work too. Memoized per
// request for identity stability. The memo is keyed by the PER-PACKAGE mock
// object, not the __vitest_native_preset_mocks container: the hot runtime
// rebuilds each package's mock per test file while reusing the container, so
// keying by the container would serve file 1's mocks to every later file in
// the worker.
const subpathMemo = new WeakMap();
function presetSubpathExports(mocks, pkg, request) {
const mock = mocks[pkg];
if (mock === null || (typeof mock !== "object" && typeof mock !== "function")) return mock;
let memo = subpathMemo.get(mock);
if (!memo) subpathMemo.set(mock, (memo = new Map()));
if (memo.has(request)) return memo.get(request);
const leaf = subpathLeafOf(request);
let exportsValue = mock;
if (leaf && Object.prototype.hasOwnProperty.call(mock, leaf)) {
const value = mock[leaf];
exportsValue =
value !== null && (typeof value === "object" || typeof value === "function")
? new Proxy(value, {
get: (t, p, r) =>
p === "default" ? t : p === "__esModule" ? true : Reflect.get(t, p, r),
has: (t, p) => p === "default" || p === "__esModule" || Reflect.has(t, p),
})
: { __esModule: true, default: value };
} else if (process.env.VITEST_NATIVE_DIAGNOSTICS === "true") {
console.warn(
`[vitest-native] '${request}' has no matching export on the '${pkg}' preset mock; serving the root mock namespace.`,
);
}
memo.set(request, exportsValue);
return exportsValue;
}

const origLoad = Module._load;
Module._load = function (request, parent, ...rest) {
const mocks = globalThis.__vitest_native_preset_mocks;
if (mocks && Object.prototype.hasOwnProperty.call(mocks, request)) return mocks[request];
if (mocks) {
if (Object.prototype.hasOwnProperty.call(mocks, request)) return mocks[request];
// Subpath require of a preset package — the real deep entry would load the
// package's native runtime. Exempt: JSON subpaths (package.json version
// gates), asset subpaths (fonts/images, stubbed from their real files by
// the Module._extensions handlers above), and Node-safe utility entries
// (jest-utils, mock, plugin) — those fall through to the real file.
const reqExtMatch = /\.([a-z0-9]+)$/i.exec(request);
const reqExt = reqExtMatch ? reqExtMatch[1].toLowerCase() : "";
if (reqExt !== "json" && !assetExtSet.has(reqExt) && !isUtilitySubpath(request)) {
const pkg = packageNameOf(request);
if (pkg !== request && Object.prototype.hasOwnProperty.call(mocks, pkg)) {
return presetSubpathExports(mocks, pkg, request);
}
}
}
return origLoad.call(this, request, parent, ...rest);
};

Expand Down
39 changes: 34 additions & 5 deletions packages/vitest-native/src/native/loader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import fs from "node:fs";
import { transformRN, isFlow } from "./transform.mjs";
import { boundarySourceFor } from "./boundary.mjs";
import { resolvePlatformFile } from "./resolve.mjs";
import { buildPkgMatcher } from "./match.mjs";
import { buildPkgMatcher, packageNameOf, subpathLeafOf, isUtilitySubpath } from "./match.mjs";

const RN_PATH = /[\\/]node_modules[\\/](react-native|@react-native)[\\/]/;
// Any file living under a node_modules directory. Platform-extension resolution
Expand Down Expand Up @@ -65,9 +65,23 @@ export async function resolve(specifier, context, nextResolve) {
// test graph or, crucially, nested inside an externalized third-party lib — is
// redirected to a synthetic module that re-exports the runtime preset mock. This
// mirrors the Vite plugin's virtual:preset modules for the Node ESM path.
// Subpath imports (e.g. react-native-gesture-handler/Swipeable) are redirected
// too — the real deep entry would pull in the package's native runtime.
// Exempt: JSON subpaths (package.json version gates), asset subpaths, and
// Node-safe utility entries (jest-utils, mock, plugin) — those pass through
// to the real file.
if (Object.prototype.hasOwnProperty.call(presetExports, specifier)) {
return { url: PRESET_SCHEME + specifier, shortCircuit: true };
}
if (!specifier.endsWith(".json") && !isUtilitySubpath(specifier)) {
const specExt = /\.([a-z0-9]+)$/i.exec(specifier);
if (!specExt || !assetExtSet.has(specExt[1].toLowerCase())) {
const pkg = packageNameOf(specifier);
if (pkg !== specifier && Object.prototype.hasOwnProperty.call(presetExports, pkg)) {
return { url: PRESET_SCHEME + specifier, shortCircuit: true };
}
}
}

const parent =
context.parentURL && context.parentURL.startsWith("file:")
Expand Down Expand Up @@ -120,14 +134,29 @@ export async function load(url, context, nextLoad) {
// by the native setup file from globalThis (this source executes in the main
// realm, so globalThis is the populated one), mirroring the Vite virtual:preset.
if (url.startsWith(PRESET_SCHEME)) {
const pkg = url.slice(PRESET_SCHEME.length);
const specifier = url.slice(PRESET_SCHEME.length);
const pkg = packageNameOf(specifier);
const names = presetExports[pkg] || [];
// For a subpath import, prefer the mock export matching the leaf module name
// (pkg/lib/Swipeable → mock.Swipeable) — real deep entries export that one
// thing as their default. Root imports (and unknown leaves) keep the
// factory-default-then-namespace behavior; unknown leaves warn under
// diagnostics since the namespace default is usually not what the importer
// wanted.
const leaf = specifier === pkg ? null : subpathLeafOf(specifier);
const fallback = `("default" in _m ? _m["default"] : _m)`;
const source = [
`const _m = (globalThis.__vitest_native_preset_mocks || {})[${JSON.stringify(pkg)}] || {};`,
...names.map((n) => `export const ${n} = _m[${JSON.stringify(n)}];`),
// Honor a factory-provided default (e.g. svg's default Svg component);
// only fall back to the namespace object when the mock has none.
`export default ("default" in _m ? _m["default"] : _m);`,
...(leaf
? [
`const _hit = ${JSON.stringify(leaf)} in _m;`,
`if (!_hit && process.env.VITEST_NATIVE_DIAGNOSTICS === "true") console.warn(${JSON.stringify(
`[vitest-native] '${specifier}' has no matching export on the '${pkg}' preset mock; serving the root mock namespace.`,
)});`,
`export default (_hit ? _m[${JSON.stringify(leaf)}] : ${fallback});`,
]
: [`export default ${fallback};`]),
].join("\n");
return { format: "module", source, shortCircuit: true };
}
Expand Down
33 changes: 33 additions & 0 deletions packages/vitest-native/src/native/match.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,36 @@ export function buildPkgMatcher(pkgs) {
);
return (normPath) => res.some((re) => re.test(normPath));
}

// The bare package name of an import specifier ("@scope/pkg/sub" → "@scope/pkg",
// "pkg/sub" → "pkg"). Relative/absolute specifiers yield strings that can never
// collide with a package name, so callers only need an equality check.
export function packageNameOf(specifier) {
if (specifier.startsWith("@")) {
const [scope, name] = specifier.split("/");
return name ? `${scope}/${name}` : specifier;
}
return specifier.split("/")[0];
}

// The leaf module name a subpath import points at ("pkg/lib/Swipeable" or
// "pkg/Swipeable.ios.js" → "Swipeable"), used to pick the matching export off a
// preset/RN mock. Returns null when there is no usable leaf (trailing slash).
export function subpathLeafOf(specifier) {
const base = specifier.split("/").pop();
if (!base) return null;
return base.split(".")[0] || null;
}

// Deep entries of preset packages that are deliberately Node-safe and must NOT
// be shadowed by the preset mock: test utilities and tooling entry points
// (react-native-gesture-handler/jest-utils, react-native-reanimated/mock,
// */jestSetup, babel `*/plugin` entries). They are designed to run under a
// Node test runner, and shadowing them replaces working code with undefined
// exports.
const UTILITY_SUBPATH_LEAVES = new Set(["jest-utils", "jestSetup", "mock", "plugin"]);

export function isUtilitySubpath(specifier) {
const leaf = subpathLeafOf(specifier);
return leaf !== null && UTILITY_SUBPATH_LEAVES.has(leaf);
}
Loading
Loading