Skip to content

Commit 523252f

Browse files
authored
fix(vite): mask comments before scanning module export names (#11473)
[skip ci]
1 parent 9c8d317 commit 523252f

4 files changed

Lines changed: 114 additions & 1 deletion

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { extractDirectExportedNames, maskJsComments } from './websocket-core-bridge.js';
4+
5+
describe('maskJsComments', () => {
6+
it('blanks line and block comments while preserving length and newlines', () => {
7+
const code = ['const a = 1; // export const x = 1;', '/* export let y;', 'export var z; */', 'export const b = 2;'].join('\n');
8+
const masked = maskJsComments(code);
9+
expect(masked).toHaveLength(code.length);
10+
expect(masked.split('\n')).toHaveLength(4);
11+
expect(masked).not.toMatch(/export (const x|let y|var z)/);
12+
expect(masked).toContain('export const b = 2;');
13+
});
14+
15+
it('leaves comment markers inside string and template literals alone', () => {
16+
const code = `const u = "http://host/*"; const t = \`a // b\`; const s = '/* x */';`;
17+
expect(maskJsComments(code)).toBe(code);
18+
});
19+
20+
it('does not let quotes inside regex literals desync string tracking', () => {
21+
const code = ['const re = /["\']/g;', 'const glob = "src/**/*.ts";', 'export const kept = 1;', 'const r2 = x.split(/\\//); // export const gone = 1;'].join('\n');
22+
expect(extractDirectExportedNames(code)).toEqual(['kept']);
23+
});
24+
25+
it('treats a slash after an operand as division, not a regex', () => {
26+
const code = ['const half = total / 2; // export const gone = 1;', 'export const kept = half / 2;'].join('\n');
27+
expect(extractDirectExportedNames(code)).toEqual(['kept']);
28+
});
29+
});

‎packages/vite/hmr/server/websocket-core-bridge.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,77 @@ export type ParsedCoreBridgeRequest = {
4242
*/
4343
export const JS_IDENTIFIER_RE = /^[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*$/u;
4444

45+
const REGEX_PRECEDING_KEYWORD_RE = /(?:^|[^$\w])(?:return|typeof|instanceof|in|of|new|delete|void|throw|case|do|else|yield|await)$/;
46+
47+
/**
48+
* Blank line and block comments (preserving newlines) so the regex export
49+
* scanners never see a commented-out `export` declaration. String, template
50+
* and regex literals are skipped so their contents are left intact.
51+
*/
52+
export function maskJsComments(code: string): string {
53+
if (!code || (code.indexOf('//') === -1 && code.indexOf('/*') === -1)) {
54+
return code;
55+
}
56+
const chars = code.split('');
57+
const n = code.length;
58+
const blank = (from: number, to: number) => {
59+
for (let k = from; k < to; k++) {
60+
if (chars[k] !== '\n') chars[k] = ' ';
61+
}
62+
};
63+
// A `/` starts a regex literal (not division) when it follows an operator,
64+
// an opening bracket, or a keyword — the standard lexer heuristic.
65+
const slashStartsRegex = (at: number): boolean => {
66+
let j = at - 1;
67+
while (j >= 0 && /\s/.test(code[j])) j--;
68+
if (j < 0) return true;
69+
if ('(,=:[!&|?{};+-*%<>~^'.includes(code[j])) return true;
70+
return REGEX_PRECEDING_KEYWORD_RE.test(code.slice(Math.max(0, j - 10), j + 1));
71+
};
72+
let i = 0;
73+
while (i < n) {
74+
const c = code[i];
75+
const next = code[i + 1];
76+
if (c === '/' && next === '/') {
77+
const eol = code.indexOf('\n', i);
78+
const stop = eol === -1 ? n : eol;
79+
blank(i, stop);
80+
i = stop;
81+
} else if (c === '/' && next === '*') {
82+
const close = code.indexOf('*/', i + 2);
83+
const stop = close === -1 ? n : close + 2;
84+
blank(i, stop);
85+
i = stop;
86+
} else if (c === "'" || c === '"' || c === '`') {
87+
i++;
88+
while (i < n && code[i] !== c) {
89+
i += code[i] === '\\' ? 2 : 1;
90+
}
91+
i++;
92+
} else if (c === '/' && slashStartsRegex(i)) {
93+
i++;
94+
let inClass = false;
95+
while (i < n && code[i] !== '\n') {
96+
const r = code[i];
97+
if (r === '\\') {
98+
i += 2;
99+
continue;
100+
}
101+
if (r === '[') inClass = true;
102+
else if (r === ']') inClass = false;
103+
else if (r === '/' && !inClass) break;
104+
i++;
105+
}
106+
i++;
107+
} else {
108+
i++;
109+
}
110+
}
111+
return chars.join('');
112+
}
113+
45114
export function extractDirectExportedNames(code: string): string[] {
115+
code = maskJsComments(code);
46116
const names = new Set<string>();
47117
const declRe = /\bexport\s+(?:async\s+)?(?:function|class)\s+([$_\p{ID_Start}][$\p{ID_Continue}]*)/gu;
48118
let match: RegExpExecArray | null;

‎packages/vite/hmr/server/websocket-served-module-helpers.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as PAT from './constants.js';
77
import { isDeepCoreSubpath } from './core-sanitize.js';
88
import { getCjsNamedExports } from '../helpers/cjs-named-exports.js';
99
import { getMonorepoWorkspaceRoot } from '../../helpers/project.js';
10-
import { extractDirectExportedNames, parseExportSpecList } from './websocket-core-bridge.js';
10+
import { extractDirectExportedNames, maskJsComments, parseExportSpecList } from './websocket-core-bridge.js';
1111
import { resolveCandidateFilePath } from './websocket-module-specifiers.js';
1212

1313
let cachedWorkspaceCoreRoot: string | null | undefined;
@@ -395,6 +395,7 @@ type ModuleExportSurface = {
395395
// - `extractExportMetadata` (below): does a module have a default export
396396
// and which named ones — shape-only, for SFC route metadata.
397397
function scanModuleExportSurface(code: string): ModuleExportSurface {
398+
code = maskJsComments(code);
398399
const ownNames = new Set<string>(extractDirectExportedNames(code));
399400
for (const m of code.matchAll(/\bexport\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*["'][^"']+["']/g)) {
400401
ownNames.add(m[1]);
@@ -601,6 +602,7 @@ export function stripViteDynamicImportVirtual(code: string): string {
601602
// answers "does this module export a default, and which named exports?" for
602603
// the Vue SFC route metadata. It intentionally ignores re-export sources.
603604
export function extractExportMetadata(code: string): { hasDefault: boolean; named: string[] } {
605+
code = maskJsComments(code);
604606
const named = new Set<string>();
605607
let hasDefault = /\bexport\s+default\b/.test(code);
606608
try {

‎packages/vite/hmr/server/websocket-star-export-expansion.spec.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,16 @@ describe('expandStarExports — transitive star re-export chains', () => {
230230
fs.rmSync(root, { recursive: true, force: true });
231231
}
232232
});
233+
234+
it('ignores commented-out export declarations in the star target', async () => {
235+
// Shape of @nativescript-community/ui-image's index-common.js.
236+
const importer = `export * from "/ns/m/node_modules/ui-image/index-common.js";`;
237+
const { server, transformer } = makeServer({
238+
'/node_modules/ui-image/index-common.js': [`module.exports = {};`, `// export const roundRadiusProperty = {};`, `/* export let alsoCommented = 1; */`, `exports.createView = () => {};`].join('\n'),
239+
});
240+
const out = await expandStarExports(importer, server, '/', false, transformer);
241+
expect(out).not.toContain('roundRadiusProperty');
242+
expect(out).not.toContain('alsoCommented');
243+
expect(warnSpy).not.toHaveBeenCalled();
244+
});
233245
});

0 commit comments

Comments
 (0)