diff --git a/packages/vite/hmr/server/websocket-bindings.spec.ts b/packages/vite/hmr/server/websocket-bindings.spec.ts index 37d27263f9..0c35fc1972 100644 --- a/packages/vite/hmr/server/websocket-bindings.spec.ts +++ b/packages/vite/hmr/server/websocket-bindings.spec.ts @@ -481,6 +481,22 @@ describe('ensureNativeScriptModuleBindings — package metadata NativeScript det expect(text).toMatch(/(?:const|var)\s+SyncStatus\s*=\s*__nsPick\(__nsVendorModule_\d+,\s*['"]SyncStatus['"]\)/); }); + it('keeps preserved imports in source order with re-exports', () => { + // Hoisting only `./html` evaluated it (and its ./web) before ./scroll: TDZ on `Scroll`. + const input = [`export { Scroll } from './scroll';`, `export * from './web';`, `import './html';`, `import { defineStore } from 'pinia';`, `export const s = defineStore;`].join('\n'); + + for (const out of [ensureNativeScriptModuleBindings(input), ensureNativeScriptModuleBindings(input.replace(/^import \{ defineStore.*$/m, ''))]) { + const scroll = out.indexOf(`export { Scroll } from './scroll';`); + const web = out.indexOf(`export * from './web';`); + const html = out.indexOf(`import './html';`); + + expect(scroll).toBeGreaterThanOrEqual(0); + expect(web).toBeGreaterThan(scroll); + expect(html).toBeGreaterThan(web); + expect(out.split(`import './html';`)).toHaveLength(2); + } + }); + it('is safe to run twice on the same module text', () => { const input = `import { defineStore } from "pinia";\nexport const useServices = defineStore('services', () => ({}));\n`; const once = ensureNativeScriptModuleBindings(input); diff --git a/packages/vite/hmr/server/websocket-module-bindings.ts b/packages/vite/hmr/server/websocket-module-bindings.ts index ce56b06949..d866b85011 100644 --- a/packages/vite/hmr/server/websocket-module-bindings.ts +++ b/packages/vite/hmr/server/websocket-module-bindings.ts @@ -218,7 +218,19 @@ function rewriteWorkerReExportSpecifiers(code: string): string { return code; } -function stripTopLevelImportRecords(code: string, records: TopLevelImportRecord[]): string { +function collectTopLevelReExportDeclarations(code: string): Array<{ start: number; end: number; text: string }> { + try { + const body = babelParse(code, { sourceType: 'module', plugins: [...MODULE_IMPORT_ANALYSIS_PLUGINS] }).program.body; + return body + .filter((node) => node.type === 'ExportAllDeclaration' || (node.type === 'ExportNamedDeclaration' && !!node.source)) + .filter((node) => typeof node.start === 'number' && typeof node.end === 'number') + .map((node) => ({ start: node.start!, end: node.end!, text: code.slice(node.start!, node.end!).trim() })); + } catch { + return []; + } +} + +function stripTopLevelImportRecords(code: string, records: Array<{ start: number; end: number }>): string { let stripped = code; for (const record of [...records].sort((left, right) => right.start - left.start)) { stripped = stripped.slice(0, record.start) + stripped.slice(record.end); @@ -237,7 +249,7 @@ export function ensureNativeScriptModuleBindings(code: string, options?: EnsureN return code; } - const preservedImports: string[] = []; + const preservedImports: Array<{ start: number; text: string }> = []; const modules = new Map(); const getModuleBinding = (canonical: string) => { @@ -278,8 +290,9 @@ export function ensureNativeScriptModuleBindings(code: string, options?: EnsureN if (!original) { continue; } + const preserve = (text: string) => preservedImports.push({ start: record.start, text }); if (record.isTypeOnly) { - preservedImports.push(original); + preserve(original); continue; } @@ -287,18 +300,18 @@ export function ensureNativeScriptModuleBindings(code: string, options?: EnsureN const specifier = rawSpec.replace(PAT.QUERY_PATTERN, ''); const workerSpecifier = options?.vendorImportsAsHttp ? getWorkerNodeModulesSpecifier(specifier) : null; if (workerSpecifier) { - preservedImports.push(rewritePreservedImportSpecifier(original, rawSpec, workerSpecifier)); + preserve(rewritePreservedImportSpecifier(original, rawSpec, workerSpecifier)); continue; } const preservedSpecifier = getPreservedImportSpecifier(specifier, options); if (!record.clause) { if (preservedSpecifier) { - preservedImports.push(rewritePreservedImportSpecifier(original, rawSpec, preservedSpecifier)); + preserve(rewritePreservedImportSpecifier(original, rawSpec, preservedSpecifier)); continue; } if (isEsmFrameworkPackageSpecifier(specifier)) { - preservedImports.push(original); + preserve(original); continue; } const runtimePluginBareSpecifier = resolveInternalRuntimePluginBareSpecifier(specifier, getProjectRootPath()); @@ -314,18 +327,18 @@ export function ensureNativeScriptModuleBindings(code: string, options?: EnsureN // ("does not provide an export named ..."). When vendorImportsAsHttp // is set (worker serve), fall through to the HTTP rewrite below. if (options?.preserveNonPluginVendorImports && !runtimePluginSpecifier && !options?.vendorImportsAsHttp) { - preservedImports.push(original); + preserve(original); continue; } if (!canonical && isLikelyNativeScriptPluginSpecifier(specifier, getProjectRootPath()) && !isUnvendoredPackage(specifier)) { canonical = specifier; } if (canonical && /^@nativescript\/core(\b|\/)/i.test(canonical)) { - preservedImports.push(original); + preserve(original); continue; } if (!canonical) { - preservedImports.push(original); + preserve(original); continue; } if (options?.vendorImportsAsHttp) { @@ -343,7 +356,7 @@ export function ensureNativeScriptModuleBindings(code: string, options?: EnsureN // both drops subpath-only exports ("does not provide an export // named 'TableName'") and manufactures import cycles through // the barrel (lodash-es `add.js` ↔ root → TDZ fatal). - preservedImports.push(rewritePreservedImportSpecifier(original, rawSpec, `/ns/m/node_modules/${normalizeNodeModulesSpecifier(specifier) || (/^[^./]/.test(specifier) ? specifier : canonical)}?ns_worker=1`)); + preserve(rewritePreservedImportSpecifier(original, rawSpec, `/ns/m/node_modules/${normalizeNodeModulesSpecifier(specifier) || (/^[^./]/.test(specifier) ? specifier : canonical)}?ns_worker=1`)); continue; } const binding = getModuleBinding(canonical); @@ -352,11 +365,11 @@ export function ensureNativeScriptModuleBindings(code: string, options?: EnsureN } if (preservedSpecifier) { - preservedImports.push(rewritePreservedImportSpecifier(original, rawSpec, preservedSpecifier)); + preserve(rewritePreservedImportSpecifier(original, rawSpec, preservedSpecifier)); continue; } if (isEsmFrameworkPackageSpecifier(specifier)) { - preservedImports.push(original); + preserve(original); continue; } const runtimePluginBareSpecifier = resolveInternalRuntimePluginBareSpecifier(specifier, getProjectRootPath()); @@ -369,24 +382,24 @@ export function ensureNativeScriptModuleBindings(code: string, options?: EnsureN // realm's import map; in a worker serve (vendorImportsAsHttp) it must not // win over the /ns/m HTTP rewrite. if (options?.preserveNonPluginVendorImports && !runtimePluginSpecifier && !options?.vendorImportsAsHttp) { - preservedImports.push(original); + preserve(original); continue; } if (!canonical && isLikelyNativeScriptPluginSpecifier(specifier, getProjectRootPath()) && !isUnvendoredPackage(specifier)) { canonical = specifier; } if (canonical && /^@nativescript\/core(\b|\/)/i.test(canonical)) { - preservedImports.push(original); + preserve(original); continue; } if (!canonical) { - preservedImports.push(original); + preserve(original); continue; } if (options?.vendorImportsAsHttp) { // See the side-effect branch above for the `ns_worker=1` rationale // and why the URL must keep the importer's concrete subpath. - preservedImports.push(rewritePreservedImportSpecifier(original, rawSpec, `/ns/m/node_modules/${normalizeNodeModulesSpecifier(specifier) || (/^[^./]/.test(specifier) ? specifier : canonical)}?ns_worker=1`)); + preserve(rewritePreservedImportSpecifier(original, rawSpec, `/ns/m/node_modules/${normalizeNodeModulesSpecifier(specifier) || (/^[^./]/.test(specifier) ? specifier : canonical)}?ns_worker=1`)); continue; } @@ -415,14 +428,21 @@ export function ensureNativeScriptModuleBindings(code: string, options?: EnsureN binding.default.add(trimmed); } - code = stripTopLevelImportRecords(code, importRecords); + // Re-exports move with the imports: ESM evaluates module requests in source order. + const reExports = collectTopLevelReExportDeclarations(code); + for (const reExport of reExports) { + preservedImports.push({ start: reExport.start, text: reExport.text }); + } + code = stripTopLevelImportRecords(code, [...importRecords, ...reExports]); + const preserved = preservedImports.length + ? preservedImports + .sort((left, right) => left.start - right.start) + .map((entry) => entry.text) + .join('\n') + '\n' + : ''; if (!modules.size) { - if (preservedImports.length) { - const preserved = preservedImports.join('\n') + '\n'; - return preserved + code; - } - return code; + return preserved + code; } let injection = 'var __nsVendorRegistry = (globalThis.__nsVendorRegistry ||= new Map());\n'; @@ -470,7 +490,6 @@ export function ensureNativeScriptModuleBindings(code: string, options?: EnsureN } injection += '\n'; - const preserved = preservedImports.length ? preservedImports.join('\n') + '\n' : ''; return preserved + injection + code; } diff --git a/packages/vite/hmr/server/websocket-served-module-helpers.spec.ts b/packages/vite/hmr/server/websocket-served-module-helpers.spec.ts index 41bac48e97..dd4fda8064 100644 --- a/packages/vite/hmr/server/websocket-served-module-helpers.spec.ts +++ b/packages/vite/hmr/server/websocket-served-module-helpers.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { classifyServedModule, classifyServedRequest, hasEsmDefaultExport } from './websocket-served-module-helpers.js'; +import { classifyServedModule, classifyServedRequest, hasEsmDefaultExport, hoistTopLevelStaticImports } 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', () => { @@ -44,3 +44,23 @@ describe('hasEsmDefaultExport', () => { expect(hasEsmDefaultExport('export default (;')).toBeNull(); }); }); + +describe('hoistTopLevelStaticImports', () => { + const hoist = (...code: string[]) => + hoistTopLevelStaticImports(code.join('\n')) + .split('\n') + .filter((line) => line.trim()); + + it('keeps a side-effect import after the re-exports it follows', () => { + // Hoisting only the import evaluated ./web before ./scroll (TDZ on `Scroll`). + expect(hoist('const __DEV__ = true;', "export { Scroll } from './scroll';", "export * from './web';", "import './html';", 'export function f() {}')).toEqual(["export { Scroll } from './scroll';", "export * from './web';", "import './html';", 'const __DEV__ = true;', 'export function f() {}']); + }); + + it('hoists imports above preceding statements', () => { + expect(hoist('const a = 1;', "import { b } from './b';", 'console.log(a, b);')).toEqual(["import { b } from './b';", 'const a = 1;', 'console.log(a, b);']); + }); + + it('leaves local export declarations in place', () => { + expect(hoist('const x = 1;', 'export { x };', "import './a';")).toEqual(["import './a';", 'const x = 1;', 'export { x };']); + }); +}); diff --git a/packages/vite/hmr/server/websocket-served-module-helpers.ts b/packages/vite/hmr/server/websocket-served-module-helpers.ts index 6d78565381..2ffd209c17 100644 --- a/packages/vite/hmr/server/websocket-served-module-helpers.ts +++ b/packages/vite/hmr/server/websocket-served-module-helpers.ts @@ -167,8 +167,26 @@ export function collectTopLevelImportRecords(code: string): TopLevelImportRecord } } +function collectTopLevelModuleRequests(code: string): Array<{ start: number; end: number; text: string }> { + if (!code || typeof code !== 'string' || !/\b(?:import|export)\b/.test(code)) { + return []; + } + + try { + const body = babelParse(code, { sourceType: 'module', plugins: MODULE_IMPORT_ANALYSIS_PLUGINS }).program.body; + return body + .filter((node) => t.isImportDeclaration(node) || t.isExportAllDeclaration(node) || (t.isExportNamedDeclaration(node) && !!node.source)) + .filter((node) => typeof node.start === 'number' && typeof node.end === 'number') + .map((node) => ({ start: node.start!, end: node.end!, text: code.slice(node.start!, node.end!) })); + } catch { + return []; + } +} + +/** Hoists top-level imports and `export ... from` declarations, keeping their order. */ export function hoistTopLevelStaticImports(code: string): string { - const imports = collectTopLevelImportRecords(code); + // Re-exports move too: ESM evaluates every module request in source order. + const imports = collectTopLevelModuleRequests(code); if (!imports.length) { return code; }