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
29 changes: 29 additions & 0 deletions packages/vite/hmr/server/websocket-core-bridge.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';

import { extractDirectExportedNames, maskJsComments } from './websocket-core-bridge.js';

describe('maskJsComments', () => {
it('blanks line and block comments while preserving length and newlines', () => {
const code = ['const a = 1; // export const x = 1;', '/* export let y;', 'export var z; */', 'export const b = 2;'].join('\n');
const masked = maskJsComments(code);
expect(masked).toHaveLength(code.length);
expect(masked.split('\n')).toHaveLength(4);
expect(masked).not.toMatch(/export (const x|let y|var z)/);
expect(masked).toContain('export const b = 2;');
});

it('leaves comment markers inside string and template literals alone', () => {
const code = `const u = "http://host/*"; const t = \`a // b\`; const s = '/* x */';`;
expect(maskJsComments(code)).toBe(code);
});

it('does not let quotes inside regex literals desync string tracking', () => {
const code = ['const re = /["\']/g;', 'const glob = "src/**/*.ts";', 'export const kept = 1;', 'const r2 = x.split(/\\//); // export const gone = 1;'].join('\n');
expect(extractDirectExportedNames(code)).toEqual(['kept']);
});

it('treats a slash after an operand as division, not a regex', () => {
const code = ['const half = total / 2; // export const gone = 1;', 'export const kept = half / 2;'].join('\n');
expect(extractDirectExportedNames(code)).toEqual(['kept']);
});
});
70 changes: 70 additions & 0 deletions packages/vite/hmr/server/websocket-core-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,77 @@ export type ParsedCoreBridgeRequest = {
*/
export const JS_IDENTIFIER_RE = /^[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*$/u;

const REGEX_PRECEDING_KEYWORD_RE = /(?:^|[^$\w])(?:return|typeof|instanceof|in|of|new|delete|void|throw|case|do|else|yield|await)$/;

/**
* Blank line and block comments (preserving newlines) so the regex export
* scanners never see a commented-out `export` declaration. String, template
* and regex literals are skipped so their contents are left intact.
*/
export function maskJsComments(code: string): string {
if (!code || (code.indexOf('//') === -1 && code.indexOf('/*') === -1)) {
return code;
}
const chars = code.split('');
const n = code.length;
const blank = (from: number, to: number) => {
for (let k = from; k < to; k++) {
if (chars[k] !== '\n') chars[k] = ' ';
}
};
// A `/` starts a regex literal (not division) when it follows an operator,
// an opening bracket, or a keyword — the standard lexer heuristic.
const slashStartsRegex = (at: number): boolean => {
let j = at - 1;
while (j >= 0 && /\s/.test(code[j])) j--;
if (j < 0) return true;
if ('(,=:[!&|?{};+-*%<>~^'.includes(code[j])) return true;
return REGEX_PRECEDING_KEYWORD_RE.test(code.slice(Math.max(0, j - 10), j + 1));
};
let i = 0;
while (i < n) {
const c = code[i];
const next = code[i + 1];
if (c === '/' && next === '/') {
const eol = code.indexOf('\n', i);
const stop = eol === -1 ? n : eol;
blank(i, stop);
i = stop;
} else if (c === '/' && next === '*') {
const close = code.indexOf('*/', i + 2);
const stop = close === -1 ? n : close + 2;
blank(i, stop);
i = stop;
} else if (c === "'" || c === '"' || c === '`') {
i++;
while (i < n && code[i] !== c) {
i += code[i] === '\\' ? 2 : 1;
}
i++;
} else if (c === '/' && slashStartsRegex(i)) {
i++;
let inClass = false;
while (i < n && code[i] !== '\n') {
const r = code[i];
if (r === '\\') {
i += 2;
continue;
}
if (r === '[') inClass = true;
else if (r === ']') inClass = false;
else if (r === '/' && !inClass) break;
i++;
}
i++;
} else {
i++;
}
}
return chars.join('');
}

export function extractDirectExportedNames(code: string): string[] {
code = maskJsComments(code);
const names = new Set<string>();
const declRe = /\bexport\s+(?:async\s+)?(?:function|class)\s+([$_\p{ID_Start}][$\p{ID_Continue}]*)/gu;
let match: RegExpExecArray | null;
Expand Down
4 changes: 3 additions & 1 deletion packages/vite/hmr/server/websocket-served-module-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as PAT from './constants.js';
import { isDeepCoreSubpath } from './core-sanitize.js';
import { getCjsNamedExports } from '../helpers/cjs-named-exports.js';
import { getMonorepoWorkspaceRoot } from '../../helpers/project.js';
import { extractDirectExportedNames, parseExportSpecList } from './websocket-core-bridge.js';
import { extractDirectExportedNames, maskJsComments, parseExportSpecList } from './websocket-core-bridge.js';
import { resolveCandidateFilePath } from './websocket-module-specifiers.js';

let cachedWorkspaceCoreRoot: string | null | undefined;
Expand Down Expand Up @@ -395,6 +395,7 @@ type ModuleExportSurface = {
// - `extractExportMetadata` (below): does a module have a default export
// and which named ones — shape-only, for SFC route metadata.
function scanModuleExportSurface(code: string): ModuleExportSurface {
code = maskJsComments(code);
const ownNames = new Set<string>(extractDirectExportedNames(code));
for (const m of code.matchAll(/\bexport\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*["'][^"']+["']/g)) {
ownNames.add(m[1]);
Expand Down Expand Up @@ -601,6 +602,7 @@ export function stripViteDynamicImportVirtual(code: string): string {
// answers "does this module export a default, and which named exports?" for
// the Vue SFC route metadata. It intentionally ignores re-export sources.
export function extractExportMetadata(code: string): { hasDefault: boolean; named: string[] } {
code = maskJsComments(code);
const named = new Set<string>();
let hasDefault = /\bexport\s+default\b/.test(code);
try {
Expand Down
12 changes: 12 additions & 0 deletions packages/vite/hmr/server/websocket-star-export-expansion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,16 @@ describe('expandStarExports — transitive star re-export chains', () => {
fs.rmSync(root, { recursive: true, force: true });
}
});

it('ignores commented-out export declarations in the star target', async () => {
// Shape of @nativescript-community/ui-image's index-common.js.
const importer = `export * from "/ns/m/node_modules/ui-image/index-common.js";`;
const { server, transformer } = makeServer({
'/node_modules/ui-image/index-common.js': [`module.exports = {};`, `// export const roundRadiusProperty = {};`, `/* export let alsoCommented = 1; */`, `exports.createView = () => {};`].join('\n'),
});
const out = await expandStarExports(importer, server, '/', false, transformer);
expect(out).not.toContain('roundRadiusProperty');
expect(out).not.toContain('alsoCommented');
expect(warnSpy).not.toHaveBeenCalled();
});
});
Loading