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
64 changes: 56 additions & 8 deletions packages/vite/hmr/server/deps-bundle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,13 @@ describe('collectDepsModuleExportInfo', () => {
expect(info.hasDefault).toBe(false);
});

it('reads a minified one-line .mjs as ESM (clsx shape)', () => {
const abs = write('min/clsx.mjs', `function r(e){return e}export function clsx(){return r(arguments[0])}export default clsx;`);
const info = collectDepsModuleExportInfo(abs, 'ios');
expect(info.names).toEqual(['clsx']);
expect(info.hasDefault).toBe(true);
});

it('flags UMD factories with no static export names as opaque CJS', () => {
const abs = write('umd.js', `!function(e,t){"object"==typeof exports?module.exports=t():e.lib=t()}(this,function(){return {}});\n`);
const info = collectDepsModuleExportInfo(abs, 'ios');
Expand Down Expand Up @@ -235,11 +242,12 @@ describe('buildDepsShimCode', () => {
});

it('filters reserved words and non-identifier names but keeps Unicode identifiers', () => {
const code = buildDepsShimCode('node_modules/pkg-a/index.js', ['ok', 'default', 'class', 'not-an-ident', '\u0275\u0275defineInjectable'], false);
const code = buildDepsShimCode('node_modules/pkg-a/index.js', ['ok', 'default', 'class', 'package', 'not-an-ident', '\u0275\u0275defineInjectable'], false);
expect(code).toContain('export const ok = ');
expect(code).toContain('export const \u0275\u0275defineInjectable = ');
expect(code).not.toContain('export const default');
expect(code).not.toContain('export const class');
expect(code).not.toContain('export const package');
expect(code).not.toContain('not-an-ident');
});
});
Expand Down Expand Up @@ -595,18 +603,58 @@ describe('createDepsBundleService', () => {
expect(cjsShim).toContain('export const eTwo = ');
});

