Skip to content
Open
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
52 changes: 52 additions & 0 deletions packages/vite/hmr/server/websocket-import-records-fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest';

// parseAst (Oxc) rejected → the Babel fallback must produce identical records.
vi.mock('vite', async (importOriginal) => {
const original = await importOriginal<typeof import('vite')>();
return {
...original,
parseAst: () => {
throw new Error('simulated Oxc parse failure');
},
};
});

const { collectTopLevelImportRecords } = await import('./websocket-served-module-helpers.js');
const { ensureNativeScriptModuleBindings } = await import('./websocket-module-bindings.js');

describe('collectTopLevelImportRecords (Babel fallback)', () => {
it('produces the same records when parseAst throws', () => {
const code = `const s = "héllo→";\nimport def, { a, b as c, "d-e" as f } from "pkg";\nimport type { T } from "./t";`;
const records = collectTopLevelImportRecords(code);
expect(records).toHaveLength(2);
expect(records[0].source).toBe('pkg');
expect(records[0].text).toBe(`import def, { a, b as c, "d-e" as f } from "pkg";`);
expect(records[0].hasOnlyNamedSpecifiers).toBe(false);
expect(records[0].namedBindings).toEqual([
{ importedName: 'a', text: 'a' },
{ importedName: 'b', text: 'b as c' },
{ importedName: 'd-e', text: '"d-e" as f' },
]);
expect(records[1].source).toBe('./t');
});

it('still rewrites a vendor plugin import through the Babel fallback', () => {
const input = `import { defineStore } from "pinia";\nexport const useServices = defineStore('services', () => ({}));\n`;
const out = ensureNativeScriptModuleBindings(input);
expect(out).not.toMatch(/from\s+["']pinia["']/);
expect(out).toContain('globalThis.__nsVendorRegistry');
});
});

const { collectStaticImportDefaultIntents } = await import('./websocket-served-module-helpers.js');

describe('collectStaticImportDefaultIntents (Babel fallback)', () => {
it('produces the same intents when parseAst throws', () => {
const code = `import def, { a } from "x";\nimport { b } from "y";\nimport type { T } from "./t";`;
expect(collectStaticImportDefaultIntents(code)).toEqual([
{ src: 'x', wantsDefault: true },
{ src: 'y', wantsDefault: false },
{ src: './t', wantsDefault: false },
]);
});
});
55 changes: 29 additions & 26 deletions packages/vite/hmr/server/websocket-module-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { astExtractImportsAndStripTypes } from '../helpers/ast-extract.js';
import { getProjectRootPath } from '../../helpers/project.js';
import { extractRootPackageName } from '../shared/package-classifier.js';
import { isEsmFrameworkPackageSpecifier, isLikelyNativeScriptPluginSpecifier, isLikelyNativeScriptRuntimePluginSpecifier, normalizeNodeModulesSpecifier, resolveInternalRuntimePluginBareSpecifier, resolveNodeModulesPackageBoundary, resolveVendorFromCandidate, shouldPreserveBareRuntimePluginSubpathImport } from './websocket-module-specifiers.js';
import { MODULE_IMPORT_ANALYSIS_PLUGINS } from './websocket-served-module-helpers.js';
import { MODULE_IMPORT_ANALYSIS_PLUGINS, parseTopLevelStatements } from './websocket-served-module-helpers.js';
import { getVendorManifest } from '../shared/vendor/registry.js';
import { isBuiltinModuleId } from '../../helpers/builtin-modules.js';

Expand Down Expand Up @@ -76,33 +76,36 @@ function hasExplicitRuntimePluginScriptExtension(segment: string): boolean {
}

function collectTopLevelImportRecords(code: string): TopLevelImportRecord[] {
try {
const ast = babelParse(code, {
sourceType: 'module',
plugins: [...MODULE_IMPORT_ANALYSIS_PLUGINS],
}) as any;
const body = ast?.program?.body;
if (!Array.isArray(body)) {
return [];
}

return body
.filter((node: any) => node?.type === 'ImportDeclaration' && typeof node.start === 'number' && typeof node.end === 'number' && typeof node.source?.value === 'string')
.map((node: any) => {
const text = code.slice(node.start as number, node.end as number);
const clause = Array.isArray(node.specifiers) && node.specifiers.length > 0 ? text.match(/^\s*import\s+([\s\S]*?)\s+from\s+["'][^"']+["'];?\s*$/)?.[1]?.trim() || null : null;
return {
start: node.start as number,
end: node.end as number,
text,
source: node.source.value as string,
clause,
isTypeOnly: node.importKind === 'type',
};
});
} catch {
let body = parseTopLevelStatements(code);
if (!body) {
try {
body =
(
babelParse(code, {
sourceType: 'module',
plugins: [...MODULE_IMPORT_ANALYSIS_PLUGINS],
}) as any
)?.program?.body ?? null;
} catch {}
}
if (!Array.isArray(body)) {
return [];
}

return body
.filter((node: any) => node?.type === 'ImportDeclaration' && typeof node.start === 'number' && typeof node.end === 'number' && typeof node.source?.value === 'string')
.map((node: any) => {
const text = code.slice(node.start as number, node.end as number);
const clause = Array.isArray(node.specifiers) && node.specifiers.length > 0 ? text.match(/^\s*import\s+([\s\S]*?)\s+from\s+["'][^"']+["'];?\s*$/)?.[1]?.trim() || null : null;
return {
start: node.start as number,
end: node.end as number,
text,
source: node.source.value as string,
clause,
isTypeOnly: node.importKind === 'type',
};
});
}

/** Value re-exports (`export * from 'x'`, `export { a } from 'x'`), as the positions of their source literals. */
Expand Down
21 changes: 2 additions & 19 deletions packages/vite/hmr/server/websocket-ns-m.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import type { TransformResult, ViteDevServer } from 'vite';

import { parse as babelParse } from '@babel/parser';
import traverse from '@babel/traverse';

import { sanitizeStrayCoreReferences } from './core-sanitize.js';
import { getMonorepoWorkspaceRoot } from '../../helpers/project.js';
import { isRuntimeGraphExcludedPath } from './runtime-graph-filter.js';
Expand All @@ -12,7 +9,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, hasEsmDefaultExport, 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, wrapCommonJsModuleForDevice, ensureWorkerEntryGlobalsImport, classifyServedRequest, collectStaticImportDefaultIntents } 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 All @@ -21,8 +18,6 @@ import { appendInlineSourceMap, buildServedModuleSourceMap, type InputSourceMap
import { getSharedDepsBundleService, registerDepsBundleRoute, type DepsBundleService } from './deps-bundle.js';
import type { FrameworkServedModuleContext, FrameworkServerStrategy } from './framework-strategy.js';

const babelTraverse: any = (traverse as any)?.default || (traverse as any);

type SharedTransformRequestFn = (url: string, timeoutMs?: number) => Promise<TransformResult | null>;
type UpsertGraphModuleFn = (rawId: string, code: string, deps: string[], options?: { bumpVersion?: boolean; emitDeltaOnInsert?: boolean; broadcastDelta?: boolean }) => void;

Expand Down Expand Up @@ -508,19 +503,7 @@ export function registerNsModuleServerRoute(server: ViteDevServer, options: Regi
try {
const devCheck = process.env.NODE_ENV !== 'production';
if (devCheck) {
const ast = babelParse(code, {
sourceType: 'module',
plugins: MODULE_IMPORT_ANALYSIS_PLUGINS,
}) as any;
const imports: Array<{ src: string; wantsDefault: boolean }> = [];
babelTraverse(ast, {
ImportDeclaration(p: any) {
const src = p.node.source.value;
if (typeof src !== 'string') return;
const wantsDefault = p.node.specifiers.some((s: any) => s.type === 'ImportDefaultSpecifier');
imports.push({ src, wantsDefault });
},
});
const imports = collectStaticImportDefaultIntents(code);
// Resolve and verify each static import that asks for default
for (const imp of imports) {
if (!imp.wantsDefault) continue;
Expand Down
89 changes: 89 additions & 0 deletions packages/vite/hmr/server/websocket-served-module-helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,92 @@ describe('hasEsmDefaultExport', () => {
expect(hasEsmDefaultExport('export default (;')).toBeNull();
});
});

import { collectTopLevelImportRecords } from './websocket-served-module-helpers.js';
import { ensureNativeScriptModuleBindings } from './websocket-module-bindings.js';

describe('collectTopLevelImportRecords (Oxc parseAst path)', () => {
it('collects default, named, and string-named specifiers', () => {
const code = `import def, { a, b as c, "d-e" as f } from "pkg";`;
const records = collectTopLevelImportRecords(code);
expect(records).toHaveLength(1);
expect(records[0].source).toBe('pkg');
expect(records[0].text).toBe(code);
expect(records[0].start).toBe(0);
expect(records[0].end).toBe(code.length);
expect(records[0].hasOnlyNamedSpecifiers).toBe(false);
expect(records[0].namedBindings).toEqual([
{ importedName: 'a', text: 'a' },
{ importedName: 'b', text: 'b as c' },
{ importedName: 'd-e', text: '"d-e" as f' },
]);
});

it('marks named-only specifier lists and exact text after non-ASCII content', () => {
const code = `const s = "héllo→";\nimport { x } from "m";`;
const records = collectTopLevelImportRecords(code);
expect(records).toHaveLength(1);
expect(records[0].text).toBe(`import { x } from "m";`);
expect(records[0].hasOnlyNamedSpecifiers).toBe(true);
});

it('collects import type declarations', () => {
const records = collectTopLevelImportRecords(`import type { T } from "./t";`);
expect(records).toHaveLength(1);
expect(records[0].source).toBe('./t');
});

it('handles TS+JSX input', () => {
const code = `import { render } from "x";\ninterface P { a: number }\nconst el = <div a={1} />;\nrender(el);\n`;
const records = collectTopLevelImportRecords(code);
expect(records).toHaveLength(1);
expect(records[0].source).toBe('x');
});

it('returns [] for code with no imports', () => {
expect(collectTopLevelImportRecords(`const a = 1;\nexport { a };`)).toEqual([]);
});

it('parses a long minified line full of block comments without Babel quadratic scan', () => {
// Babel's skipBlockComment rescans to the next line break per comment;
// 12000 comments over a ~20MB single line is ~10s through the old path.
// Measured on this machine: Babel 10.32s, Oxc 0.047s.
const code = `import {a} from "m";\n` + '/*#__PURE__*/'.repeat(12000) + 'const s="' + 'x'.repeat(20 * 1000 * 1000) + '";';
const t0 = performance.now();
const records = collectTopLevelImportRecords(code);
const ms = performance.now() - t0;
expect(records).toHaveLength(1);
expect(records[0].source).toBe('m');
expect(ms).toBeLessThan(2000);
}, 30000);
});

describe('ensureNativeScriptModuleBindings via Oxc import records', () => {
it('still rewrites a vendor plugin import', () => {
const input = `import { defineStore } from "pinia";\nexport const useServices = defineStore('services', () => ({}));\n`;
const out = ensureNativeScriptModuleBindings(input);
expect(out).not.toMatch(/from\s+["']pinia["']/);
expect(out).toContain('globalThis.__nsVendorRegistry');
});
});

import { collectStaticImportDefaultIntents } from './websocket-served-module-helpers.js';

describe('collectStaticImportDefaultIntents (Oxc parseAst path)', () => {
it('reports default/named/namespace mixes', () => {
const code = `import def, { a } from "x";\nimport { b } from "y";\nimport * as ns from "z";\nimport type { T } from "./t";`;
expect(collectStaticImportDefaultIntents(code)).toEqual([
{ src: 'x', wantsDefault: true },
{ src: 'y', wantsDefault: false },
{ src: 'z', wantsDefault: false },
{ src: './t', wantsDefault: false },
]);
});

it('handles the long-line comment payload fast', () => {
const code = `import {a} from "m";\n` + '/*#__PURE__*/'.repeat(12000) + 'const s="' + 'x'.repeat(20 * 1000 * 1000) + '";';
const t0 = performance.now();
expect(collectStaticImportDefaultIntents(code)).toEqual([{ src: 'm', wantsDefault: false }]);
expect(performance.now() - t0).toBeLessThan(2000);
}, 30000);
});
109 changes: 81 additions & 28 deletions packages/vite/hmr/server/websocket-served-module-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { parse as babelParse } from '@babel/parser';
import traverse from '@babel/traverse';
import * as t from '@babel/types';
import { existsSync, readFileSync } from 'fs';
import * as path from 'path';
import { parseAst } from 'vite';

const babelTraverse: any = (traverse as any)?.default || (traverse as any);

import * as PAT from './constants.js';
import { isDeepCoreSubpath } from './core-sanitize.js';
Expand Down Expand Up @@ -130,41 +134,90 @@ export type TopLevelImportRecord = {
namedBindings: Array<{ importedName: string; text: string }>;
};

/**
* Top-level statements via Vite's native (Oxc) parser. Babel's block-comment
* line tracking rescans to the next line break for every comment, so long
* minified lines with many comments parse quadratically. Returns null when
* Oxc rejects the code so callers can fall back to Babel.
*/
export function parseTopLevelStatements(code: string): any[] | null {
try {
const ast = parseAst(code, { lang: 'tsx' }) as any;
return Array.isArray(ast?.body) ? ast.body : null;
} catch {
return null;
}
}

/**
* Static import sources and whether each asks for a default binding.
*/
export function collectStaticImportDefaultIntents(code: string): Array<{ src: string; wantsDefault: boolean }> {
const body = parseTopLevelStatements(code);
if (body) {
const imports: Array<{ src: string; wantsDefault: boolean }> = [];
for (const node of body) {
if (node?.type !== 'ImportDeclaration' || typeof node.source?.value !== 'string') continue;
imports.push({
src: node.source.value,
wantsDefault: Array.isArray(node.specifiers) && node.specifiers.some((s: any) => s?.type === 'ImportDefaultSpecifier'),
});
}
return imports;
}
const ast = babelParse(code, {
sourceType: 'module',
plugins: MODULE_IMPORT_ANALYSIS_PLUGINS,
}) as any;
const imports: Array<{ src: string; wantsDefault: boolean }> = [];
babelTraverse(ast, {
ImportDeclaration(p: any) {
const src = p.node.source.value;
if (typeof src !== 'string') return;
imports.push({ src, wantsDefault: p.node.specifiers.some((s: any) => s.type === 'ImportDefaultSpecifier') });
},
});
return imports;
}

export function collectTopLevelImportRecords(code: string): TopLevelImportRecord[] {
if (!code || typeof code !== 'string' || !/\bimport\b/.test(code)) {
return [];
}

try {
const ast = babelParse(code, {
sourceType: 'module',
plugins: MODULE_IMPORT_ANALYSIS_PLUGINS,
}) as any;
const body = ast?.program?.body;
if (!Array.isArray(body)) {
return [];
}

return body
.filter((node: any) => t.isImportDeclaration(node) && typeof node.start === 'number' && typeof node.end === 'number' && typeof node.source?.value === 'string')
.map((node: any) => ({
start: node.start as number,
end: node.end as number,
text: code.slice(node.start as number, node.end as number),
source: node.source.value as string,
hasOnlyNamedSpecifiers: Array.isArray(node.specifiers) && node.specifiers.length > 0 && node.specifiers.every((spec: any) => t.isImportSpecifier(spec)),
namedBindings: Array.isArray(node.specifiers)
? node.specifiers
.filter((spec: any) => t.isImportSpecifier(spec) && typeof spec.start === 'number' && typeof spec.end === 'number')
.map((spec: any) => ({
importedName: t.isIdentifier(spec.imported) ? spec.imported.name : String(spec.imported?.value || ''),
text: code.slice(spec.start as number, spec.end as number),
}))
: [],
}));
} catch {
let body = parseTopLevelStatements(code);
if (!body) {
try {
body =
(
babelParse(code, {
sourceType: 'module',
plugins: MODULE_IMPORT_ANALYSIS_PLUGINS,
}) as any
)?.program?.body ?? null;
} catch {}
}
if (!Array.isArray(body)) {
return [];
}

return body
.filter((node: any) => node?.type === 'ImportDeclaration' && typeof node.start === 'number' && typeof node.end === 'number' && typeof node.source?.value === 'string')
.map((node: any) => ({
start: node.start as number,
end: node.end as number,
text: code.slice(node.start as number, node.end as number),
source: node.source.value as string,
hasOnlyNamedSpecifiers: Array.isArray(node.specifiers) && node.specifiers.length > 0 && node.specifiers.every((spec: any) => spec?.type === 'ImportSpecifier'),
namedBindings: Array.isArray(node.specifiers)
? node.specifiers
.filter((spec: any) => spec?.type === 'ImportSpecifier' && typeof spec.start === 'number' && typeof spec.end === 'number')
.map((spec: any) => ({
importedName: spec.imported?.type === 'Identifier' ? spec.imported.name : String(spec.imported?.value || ''),
text: code.slice(spec.start as number, spec.end as number),
}))
: [],
}));
}

export function hoistTopLevelStaticImports(code: string): string {
Expand Down
Loading