diff --git a/extensions/ext-parser-babel/src/parser-only.test.ts b/extensions/ext-parser-babel/src/parser-only.test.ts
index 449f2f2e65..06a2983ee0 100644
--- a/extensions/ext-parser-babel/src/parser-only.test.ts
+++ b/extensions/ext-parser-babel/src/parser-only.test.ts
@@ -28,6 +28,30 @@ describe("BabelParseOnlyParser", () => {
assertEquals(decorated.type, "File");
});
+ it("parses compiled JSX under a Markdown or MDX path", async () => {
+ // Markdown and MDX reach the parser as compiled JSX. Choosing the Babel
+ // plugins from the authored extension would leave JSX off and the markup
+ // would parse as a regular expression.
+ const compiled = "export default function MDXContent() { return
Title
; }";
+
+ const parsed = await Promise.all(
+ ["page.mdx", "page.md", "page.MDX"].map((filePath) =>
+ parser.parse({ code: compiled, filePath })
+ ),
+ );
+
+ assertEquals(parsed.map((ast) => ast.type), ["File", "File", "File"]);
+ });
+
+ it("keeps `x` a type assertion for a `.ts` path", async () => {
+ const asserted = await parser.parse({
+ code: "const value = input;",
+ filePath: "module.ts",
+ });
+
+ assertEquals(asserted.type, "File");
+ });
+
it("preserves Babel syntax-error identity and location metadata", async () => {
let thrown: unknown;
try {
diff --git a/extensions/ext-parser-babel/src/parser-only.ts b/extensions/ext-parser-babel/src/parser-only.ts
index 9ffad86046..8f8ae205c2 100644
--- a/extensions/ext-parser-babel/src/parser-only.ts
+++ b/extensions/ext-parser-babel/src/parser-only.ts
@@ -18,6 +18,21 @@ export interface BabelParseOnlyParserContract {
parse(options: ParseOptions): Promise;
}
+/**
+ * The path the plugin choice reasons about.
+ *
+ * Markdown and MDX can reach this parser as compiled JSX, and the authored
+ * `.md` or `.mdx` extension would switch JSX off, so the emitted markup parses
+ * as a regular expression and throws "Unterminated regular expression". Map
+ * them onto a `.tsx` path for the plugin choice only. Nothing else reads this
+ * value, and `.ts` keeps `x` a type assertion because only Markdown paths
+ * are rewritten.
+ */
+function parseablePath(filePath?: string): string | undefined {
+ if (filePath === undefined) return undefined;
+ return filePath.replace(/\.mdx?$/i, ".tsx");
+}
+
function pickPlugins(filePath?: string): parser.ParserPlugin[] {
const normalizedPath = filePath?.toLowerCase() ?? "";
const supportsJsx = !filePath ||
@@ -53,7 +68,7 @@ export class BabelParseOnlyParser implements BabelParseOnlyParserContract {
sourceType: "unambiguous",
allowReturnOutsideFunction: options.allowReturnOutsideFunction === true ||
/\.(?:cjs|js)$/.test(filePath),
- plugins: pickPlugins(options.filePath),
+ plugins: pickPlugins(parseablePath(options.filePath)),
});
const node: { type: string } = ast;
return Promise.resolve(node as ASTNode);
diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
index 9ebe487085..f8f6ee9e30 100644
--- a/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
+++ b/src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
@@ -3,8 +3,11 @@ import "../../plugins/__tests__/code-parser-setup.ts";
import { stop as stopEsbuild } from "#veryfront/platform/compat/esbuild.ts";
import { assertEquals, assertRejects, assertStringIncludes } from "#veryfront/testing/assert.ts";
import { afterAll, describe, it } from "#veryfront/testing/bdd.ts";
+import { tryResolve } from "#veryfront/extensions/contracts.ts";
+import type { CodeParser } from "#veryfront/extensions/parser/index.ts";
import {
browserServerExportsStripPlugin,
+ moduleReferenceWalkers,
stripServerOnlyExports,
} from "./browser-server-exports-strip.ts";
import { COMPILE_SOURCE_MAP_DIRECTIVE_METADATA, compilePlugin } from "./compile.ts";
@@ -1238,4 +1241,584 @@ describe("browser-server-exports-strip", () => {
assertEquals(browserServerExportsStripPlugin.condition?.(ctx("", "browser")), true);
});
});
+
+ describe("TypeScript reference classification", () => {
+ /**
+ * Both walkers' answers restricted to `names`, so a fixture asserts which
+ * module-level bindings each one attributes to runtime code without
+ * listing every local it also sees.
+ */
+ async function referencesAmong(
+ code: string,
+ names: string[],
+ filePath = "page.tsx",
+ ): Promise<{ referenced: string[]; free: string[] }> {
+ const parser = tryResolve("CodeParser");
+ if (!parser) throw new Error("no CodeParser extension is registered");
+ const ast = await parser.parse({ code, filePath });
+ const { referenced, free } = moduleReferenceWalkers(ast);
+ const pick = (found: Set) => names.filter((name) => found.has(name));
+ return { referenced: pick(referenced), free: pick(free) };
+ }
+
+ /**
+ * The defect this classification fixes is the two walkers disagreeing, so
+ * every fixture asserts the same expectation against both.
+ */
+ async function assertWalkers(
+ code: string,
+ names: string[],
+ expected: string[],
+ ): Promise {
+ const { referenced, free } = await referencesAmong(code, names);
+ assertEquals(referenced, expected, "referencedIdentifiers");
+ assertEquals(free, expected, "freeReferencedIdentifiers");
+ }
+
+ describe("erased type positions do not reference a binding", () => {
+ it("ignores `typeof` in a parameter type annotation", async () => {
+ await assertWalkers(
+ [
+ `import { KEY, used } from "./server.ts";`,
+ `export default function Page(p: { k: typeof KEY }) { return used(p); }`,
+ ].join("\n"),
+ ["KEY", "used"],
+ ["used"],
+ );
+ });
+
+ it("ignores a type alias over `ReturnType`", async () => {
+ await assertWalkers(
+ [
+ `import { loadUser, render } from "./server.ts";`,
+ `type User = ReturnType;`,
+ `export default function Page(u: User) { return render(u); }`,
+ ].join("\n"),
+ ["loadUser", "render"],
+ ["render"],
+ );
+ });
+
+ it("ignores an interface member type", async () => {
+ await assertWalkers(
+ `import { Loader, run } from "./server.ts";
+interface Shape { l: Loader; m(a: Loader): Loader }
+export default function Page() { return run(); }`,
+ ["Loader", "run"],
+ ["run"],
+ );
+ });
+
+ it("ignores the type operand of `as` and `satisfies` but keeps the value", async () => {
+ await assertWalkers(
+ `import { Cast, raw, SAT } from "./server.ts";
+export const a = raw as Cast;
+export const b = raw satisfies typeof SAT;`,
+ ["Cast", "raw", "SAT"],
+ ["raw"],
+ );
+ });
+
+ it("ignores heritage clauses in a type position", async () => {
+ await assertWalkers(
+ `import { Iface, Base, Mixin } from "./server.ts";
+interface Derived extends Base { x: number }
+export class Page extends Mixin implements Iface {}`,
+ ["Iface", "Base", "Mixin"],
+ ["Mixin"],
+ );
+ });
+
+ it("ignores type parameters and type arguments", async () => {
+ await assertWalkers(
+ `import { Bound, TArg, call } from "./server.ts";
+export function f(x: T) { return call(x); }`,
+ ["Bound", "TArg", "call"],
+ ["call"],
+ );
+ });
+
+ it("ignores type-only import and export specifiers", async () => {
+ await assertWalkers(
+ `import { hashOf, type Cfg } from "./server.ts";
+import type { Only } from "./types.ts";
+export type { Cfg };
+export { type Only };
+export const h = hashOf("x");`,
+ ["hashOf", "Cfg", "Only"],
+ ["hashOf"],
+ );
+ });
+
+ it("ignores `declare` forms and declared function signatures", async () => {
+ await assertWalkers(
+ `import { Amb, live } from "./server.ts";
+declare const ambient: Amb;
+declare function ambientFn(a: Amb): Amb;
+declare class Ambient extends Amb {}
+export const v = live();`,
+ ["Amb", "live"],
+ ["live"],
+ );
+ });
+
+ it("keeps a decorator on a declared property, which still emits a runtime call", async () => {
+ // `@audit declare id: string` is ambient in the type system, but tsc and
+ // esbuild both emit a `__decorate` call for it, so the decorator is a
+ // real read. Erasing it deleted the import the call needs and produced a
+ // ReferenceError at browser module evaluation.
+ const code = [
+ 'import { audit } from "./audit.ts";',
+ 'import { getEnv } from "veryfront";',
+ 'const KEY = getEnv("SECRET");',
+ "export async function getServerData() { return { props: { k: KEY } }; }",
+ "class Model {",
+ " @audit declare id: string;",
+ "}",
+ "export default function Page() { return null; }",
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "/project/app/page.tsx");
+
+ // The NAMED binding must survive: the emitted `__decorate` call reads
+ // `audit` by name. Asserting only on the specifier would pass even when
+ // the import is demoted to a bare side-effect import, which is the bug.
+ assertStringIncludes(result, "{ audit }");
+ assertNotIncludes(result, 'getEnv("SECRET")');
+ });
+
+ it("still erases an undecorated declared property", async () => {
+ const code = [
+ 'import { audit } from "./audit.ts";',
+ 'import { getEnv } from "veryfront";',
+ 'const KEY = getEnv("SECRET");',
+ "export async function getServerData() { return { props: { k: KEY } }; }",
+ "class Model {",
+ " declare id: string;",
+ "}",
+ "export default function Page() { return null; }",
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "/project/app/page.tsx");
+
+ // The binding goes; the module specifier is demoted to a side-effect
+ // import by dropUnusedImportBindings, which is pre-existing behaviour
+ // and not what this case is about.
+ assertNotIncludes(result, "{ audit }");
+ assertNotIncludes(result, 'getEnv("SECRET")');
+ });
+
+ it("ignores an ambient namespace but not its runtime sibling", async () => {
+ await assertWalkers(
+ `import { AMBIENT_ONLY, RUNTIME_ONLY } from "./server.ts";
+declare namespace Ambient { const a: typeof AMBIENT_ONLY; }
+namespace Runtime { export const b = RUNTIME_ONLY; }
+export const used = Runtime.b;`,
+ ["AMBIENT_ONLY", "RUNTIME_ONLY"],
+ ["RUNTIME_ONLY"],
+ );
+ });
+ });
+
+ describe("value-emitting TypeScript nodes do reference a binding", () => {
+ it("keeps an enum member initialiser", async () => {
+ await assertWalkers(
+ `import { compute, SEED } from "./server.ts";
+export enum Level { Low = compute(SEED) }`,
+ ["compute", "SEED"],
+ ["compute", "SEED"],
+ );
+ });
+
+ it("binds enum member names while walking their initialisers", async () => {
+ // `Both = Read` names a preceding MEMBER, not module scope. Without an
+ // enum-member scope the pass reported `Read` as free, pulled the
+ // unrelated `const Read = boot()` into the hook closure, and deleted it
+ // together with its side-effectful import.
+ const code = [
+ 'import { boot } from "./boot.ts";',
+ "const Read = boot();",
+ "export async function getServerData() {",
+ " enum Access { Read = 1, Both = Read }",
+ " return { props: { a: Access.Both } };",
+ "}",
+ "export default function Page() { return null; }",
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "/project/app/page.tsx");
+
+ assertStringIncludes(result, "const Read = boot()");
+ assertStringIncludes(result, "./boot.ts");
+ });
+
+ it("does not hoist a block-scoped enum into the enclosing function scope", async () => {
+ // An enum nested in a block is block scoped: TypeScript emits `let` there.
+ // Hoisting it into the function scope made the outer `consume(Alias)` read
+ // look shadowed, so import liveness reduced the named import to a bare
+ // side-effect import and left `client` with an unresolved binding.
+ const code = [
+ 'import { Alias, secret } from "./lib.ts";',
+ "export async function getServerData() { return { props: { s: secret } }; }",
+ "export function client() {",
+ " consume(Alias);",
+ " if (false) { enum Alias { X } }",
+ "}",
+ "declare function consume(v: unknown): void;",
+ "export default function Page() { client(); return null; }",
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "/project/app/page.tsx");
+
+ assertStringIncludes(result, "import { Alias");
+ });
+
+ it("still reduces an import a same-scope enum genuinely shadows", async () => {
+ // Paired with the case above: asserting only that the import survives
+ // would also pass if the pass stopped reducing imports altogether.
+ const code = [
+ 'import { Alias, secret } from "./lib.ts";',
+ "export async function getServerData() { return { props: { s: secret } }; }",
+ "export function client() {",
+ " enum Alias { X }",
+ " consume(Alias);",
+ "}",
+ "declare function consume(v: unknown): void;",
+ "export default function Page() { client(); return null; }",
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "/project/app/page.tsx");
+
+ assertNotIncludes(result, "import { Alias");
+ });
+
+ it("still strips a module binding an enum initialiser genuinely reads", async () => {
+ // No member is named `Read` here, so `Read` really is a module-scope
+ // read owned only by the hook and must still be removed.
+ const code = [
+ 'import { boot } from "./boot.ts";',
+ "const Read = boot();",
+ "export async function getServerData() {",
+ " enum Access { X = 1 }",
+ " return { props: { a: Access.X, r: Read } };",
+ "}",
+ "export default function Page() { return null; }",
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "/project/app/page.tsx");
+
+ assertNotIncludes(result, "const Read = boot()");
+ });
+
+ it("keeps a runtime namespace body", async () => {
+ await assertWalkers(
+ `import { NSREF } from "./server.ts";
+export namespace Runtime { export const value = NSREF; }`,
+ ["NSREF"],
+ ["NSREF"],
+ );
+ });
+
+ it("keeps a parameter property default", async () => {
+ await assertWalkers(
+ `import { Dep, DEFAULT_DEP } from "./server.ts";
+export class Service { constructor(private readonly dep: Dep = DEFAULT_DEP) {} }`,
+ ["Dep", "DEFAULT_DEP"],
+ ["DEFAULT_DEP"],
+ );
+ });
+
+ it("keeps an import-equals alias target", async () => {
+ await assertWalkers(
+ `import { NS } from "./server.ts";
+import Alias = NS.Sub;
+export const v = Alias;`,
+ ["NS"],
+ ["NS"],
+ );
+ });
+
+ it("keeps an export assignment operand", async () => {
+ await assertWalkers(
+ `import { handler } from "./server.ts";
+export = handler;`,
+ ["handler"],
+ ["handler"],
+ );
+ });
+
+ it("keeps an `accessor` field initialiser", async () => {
+ await assertWalkers(
+ `import { ACCESSOR_INIT } from "./server.ts";
+export class Page { accessor field = ACCESSOR_INIT; }`,
+ ["ACCESSOR_INIT"],
+ ["ACCESSOR_INIT"],
+ );
+ });
+
+ it("keeps decorator arguments on a class and on its members", async () => {
+ await assertWalkers(
+ `import { decorate, CLASS_TOKEN, inject, MEMBER_TOKEN, METHOD_TOKEN } from "./server.ts";
+@decorate(CLASS_TOKEN)
+export class Page {
+ @inject(MEMBER_TOKEN) field = 1;
+ @inject(METHOD_TOKEN) method() { return 1; }
+}`,
+ ["decorate", "CLASS_TOKEN", "inject", "MEMBER_TOKEN", "METHOD_TOKEN"],
+ ["decorate", "CLASS_TOKEN", "inject", "MEMBER_TOKEN", "METHOD_TOKEN"],
+ );
+ });
+
+ it("keeps the value side of `as`, `satisfies`, `!` and instantiation", async () => {
+ await assertWalkers(
+ `import { raw, maybe, generic, TArg } from "./server.ts";
+export const a = raw as unknown;
+export const b = maybe!;
+export const c = generic;`,
+ ["raw", "maybe", "generic", "TArg"],
+ ["raw", "maybe", "generic"],
+ );
+ });
+
+ it("treats runtime TypeScript declaration names as bindings, not reads", async () => {
+ // The flat walker remains conservative for module-declaration
+ // liveness, but enum declaration and member IDs are fixed names, not
+ // reads. Import liveness uses the scope-aware walker. That walker must
+ // report none of the local names, because its answer also grows the
+ // hook dependency closure and can delete an unrelated declaration.
+ const { referenced, free } = await referencesAmong(
+ `import { Level, Low, Runtime, Alias } from "./server.ts";
+export function hook() {
+ enum Level { Low = 1 }
+ namespace Runtime { export const v = 1; }
+ return Level.Low;
+}`,
+ ["Level", "Low", "Runtime", "Alias"],
+ );
+
+ assertEquals(free, []);
+ assertEquals(referenced, ["Level", "Runtime"]);
+ });
+ });
+
+ describe("stripping authored TypeScript source", () => {
+ it("drops a hook-only import referenced only from a type position", async () => {
+ const code = [
+ `import { hashOf } from "../lib/server-only.ts";`,
+ `export async function getServerData() {`,
+ ` return { props: { h: hashOf("x") } };`,
+ `}`,
+ `export default function Page(p: { k: typeof hashOf }) { return null; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertNotIncludes(result, "server-only.ts");
+ });
+
+ it("deletes a mixed value and type import instead of reducing it", async () => {
+ const code = [
+ `import { hashOf, type Cfg } from "../lib/server-only.ts";`,
+ `export function getServerData(): { props: { c: Cfg } } {`,
+ ` return { props: { c: hashOf() } };`,
+ `}`,
+ `export default function Page() { return null; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertNotIncludes(result, "server-only.ts");
+ });
+
+ it("keeps an import an enum member initialiser still reads", async () => {
+ const code = [
+ `import { SEED } from "../lib/shared.ts";`,
+ `export function getServerData() { return { props: { s: SEED } }; }`,
+ `export enum Level { Low = SEED }`,
+ `export default function Page() { return Level.Low; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertStringIncludes(result, "shared.ts");
+ assertStringIncludes(result, "SEED");
+ });
+
+ it("keeps a module-scope binding a runtime namespace still reads", async () => {
+ const code = [
+ `import { makeToken } from "../lib/shared.ts";`,
+ `const TOKEN = makeToken();`,
+ `export function getServerData() { return { props: { t: TOKEN } }; }`,
+ `export namespace Config { export const value = TOKEN; }`,
+ `export default function Page() { return Config.value; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertStringIncludes(result, "const TOKEN = makeToken()");
+ });
+
+ it("keeps an import a parameter property default still reads", async () => {
+ const code = [
+ `import { DEFAULT_DEP } from "../lib/shared.ts";`,
+ `export function getServerData() { return { props: { d: DEFAULT_DEP } }; }`,
+ `export class Service { constructor(public dep = DEFAULT_DEP) {} }`,
+ `export default function Page() { return new Service().dep; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertStringIncludes(result, "shared.ts");
+ assertStringIncludes(result, "DEFAULT_DEP");
+ });
+
+ it("does not keep an import binding whose name matches an enum member", async () => {
+ const code = [
+ `import { secretOnly, Low } from "../lib/server-only.ts";`,
+ `export function getServerData() { return { props: { s: secretOnly } }; }`,
+ `export enum Level { Low = 1 }`,
+ `export default function Page() { return Level.Low; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertNotIncludes(result, "{ secretOnly, Low }");
+ // A mixed project import keeps its pre-existing side-effect contract.
+ // The TypeScript fix removes the false live binding; it does not prove
+ // that evaluating the imported module is safe to delete.
+ assertStringIncludes(result, `import "../lib/server-only.ts"`);
+ assertStringIncludes(result, "Level.Low");
+ });
+
+ it("keeps an import read after a static block shadows its name", async () => {
+ const code = [
+ `import { token } from "../lib/client.ts";`,
+ `function local() { return "cache"; }`,
+ `export class Cache { static { const token = local(); } }`,
+ `export function getServerData() { return { props: { token } }; }`,
+ `export default function Page() { return token; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertStringIncludes(result, `{ token }`);
+ assertStringIncludes(result, `return token`);
+ });
+
+ it("keeps an import that matches a nested namespace segment", async () => {
+ const code = [
+ `import { B } from "../lib/client.ts";`,
+ `export namespace A.B { export const value = 1; }`,
+ `export function getServerData() { return { props: { b: B } }; }`,
+ `export default function Page() { return B; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertStringIncludes(result, `{ B }`);
+ assertStringIncludes(result, `return B`);
+ });
+
+ it("drops a hook-only binding shadowed by a surviving enum member", async () => {
+ const code = [
+ `import { getEnv } from "veryfront";`,
+ `const Read = getEnv("SECRET_KEY");`,
+ `export enum Access { Read = 1, Both = Read }`,
+ `export function getServerData() { return { props: { r: Read } }; }`,
+ `export default function Page() { return Access.Both; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertNotIncludes(result, "SECRET_KEY");
+ assertNotIncludes(result, "const Read =");
+ assertStringIncludes(result, "Both = Read");
+ });
+
+ it("keeps a module binding shadowed by a hoisted namespace var", async () => {
+ const code = [
+ `import { boot } from "../lib/analytics.ts";`,
+ `const token = boot();`,
+ `export function getServerData() {`,
+ ` namespace N { consume(token); if (false) { var token = 1; } }`,
+ ` return { props: {} };`,
+ `}`,
+ `export default function Page() { return null; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertStringIncludes(result, "const token = boot()");
+ assertStringIncludes(result, "analytics.ts");
+ });
+
+ it("does not keep an import shadowed by a hoisted namespace alias", async () => {
+ const code = [
+ `import { secretOnly, Alias } from "../lib/server-only.ts";`,
+ `export namespace M {`,
+ ` queue(() => Alias.x);`,
+ ` import Alias = ClientNS;`,
+ `}`,
+ `export function getServerData() { return { props: { s: secretOnly } }; }`,
+ `export default function Page() { return M; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertNotIncludes(result, "{ secretOnly, Alias }");
+ assertStringIncludes(result, `import "../lib/server-only.ts"`);
+ assertStringIncludes(result, "import Alias = ClientNS");
+ });
+
+ it("drops a hook-only binding that matches a qualified-name property", async () => {
+ const code = [
+ `import { getEnv } from "veryfront";`,
+ `const Sub = getEnv("SECRET_KEY");`,
+ `import Alias = ClientNS.Sub;`,
+ `export function getServerData() { return { props: { s: Sub } }; }`,
+ `export default function Page() { return Alias; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertNotIncludes(result, "SECRET_KEY");
+ assertNotIncludes(result, "const Sub =");
+ assertStringIncludes(result, "ClientNS.Sub");
+ });
+
+ it("does not keep an import shadowed by a namespace-local enum", async () => {
+ const code = [
+ `import { secretOnly, Alias } from "../lib/server-only.ts";`,
+ `export namespace M {`,
+ ` queue(() => Alias);`,
+ ` enum Alias { X }`,
+ `}`,
+ `export function getServerData() { return { props: { s: secretOnly } }; }`,
+ `export default function Page() { return M; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertNotIncludes(result, "{ secretOnly, Alias }");
+ assertStringIncludes(result, `import "../lib/server-only.ts"`);
+ assertStringIncludes(result, "enum Alias");
+ });
+
+ it("keeps a declaration whose name matches a hook-local enum member", async () => {
+ const code = [
+ `import { boot } from "../lib/analytics.ts";`,
+ `const Low = boot();`,
+ `export function getServerData() {`,
+ ` enum Level { Low = 1 }`,
+ ` return { props: { l: Level.Low } };`,
+ `}`,
+ `export default function Page() { return null; }`,
+ ].join("\n");
+
+ const result = await stripServerOnlyExports(code, "page.tsx");
+
+ assertStringIncludes(result, "const Low = boot()");
+ assertStringIncludes(result, "analytics.ts");
+ });
+ });
+ });
});
diff --git a/src/transforms/pipeline/stages/browser-server-exports-strip.ts b/src/transforms/pipeline/stages/browser-server-exports-strip.ts
index 63d7d5bcf1..d68030f085 100644
--- a/src/transforms/pipeline/stages/browser-server-exports-strip.ts
+++ b/src/transforms/pipeline/stages/browser-server-exports-strip.ts
@@ -119,6 +119,82 @@ function walk(node: Node, visit: (node: Node) => boolean | void): void {
for (const child of children(node)) walk(child, visit);
}
+/**
+ * TypeScript nodes that survive type erasure and emit runtime code.
+ *
+ * Everything else the TypeScript grammar adds is erased before the module
+ * runs, so an identifier read inside it is a type reference and must not keep a
+ * binding alive. Getting the split wrong is unsafe in both directions: treating
+ * a runtime node as erased deletes live code, and treating an erased node as
+ * runtime pins a server-only import into the browser artifact.
+ *
+ * The list is closed and enumerable, which is the point: it is a decidable
+ * question, unlike proving what a module does to an intrinsic. A TypeScript
+ * node type this pass does not know is erased by default. Any new TypeScript
+ * node type that emits runtime code must be added to this allowlist.
+ *
+ * The split is invisible while this stage runs after the compile stage, which
+ * erases every TypeScript node before this pass sees the module. It exists so
+ * the stage stays correct when it runs on authored source.
+ */
+const RUNTIME_TS_NODE_TYPES = new Set([
+ // Value expressions wrapping a value expression plus an erased type operand.
+ "TSAsExpression",
+ "TSSatisfiesExpression",
+ "TSNonNullExpression",
+ "TSTypeAssertion",
+ "TSInstantiationExpression",
+ // `enum E { A = compute() }` emits an object and evaluates each initialiser.
+ "TSEnumDeclaration",
+ "TSEnumBody",
+ "TSEnumMember",
+ // `namespace N { … }` with a body emits an IIFE over a runtime object.
+ "TSModuleDeclaration",
+ "TSModuleBlock",
+ // `constructor(private dep = fallback())` emits an assignment in the body.
+ "TSParameterProperty",
+ // `import L = require("./l.ts")` and `import A = N.Sub` both emit a binding.
+ "TSImportEqualsDeclaration",
+ "TSExternalModuleReference",
+ "TSQualifiedName",
+ // `export = handler` emits an assignment to the module export.
+ "TSExportAssignment",
+]);
+
+/**
+ * Whether the compiler erases `node` and everything under it, so no identifier
+ * inside it is a runtime read.
+ *
+ * Both reference walkers ask this, and they must ask the same question. A
+ * walker that counts a type-position read as a runtime reference keeps the
+ * server-only import that binding came from; a walker that skips a runtime
+ * TypeScript node reports live code as dead.
+ */
+/** Whether a node carries decorators, which emit a runtime call even when the
+ * declaration they annotate is ambient. */
+function nodeHasDecorators(node: Node): boolean {
+ const decorators = node.decorators;
+ return Array.isArray(decorators) && decorators.length > 0;
+}
+
+function isErasedTypeNode(node: Node): boolean {
+ // `declare const`, `declare class`, `declare namespace`, `declare enum` and
+ // `declare prop: T` are all ambient: they emit nothing.
+ //
+ // Decorators are the exception. Both tsc and esbuild emit a runtime
+ // `__decorate` call for `@audit declare id: string`, so the decorator
+ // expression is a real read even though the property it annotates is not.
+ // Erasing it here deletes the import the decorator needs and the emitted
+ // call then throws a ReferenceError at module evaluation.
+ if (node.declare === true) return !nodeHasDecorators(node);
+ // `import { type Cfg }`, `export { type Cfg }`, `export type { Cfg }`.
+ if (node.importKind === "type" || node.exportKind === "type") return true;
+ if (!node.type.startsWith("TS")) return false;
+ if (!RUNTIME_TS_NODE_TYPES.has(node.type)) return true;
+ // An ambient `declare module "x";` has no body to run.
+ return node.type === "TSModuleDeclaration" && !isNode(node.body);
+}
+
function nodeName(value: unknown): string | null {
if (!isNode(value)) return null;
const name = value.name;
@@ -168,6 +244,13 @@ function patternBoundNames(pattern: Node): string[] {
return;
}
+ // `constructor(private dep: Dep)` binds `dep` as a parameter and assigns
+ // it to `this` at runtime.
+ if (node.type === "TSParameterProperty") {
+ if (isNode(node.parameter)) collect(node.parameter);
+ return;
+ }
+
if (node.type === "ArrayPattern") {
for (const element of Array.isArray(node.elements) ? node.elements : []) {
if (isNode(element)) collect(element);
@@ -313,7 +396,8 @@ function emptyServerOnlyHooks(
/**
* Identifiers the module reads, ignoring import statements and the positions
* where an identifier is a fixed name rather than a reference (`a.hashOf`,
- * `{ hashOf: 1 }`). Over-counting only ever keeps an import.
+ * `{ hashOf: 1 }`). This flat walk is used for module-declaration liveness;
+ * import liveness uses the scope-aware walker below.
*
* `excluded` holds identifier nodes that are binding *positions* rather than
* references (the `id` a declaration introduces), so a declaration is not
@@ -329,20 +413,54 @@ function referencedIdentifiers(body: Node[], excluded?: WeakSet): Set {
+ if (node.type !== "TSEnumDeclaration") return;
+ const container = isNode(node.body) ? node.body : node;
+ const members = Array.isArray(container.members) ? container.members : [];
+ const localNames = new Set();
+ const enumName = nodeName(node.id);
+ if (enumName) localNames.add(enumName);
+ for (const member of members) {
+ if (!isNode(member)) continue;
+ const memberId = isNode(member.id) ? member.id : undefined;
+ const memberName = nodeName(memberId) ?? stringLiteralText(memberId);
+ if (memberName) localNames.add(memberName);
+ }
+ for (const member of members) {
+ if (!isNode(member) || !isNode(member.initializer)) continue;
+ walk(member.initializer, (candidate) => {
+ if (
+ candidate.type === "Identifier" &&
+ localNames.has(nodeName(candidate) ?? "")
+ ) fixedNames.add(candidate);
+ });
+ }
+ };
+
for (const statement of body) {
if (statement.type === "ImportDeclaration") continue;
walk(statement, (node) => {
if (node.type === "ImportDeclaration") return false;
+ // A type position is not a runtime read. Without this the walker counts
+ // `p: typeof KEY` as a use of `KEY` and keeps the server-only import it
+ // came from.
+ if (isErasedTypeNode(node)) return false;
+ markEnumLocalReferences(node);
markFixedName(node);
if (node.type === "Identifier" || node.type === "JSXIdentifier") {
@@ -438,6 +556,15 @@ function freeReferencedIdentifiers(root: Node): Set {
for (const name of patternBoundNames(value)) scope.names.add(name);
};
+ const bindHoistedRuntimeTsDeclaration = (scope: LexicalScope, node: Node): boolean => {
+ if (
+ node.type !== "TSEnumDeclaration" && node.type !== "TSModuleDeclaration" &&
+ node.type !== "TSImportEqualsDeclaration"
+ ) return false;
+ if (!isErasedTypeNode(node)) bindPatternNames(scope, node.id);
+ return true;
+ };
+
const bindDirectDeclarations = (scope: LexicalScope, node: Node): void => {
const body = node.body;
if (!Array.isArray(body)) return;
@@ -448,6 +575,7 @@ function freeReferencedIdentifiers(root: Node): Set {
bindPatternNames(scope, statement.id);
continue;
}
+ if (bindHoistedRuntimeTsDeclaration(scope, statement)) continue;
if (statement.type !== "VariableDeclaration") continue;
for (
const declarator of Array.isArray(statement.declarations) ? statement.declarations : []
@@ -459,10 +587,20 @@ function freeReferencedIdentifiers(root: Node): Set {
const bindNestedVarDeclarations = (scope: LexicalScope, node: Node): void => {
for (const child of children(node)) {
+ // Only `var` hoists. An enum, a namespace or an import-equals nested in a
+ // block is block scoped (TypeScript emits `let` there), so binding it
+ // into the enclosing function scope makes an unrelated outer read look
+ // shadowed. `bindDirectDeclarations` already binds these at whichever
+ // scope actually contains them, so they need no hoisting pass.
+ if (
+ child.type === "TSEnumDeclaration" || child.type === "TSImportEqualsDeclaration"
+ ) continue;
if (
child.type === "FunctionDeclaration" || child.type === "FunctionExpression" ||
child.type === "ArrowFunctionExpression" || child.type === "ObjectMethod" ||
- child.type === "ClassMethod"
+ child.type === "ClassMethod" || child.type === "ClassDeclaration" ||
+ child.type === "ClassExpression" || child.type === "StaticBlock" ||
+ child.type === "TSModuleDeclaration"
) {
continue;
}
@@ -474,7 +612,6 @@ function freeReferencedIdentifiers(root: Node): Set {
if (isNode(declarator)) bindPatternNames(scope, declarator.id);
}
}
-
bindNestedVarDeclarations(scope, child);
}
};
@@ -483,8 +620,23 @@ function freeReferencedIdentifiers(root: Node): Set {
for (const child of children(node)) visit(child, scopes);
};
+ const visitDecorators = (node: Node, scopes: LexicalScope[]): void => {
+ for (const decorator of Array.isArray(node.decorators) ? node.decorators : []) {
+ if (isNode(decorator)) visit(decorator, scopes);
+ }
+ };
+
const visitPatternRuntime = (pattern: Node, scopes: LexicalScope[]): void => {
- if (pattern.type === "Identifier") return;
+ if (pattern.type === "Identifier") {
+ visitDecorators(pattern, scopes);
+ return;
+ }
+
+ if (pattern.type === "TSParameterProperty") {
+ visitDecorators(pattern, scopes);
+ if (isNode(pattern.parameter)) visitPatternRuntime(pattern.parameter, scopes);
+ return;
+ }
if (pattern.type === "AssignmentPattern") {
if (isNode(pattern.left)) visitPatternRuntime(pattern.left, scopes);
@@ -572,6 +724,7 @@ function freeReferencedIdentifiers(root: Node): Set {
};
const visitObjectMember = (node: Node, scopes: LexicalScope[]): void => {
+ visitDecorators(node, scopes);
if (node.computed === true && isNode(node.key)) visit(node.key, scopes);
if (isNode(node.value)) visit(node.value, scopes);
};
@@ -606,23 +759,12 @@ function freeReferencedIdentifiers(root: Node): Set {
}
};
- const visitTsExpression = (node: Node, scopes: LexicalScope[]): boolean => {
- if (
- node.type === "TSAsExpression" || node.type === "TSTypeAssertion" ||
- node.type === "TSNonNullExpression" || node.type === "TSInstantiationExpression" ||
- node.type === "TSSatisfiesExpression"
- ) {
- if (isNode(node.expression)) visit(node.expression, scopes);
- return true;
- }
-
- if (node.type.startsWith("TS")) return true;
- return false;
- };
-
const visit = (node: Node, scopes: LexicalScope[]): void => {
if (node.type === "ImportDeclaration") return;
- if (visitTsExpression(node, scopes)) return;
+ // Same classification `referencedIdentifiers` uses. A value-emitting
+ // TypeScript node such as an enum or a namespace body falls through to the
+ // generic walk below, and its erased type operand is skipped there in turn.
+ if (isErasedTypeNode(node)) return;
if (node.type === "Identifier" || node.type === "JSXIdentifier") {
const name = nodeName(node);
@@ -630,7 +772,10 @@ function freeReferencedIdentifiers(root: Node): Set {
return;
}
- if (node.type === "Program" || node.type === "BlockStatement") {
+ if (
+ node.type === "Program" || node.type === "BlockStatement" ||
+ node.type === "TSModuleBlock"
+ ) {
const scope: LexicalScope = { kind: "block", names: new Set() };
bindDirectDeclarations(scope, node);
for (const statement of Array.isArray(node.body) ? node.body : []) {
@@ -639,6 +784,19 @@ function freeReferencedIdentifiers(root: Node): Set {
return;
}
+ if (node.type === "StaticBlock") {
+ // A static block is its own var and lexical scope. Without this, a local
+ // declaration can bind the surrounding program scope and hide a later
+ // read of an imported binding with the same name.
+ const staticScope: LexicalScope = { kind: "function", names: new Set() };
+ bindDirectDeclarations(staticScope, node);
+ bindNestedVarDeclarations(staticScope, node);
+ for (const statement of Array.isArray(node.body) ? node.body : []) {
+ if (isNode(statement)) visit(statement, [staticScope, ...scopes]);
+ }
+ return;
+ }
+
if (node.type === "VariableDeclaration") {
visitVariableDeclaration(node, scopes);
return;
@@ -652,8 +810,65 @@ function freeReferencedIdentifiers(root: Node): Set {
return;
}
+ // A runtime TypeScript declaration binds its own name and, for an enum,
+ // names its members. Only the initialisers read anything, so descending
+ // blindly would report `enum Level { Low }` as a read of an unrelated
+ // module-scope `Low` and let the pass delete it.
+ if (node.type === "TSEnumDeclaration") {
+ bindPatternNames(scopes[0] ?? rootScope, node.id);
+ const container = isNode(node.body) ? node.body : node;
+ const members = Array.isArray(container.members) ? container.members : [];
+ // Member initialisers can name a preceding member without qualifying it,
+ // as in `enum Access { Read = 1, Both = Read }`. Those names resolve to
+ // the enum, not to module scope, so bind them in their own scope first:
+ // otherwise `Read` reads as free and the pass pulls an unrelated
+ // module-scope `Read` into the hook closure and deletes it.
+ const scope: LexicalScope = { kind: "block", names: new Set() };
+ for (const member of members) {
+ if (!isNode(member)) continue;
+ const memberId = isNode(member.id) ? member.id : undefined;
+ const memberName = nodeName(memberId) ?? stringLiteralText(memberId);
+ if (memberName) scope.names.add(memberName);
+ }
+ for (const member of members) {
+ if (isNode(member) && isNode(member.initializer)) {
+ visit(member.initializer, [scope, ...scopes]);
+ }
+ }
+ return;
+ }
+
+ if (node.type === "TSModuleDeclaration") {
+ bindPatternNames(scopes[0] ?? rootScope, node.id);
+ // Every emitted namespace IIFE introduces its own binding scope. For a
+ // dotted declaration such as `namespace A.B`, B belongs to A's scope,
+ // not to the surrounding module.
+ const namespaceScope: LexicalScope = { kind: "function", names: new Set() };
+ bindPatternNames(namespaceScope, node.id);
+ if (isNode(node.body)) {
+ if (node.body.type === "TSModuleBlock") {
+ bindNestedVarDeclarations(namespaceScope, node.body);
+ }
+ visit(node.body, [namespaceScope, ...scopes]);
+ }
+ return;
+ }
+
+ if (node.type === "TSImportEqualsDeclaration") {
+ bindPatternNames(scopes[0] ?? rootScope, node.id);
+ if (isNode(node.moduleReference)) visit(node.moduleReference, scopes);
+ return;
+ }
+
+ // `import Alias = NS.Sub`: only `NS` is a read, `Sub` is a fixed name.
+ if (node.type === "TSQualifiedName") {
+ if (isNode(node.left)) visit(node.left, scopes);
+ return;
+ }
+
if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
if (node.type === "ClassDeclaration") bindPatternNames(scopes[0] ?? rootScope, node.id);
+ visitDecorators(node, scopes);
const body = node.body;
if (isNode(body)) visitChildren(body, scopes);
if (isNode(node.superClass)) visit(node.superClass, scopes);
@@ -689,12 +904,16 @@ function freeReferencedIdentifiers(root: Node): Set {
return;
}
- if (node.type === "ObjectProperty" || node.type === "ClassProperty") {
+ if (
+ node.type === "ObjectProperty" || node.type === "ClassProperty" ||
+ node.type === "ClassAccessorProperty"
+ ) {
visitObjectMember(node, scopes);
return;
}
if (node.type === "ObjectMethod" || node.type === "ClassMethod") {
+ visitDecorators(node, scopes);
if (node.computed === true && isNode(node.key)) visit(node.key, scopes);
visitFunction(node, scopes);
return;
@@ -748,6 +967,33 @@ function hookReferencedIdentifiers(body: Node[], targets: Set): Set;
+ free: Set;
+} {
+ const program = (ast as { program?: unknown }).program;
+ const root: Node = isNode(program) ? program : ast;
+ return {
+ referenced: referencedIdentifiers(bodyOf(ast)),
+ free: freeReferencedIdentifiers(root),
+ };
+}
+
function literalText(node: Node | undefined): string | null {
if (!node) return null;
return typeof node.value === "string" ? node.value : nodeName(node);
@@ -980,6 +1226,11 @@ function importedBindings(statement: Node): string[] {
for (const specifier of Array.isArray(statement.specifiers) ? statement.specifiers : []) {
if (!isNode(specifier)) continue;
+ // `import { hashOf, type Cfg }`: `Cfg` is erased before the module runs, so
+ // it is not a binding that has to be kept alive. Counting it would stop
+ // `hashOf` alone from proving the import hook-only, and the statement would
+ // be reduced to a side-effect import instead of deleted.
+ if (specifier.importKind === "type") continue;
const name = nodeName(specifier.local);
if (name) bindings.push(name);
}
@@ -996,7 +1247,10 @@ function importedBindings(statement: Node): string[] {
* the legacy conservative side-effect rewrite.
*/
function dropUnusedImportBindings(body: Node[], hookClosure: Set): Node[] {
- const referenced = referencedIdentifiers(body);
+ // Import liveness must be scope-aware. A local enum member, namespace,
+ // parameter property, or ordinary nested binding can share a spelling with
+ // an import without reading that imported binding.
+ const referenced = freeReferencedIdentifiers({ type: "Program", body });
return body.filter((statement) => {
if (statement.type !== "ImportDeclaration") return true;