From 27edbe341bee5d85b5454a542caa7f81cc053873 Mon Sep 17 00:00:00 2001 From: Geoffrey Sechter Date: Sat, 28 Mar 2026 11:15:07 -0700 Subject: [PATCH] fix: resolve pnpm content-addressable store packages in vxrn patch applyDependencyPatches now globs .pnpm/@*/node_modules to discover packages that aren't hoisted to node_modules/ or .pnpm/node_modules/. Adds real-path deduplication to prevent double-patching when symlinks and store entries resolve to the same files. Includes tests for store discovery, dedup, version constraints, and flat node_modules regression. --- packages/vxrn/src/utils/patches.test.ts | 284 ++++++++++++++++++++++++ packages/vxrn/src/utils/patches.ts | 57 ++++- 2 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 packages/vxrn/src/utils/patches.test.ts diff --git a/packages/vxrn/src/utils/patches.test.ts b/packages/vxrn/src/utils/patches.test.ts new file mode 100644 index 0000000000..3326d7ea10 --- /dev/null +++ b/packages/vxrn/src/utils/patches.test.ts @@ -0,0 +1,284 @@ +import FSExtra from 'fs-extra' +import { join } from 'node:path' +import { mkdtemp, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// mock heavy deps that aren't needed for patch resolution tests +vi.mock('@vxrn/compiler', () => ({ transformSWC: vi.fn() })) +vi.mock('@vxrn/vite-flow', () => ({ transformFlowBabel: vi.fn() })) + +import { + type DepPatch, + applyDependencyPatches, + moduleToPnpmStorePattern, +} from './patches' + +// helper to create a minimal package.json +function makePkg(name: string, version: string, extras?: Record) { + return JSON.stringify({ name, version, ...extras }, null, 2) +} + +describe('moduleToPnpmStorePattern', () => { + it('converts scoped package names', () => { + expect(moduleToPnpmStorePattern('@react-navigation/core')).toBe( + '@react-navigation+core' + ) + }) + + it('converts other scoped packages', () => { + expect(moduleToPnpmStorePattern('@expo/cli')).toBe('@expo+cli') + }) + + it('passes through unscoped packages unchanged', () => { + expect(moduleToPnpmStorePattern('react-native-web')).toBe('react-native-web') + }) +}) + +describe('applyDependencyPatches', () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'vxrn-patch-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + // helper to set up node_modules structure and run patches + async function setupAndPatch(opts: { + patches: DepPatch[] + setupFs: (nmDir: string) => Promise + }) { + const nmDir = join(tmpDir, 'node_modules') + await FSExtra.ensureDir(nmDir) + await opts.setupFs(nmDir) + await applyDependencyPatches(opts.patches, { root: tmpDir }) + return nmDir + } + + it('discovers packages in pnpm content-addressable store (non-hoisted)', async () => { + const patches: DepPatch[] = [ + { + module: '@react-navigation/core', + patchFiles: { + 'package.json': (contents) => { + if (!contents) return + const pkg = JSON.parse(contents) + pkg.exports = pkg.exports || {} + pkg.exports['./lib/module/NavigationBuilderContext'] = + './lib/module/NavigationBuilderContext.js' + return JSON.stringify(pkg, null, 2) + }, + }, + }, + ] + + const nmDir = await setupAndPatch({ + patches, + setupFs: async (nm) => { + // create pnpm store entry (package NOT hoisted to top-level or .pnpm/node_modules) + const storePkgDir = join( + nm, + '.pnpm', + '@react-navigation+core@7.16.1_react@19.0.0', + 'node_modules', + '@react-navigation', + 'core' + ) + await FSExtra.ensureDir(storePkgDir) + await FSExtra.writeFile( + join(storePkgDir, 'package.json'), + makePkg('@react-navigation/core', '7.16.1') + ) + }, + }) + + // verify the patch was applied in the store + const patchedPkg = await FSExtra.readJSON( + join( + nmDir, + '.pnpm', + '@react-navigation+core@7.16.1_react@19.0.0', + 'node_modules', + '@react-navigation', + 'core', + 'package.json' + ) + ) + expect(patchedPkg.exports['./lib/module/NavigationBuilderContext']).toBe( + './lib/module/NavigationBuilderContext.js' + ) + }) + + it('deduplicates symlink and store entry pointing to same files', async () => { + let patchCallCount = 0 + + const patches: DepPatch[] = [ + { + module: 'test-pkg', + patchFiles: { + 'index.js': (contents) => { + patchCallCount++ + return (contents || '') + '\n// patched' + }, + }, + }, + ] + + await setupAndPatch({ + patches, + setupFs: async (nm) => { + // create the real package in pnpm store + const storeDir = join( + nm, + '.pnpm', + 'test-pkg@1.0.0', + 'node_modules', + 'test-pkg' + ) + await FSExtra.ensureDir(storeDir) + await FSExtra.writeFile(join(storeDir, 'package.json'), makePkg('test-pkg', '1.0.0')) + await FSExtra.writeFile(join(storeDir, 'index.js'), 'module.exports = {}') + + // create symlink at top-level node_modules (like pnpm does when hoisted) + const symlinkTarget = join(nm, 'test-pkg') + await symlink(storeDir, symlinkTarget, 'dir') + + // also create .pnpm/node_modules hoisted symlink + const pnpmHoisted = join(nm, '.pnpm', 'node_modules', 'test-pkg') + await FSExtra.ensureDir(join(nm, '.pnpm', 'node_modules')) + await symlink(storeDir, pnpmHoisted, 'dir') + }, + }) + + // the non-idempotent patch should have been called exactly once due to dedup + expect(patchCallCount).toBe(1) + }) + + it('patches flat node_modules (non-pnpm regression)', async () => { + const patches: DepPatch[] = [ + { + module: 'test-pkg', + patchFiles: { + 'index.js': (contents) => { + return (contents || '') + '\n// patched' + }, + }, + }, + ] + + const nmDir = await setupAndPatch({ + patches, + setupFs: async (nm) => { + // standard flat node_modules layout, no .pnpm + const pkgDir = join(nm, 'test-pkg') + await FSExtra.ensureDir(pkgDir) + await FSExtra.writeFile(join(pkgDir, 'package.json'), makePkg('test-pkg', '1.0.0')) + await FSExtra.writeFile(join(pkgDir, 'index.js'), 'module.exports = {}') + }, + }) + + const content = await FSExtra.readFile(join(nmDir, 'test-pkg', 'index.js'), 'utf-8') + expect(content).toContain('// patched') + }) + + it('respects version constraints across multiple store versions', async () => { + const patches: DepPatch[] = [ + { + module: 'test-pkg', + patchFiles: { + version: '1.*', + 'index.js': () => '// v1 patched', + }, + }, + ] + + await setupAndPatch({ + patches, + setupFs: async (nm) => { + // v1 store entry + const v1Dir = join(nm, '.pnpm', 'test-pkg@1.0.0', 'node_modules', 'test-pkg') + await FSExtra.ensureDir(v1Dir) + await FSExtra.writeFile(join(v1Dir, 'package.json'), makePkg('test-pkg', '1.0.0')) + await FSExtra.writeFile(join(v1Dir, 'index.js'), 'module.exports = "v1"') + + // v2 store entry + const v2Dir = join(nm, '.pnpm', 'test-pkg@2.0.0', 'node_modules', 'test-pkg') + await FSExtra.ensureDir(v2Dir) + await FSExtra.writeFile(join(v2Dir, 'package.json'), makePkg('test-pkg', '2.0.0')) + await FSExtra.writeFile(join(v2Dir, 'index.js'), 'module.exports = "v2"') + }, + }) + + const nmDir = join(tmpDir, 'node_modules') + + // v1 should be patched + const v1Content = await FSExtra.readFile( + join(nmDir, '.pnpm', 'test-pkg@1.0.0', 'node_modules', 'test-pkg', 'index.js'), + 'utf-8' + ) + expect(v1Content).toBe('// v1 patched') + + // v2 should NOT be patched + const v2Content = await FSExtra.readFile( + join(nmDir, '.pnpm', 'test-pkg@2.0.0', 'node_modules', 'test-pkg', 'index.js'), + 'utf-8' + ) + expect(v2Content).toBe('module.exports = "v2"') + }) + + it('deduplicates when hoisted symlink and store entry coexist', async () => { + let patchCallCount = 0 + + const patches: DepPatch[] = [ + { + module: 'test-pkg', + patchFiles: { + 'index.js': (contents) => { + patchCallCount++ + return (contents || '') + '\n// marker' + }, + }, + }, + ] + + const nmDir = await setupAndPatch({ + patches, + setupFs: async (nm) => { + // real package in store + const storeDir = join( + nm, + '.pnpm', + 'test-pkg@1.0.0', + 'node_modules', + 'test-pkg' + ) + await FSExtra.ensureDir(storeDir) + await FSExtra.writeFile(join(storeDir, 'package.json'), makePkg('test-pkg', '1.0.0')) + await FSExtra.writeFile(join(storeDir, 'index.js'), 'module.exports = {}') + + // hoisted symlink in .pnpm/node_modules + await FSExtra.ensureDir(join(nm, '.pnpm', 'node_modules')) + await symlink(storeDir, join(nm, '.pnpm', 'node_modules', 'test-pkg'), 'dir') + }, + }) + + expect(patchCallCount).toBe(1) + + // verify the file was actually patched + const content = await FSExtra.readFile( + join( + nmDir, + '.pnpm', + 'test-pkg@1.0.0', + 'node_modules', + 'test-pkg', + 'index.js' + ), + 'utf-8' + ) + expect(content).toContain('// marker') + }) +}) diff --git a/packages/vxrn/src/utils/patches.ts b/packages/vxrn/src/utils/patches.ts index ced25b084d..a1726fd57f 100644 --- a/packages/vxrn/src/utils/patches.ts +++ b/packages/vxrn/src/utils/patches.ts @@ -1,7 +1,9 @@ import { transformSWC } from '@vxrn/compiler' import { transformFlowBabel } from '@vxrn/vite-flow' +import Glob from 'fast-glob' import findNodeModules from 'find-node-modules' import FSExtra from 'fs-extra' +import { realpathSync } from 'node:fs' import { join } from 'node:path' import { rename } from 'node:fs/promises' import semver from 'semver' @@ -93,22 +95,58 @@ async function writePatchStats(nodeModulesDir: string, stats: PatchStats) { type PatchResult = 'applied' | 'ok' | 'skipped' +/** + * Convert a module name to a pnpm content-addressable store glob pattern. + * Scoped packages: @scope/name -> @scope+name + * Unscoped packages pass through unchanged. + */ +export function moduleToPnpmStorePattern(moduleName: string): string { + return moduleName.replace('/', '+') +} + export async function applyDependencyPatches( patches: DepPatch[], { root = process.cwd() }: { root?: string } = {} ) { + // collect unique module names for pnpm store globbing + const patchModuleNames = [...new Set(patches.map((p) => p.module))] + const nodeModulesDirs = findNodeModules({ cwd: root }).flatMap((relativePath) => { const dir = join(root, relativePath) + const dirs = [dir] + + const pnpmBase = join(dir, '.pnpm') + if (!FSExtra.existsSync(pnpmBase)) return dirs + // pnpm hoists transitive deps into .pnpm/node_modules/ (symlinks to store) - // so we need to patch there too - const pnpmDir = join(dir, '.pnpm', 'node_modules') - return FSExtra.existsSync(pnpmDir) ? [dir, pnpmDir] : [dir] + const pnpmHoistedDir = join(pnpmBase, 'node_modules') + if (FSExtra.existsSync(pnpmHoistedDir)) { + dirs.push(pnpmHoistedDir) + } + + // pnpm content-addressable store: scan for store entries matching our patch modules + // handles cases where packages aren't hoisted (strict mode, peer dep conflicts, hoist=false) + for (const moduleName of patchModuleNames) { + const storePattern = moduleToPnpmStorePattern(moduleName) + const storeMatches = Glob.sync(`${storePattern}@*/node_modules`, { + cwd: pnpmBase, + onlyDirectories: true, + }) + for (const match of storeMatches) { + dirs.push(join(pnpmBase, match)) + } + } + + return dirs }) // track results per module const results = new Map() // track modules that already warned about transform failures (to avoid spam) const transformWarnedModules = new Set() + // track real paths per (patch index, real dir) to prevent double-patching + // when symlinks and store entries resolve to the same physical files + const patchedRealPaths = new Set() await Promise.all( nodeModulesDirs.map(async (nodeModulesDir) => { @@ -123,6 +161,19 @@ export async function applyDependencyPatches( if (!FSExtra.existsSync(nodeModuleDir)) return + // deduplicate by real path to avoid patching the same files twice + // (multiple symlinks/paths can resolve to the same pnpm store entry) + let realModuleDir: string + try { + realModuleDir = realpathSync(nodeModuleDir) + } catch { + return + } + const patchIdx = patches.indexOf(patch) + const dedupKey = `${patchIdx}\0${realModuleDir}` + if (patchedRealPaths.has(dedupKey)) return + patchedRealPaths.add(dedupKey) + const version = patch.patchFiles.version if (typeof version === 'string') { const pkgJSON = await FSExtra.readJSON(join(nodeModuleDir, 'package.json'))