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
26 changes: 23 additions & 3 deletions packages/vite/hmr/server/deps-bundle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,14 @@ function createFixtureProject(): string {
write('node_modules/pkg-a/index.js', `import { helper } from './helper.js';\nexport const A = 'a' + helper;\nexport * from './extra.js';\nexport const load = () => import('./lazy.js');\n`);
write('node_modules/pkg-a/helper.js', `export const helper = 'h';\n`);
write('node_modules/pkg-a/extra.js', `export const EXTRA = 1;\n`);
write('node_modules/pkg-a/lazy.js', `export const LAZY = 1;\n`);
write('node_modules/pkg-a/lazy.js', `import { LAZY_DEP } from './lazy-dep.js';\nexport const LAZY = LAZY_DEP;\nexport const loadMore = () => import('./lazier.js');\n`);
write('node_modules/pkg-a/lazy-dep.js', `export const LAZY_DEP = 1;\n`);
write('node_modules/pkg-a/lazier.js', `export const LAZIER = 2;\n`);
// A dynamic import whose closure cannot resolve, next to one that can.
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 const K = 'k';\nexport const broken = () => import('./broken.js');\nexport const fine = () => import('./fine.js');\n`);
write('node_modules/pkg-k/broken.js', `export * from './not-shipped.js';\n`);
write('node_modules/pkg-k/fine.js', `export const FINE = 1;\n`);
// CJS package: bundleable, but not shim-servable (no static ESM exports).
write('node_modules/pkg-b/package.json', JSON.stringify({ name: 'pkg-b', version: '1.0.0', main: 'index.js' }));
write('node_modules/pkg-b/index.js', `module.exports = { b: 1 };\n`);
Expand Down Expand Up @@ -370,9 +377,13 @@ describe('generateDepsBundle', () => {
expect(state!.keys).toContain('node_modules/pkg-a/extra.js');
expect(state!.keys).toContain('node_modules/pkg-b/index.js');
expect(state!.keys).toContain('node_modules/pkg-c/dist/entry.mjs');
// Dynamic import target stays external at its canonical /ns/m URL.
expect(state!.keys).not.toContain('node_modules/pkg-a/lazy.js');
// Dynamic import targets stay external at their canonical /ns/m URL, but
// they and their closures (including chained import()s) are registered.
expect(state!.code).toContain('"/ns/m/node_modules/pkg-a/lazy"');
expect(state!.code).toContain('"/ns/m/node_modules/pkg-a/lazier"');
expect(state!.keys).toContain('node_modules/pkg-a/lazy.js');
expect(state!.keys).toContain('node_modules/pkg-a/lazy-dep.js');
expect(state!.keys).toContain('node_modules/pkg-a/lazier.js');
expect(state!.code).toContain('__NS_DEPS_MODULES__');
expect(state!.specToKey.get('/node_modules/pkg-c')).toBe('node_modules/pkg-c/dist/entry.mjs');
});
Expand Down Expand Up @@ -419,6 +430,15 @@ describe('generateDepsBundle', () => {
}
});

it('leaves a dynamic import whose closure fails to build per-module', async () => {
const state = await generateDepsBundle({ projectRoot, platform: 'ios', mode: 'development', flavor: 'typescript', recordedPaths: ['/ns/m/node_modules/pkg-k/index.js'] });
expect(state).not.toBeNull();
expect(state!.keys).toContain('node_modules/pkg-k/index.js');
expect(state!.keys).toContain('node_modules/pkg-k/fine.js');
expect(state!.keys).not.toContain('node_modules/pkg-k/broken.js');
expect(state!.code).toContain('"/ns/m/node_modules/pkg-k/broken"');
});

it('returns null when the recording has no bundleable node_modules entries', async () => {
const state = await generateDepsBundle({ projectRoot, platform: 'ios', mode: 'development', flavor: 'typescript', recordedPaths: ['/ns/m/src/app'] });
expect(state).toBeNull();
Expand Down
80 changes: 56 additions & 24 deletions packages/vite/hmr/server/deps-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
* the live core realm.
* - Dynamic `import()` targets inside dep code are externalized back to
* their canonical `/ns/m/node_modules/...` URL, preserving lazy evaluation
* and per-URL identity for modules the boot never touched.
* and per-URL identity. Their closures are still bundled (best effort),
* so that URL serves a shim instead of a per-module fetch chain.
* - `@nativescript/vite` client modules stay per-module (dev tooling loads
* independently of the app dependency closure).
* - Everything else resolvable is internalized — platform-suffix resolution
Expand Down Expand Up @@ -450,7 +451,7 @@ const NODE_BUILTIN_SET = new Set(NODE_EXTERNALS);
* finishes and throws). Pass 2 registers every internalized file, keeping
* per-URL identity for all of them.
*/
function createDepsImportRoutingPlugin(projectRoot: string, workspaceRoot: string | null, platform: string, flavor: string | undefined): esbuild.Plugin {
function createDepsImportRoutingPlugin(projectRoot: string, workspaceRoot: string | null, platform: string, flavor: string | undefined, onDynamicImport?: (absPath: string) => void): esbuild.Plugin {
return {
name: 'ns-deps-import-routing',
setup(build) {
Expand All @@ -471,6 +472,7 @@ function createDepsImportRoutingPlugin(projectRoot: string, workspaceRoot: strin
if (probe.errors.length > 0 || !probe.path || probe.external) return probe;
const key = depsRegistryKeyForFile(probe.path);
if (!key) return probe;
if (!NEVER_BUNDLED_KEY_RE.test(key)) onDynamicImport?.(probe.path);
return { path: `/ns/m/${key.replace(SCRIPT_EXT_RE, '')}`, external: true };
}
if (spec.startsWith('.') || spec.startsWith('/') || /^[A-Za-z]:[\\/]/.test(spec)) return undefined;
Expand Down Expand Up @@ -749,13 +751,13 @@ export async function generateDepsBundle(options: GenerateDepsBundleOptions): Pr
// createNodeBuiltinPolyfillEsbuildPlugin bundles installed npm polyfills
// (buffer, events, ...) that would otherwise leak as bare builtin externals
// the device cannot resolve.
const buildPlugins = (): esbuild.Plugin[] => [
const buildPlugins = (onDynamicImport?: (absPath: string) => void): esbuild.Plugin[] => [
builtinModulesEsbuildPlugin(),
createWebpackLoaderStubEsbuildPlugin(),
createNodeBuiltinPolyfillEsbuildPlugin(projectRoot, NODE_BUILTINS),
createNativeClassEsbuildPlugin(platform as Platform),
createVendorEsbuildPlugin(projectRoot),
createDepsImportRoutingPlugin(projectRoot, workspaceRoot, String(platform), flavor),
createDepsImportRoutingPlugin(projectRoot, workspaceRoot, String(platform), flavor, onDynamicImport),
...(flavor === 'angular' ? [createDepsAngularLinkerPlugin(projectRoot)] : []),
...(flavor === 'solid' ? [createSolidJsxEsbuildPlugin(projectRoot)] : []),
createUnicodeRegexEsbuildPlugin(projectRoot),
Expand Down Expand Up @@ -787,28 +789,58 @@ export async function generateDepsBundle(options: GenerateDepsBundleOptions): Pr
// registers EVERY input, so any /ns/m URL that later resolves to a bundled
// file serves a shim into the single bundle realm.
const entryKeySet = new Set(entries.map((e) => e.key));
const discovery = await esbuild.build({
...sharedBuildOptions,
stdin: {
contents: entries.map((e) => `import ${JSON.stringify(e.absPath)};`).join('\n'),
resolveDir: projectRoot,
sourcefile: 'ns-deps-bundle-discovery.ts',
loader: 'ts',
},
metafile: true,
plugins: buildPlugins(),
});
const dynamicTargets = new Set<string>();
const discover = (absPaths: string[], metafile: boolean) =>
esbuild.build({
...sharedBuildOptions,
stdin: {
contents: absPaths.map((p) => `import ${JSON.stringify(p)};`).join('\n'),
resolveDir: projectRoot,
sourcefile: 'ns-deps-bundle-discovery.ts',
loader: 'ts',
},
metafile,
plugins: buildPlugins((absPath) => dynamicTargets.add(absPath)),
});

const files: DepsBundleEntryFile[] = entries.map(({ key, absPath }) => ({ key, absPath }));
for (const [input, meta] of Object.entries(discovery.metafile?.inputs ?? {})) {
if (input === '<stdin>' || input.includes(':') || !input.includes('node_modules/')) continue;
const absPath = path.resolve(projectRoot, input);
if (!existsSync(absPath)) continue;
const key = depsRegistryKeyForFile(absPath);
if (!key || entryKeySet.has(key) || NEVER_BUNDLED_KEY_RE.test(key)) continue;
entryKeySet.add(key);
// Evaluated on first use, as the dependency graph would, not up front.
files.push({ key, absPath, lazy: true, cjs: meta.format !== 'esm' });
const register = (metafile: esbuild.Metafile | undefined) => {
for (const [input, meta] of Object.entries(metafile?.inputs ?? {})) {
if (input === '<stdin>' || input.includes(':') || !input.includes('node_modules/')) continue;
const absPath = path.resolve(projectRoot, input);
if (!existsSync(absPath)) continue;
const key = depsRegistryKeyForFile(absPath);
if (!key || entryKeySet.has(key) || NEVER_BUNDLED_KEY_RE.test(key)) continue;
entryKeySet.add(key);
// Evaluated on first use, as the dependency graph would, not up front.
files.push({ key, absPath, lazy: true, cjs: meta.format !== 'esm' });
}
};
const entryPaths = entries.map((e) => e.absPath);
register((await discover(entryPaths, true)).metafile);

// Dynamic import() targets and their closures are registered too, so a
// module loaded on demand after boot comes from the bundle rather than one
// request per file. A target whose closure fails to build stays per-module.
// Probe builds run without `metafile`: esbuild 0.27 throws outside the
// build promise when a build with `metafile` set fails.
const discoverOptional = async (targets: string[]) => {
try {
await discover(targets, false);
} catch {
return false;
}
register((await discover(targets, true)).metafile);
return true;
};
const tried = new Set<string>();
for (;;) {
const pending = [...dynamicTargets].filter((p) => !tried.has(p) && !entryKeySet.has(depsRegistryKeyForFile(p)!));
if (!pending.length) break;
pending.forEach((p) => tried.add(p));
if (!(await discoverOptional(pending)) && pending.length > 1) {
for (const target of pending) await discoverOptional([target]);
}
}

const buildResult = await esbuild.build({
Expand Down
Loading