diff --git a/packages/vite/hmr/server/deps-bundle.spec.ts b/packages/vite/hmr/server/deps-bundle.spec.ts index 4952205de5..531f3fe36e 100644 --- a/packages/vite/hmr/server/deps-bundle.spec.ts +++ b/packages/vite/hmr/server/deps-bundle.spec.ts @@ -1,9 +1,9 @@ -import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { afterAll, describe, expect, it } from 'vitest'; -import { DEPS_BUNDLE_PATH, buildDepsBundleEntryCode, buildDepsCandidateSpecs, buildDepsShimCode, buildDepsVendorRuntimeModule, collectDepsModuleExportInfo, computeDepsBundleCacheKey, createDepsBundleService, depsRegistryKeyForFile, generateDepsBundle, isDepsPerModuleServingEnabled, resolveDepsEntriesFromRecording, resolveDepsEntriesFromVendorCollection } from './deps-bundle.js'; +import { DEPS_BUNDLE_PATH, buildDepsBundleEntryCode, buildDepsCandidateSpecs, buildDepsShimCode, buildDepsVendorRuntimeModule, collectDepsModuleExportInfo, computeDepsBundleCacheKey, createDepsBundleService, depsRegistryKeyForFile, generateDepsBundle, isDepsPerModuleServingEnabled, resolveDepsEntriesFromRecording, resolveDepsEntriesFromVendorCollection, tryLoadDepsBundleFromDisk } from './deps-bundle.js'; describe('isDepsPerModuleServingEnabled', () => { it('is off by default and on for 1/true', () => { @@ -120,7 +120,7 @@ describe('collectDepsModuleExportInfo', () => { expect(collectDepsModuleExportInfo(abs, 'android', new Set()).names).toEqual(['AndroidCanvas', 'own']); }); - it('bails to null for bare export * (name set lives in another package) WITHOUT the opaqueCjs flag', () => { + it('bails to null for bare export * without a resolver (name set lives in another package) WITHOUT the opaqueCjs flag', () => { // ESM bails must stay per-module: their bundled namespace has real named // exports the server cannot enumerate, so a default-only shim would // break `import { x }` consumers. Only CJS bails get opaqueCjs. @@ -130,6 +130,31 @@ describe('collectDepsModuleExportInfo', () => { expect(info.opaqueCjs).toBeUndefined(); }); + it('follows bare export * through the resolver and merges the target names', () => { + const leaf = write('bare/leaf.esm.js', `const ref = () => {};\nconst h = () => {};\nexport { h, ref };\n`); + const abs = write('bare/root.js', `export * from './local.js';\nexport * from 'leaf-pkg';\nexport { own } from './x';\n`); + write('bare/local.js', `export const init = () => {};\n`); + const resolver = (spec: string, importer: string) => (spec === 'leaf-pkg' && importer === abs ? leaf : null); + const info = collectDepsModuleExportInfo(abs, 'ios', new Set(), resolver); + expect(info.names).toEqual(['h', 'init', 'own', 'ref']); + expect(info.hasDefault).toBe(false); + }); + + it('bails to null when the resolver has no edge for the importer', () => { + const abs = write('bare/no-edge.js', `export * from 'leaf-pkg';\n`); + expect(collectDepsModuleExportInfo(abs, 'ios', new Set(), () => null).names).toBeNull(); + }); + + it('bails to null when the bare export * target is opaque CJS', () => { + // A default-only namespace cannot satisfy `export *`. + const umd = write('bare/umd.js', `module.exports = (function () { return { a: 1 }; })();\n`); + expect(collectDepsModuleExportInfo(umd, 'ios').opaqueCjs).toBe(true); + const abs = write('bare/umd-root.js', `export * from 'umd-pkg';\nexport const own = 1;\n`); + const info = collectDepsModuleExportInfo(abs, 'ios', new Set(), () => umd); + expect(info.names).toBeNull(); + expect(info.opaqueCjs).toBeUndefined(); + }); + it('bails to null for unresolvable relative export * targets', () => { const abs = write('broken-star.js', `export * from './does-not-exist.js';\n`); const info = collectDepsModuleExportInfo(abs, 'ios'); @@ -243,6 +268,16 @@ function createFixtureProject(): string { write('node_modules/pkg-i/index.js', `export const I = 'i';\n`); write('node_modules/pkg-h/package.json', JSON.stringify({ name: 'pkg-h', version: '1.0.0', module: 'index.js' })); write('node_modules/pkg-h/index.js', `import { StoreToken } from 'pkg-g';\nimport { I } from 'pkg-i';\nexport const H = StoreToken + I;\n`); + // Root re-exporting another package bare (nativescript-vue's + // `export * from '@vue/runtime-core'` shape): must shim, not serve per-module. + write('node_modules/pkg-k/package.json', JSON.stringify({ name: 'pkg-k', version: '1.0.0', module: 'index.js' })); + write('node_modules/pkg-k/index.js', `export * from 'pkg-i';\nexport const K = 'k';\n`); + // Conditional exports: the star must follow the `import` edge, never the + // `require` edge a CJS call in the same file resolves. + write('node_modules/pkg-l/package.json', JSON.stringify({ name: 'pkg-l', version: '1.0.0', exports: { '.': { require: './cjs.js', import: './esm.js' } } })); + write('node_modules/pkg-l/esm.js', `export const fromEsm = 'esm';\n`); + write('node_modules/pkg-l/cjs.js', `exports.fromCjs = 'cjs';\n`); + write('node_modules/pkg-m/index.js', `export * from 'pkg-l';\nconst viaRequire = require('pkg-l');\nexport const M = viaRequire.fromCjs;\n`); // Package shipping a stray root `index.ts` SOURCE whose relative imports // don't exist in the published package (solid-navigation shape) — the // declared entry (`main`) must win over the index fan-out. @@ -260,6 +295,7 @@ const recordedPaths = [ '/ns/m/node_modules/pkg-d', // platform-suffixed package root '/ns/m/node_modules/pkg-d/impl', // extensionless platform-suffixed file '/ns/m/node_modules/pkg-e/index.js', // transpiled CJS + '/ns/m/node_modules/pkg-k/index.js', // bare `export *` root '/ns/m/node_modules/@nativescript/core/ui/frame', // owned by /ns/core bridge '/ns/m/node_modules/@nativescript/vite/hmr/client', // never-bundled dev tooling '/ns/m/node_modules/pkg-a/styles.css', // non-script asset @@ -273,7 +309,7 @@ describe('resolveDepsEntriesFromRecording', () => { it('maps recorded node_modules paths to deduped entries and skips core/blocked/assets/app code', () => { const entries = resolveDepsEntriesFromRecording(recordedPaths, projectRoot, null, 'ios'); const keys = entries.map((e) => e.key); - expect(keys).toEqual(['node_modules/pkg-a/index.js', 'node_modules/pkg-b/index.js', 'node_modules/pkg-c/dist/entry.mjs', 'node_modules/pkg-d/index.ios.js', 'node_modules/pkg-d/impl.ios.js', 'node_modules/pkg-e/index.js']); + expect(keys).toEqual(['node_modules/pkg-a/index.js', 'node_modules/pkg-b/index.js', 'node_modules/pkg-c/dist/entry.mjs', 'node_modules/pkg-d/index.ios.js', 'node_modules/pkg-d/impl.ios.js', 'node_modules/pkg-e/index.js', 'node_modules/pkg-k/index.js']); const rootEntry = entries.find((e) => e.spec === '/node_modules/pkg-c'); expect(rootEntry?.absPath).toBe(path.join(projectRoot, 'node_modules/pkg-c/dist/entry.mjs')); }); @@ -318,6 +354,33 @@ describe('generateDepsBundle', () => { expect(second!.hash).toBe(first!.hash); expect(second!.code).toBe(first!.code); expect(second!.keys).toEqual(first!.keys); + expect(second!.bareImportEdges).toEqual(first!.bareImportEdges); + }); + + it('records the bare import edges esbuild resolved, keyed by importer', async () => { + const state = await generateDepsBundle({ projectRoot, platform: 'ios', mode: 'development', flavor: 'typescript', recordedPaths }); + expect(state!.bareImportEdges.get('node_modules/pkg-k/index.js')).toEqual({ 'pkg-i': 'node_modules/pkg-i/index.js' }); + // Relative edges are not recorded; the collector resolves those itself. + expect(state!.bareImportEdges.get('node_modules/pkg-a/index.js')).toBeUndefined(); + }); + + it('records the import-statement edge, not the require() edge, for a conditional-exports package', async () => { + const state = await generateDepsBundle({ projectRoot, platform: 'ios', mode: 'development', flavor: 'typescript', recordedPaths: ['/ns/m/node_modules/pkg-m/index.js'] }); + expect(state!.bareImportEdges.get('node_modules/pkg-m/index.js')).toEqual({ 'pkg-l': 'node_modules/pkg-l/esm.js' }); + }); + + it('rejects a cached bundle whose edge table is malformed', async () => { + await generateDepsBundle({ projectRoot, platform: 'ios', mode: 'development', flavor: 'typescript', recordedPaths }); + const cacheDir = path.join(projectRoot, 'node_modules', '.ns-vite'); + const metaName = readdirSync(cacheDir).find((f) => f.startsWith('deps-bundle-ios-') && f.endsWith('.json'))!; + const metaPath = path.join(cacheDir, metaName); + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); + expect(tryLoadDepsBundleFromDisk(projectRoot, 'ios', meta.key)).not.toBeNull(); + writeFileSync(metaPath, JSON.stringify({ ...meta, bareImportEdges: [] })); + expect(tryLoadDepsBundleFromDisk(projectRoot, 'ios', meta.key)).toBeNull(); + writeFileSync(metaPath, JSON.stringify({ ...meta, bareImportEdges: { importer: { spec: 42 } } })); + expect(tryLoadDepsBundleFromDisk(projectRoot, 'ios', meta.key)).toBeNull(); + writeFileSync(metaPath, JSON.stringify(meta)); }); it('returns null when the recording has no bundleable node_modules entries', async () => { @@ -470,6 +533,27 @@ describe('createDepsBundleService', () => { expect(service.getShimForSpec('/node_modules/pkg-a/helper.js')).toContain('export const helper = '); }); + it('serves a shim for a root whose export * target is another package (nativescript-vue shape)', async () => { + // Names come from the file esbuild actually bundled for that edge, so the + // shim matches the bundle namespace; before, this root was served + // per-module and its module-scope side effects ran a second time. + const service = makeService(); + await service.ensureBuilt(); + const shim = service.getShimForSpec('/node_modules/pkg-k/index.js'); + expect(shim).toContain('export const K = '); + expect(shim).toContain('export const I = '); + expect(service.getShimForSpec('/node_modules/pkg-k')).toContain('export const I = '); + }); + + it('shims a star over a conditional-exports package with the ESM names esbuild bundled', async () => { + const service = makeService(['/ns/m/node_modules/pkg-m/index.js']); + await service.ensureBuilt(); + const shim = service.getShimForSpec('/node_modules/pkg-m/index.js'); + expect(shim).toContain('export const fromEsm = '); + expect(shim).toContain('export const M = '); + expect(shim).not.toContain('fromCjs'); + }); + it('serves shims for platform-suffixed packages and transpiled CJS', async () => { const service = makeService(); await service.ensureBuilt(); diff --git a/packages/vite/hmr/server/deps-bundle.ts b/packages/vite/hmr/server/deps-bundle.ts index dbc95ad622..8f17359904 100644 --- a/packages/vite/hmr/server/deps-bundle.ts +++ b/packages/vite/hmr/server/deps-bundle.ts @@ -401,6 +401,28 @@ function createDepsImportRoutingPlugin(projectRoot: string, workspaceRoot: strin }; } +/** Bare import edges esbuild resolved inside the bundle, keyed by importer. */ +function collectBareImportEdges(inputs: Record, projectRoot: string, bundledKeys: ReadonlySet): Map> { + const edges = new Map>(); + for (const [input, info] of Object.entries(inputs)) { + if (input === '' || input.includes(':')) continue; + const importerKey = depsRegistryKeyForFile(path.resolve(projectRoot, input)); + if (!importerKey || !bundledKeys.has(importerKey)) continue; + for (const imp of info.imports ?? []) { + const spec = imp.original; + // Static ESM edges only: `require()` resolves the `require` condition. + if (!spec || imp.external || imp.kind !== 'import-statement' || spec.startsWith('.') || spec.startsWith('/')) continue; + if (imp.path.includes(':')) continue; + const targetKey = depsRegistryKeyForFile(path.resolve(projectRoot, imp.path)); + if (!targetKey || !bundledKeys.has(targetKey)) continue; + let record = edges.get(importerKey); + if (!record) edges.set(importerKey, (record = {})); + record[spec] = targetKey; + } + } + return edges; +} + /** * Angular partial-declaration linker for the deps bundle. Broader than the * vendor build's `@angular/`-scoped pass: recorded closures include partial- @@ -444,6 +466,13 @@ export interface DepsBundleState { keyToFile: Map; /** Vendor-manifest specifier → registry key (backs __nsVendorRegistry). */ vendorSpecToKey: Map; + /** + * Importer registry key → bare specifier → resolved registry key, from + * esbuild's metafile. Records how the bundle actually resolved each bare + * import edge, so shim export discovery can follow `export * from 'pkg'` + * to the exact file esbuild bundled for that importer. + */ + bareImportEdges: Map>; hash: string; builtAt: number; buildMs: number; @@ -465,7 +494,7 @@ export interface GenerateDepsBundleOptions { // with `NS_DEPS_BUNDLE_NO_DISK_CACHE=1`. // ============================================================================ -const DEPS_BUNDLE_DISK_CACHE_SCHEMA = 4; +const DEPS_BUNDLE_DISK_CACHE_SCHEMA = 5; function isDepsBundleDiskCacheDisabled(env: NodeJS.ProcessEnv = process.env): boolean { const v = env.NS_DEPS_BUNDLE_NO_DISK_CACHE; @@ -505,6 +534,18 @@ function depsBundleCacheFileBase(platform: string, key: string): string { return `deps-bundle-${platform}-${key.slice(0, 12)}`; } +const isStringRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) && Object.values(v).every((x) => typeof x === 'string'); + +function readBareImportEdges(raw: unknown): Map> | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const edges = new Map>(); + for (const [importer, record] of Object.entries(raw as Record)) { + if (!isStringRecord(record)) return null; + edges.set(importer, record); + } + return edges; +} + export function tryLoadDepsBundleFromDisk(projectRoot: string, platform: string, key: string): DepsBundleState | null { try { const dir = depsBundleCacheDir(projectRoot); @@ -515,6 +556,8 @@ export function tryLoadDepsBundleFromDisk(projectRoot: string, platform: string, const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); if (!meta || meta.schema !== DEPS_BUNDLE_DISK_CACHE_SCHEMA || meta.key !== key) return null; if (!Array.isArray(meta.keys) || typeof meta.specToKey !== 'object' || typeof meta.keyToFile !== 'object' || typeof meta.vendorSpecToKey !== 'object') return null; + const bareImportEdges = readBareImportEdges(meta.bareImportEdges); + if (!bareImportEdges) return null; const code = readFileSync(codePath, 'utf-8'); const hash = createHash('sha1').update(code).digest('hex'); if (hash !== meta.hash) return null; @@ -524,6 +567,7 @@ export function tryLoadDepsBundleFromDisk(projectRoot: string, platform: string, specToKey: new Map(Object.entries(meta.specToKey as Record)), keyToFile: new Map(Object.entries(meta.keyToFile as Record)), vendorSpecToKey: new Map(Object.entries(meta.vendorSpecToKey as Record)), + bareImportEdges, hash, builtAt: typeof meta.builtAt === 'number' ? meta.builtAt : Date.now(), buildMs: typeof meta.buildMs === 'number' ? meta.buildMs : 0, @@ -558,6 +602,7 @@ export function saveDepsBundleToDisk(projectRoot: string, platform: string, key: specToKey: Object.fromEntries(state.specToKey), keyToFile: Object.fromEntries(state.keyToFile), vendorSpecToKey: Object.fromEntries(state.vendorSpecToKey), + bareImportEdges: Object.fromEntries(state.bareImportEdges), }), ); } catch (error: any) { @@ -686,7 +731,8 @@ export async function generateDepsBundle(options: GenerateDepsBundleOptions): Pr }); const files: { key: string; absPath: string }[] = entries.map(({ key, absPath }) => ({ key, absPath })); - for (const input of Object.keys(discovery.metafile?.inputs ?? {})) { + const metaInputs = discovery.metafile?.inputs ?? {}; + for (const input of Object.keys(metaInputs)) { if (input === '' || input.includes(':') || !input.includes('node_modules/')) continue; const absPath = path.resolve(projectRoot, input); if (!existsSync(absPath)) continue; @@ -695,6 +741,7 @@ export async function generateDepsBundle(options: GenerateDepsBundleOptions): Pr entryKeySet.add(key); files.push({ key, absPath }); } + const bareImportEdges = collectBareImportEdges(metaInputs, projectRoot, entryKeySet); const buildResult = await esbuild.build({ ...sharedBuildOptions, @@ -721,6 +768,7 @@ export async function generateDepsBundle(options: GenerateDepsBundleOptions): Pr specToKey: new Map(entries.map((e) => [e.spec, e.key])), keyToFile: new Map(files.map((f) => [f.key, f.absPath])), vendorSpecToKey: vendorSeed.vendorSpecToKey, + bareImportEdges, hash, builtAt: Date.now(), buildMs: Date.now() - t0, @@ -799,11 +847,18 @@ function collectCjsExportInfo(code: string): DepsModuleExportInfo { * statically enumerable — with two DIFFERENT consequences downstream: * CJS/UMD files additionally carry `opaqueCjs: true` and still get a * default-only shim (their bundled namespace has nothing but `default`); - * ESM files with a bare `export * from 'pkg'` or an unresolvable relative - * `export *` target keep per-module serving, where the transform pipeline - * resolves the star through the import map. + * ESM files with an unresolvable `export *` target keep per-module serving, + * where the transform pipeline resolves the star through the import map. A + * bare `export * from 'pkg'` is followed only through `resolveBare`, which the + * bundle service backs with esbuild's own resolved import edges — so the shim + * enumerates exactly the file esbuild bundled for that importer, never a + * scanner guess. Without it a root such as nativescript-vue's + * (`export * from '@vue/runtime-core'`) was served per-module and evaluated — + * with its module-scope `init()` — a second time next to the bundle's copy. */ -export function collectDepsModuleExportInfo(absPath: string, platform: string, seen: Set = new Set()): DepsModuleExportInfo { +export type BareReExportResolver = (spec: string, importerAbsPath: string) => string | null; + +export function collectDepsModuleExportInfo(absPath: string, platform: string, seen: Set = new Set(), resolveBare?: BareReExportResolver): DepsModuleExportInfo { if (seen.has(absPath)) return { names: [], hasDefault: false }; seen.add(absPath); let code = ''; @@ -828,10 +883,9 @@ export function collectDepsModuleExportInfo(absPath: string, platform: string, s const starRe = /^[ \t]*export\s+\*\s+from\s+["']([^"']+)["']/gm; while ((match = starRe.exec(code)) !== null) { const spec = match[1]; - if (!spec.startsWith('.')) return { names: null, hasDefault: false }; - const target = resolveLocalReExportTarget(spec, absPath, platform); + const target = spec.startsWith('.') ? resolveLocalReExportTarget(spec, absPath, platform) : resolveBare ? resolveBare(spec, absPath) : null; if (!target) return { names: null, hasDefault: false }; - const child = collectDepsModuleExportInfo(target, platform, seen); + const child = collectDepsModuleExportInfo(target, platform, seen, resolveBare); if (child.names === null) return { names: null, hasDefault: false }; for (const name of child.names) names.add(name); } @@ -966,6 +1020,14 @@ export function createDepsBundleService(options: CreateDepsBundleServiceOptions) return building; }; + // Follow `export * from 'pkg'` along the edge esbuild recorded for this importer. + const resolveBareReExport: BareReExportResolver = (spec, importerAbsPath) => { + if (!state) return null; + const importerKey = depsRegistryKeyForFile(importerAbsPath); + const targetKey = importerKey ? state.bareImportEdges.get(importerKey)?.[spec] : undefined; + return targetKey ? (state.keyToFile.get(targetKey) ?? null) : null; + }; + const keyForSpec = (spec: string): string | null => { if (!state) return null; const direct = state.specToKey.get(spec); @@ -989,7 +1051,7 @@ export function createDepsBundleService(options: CreateDepsBundleServiceOptions) const key = keyForSpec(spec); if (key) { const file = state.keyToFile.get(key); - const info: DepsModuleExportInfo = file ? collectDepsModuleExportInfo(file, String(options.platform)) : { names: null, hasDefault: false }; + const info: DepsModuleExportInfo = file ? collectDepsModuleExportInfo(file, String(options.platform), new Set(), resolveBareReExport) : { names: null, hasDefault: false }; if (info.names !== null) { shim = buildDepsShimCode(key, info.names, info.hasDefault); } else if (info.opaqueCjs) {