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
82 changes: 80 additions & 2 deletions packages/vite/hmr/server/websocket-ns-m.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { registerNsModuleServerRoute, type RegisterNsModuleServerRouteOptions } from './websocket-ns-m.js';
import { interceptNgComponentRequest } from '../frameworks/angular/server/ng-component-route.js';
Expand Down Expand Up @@ -67,7 +67,7 @@ function mount(overrides: Partial<RegisterNsModuleServerRouteOptions> = {}) {
};
registerNsModuleServerRoute(server, options);
expect(handlers).toHaveLength(1);
return { handler: handlers[0], ensureInitialGraphPopulationStarted, upsertGraphModule };
return { handler: handlers[0], server, ensureInitialGraphPopulationStarted, upsertGraphModule };
}

describe('registerNsModuleServerRoute', () => {
Expand Down Expand Up @@ -204,3 +204,81 @@ describe('registerNsModuleServerRoute', () => {
});
});
});

describe('native default-import link checking', () => {
beforeEach(() => vi.stubEnv('NODE_ENV', 'development'));
afterEach(() => vi.unstubAllEnvs());

const importer = "import value from 'http://localhost:5173/ns/m/src/target.mjs'; export { value };";

function mountLinkCheck(source: string) {
let targetCode = source;
let graphVersion = 0;
const fixture = mount({
sharedTransformRequest: async () => ({ code: importer, map: null }),
getGraphVersion: () => graphVersion,
});
fixture.server.transformRequest.mockImplementation(async () => ({ code: targetCode }));
return {
async rejects() {
fixture.server.transformRequest.mockClear();
const res = makeRes();
await fixture.handler({ url: '/ns/m/src/importer.mjs' }, res, vi.fn());
expect(res.statusCode).toBe(200);
expect(fixture.server.transformRequest).toHaveBeenCalled();
expect(res.body).toContain('export');
return res.body!.includes('[link-check] Missing default export');
},
edit(source: string) {
targetCode = source;
graphVersion++;
},
};
}

it.each([
['direct', 'export default 17;', false],
['function', 'export default function named() {}', false],
['class', 'export default class Named {}', false],
['alias', 'const named = 23; export { named as default };', false],
['multiple specifiers', 'const named = 23, other = 1; export { other, named as default };', false],
['quoted name', 'const named = 23; export { named as "default" };', false],
['default re-export', 'export { default } from "./base.mjs";', false],
['aliased re-export', 'export { other as default } from "./base.mjs";', false],
['namespace re-export', 'export * as default from "./base.mjs";', false],
['named only', 'export const named = 99;', true],
['star re-export', 'export * from "./base.mjs";', true],
['comment', '// export default 17;\nexport const named = 99;', true],
['string', 'export const text = "export default 17";', true],
['type-only declaration', 'export type { Value as default } from "./types";', true],
['type-only specifier', 'export { type Value as default } from "./types";', true],
['CommonJS module.exports', 'module.exports = 17;', false],
['CommonJS exports', 'exports.named = 17;', false],
] as const)('%s', async (_name, source, rejected) => {
expect(await mountLinkCheck(source).rejects()).toBe(rejected);
});

it('keeps checking later imports when a target cannot be parsed', async () => {
const fixture = mount({
sharedTransformRequest: async () => ({
code: "import a from 'http://localhost:5173/ns/m/src/a.mjs'; import b from 'http://localhost:5173/ns/m/src/b.mjs'; export { a, b };",
map: null,
}),
});
fixture.server.transformRequest.mockImplementation(async (url: string) => ({ code: url.includes('/a.mjs') ? 'export default (;' : 'export const named = 99;' }));
const res = makeRes();
await fixture.handler({ url: '/ns/m/src/importer.mjs' }, res, vi.fn());
expect(res.body).toContain('[link-check] Missing default export in /ns/m/src/b.mjs');
});

it('rechecks the same importer after its target gains or loses a default', async () => {
const fixture = mountLinkCheck('export const named = 99;');
expect(await fixture.rejects()).toBe(true);
fixture.edit('const named = 23; export { named as default };');
expect(await fixture.rejects()).toBe(false);
fixture.edit('export const named = 99;');
expect(await fixture.rejects()).toBe(true);
fixture.edit('export default 17;');
expect(await fixture.rejects()).toBe(false);
});
});
5 changes: 3 additions & 2 deletions packages/vite/hmr/server/websocket-ns-m.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { createNsMRequestContext, resolveFsAnchoredSpec, resolveNsMTransformedMo
import { setDeviceModuleHeaders } from './route-helpers.js';
import { RAW_QUERY_PATTERN } from './constants.js';
import { CSS_MODULE_RE, buildCssRegisterSnippetFromVar, normalizeCssForDevice } from './css-device-module.js';
import { assertNoOptimizedArtifacts, buildBootProgressSnippet, canonicalizeRtImports, dedupeRtNamedImportsAgainstDestructures, deduplicateLinkerImports, ensureDestructureCoreImports, ensureGuardPlainDynamicImports, ensureVariableDynamicImportHelper, expandStarExports, hoistTopLevelStaticImports, MODULE_IMPORT_ANALYSIS_PLUGINS, wrapCommonJsModuleForDevice, ensureWorkerEntryGlobalsImport, classifyServedRequest } from './websocket-served-module-helpers.js';
import { assertNoOptimizedArtifacts, buildBootProgressSnippet, canonicalizeRtImports, dedupeRtNamedImportsAgainstDestructures, deduplicateLinkerImports, ensureDestructureCoreImports, ensureGuardPlainDynamicImports, ensureVariableDynamicImportHelper, expandStarExports, hasEsmDefaultExport, hoistTopLevelStaticImports, MODULE_IMPORT_ANALYSIS_PLUGINS, wrapCommonJsModuleForDevice, ensureWorkerEntryGlobalsImport, classifyServedRequest } from './websocket-served-module-helpers.js';
import { cleanCode, collectImportDependencies, isWorkerEntryModuleId, processCodeForDevice, rewriteImports } from './websocket-device-transform.js';
import { REQUIRE_GUARD_SNIPPET } from './require-guard.js';
import { getServerOrigin } from './server-origin.js';
Expand Down Expand Up @@ -565,7 +565,8 @@ export function registerNsModuleServerRoute(server: ViteDevServer, options: Regi
}
} catch {}
if (!targetCode) continue;
const hasDefault = /\bexport\s+default\b/.test(targetCode) || /export\s*\{\s*default\s*(?:as\s*default)?\s*\}/.test(targetCode);
const hasDefault = hasEsmDefaultExport(targetCode);
if (hasDefault === null) continue;
if (!hasDefault) {
// CJS/UMD modules won't have `export default` — they get CJS-wrapped
// by the serving pipeline. Only warn, don't fatally block the importer.
Expand Down
22 changes: 21 additions & 1 deletion packages/vite/hmr/server/websocket-served-module-helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { classifyServedModule, classifyServedRequest } from './websocket-served-module-helpers.js';
import { classifyServedModule, classifyServedRequest, hasEsmDefaultExport } from './websocket-served-module-helpers.js';

describe('classifyServedRequest', () => {
it('treats a package request as library code when its resolved id is a symlink target outside node_modules', () => {
Expand All @@ -24,3 +24,23 @@ describe('classifyServedRequest', () => {
expect(classifyServedModule('/node_modules/pkg/index.js')).toBe('library');
});
});

describe('hasEsmDefaultExport', () => {
it.each([
['export default 17;', true],
['const named = 23; export { named as default };', true],
['export { default } from "./base.mjs";', true],
['export * as default from "./base.mjs";', true],
['export const named = 99;', false],
['export * from "./base.mjs";', false],
['// export default 17;\nexport const text = "export default 17";', false],
['export { type Value as default } from "./types";', false],
['module.exports = 17;', false],
])('%s → %s', (code, expected) => {
expect(hasEsmDefaultExport(code)).toBe(expected);
});

it('returns null for unparseable code', () => {
expect(hasEsmDefaultExport('export default (;')).toBeNull();
});
});
15 changes: 15 additions & 0 deletions packages/vite/hmr/server/websocket-served-module-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,21 @@ export function classifyServedRequest(spec: string | undefined | null, resolvedI

export const MODULE_IMPORT_ANALYSIS_PLUGINS = ['typescript', 'jsx', 'importMeta', 'topLevelAwait', 'classProperties', 'classPrivateProperties', 'classPrivateMethods', 'decorators-legacy'] as any;

/** Whether `code` has an ESM default export, or `null` when it cannot be parsed. */
export function hasEsmDefaultExport(code: string): boolean | null {
let body: t.Statement[];
try {
body = babelParse(code, { sourceType: 'module', plugins: MODULE_IMPORT_ANALYSIS_PLUGINS }).program.body;
} catch {
return null;
}
return body.some((node) => {
if (t.isExportDefaultDeclaration(node)) return true;
if (!t.isExportNamedDeclaration(node) || node.exportKind === 'type') return false;
return node.specifiers.some((spec) => !(t.isExportSpecifier(spec) && spec.exportKind === 'type') && (t.isIdentifier(spec.exported) ? spec.exported.name : spec.exported.value) === 'default');
});
}

export type TopLevelImportRecord = {
start: number;
end: number;
Expand Down
Loading