fix(js): an export default line inside a template literal no longer names the default (#3068)

statedDefaultExport reads a JS/TS file's own export default statement
by scanning comment-stripped source for line-initial `export default`
heads. Strings are deliberately not masked there (a masker that loses
step on nested templates blanked real statements), and a head counted
when it named a node of the file, the first such head winning. A code
generator's template text breaks that: in

  export function App() {}  export function Main() {}
  export const template = `
  export default App
  `;
  export default Main;

the template's line names the real App, so `import X from './gen'`
bound to App instead of Main (issue #2968). A template holding
`export default () => …` also made a module whose real default is an
expression read as anonymous, which keeps every guess away.

templateLiteralTextAt (strip-comments.ts) scans the source once for
template-literal text: it follows `${…}` nesting (a template inside an
interpolation, a brace inside a regex there), and skips comments,
quoted strings and regex literals, which may hold a backtick; a `/`
after a value divides, anywhere else it opens a regex. A head inside
template text is now skipped. When some template never closes the scan
vouches for nothing and every head counts, as before. Nothing else
changes: a head outside template text is read exactly as it was.

Validation: default-export-declaration.test.ts gains the issue's file,
a template returned by a function behind a nested template, one passed
at module scope and one in a class field, a template's anonymous
default above an expression default, and controls (a regex and a string
holding a backtick, a division after a parenthesis, and a file whose
scan can't close a template); strip-comments.test.ts tests the scan
directly. Red with main's resolver: 2 of 10 failed; green: all pass.
Checked against tree-sitter on every line-initial head of 4,191
JS/TS files in 16 corpora (outline, excalidraw, vben, bitwarden apps,
mastodon, shadcn docs, zod, bulletproof-react, express, astro, react,
trezor-suite, this repo's src, tests and ui): of 4,149 real export
default statements the scan puts none inside template text, and all
322 heads it does place there are template text. A/B on excalidraw
d3a9c2a1, vben 61c07936, astro e3bec3d2 (packages/astro/src and
packages/integrations), with gin-gonic/gin 0f09c3a9 and psf/requests
3c7baaa0 as controls, both arms on main 4b0f72b7 through the native
kernel (and again on 66449255 through wasm): all six byte-identical,
since no default import there is decided by template text; index time
unchanged.

Not in this change: hasEsmDefaultExport, which only gates
require('./x').default, still reads the raw text.

Reimplements PR #3042 by @johnhldiver-beep.

Fixes #2968

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 12:55:21 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 617769279d
commit ed6b88e53a
5 changed files with 250 additions and 4 deletions
+9 -3
View File
@@ -12,7 +12,7 @@ import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport
import { applyAliases, aliasAndBaseUrlCandidates } from './path-aliases';
import { extractLocalExportAliases } from './alias-binding';
import { resolveWorkspaceImport, workspaceImportTargets, type WorkspaceImportTargets } from './workspace-packages';
import { stripCommentsForRegex } from './strip-comments';
import { stripCommentsForRegex, templateLiteralTextAt } from './strip-comments';
import { makeLineAt } from './synth-utils';
import { dartDirectiveFile } from './dart-libraries';
import { ambientModuleDeclares, findAmbientModuleExport, findAmbientModuleFile, inBlock, isInModuleBlock, namespaceBlocks, type NamespaceBlock } from './ambient-modules';
@@ -207,8 +207,12 @@ function readDefaultExport(code: string, at: number): { name: string; nameAt: nu
* template whose `${…}` holds another template, or a regex with a backtick,
* sets a masker out of step, and it blanked the real statement after it
* (outline's 2,900-line styled-components `Styles.ts`). A statement must
* name a node of the file instead — one a template only writes as text
* names none — and of several statements, one that does wins.
* name a node of the file instead, and of several statements, one that does
* wins. A line of a template's text is no statement either, though it can
* name one: a code generator's `export default App` names the file's own
* `App` (#2968). So a head inside template text is skipped, when a scan that
* follows `${…}` nesting and regex literals closes every template; one that
* doesn't vouches for nothing, and every head counts.
*/
function statedDefaultExport(filePath: string, idx: FileExportIndex, context: ResolutionContext): StatedDefault {
if (idx.statedDefault !== undefined) return idx.statedDefault;
@@ -217,8 +221,10 @@ function statedDefaultExport(filePath: string, idx: FileExportIndex, context: Re
if (source && /\bexport\s+default\b/.test(source)) {
const code = stripCommentsForRegex(source, 'typescript');
const lineAt = makeLineAt(code, 1);
const inTemplateText = templateLiteralTextAt(source);
EXPORT_DEFAULT_HEAD.lastIndex = 0;
for (let m = EXPORT_DEFAULT_HEAD.exec(code); m !== null; m = EXPORT_DEFAULT_HEAD.exec(code)) {
if (inTemplateText?.(m.index)) continue;
const value = readDefaultExport(code, m.index + m[0].length);
if (value === 'anonymous') {
stated ??= 'anonymous';
+87
View File
@@ -82,6 +82,93 @@ export function blankStringContents(text: string, regexLiterals?: number[]): str
return out.join('');
}
/** A keyword after which a `/` opens a regex literal, at the end of the text before it. */
const REGEX_AFTER_KEYWORD = /(?:^|[^\w$])(?:return|throw|case|yield|await|else|do|typeof|void|delete|new|in|of|instanceof)$/;
/**
* Whether the `/` at `at` opens a regex literal: it does where an expression
* starts, and divides after a value — a `)`, `]`, string, name or number.
*/
function slashOpensRegex(src: string, at: number): boolean {
let j = at - 1;
while (j >= 0 && (src[j] === ' ' || src[j] === '\t' || src[j] === '\n' || src[j] === '\r')) j--;
if (j < 0) return true;
const before = src[j]!;
if (before === ')' || before === ']' || before === '"' || before === "'" || before === '`') return false;
if (/[\w$]/.test(before)) return REGEX_AFTER_KEYWORD.test(src.slice(Math.max(0, j - 10), j + 1));
return true;
}
/**
* Where JS/TS template-literal text lies in `src`: a test of whether offset
* `at` falls in a template's literal text — not its `${…}` code — or null when
* a template never closes, so the scan vouches for nothing. Offsets match
* {@link stripCommentsForRegex}'s output, which keeps them.
*
* Unlike {@link blankStringContents} it follows `${…}` nesting (a template in
* an interpolation, a brace in a regex there) and skips comments, strings and
* regex literals, which may hold a backtick (`/[`]/`).
*/
export function templateLiteralTextAt(src: string): ((at: number) => boolean) | null {
/** Template text spans, `[start, end)` pairs in order. */
const spans: number[] = [];
/** Innermost last: an open template's current text start, or an open `${…}`'s unclosed braces. */
const open: Array<{ text: number } | { braces: number }> = [];
const n = src.length;
for (let i = 0; i < n; i++) {
const c = src[i];
const top = open[open.length - 1];
if (top && 'text' in top) {
if (c === '\\') i++;
else if (c === '`') {
spans.push(top.text, i);
open.pop();
} else if (c === '$' && src[i + 1] === '{') {
spans.push(top.text, i);
open.push({ braces: 0 });
i++;
}
continue;
}
if (c === '`') open.push({ text: i + 1 });
else if (c === '"' || c === "'") {
for (i++; i < n && src[i] !== c && src[i] !== '\n'; i++) if (src[i] === '\\') i++;
} else if (c === '/' && src[i + 1] === '/') {
while (i < n && src[i] !== '\n') i++;
} else if (c === '/' && src[i + 1] === '*') {
const close = src.indexOf('*/', i + 2);
i = close < 0 ? n : close + 1;
} else if (c === '/' && slashOpensRegex(src, i)) {
let inClass = false;
for (let j = i + 1; j < n && src[j] !== '\n'; j++) {
if (src[j] === '\\') j++;
else if (src[j] === '[') inClass = true;
else if (src[j] === ']') inClass = false;
else if (src[j] === '/' && !inClass) {
i = j;
break;
}
}
} else if (top && c === '{') top.braces++;
else if (top && c === '}' && top.braces-- === 0) {
open.pop();
(open[open.length - 1] as { text: number }).text = i + 1;
}
}
if (open.length > 0) return null;
return (at) => {
// The first span ending after `at`, then whether it starts at or before it.
let lo = 0;
let hi = spans.length / 2;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (spans[2 * mid + 1]! <= at) lo = mid + 1;
else hi = mid;
}
return lo < spans.length / 2 && spans[2 * lo]! <= at;
};
}
export type CommentLang =
| 'python'
| 'javascript'