it('serves a default-only shim for bundled opaque CJS (no static export names)', async () => {
// pkg-b is `module.exports = { b: 1 }` — statically opaque. Its bundled
// namespace is `{ default: { b: 1 } }`, so the shim re-exports exactly
// that. Falling through to the per-module transform here would evaluate
// the package a SECOND time (the moment-timezone realm split from the
// §4.4 flag-on qualification).
it('serves a shim for bundled opaque CJS (no static export names), with the names it has when loaded', async () => {
// pkg-b is `module.exports = { b: 1 }` — statically opaque. Falling
// through to the per-module transform here would evaluate the package a
// SECOND time (the moment-timezone realm split from the §4.4 flag-on
// qualification).
const service = makeService();
await service.ensureBuilt();
const shim = service.getShimForSpec('/node_modules/pkg-b/index.js');
expect(shim).toContain(`import "${DEPS_BUNDLE_PATH}";`);
expect(shim).toContain('export default __ns_dep_ns__.default;');
expect(shim).not.toContain('export const ');
expect(shim).toContain('export const b = __ns_dep_ns__.b;');
});

it('exports the names of CJS and minified ESM entries a line scan cannot see (react, clsx), without loading files that never reference exports (zone.js)', async () => {
const root = mkdtempSync(path.join(realpathSync(tmpdir()), 'ns-deps-named-'));
try {
const write = (rel: string, contents: string) => {
mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
writeFileSync(path.join(root, rel), contents);
};
write('package.json', JSON.stringify({ name: 'fixture-app', version: '1.0.0' }));
write('node_modules/react-like/package.json', JSON.stringify({ name: 'react-like', version: '1.0.0', main: 'index.js' }));
write('node_modules/react-like/index.js', `if (process.env.NODE_ENV === 'production') {\n module.exports = require('./cjs/react.production.js');\n} else {\n module.exports = require('./cjs/react.development.js');\n}\n`);
write('node_modules/react-like/cjs/react.production.js', `exports.Fragment = Symbol.for('react.fragment');\nexports.createElement = () => null;\n`);
write('node_modules/react-like/cjs/react.development.js', `exports.Fragment = Symbol.for('react.fragment');\nexports.createElement = () => null;\n`);
write('node_modules/clsx-like/package.json', JSON.stringify({ name: 'clsx-like', version: '1.0.0', exports: { '.': { import: './dist/clsx.mjs', default: './dist/clsx.js' } } }));
write('node_modules/clsx-like/dist/clsx.mjs', `function r(e){return e}export function clsx(){return r(arguments[0])}export default clsx;`);
write('node_modules/clsx-like/dist/clsx.js', `function clsx(){}module.exports = clsx;module.exports.clsx = clsx;`);
write('node_modules/zone-like/package.json', JSON.stringify({ name: 'zone-like', version: '1.0.0', main: 'zone.js' }));
write('node_modules/zone-like/zone.js', `globalThis.__nsZoneLikeLoaded = true;\n`);
const service = createDepsBundleService({
projectRoot: root,
platform: 'ios',
mode: 'development',
flavor: 'typescript',
getRecordedPaths: () => ['/ns/m/node_modules/react-like/index.js', '/ns/m/node_modules/clsx-like/dist/clsx.mjs', '/ns/m/node_modules/zone-like/zone.js'],
});
await service.ensureBuilt();
const reactShim = service.getShimForSpec('/node_modules/react-like/index.js');
expect(reactShim).toContain('export const Fragment = __ns_dep_ns__.Fragment;');
expect(reactShim).toContain('export const createElement = __ns_dep_ns__.createElement;');
expect(reactShim).toContain('export default __ns_dep_ns__.default;');
const clsxShim = service.getShimForSpec('/node_modules/clsx-like/dist/clsx.mjs');
expect(clsxShim).toContain('export const clsx = __ns_dep_ns__.clsx;');
expect(clsxShim).toContain('export default __ns_dep_ns__.default;');
const zoneShim = service.getShimForSpec('/node_modules/zone-like/zone.js');
expect(zoneShim).toContain('export default __ns_dep_ns__.default;');
expect(zoneShim).not.toContain('export const ');
expect((globalThis as any).__nsZoneLikeLoaded).toBeUndefined();
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('falls through to per-module serving for unbundled and non-node_modules specs', async () => {
Expand Down
47 changes: 25 additions & 22 deletions packages/vite/hmr/server/deps-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { getAngularLinkerFactory, runAngularLinker } from '../frameworks/angular
import { getBlockedDeviceNodeModulesReason, isFlavorClientPackageSpecifier, resolveCandidateFilePath } from './websocket-module-specifiers.js';
import { extractDirectExportedNames, JS_IDENTIFIER_RE, parseExportSpecList } from './websocket-core-bridge.js';
import { hasExistingDefaultExport } from './ns-core-cjs-shape.js';
import { getCjsNamedExports } from '../helpers/cjs-named-exports.js';
import { loadPersistedBootRecording } from './boot-recording.js';
import { setDeviceModuleHeaders } from './route-helpers.js';

Expand Down Expand Up @@ -842,14 +843,9 @@ export interface DepsModuleExportInfo {
/**
* True for CJS files with `names: null` — UMD factories and
* `module.exports = require('./x')` re-export chains (moment-timezone's
* index.js). esbuild's CJS→ESM interop gives their bundled namespace
* exactly ONE property, `default`, so a default-only shim IS the complete
* export surface — and serving it preserves single-evaluation identity,
* which is load-bearing for patch-style packages that mutate another
* package at evaluation time (`moment.tz.load(...)`). ESM files with
* `names: null` (bare `export * from 'pkg'`) must NOT get this flag:
* their bundled namespace has real named exports the server cannot
* enumerate, so they keep per-module serving.
* index.js). ESM files with `names: null` (bare `export * from 'pkg'`)
* must NOT get this flag: their bundled namespace has real named exports
* the server cannot enumerate, so they keep per-module serving.
*/
opaqueCjs?: boolean;
}
Expand Down Expand Up @@ -888,8 +884,7 @@ function resolveBareReExportTarget(spec: string, importerPath: string, platform:
// `Object.defineProperty(exports, "NAME", ...)` assignments (transpiled-to-CJS
// packages like downsample). UMD factories (`module.exports = fn()`) expose no
// static names — those are flagged `opaqueCjs` so the shim path can still
// serve their (default-only) namespace instead of re-evaluating them
// per-module.
// serve them from the bundle instead of re-evaluating them per-module.
const CJS_EXPORT_ASSIGN_RE = /\bexports\.([$_\p{ID_Start}][$\p{ID_Continue}]*)\s*=/gu;
const CJS_DEFINE_PROPERTY_RE = /\bObject\.defineProperty\(\s*exports\s*,\s*["']([$_\p{ID_Start}][$\p{ID_Continue}]*)["']/gu;

Expand All @@ -904,6 +899,17 @@ function collectCjsExportInfo(code: string): DepsModuleExportInfo {
return { names: Array.from(names).sort(), hasDefault };
}

// A file that never references `exports` has no CommonJS export surface, and
// loading it only runs its side effects in the dev server process (zone.js
// replaces the server's global Promise and timers).
function loadedCjsExportNames(absPath: string): string[] {
try {
return /\bexports\b/.test(readFileSync(absPath, 'utf8')) ? getCjsNamedExports(absPath) : [];
} catch {
return [];
}
}

/**
* Static export-name discovery for a node_modules file. ESM: direct exports
* plus recursion through `export * from` chains — relative targets (the
Expand All @@ -913,8 +919,8 @@ function collectCjsExportInfo(code: string): DepsModuleExportInfo {
* interop exposes those names on the bundled namespace at runtime). Returns
* `names: null` for files whose export surface is not 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 an `export *` target
* `opaqueCjs: true` and still get a shim, with names enumerated by loading
* the file on the dev server; ESM files with an `export *` target
* that resolves nowhere on disk keep per-module serving, where the transform
* pipeline resolves the star through the import map. A BUNDLED file served
* per-module evaluates a second time, so any resolvable star must be followed.
Expand All @@ -928,7 +934,7 @@ export function collectDepsModuleExportInfo(absPath: string, platform: string, s
} catch {
return { names: null, hasDefault: false };
}
if (!ESM_SYNTAX_RE.test(code)) return collectCjsExportInfo(code);
if (!/\.mjs$/i.test(absPath) && !ESM_SYNTAX_RE.test(code)) return collectCjsExportInfo(code);
const names = new Set<string>(extractDirectExportedNames(code));
let match: RegExpExecArray | null;
const starAsRe = /\bexport\s+\*\s+as\s+([$_\p{ID_Start}][$\p{ID_Continue}]*)\s+from\s+["'][^"']+["']/gu;
Expand All @@ -954,7 +960,7 @@ export function collectDepsModuleExportInfo(absPath: string, platform: string, s
return { names: Array.from(names).sort(), hasDefault: hasExistingDefaultExport(code) };
}

const RESERVED_EXPORT_NAMES = new Set(['default', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'let', 'static', 'yield', 'await', 'eval', 'arguments', '__ns_dep_ns__']);
const RESERVED_EXPORT_NAMES = new Set(['default', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'let', 'static', 'yield', 'await', 'eval', 'arguments', 'implements', 'interface', 'package', 'private', 'protected', 'public', '__ns_dep_ns__']);

/**
* Shim served at `/ns/m/node_modules/...` for a bundled module. The static
Expand Down Expand Up @@ -1107,14 +1113,11 @@ export function createDepsBundleService(options: CreateDepsBundleServiceOptions)
const info: DepsModuleExportInfo = file ? collectDepsModuleExportInfo(file, String(options.platform)) : { names: null, hasDefault: false };
if (info.names !== null) {
shim = buildDepsShimCode(key, info.names, info.hasDefault);
} else if (info.opaqueCjs) {
// Statically opaque CJS/UMD (e.g. moment-timezone): the bundled
// namespace is `{ default: module.exports }` and nothing else, so
// a default-only shim is the COMPLETE surface. Serving the shim
// (not the per-module transform) keeps the single bundle
// evaluation — patch-style packages that mutate another package
// at eval time break if evaluated a second time.
shim = buildDepsShimCode(key, [], true);
} else if (info.opaqueCjs && file) {
// Serving the shim (not the per-module transform) keeps the single
// bundle evaluation — patch-style packages that mutate another
// package at eval time break if evaluated a second time.
shim = buildDepsShimCode(key, loadedCjsExportNames(file), true);
}
}
shimCache.set(spec, shim);
Expand Down
Loading