From 4fe5852b98a9bcbcfbe11a560d8867b0c8362367 Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Thu, 2 Jul 2026 21:55:33 +0100 Subject: [PATCH 1/2] fix(resolution): leaf-aware subpath imports, real package.json, preset subpath shadowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three resolution-fidelity gaps around deep (subpath) imports, all silent failures: - react-native subpath default exports (mock engine): import Platform from 'react-native/Libraries/Utilities/Platform' received the whole mock object, so Platform.OS was undefined with no error. Virtual subpath modules and the CJS bridge now derive the intended export from the subpath basename and serve it as the default; CJS requires get Babel-interop shape via a live wrapper so both direct-property and _interopRequireDefault consumers work. Unknown leaves keep the whole-mock fallback. - Package manifests: require('react-native/package.json').version (and preset equivalents) read the mock instead of the real file, breaking version gates. Both interception layers now exempt manifests, falling back to the mock only when the package is not installed. - Preset subpath shadowing (both engines): deep imports of preset packages (react-native-gesture-handler/Swipeable) bypassed the preset mock and loaded real native-runtime code, or failed resolution on versions that no longer ship the deep file. All three redirect layers (Vite plugin, ESM loader hook, CJS require hook) now match subpaths leaf-aware. Exempt: JSON, asset extensions, and Node-safe utility entries (jest-utils, jestSetup, mock, plugin), which pass through to the real file. Unknown leaves warn under diagnostics. CJS interop wrappers are memoized per specifier keyed by the per-package mock object — not the container, which the hot runtime reuses across files while rebuilding the per-package values (a container-keyed memo served file 1's mocks to every later file in a hot worker; caught in review with a two-file repro, now guarded by a cross-file identity test). --- .changeset/subpath-resolution-fidelity.md | 9 ++ packages/vitest-native/src/cjs-bridge.ts | 121 ++++++++++++++++- packages/vitest-native/src/native/hooks.mjs | 57 +++++++- packages/vitest-native/src/native/loader.mjs | 39 +++++- packages/vitest-native/src/native/match.mjs | 33 +++++ packages/vitest-native/src/plugin.ts | 124 +++++++++++++++--- .../preset-subpath-cross-file.test.tsx | 22 ++++ .../tests-native/preset-subpath.test.tsx | 66 ++++++++++ .../vitest-native/tests/native-unit.test.ts | 118 +++++++++++++++++ .../vitest-native/tests/regression.test.ts | 49 +++++++ 10 files changed, 613 insertions(+), 25 deletions(-) create mode 100644 .changeset/subpath-resolution-fidelity.md create mode 100644 packages/vitest-native/tests-native/preset-subpath-cross-file.test.tsx create mode 100644 packages/vitest-native/tests-native/preset-subpath.test.tsx diff --git a/.changeset/subpath-resolution-fidelity.md b/.changeset/subpath-resolution-fidelity.md new file mode 100644 index 0000000..416a95e --- /dev/null +++ b/.changeset/subpath-resolution-fidelity.md @@ -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. diff --git a/packages/vitest-native/src/cjs-bridge.ts b/packages/vitest-native/src/cjs-bridge.ts index 5e7273d..9f82164 100644 --- a/packages/vitest-native/src/cjs-bridge.ts +++ b/packages/vitest-native/src/cjs-bridge.ts @@ -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 | 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(); +// The live mock objects behind presetRedirects, for leaf-aware subpath lookups. +const presetMocks = new Map>(); +// Synthetic leaf modules created on demand for subpath requires; tracked so +// uninstallCjsBridge can remove them from Module._cache. +const leafModulePaths = new Set(); + +/** + * 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, + 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): void { if (installed) return; @@ -39,6 +98,8 @@ export function installCjsBridge(mockObject: Record): 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 ( @@ -47,8 +108,28 @@ export function installCjsBridge(mockObject: Record): 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; } @@ -58,12 +139,37 @@ export function installCjsBridge(mockObject: Record): 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); @@ -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) { @@ -122,6 +236,7 @@ export function installPresetCjsBridge(moduleName: string, mockObject: Record String(e).replace(/^\./, "").toLowerCase())); for (const raw of assetExts) { const ext = "." + String(raw).replace(/^\./, ""); if (NON_ASSET.has(ext) || Module._extensions[ext]) continue; @@ -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); }; diff --git a/packages/vitest-native/src/native/loader.mjs b/packages/vitest-native/src/native/loader.mjs index ba22270..956429a 100644 --- a/packages/vitest-native/src/native/loader.mjs +++ b/packages/vitest-native/src/native/loader.mjs @@ -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 @@ -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:") @@ -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 }; } diff --git a/packages/vitest-native/src/native/match.mjs b/packages/vitest-native/src/native/match.mjs index a915603..d5e97b5 100644 --- a/packages/vitest-native/src/native/match.mjs +++ b/packages/vitest-native/src/native/match.mjs @@ -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); +} diff --git a/packages/vitest-native/src/plugin.ts b/packages/vitest-native/src/plugin.ts index 6c9aa3e..f82c8d8 100644 --- a/packages/vitest-native/src/plugin.ts +++ b/packages/vitest-native/src/plugin.ts @@ -254,6 +254,44 @@ const RN_EXPORT_NAMES = [ "usePressability", ]; +/** Fast membership check for leaf-name lookups on subpath imports. */ +const RN_EXPORT_NAME_SET = new Set(RN_EXPORT_NAMES); + +/** + * The bare package name of an import specifier ("@scope/pkg/sub" → "@scope/pkg", + * "pkg/sub" → "pkg"). Mirrors native/match.mjs for the Vite-graph side. + */ +function packageNameOf(specifier: string): string { + 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 + * "react-native/Libraries/Utilities/Platform.ios.js" → "Platform"), used to pick + * the matching export off the mock. Mirrors native/match.mjs. + */ +function subpathLeafOf(specifier: string): string | null { + 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 (test utilities and tooling entry points). Mirrors + * native/match.mjs. + */ +const UTILITY_SUBPATH_LEAVES = new Set(["jest-utils", "jestSetup", "mock", "plugin"]); + +function isUtilitySubpath(specifier: string): boolean { + const leaf = subpathLeafOf(specifier); + return leaf !== null && UTILITY_SUBPATH_LEAVES.has(leaf); +} + /** Check if a value (or any nested value) contains functions. */ function containsFunctions(value: unknown, visited = new WeakSet()): boolean { if (typeof value === "function") return true; @@ -302,6 +340,9 @@ export function reactNative(options?: VitestNativeOptions): Plugin { // Preset export names discovered by calling factories at config time. const presetExportNames = new Map(); let assetPattern: RegExp; + // Real on-disk path of react-native/package.json (mock engine): version-gate + // reads must see the real manifest, not the virtualized mock. + let realRnPackageJson: string | undefined; // Caches for hot paths — resolveId and load are called for every import. const resolveCache = new Map(); @@ -344,6 +385,25 @@ export function reactNative(options?: VitestNativeOptions): Plugin { let engine: "mock" | "native" = requestedEngine === "native" ? "native" : "mock"; const extensions = getPlatformExtensions(platform); + // Preset redirect shared by both engines: exact package match, or a subpath of + // a preset package (pkg/Swipeable) — the real deep entry would pull in the + // package's native runtime. Exempt: JSON subpaths (package.json version + // gates), asset subpaths (fonts/images, stubbed from their real files), and + // Node-safe utility entries (jest-utils, mock, plugin). The virtual id carries + // the full specifier so load() can pick the mock export matching the leaf + // module name. Subpath matching stays inert until configResolved has built + // assetPattern — without it the asset exemption can't be applied. + const resolvePresetId = (source: string): string | undefined => { + if (presetModules.has(source)) return `\0virtual:preset:${source}`; + if (!assetPattern) return undefined; + if (source.endsWith(".json") || assetPattern.test(source) || isUtilitySubpath(source)) { + return undefined; + } + const pkg = packageNameOf(source); + if (pkg !== source && presetModules.has(pkg)) return `\0virtual:preset:${source}`; + return undefined; + }; + return { name: "vitest-native", enforce: "pre", @@ -527,6 +587,13 @@ export function reactNative(options?: VitestNativeOptions): Plugin { // Now we have the real project root — resolve options from consumer context. resolved = await resolveOptions(options, config.root); + try { + realRnPackageJson = createRequire(path.join(config.root, "package.json")).resolve( + "react-native/package.json", + ); + } catch { + // RN not installed (mock engine works without it) — keep virtualizing. + } // The authoritative engine is the one decided in config(); keep ResolvedOptions in sync. resolved.engine = engine; @@ -551,8 +618,7 @@ export function reactNative(options?: VitestNativeOptions): Plugin { // are still redirected to virtual mocks: their native runtimes can't load in // Node, so they must be shadowed exactly as under the mock engine. if (engine === "native") { - if (presetModules.has(source)) return `\0virtual:preset:${source}`; - return undefined; + return resolvePresetId(source); } // Redirect react-native root import to a virtual module. @@ -562,13 +628,19 @@ export function reactNative(options?: VitestNativeOptions): Plugin { } // Redirect react-native subpath imports (e.g. react-native/Libraries/...). + // The package manifest is exempt: `require('react-native/package.json').version` + // is a common version gate and must read the real file, not the mock. if (source.startsWith("react-native/")) { + if (source === "react-native/package.json" && realRnPackageJson) { + return realRnPackageJson; + } return `\0virtual:rn-subpath:${source}`; } - // Redirect preset-provided modules to virtual stubs. - if (presetModules.has(source)) { - return `\0virtual:preset:${source}`; + // Redirect preset-provided modules (and their subpaths) to virtual stubs. + const presetId = resolvePresetId(source); + if (presetId) { + return presetId; } // Layer 1: Metro-compatible extensionless resolution for node_modules. @@ -623,14 +695,29 @@ export function reactNative(options?: VitestNativeOptions): Plugin { const cached = virtualCodeCache.get(id); if (cached) return cached; - const moduleName = id.slice("\0virtual:preset:".length); - const exportNames = presetExportNames.get(moduleName) || []; + const specifier = id.slice("\0virtual:preset:".length); + const pkg = packageNameOf(specifier); + const exportNames = presetExportNames.get(pkg) || []; + // Subpath imports (pkg/lib/Swipeable) get the mock export matching the + // leaf module name as their default — real deep entries export that one + // thing. Root imports honor a factory-provided default (e.g. svg's Svg + // component), falling back to the namespace object when the mock has + // none; 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 code = [ - `const _m = (globalThis.__vitest_native_preset_mocks || {})['${moduleName}'] || {};`, + `const _m = (globalThis.__vitest_native_preset_mocks || {})[${JSON.stringify(pkg)}] || {};`, ...exportNames.map((n) => `export const ${n} = _m['${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"); virtualCodeCache.set(id, code); return code; @@ -655,16 +742,23 @@ export function reactNative(options?: VitestNativeOptions): Plugin { // Subpath imports (react-native/Libraries/*, react-native/jest-preset, etc.) // Re-export everything from the root mock stored on globalThis by setup.ts. // By the time test code evaluates these, setup.ts has already run. - // Code is identical for all subpaths so we cache it once. + // The default export is the mock export matching the leaf module name — + // `import Platform from 'react-native/Libraries/Utilities/Platform'` must + // yield Platform, not the whole mock. Unknown leaves fall back to the root + // mock. Code only varies by leaf, so it's cached per leaf. if (id.startsWith("\0virtual:rn-subpath:")) { - let code = virtualCodeCache.get("\0rn-subpath"); + const subpath = id.slice("\0virtual:rn-subpath:".length); + const leaf = subpathLeafOf(subpath); + const known = leaf && RN_EXPORT_NAME_SET.has(leaf) ? leaf : null; + const cacheKey = `\0rn-subpath:${known ?? ""}`; + let code = virtualCodeCache.get(cacheKey); if (!code) { code = [ `const _rn = globalThis.__vitest_native_mock || {};`, ...RN_EXPORT_NAMES.map((n) => `export const ${n} = _rn['${n}'];`), - `export default _rn;`, + known ? `export default _rn[${JSON.stringify(known)}];` : `export default _rn;`, ].join("\n"); - virtualCodeCache.set("\0rn-subpath", code); + virtualCodeCache.set(cacheKey, code); } return code; } diff --git a/packages/vitest-native/tests-native/preset-subpath-cross-file.test.tsx b/packages/vitest-native/tests-native/preset-subpath-cross-file.test.tsx new file mode 100644 index 0000000..ec5d4c6 --- /dev/null +++ b/packages/vitest-native/tests-native/preset-subpath-cross-file.test.tsx @@ -0,0 +1,22 @@ +/** + * Cross-file twin of preset-subpath.test.tsx. Under the hot runtime the worker + * (and any module-level caches in the require hooks) persists across test + * files while preset mocks are rebuilt per file — whichever of the two files + * runs later in a shared worker catches a stale subpath cache as an identity + * mismatch between the deep import and the current file's root mock. + */ +import { describe, it, expect } from "vitest"; + +describe("preset subpath identity across hot-worker files", () => { + it("CJS subpath leaf is the current file's root mock export", () => { + const viaSubpath = require("react-native-gesture-handler/Swipeable"); + const root = require("react-native-gesture-handler"); + expect(viaSubpath.default).toBe(root.Swipeable); + }); + + it("repeated requires stay identity-stable within the file", () => { + const a = require("react-native-gesture-handler/Swipeable"); + const b = require("react-native-gesture-handler/Swipeable"); + expect(a).toBe(b); + }); +}); diff --git a/packages/vitest-native/tests-native/preset-subpath.test.tsx b/packages/vitest-native/tests-native/preset-subpath.test.tsx new file mode 100644 index 0000000..58064da --- /dev/null +++ b/packages/vitest-native/tests-native/preset-subpath.test.tsx @@ -0,0 +1,66 @@ +/** + * Deep (subpath) imports of preset packages must be shadowed like the root + * import — `import Swipeable from 'react-native-gesture-handler/Swipeable'` is a + * common real-app pattern, and letting it fall through would load the package's + * real native runtime (or fail resolution entirely: RNGH 3.x no longer ships a + * /Swipeable file, so pre-3.x app code only works because of the redirect). + * + * JSON subpaths are exempt: `require('pkg/package.json').version` gates must + * read the real manifest. + */ +import { describe, it, expect } from "vitest"; +import React from "react"; +import { render, screen } from "@testing-library/react-native"; +import { Text } from "react-native"; +// @ts-expect-error — no type declarations for the deep path (redirected at runtime) +import Swipeable from "react-native-gesture-handler/Swipeable"; +import { Swipeable as RootSwipeable } from "react-native-gesture-handler"; + +describe("preset subpath imports under the native engine", () => { + it("ESM deep import yields the preset mock's leaf export and renders", async () => { + expect(Swipeable).toBeTruthy(); + expect(Swipeable).toBe(RootSwipeable); + await render( + + row content + , + ); + expect(screen.getByText("row content")).toBeTruthy(); + }); + + // Root and subpath must agree on THIS file's mock instance — under the hot + // runtime preset mocks are rebuilt per file, so a stale subpath cache would + // surface here as an identity mismatch (see preset-subpath-cross-file twin). + it("CJS subpath leaf identity matches the current root mock", () => { + const viaSubpath = require("react-native-gesture-handler/Swipeable"); + const root = require("react-native-gesture-handler"); + expect(viaSubpath.default).toBe(root.Swipeable); + }); + + it("Node-safe utility subpaths pass through to the real file", () => { + const jestUtils = require("react-native-gesture-handler/jest-utils"); + expect(typeof jestUtils.fireGestureHandler).toBe("function"); + expect(typeof jestUtils.getByGestureTestId).toBe("function"); + }); + + it("CJS deep require yields the leaf with interop default, identity-stable", () => { + const a = require("react-native-gesture-handler/Swipeable"); + const b = require("react-native-gesture-handler/Swipeable"); + expect(a.default).toBeTruthy(); + expect(a.__esModule).toBe(true); + // Memoized: repeated requires must return the same object (spies/instanceof). + expect(a).toBe(b); + }); + + it("unknown leaves fall back to the root preset mock", () => { + const mod = require("react-native-gesture-handler/lib/commonjs/whatever"); + expect(mod.State).toBeDefined(); + expect(mod.GestureHandlerRootView).toBeDefined(); + }); + + it("package.json subpath reads the real manifest", () => { + const pkg = require("react-native-gesture-handler/package.json"); + expect(pkg.name).toBe("react-native-gesture-handler"); + expect(typeof pkg.version).toBe("string"); + }); +}); diff --git a/packages/vitest-native/tests/native-unit.test.ts b/packages/vitest-native/tests/native-unit.test.ts index d890766..9fe726d 100644 --- a/packages/vitest-native/tests/native-unit.test.ts +++ b/packages/vitest-native/tests/native-unit.test.ts @@ -189,6 +189,73 @@ describe("native preset redirect (ESM loader)", () => { ); expect(result).toBe(sentinel); }); + + it("redirects a preset subpath import and serves a leaf-aware default", async () => { + await nativeLoader.initialize({ + projectRoot, + transformPkgs: [], + presetExports: { "react-native-gesture-handler": ["Swipeable", "State"] }, + assetExts: ["png", "ttf"], + }); + const resolved = await nativeLoader.resolve( + "react-native-gesture-handler/Swipeable", + { parentURL: undefined }, + () => { + throw new Error("nextResolve should not be called for a preset subpath"); + }, + ); + expect(resolved.url).toBe("vitest-native-preset:react-native-gesture-handler/Swipeable"); + const loaded = await nativeLoader.load(resolved.url, {}, () => { + throw new Error("nextLoad should not be called for a preset URL"); + }); + // Default = the mock export matching the leaf name, falling back to the + // factory default / namespace. + expect(loaded.source).toContain('const _hit = "Swipeable" in _m;'); + expect(loaded.source).toContain('export default (_hit ? _m["Swipeable"]'); + expect(loaded.source).toContain('export const State = _m["State"];'); + }); + + it("passes preset package.json and asset subpaths through to the real resolver", async () => { + // package.json falls through (and gets the JSON import attribute injected). + const jsonResult = await nativeLoader.resolve( + "react-native-gesture-handler/package.json", + { parentURL: undefined }, + () => ({ url: "file:///real/package.json", shortCircuit: true }), + ); + expect(jsonResult.url).toBe("file:///real/package.json"); + expect(jsonResult.importAttributes?.type).toBe("json"); + // Assets fall through so the real file is stubbed from disk. + const assetSentinel = { url: "file:///real/back-icon.png", shortCircuit: true }; + const assetResult = await nativeLoader.resolve( + "react-native-gesture-handler/assets/back-icon.png", + { parentURL: undefined }, + () => assetSentinel, + ); + expect(assetResult).toBe(assetSentinel); + }); +}); + +// @ts-expect-error — runtime .mjs +import { packageNameOf, subpathLeafOf } from "../src/native/match.mjs"; + +describe("specifier helpers", () => { + it("packageNameOf handles bare, scoped, and non-package specifiers", () => { + expect(packageNameOf("react-native-gesture-handler/Swipeable")).toBe( + "react-native-gesture-handler", + ); + expect(packageNameOf("@react-navigation/native/lib/commonjs/index.js")).toBe( + "@react-navigation/native", + ); + expect(packageNameOf("lodash")).toBe("lodash"); + expect(packageNameOf("./relative/path")).toBe("."); + expect(packageNameOf("/abs/path")).toBe(""); + }); + + it("subpathLeafOf extracts the leaf module name", () => { + expect(subpathLeafOf("pkg/lib/Swipeable")).toBe("Swipeable"); + expect(subpathLeafOf("react-native/Libraries/Utilities/Platform.ios.js")).toBe("Platform"); + expect(subpathLeafOf("pkg/trailing/")).toBe(null); + }); }); import { reactNative } from "../src/index.js"; @@ -434,3 +501,54 @@ describe("defaultHotMemoryLimit", () => { expect(limit).toBeLessThanOrEqual(1536 * MB); }); }); + +import { gestureHandler } from "../src/presets/index.js"; + +describe("plugin subpath resolution (mock engine)", () => { + async function makePlugin() { + const plugin = reactNative({ engine: "mock", presets: [gestureHandler()] }) as any; + await plugin.config({ root: projectRoot }, SERVE_ENV); + await plugin.configResolved({ root: projectRoot }); + return plugin; + } + + it("redirects preset subpaths to a leaf-aware virtual module", async () => { + const plugin = await makePlugin(); + const id = plugin.resolveId("react-native-gesture-handler/Swipeable", undefined); + expect(id).toBe("\0virtual:preset:react-native-gesture-handler/Swipeable"); + const code = plugin.load(id); + expect(code).toContain('const _hit = "Swipeable" in _m;'); + expect(code).toContain('export default (_hit ? _m["Swipeable"]'); + // Named exports still come from the root preset mock. + expect(code).toContain("export const State = _m['State'];"); + }); + + it("does not redirect preset package.json or asset subpaths", async () => { + const plugin = await makePlugin(); + expect(plugin.resolveId("react-native-gesture-handler/package.json", undefined)).toBe( + undefined, + ); + expect(plugin.resolveId("react-native-gesture-handler/assets/icon.png", undefined)).toBe( + undefined, + ); + }); + + it("resolves react-native/package.json to the real on-disk manifest", async () => { + const plugin = await makePlugin(); + const resolvedPath = plugin.resolveId("react-native/package.json", undefined); + expect(typeof resolvedPath).toBe("string"); + expect(resolvedPath).toMatch(/package\.json$/); + expect(fs.existsSync(resolvedPath)).toBe(true); + expect(JSON.parse(fs.readFileSync(resolvedPath, "utf8")).name).toBe("react-native"); + }); + + it("serves rn-subpath virtuals with the leaf export as default", async () => { + const plugin = await makePlugin(); + const id = plugin.resolveId("react-native/Libraries/Utilities/Platform", undefined); + expect(id).toBe("\0virtual:rn-subpath:react-native/Libraries/Utilities/Platform"); + expect(plugin.load(id)).toContain('export default _rn["Platform"];'); + // Unknown leaves keep the whole-mock default. + const unknownId = plugin.resolveId("react-native/jest-preset", undefined); + expect(plugin.load(unknownId)).toContain("export default _rn;"); + }); +}); diff --git a/packages/vitest-native/tests/regression.test.ts b/packages/vitest-native/tests/regression.test.ts index 6e7b152..1c82255 100644 --- a/packages/vitest-native/tests/regression.test.ts +++ b/packages/vitest-native/tests/regression.test.ts @@ -76,6 +76,55 @@ describe("react-native subpath imports", () => { expect(mod.View).toBeDefined(); expect(mod.StyleSheet).toBeDefined(); }); + + // The default export of a deep subpath must be the leaf module itself — + // `import Platform from 'react-native/Libraries/Utilities/Platform'` is the + // dominant real-world pattern, and receiving the whole mock made Platform.OS + // silently undefined. + it("ESM subpath default export is the leaf module, not the whole mock", async () => { + const platformMod = await import("react-native/Libraries/Utilities/Platform"); + expect(platformMod.default).toBe(Platform); + expect(platformMod.default.OS).toBe(Platform.OS); + const animatedMod = await import("react-native/Libraries/Animated/Animated"); + const rn = await import("react-native"); + expect(animatedMod.default).toBe(rn.Animated); + }); + + it("ESM subpath with an unknown leaf falls back to the whole mock", async () => { + const mod = await import("react-native/jest-preset"); + expect(mod.default.Platform).toBeDefined(); + expect(mod.default.View).toBeDefined(); + }); + + it("CJS subpath require yields the leaf with interop default", () => { + const mod = require("react-native/Libraries/Utilities/Platform"); + // Direct-property consumers (`require('.../Platform').OS`)… + expect(mod.OS).toBe(Platform.OS); + // …and Babel-interop consumers (`_interopRequireDefault(...).default`). + expect(mod.__esModule).toBe(true); + expect(mod.default.OS).toBe(Platform.OS); + }); + + it("CJS subpath requires are identity-stable across calls", () => { + const a = require("react-native/Libraries/Utilities/Platform"); + const b = require("react-native/Libraries/Utilities/Platform"); + expect(a).toBe(b); + }); + + // Version gates (`require('react-native/package.json').version`) are common in + // ecosystem libraries; the manifest must be the real file, not the mock. + it("require('react-native/package.json') reads the real manifest", () => { + const pkg = require("react-native/package.json"); + expect(pkg.name).toBe("react-native"); + expect(typeof pkg.version).toBe("string"); + expect(pkg.version).toMatch(/^\d+\.\d+\.\d+/); + }); + + it("import of react-native/package.json reads the real manifest", async () => { + const pkg = await import("react-native/package.json"); + expect(pkg.default.name).toBe("react-native"); + expect(typeof pkg.default.version).toBe("string"); + }); }); // --- Issue 4: Plugin options must reach the setup file --- From 4facf914a84dc34864a1c0b6f57751ed35deae38 Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Thu, 2 Jul 2026 22:07:58 +0100 Subject: [PATCH 2/2] test: assert utility-subpath pass-through at the resolution layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The end-to-end assertion required RNGH 3.0's real jest-utils module graph to load under Node — its ESM flavor's extensionless imports fail to resolve on some environments, which is the package's own Node compatibility, not this project's contract (and matches the pre-redirect status quo for these paths). The behavioral guarantee — utility subpaths are never shadowed — is now asserted at the loader and plugin resolution layers, with both packages registered as presets so the pass-through is attributable to the utility-leaf exemption. --- .../tests-native/preset-subpath.test.tsx | 12 ++++--- .../vitest-native/tests/native-unit.test.ts | 35 ++++++++++++++++++- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/vitest-native/tests-native/preset-subpath.test.tsx b/packages/vitest-native/tests-native/preset-subpath.test.tsx index 58064da..69dfa94 100644 --- a/packages/vitest-native/tests-native/preset-subpath.test.tsx +++ b/packages/vitest-native/tests-native/preset-subpath.test.tsx @@ -37,11 +37,13 @@ describe("preset subpath imports under the native engine", () => { expect(viaSubpath.default).toBe(root.Swipeable); }); - it("Node-safe utility subpaths pass through to the real file", () => { - const jestUtils = require("react-native-gesture-handler/jest-utils"); - expect(typeof jestUtils.fireGestureHandler).toBe("function"); - expect(typeof jestUtils.getByGestureTestId).toBe("function"); - }); + // NOTE: utility subpaths (jest-utils, jestSetup, mock, plugin) passing through + // to the real file is asserted at the resolution layer in + // tests/native-unit.test.ts. Asserting that the real third-party file then + // LOADS end-to-end would test the package's own Node compatibility (RNGH + // 3.0's ESM jest-utils graph fails to load on some environments) — that is + // not this project's contract, and pass-through was the pre-redirect status + // quo for these paths. it("CJS deep require yields the leaf with interop default, identity-stable", () => { const a = require("react-native-gesture-handler/Swipeable"); diff --git a/packages/vitest-native/tests/native-unit.test.ts b/packages/vitest-native/tests/native-unit.test.ts index 9fe726d..972974c 100644 --- a/packages/vitest-native/tests/native-unit.test.ts +++ b/packages/vitest-native/tests/native-unit.test.ts @@ -233,6 +233,37 @@ describe("native preset redirect (ESM loader)", () => { ); expect(assetResult).toBe(assetSentinel); }); + + it("passes Node-safe utility subpaths through to the real resolver", async () => { + // Both packages registered as presets — the passthrough below must be + // attributable to the utility-leaf exemption, not a missing registration. + await nativeLoader.initialize({ + projectRoot, + transformPkgs: [], + presetExports: { + "react-native-gesture-handler": ["Swipeable", "State"], + "react-native-reanimated": ["useSharedValue"], + }, + assetExts: ["png", "ttf"], + }); + // jest-utils / jestSetup / mock / plugin are deliberately Node-safe deep + // entries (test utilities, babel plugins) — shadowing them would replace + // working code with undefined exports. + for (const specifier of [ + "react-native-gesture-handler/jest-utils", + "react-native-gesture-handler/jestSetup", + "react-native-reanimated/mock", + "react-native-reanimated/plugin", + ]) { + const sentinel = { url: `file:///real/${specifier.split("/").pop()}.js`, shortCircuit: true }; + const result = await nativeLoader.resolve( + specifier, + { parentURL: undefined }, + () => sentinel, + ); + expect(result).toBe(sentinel); + } + }); }); // @ts-expect-error — runtime .mjs @@ -523,7 +554,7 @@ describe("plugin subpath resolution (mock engine)", () => { expect(code).toContain("export const State = _m['State'];"); }); - it("does not redirect preset package.json or asset subpaths", async () => { + it("does not redirect preset package.json, asset, or utility subpaths", async () => { const plugin = await makePlugin(); expect(plugin.resolveId("react-native-gesture-handler/package.json", undefined)).toBe( undefined, @@ -531,6 +562,8 @@ describe("plugin subpath resolution (mock engine)", () => { expect(plugin.resolveId("react-native-gesture-handler/assets/icon.png", undefined)).toBe( undefined, ); + expect(plugin.resolveId("react-native-gesture-handler/jest-utils", undefined)).toBe(undefined); + expect(plugin.resolveId("react-native-gesture-handler/jestSetup", undefined)).toBe(undefined); }); it("resolves react-native/package.json to the real on-disk manifest", async () => {