From 3b3cb6a5b71b10151fde60fa71f7d2471c0f06ec Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 22:05:18 +0530 Subject: [PATCH 01/10] fix: erase TypeScript types before the elision scans read a module A parenthesised type annotation on a top-level const read as a top-level call, so a module holding nothing but typed data was classified as running code at module scope. `readonly (readonly [number, number, number])[]` is an identifier immediately followed by `(`, and the module-scope scan matches exactly that shape. The cost was silent: no error, no warning, identical behaviour, and every route module importing such a helper downgraded from inert to shipping whole, with a component carrying one forced to ship along with the display-only children it renders. The analyser now erases type syntax with the framework's own stripper before it scans, at the single point where it reads a file, so the module-scope, template, import, and component scans all read the code that actually runs. Using the stripper rather than a lexical annotation matcher keeps the analyser and the runtime in agreement by construction, and it is position-preserving, so every offset the other scans depend on is unchanged. Non-TypeScript files and a source the stripper rejects are scanned as authored, which is the previous behaviour and therefore the conservative direction. --- packages/server/src/component-elision.js | 72 +++++++- .../test/elision/type-annotations.test.js | 167 ++++++++++++++++++ 2 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 packages/server/test/elision/type-annotations.test.js diff --git a/packages/server/src/component-elision.js b/packages/server/src/component-elision.js index 32a89ea71..7188b693b 100644 --- a/packages/server/src/component-elision.js +++ b/packages/server/src/component-elision.js @@ -45,6 +45,7 @@ import { redactToPlaceholders, } from './js-scan.js'; import { transitiveDeps, expandImportAlias } from './module-graph.js'; +import { stripTypeScript } from './ts-strip.js'; /** * Named imports from a `@webjsdev/core` specifier that imply the @@ -285,13 +286,22 @@ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage| * * Scans the redacted copy (strings / templates / comments blanked, regex * literals and nested `${...}` interpolation tracked by the lexer) so template - * prose and JSDoc / TS type annotations cannot trip it; quoted-string bodies, - * which redaction keeps verbatim for other rules, are blanked here too so a - * string like `"foo()"` or `"{"` is not read as a call and cannot unbalance - * the brace scan. The unbalanced-brace and unterminated-string fallbacks below - * are defense in depth: with the lexer tracking regex literals, neither should - * trigger on valid code, but if either does the module ships rather than risk - * hiding client work. + * prose and JSDoc cannot trip it; quoted-string bodies, which redaction keeps + * verbatim for other rules, are blanked here too so a string like `"foo()"` or + * `"{"` is not read as a call and cannot unbalance the brace scan. The + * unbalanced-brace and unterminated-string fallbacks below are defense in + * depth: with the lexer tracking regex literals, neither should trigger on + * valid code, but if either does the module ships rather than risk hiding + * client work. + * + * PASS TYPE-ERASED SOURCE for a TypeScript module. Redaction blanks comments + * and literals, NOT type syntax, and TS annotations are call-shaped often + * enough to matter: `readonly (readonly [number, number, number])[]` is an + * identifier immediately followed by `(`, so a module holding nothing but a + * typed data literal reads as running code at module scope (#1423). The + * analyser erases types before it calls this (`eraseTypesForScan`), so the + * request path is covered; a DIRECT caller handing it `.ts` source as authored + * is opting into that false positive. * * Over-detection is safe (a top-level arrow whose body calls something, or a * pure top-level helper call, only ships). The accepted residual misses, all @@ -299,7 +309,7 @@ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage| * top-level object / array initializer or a destructuring default, and a * side-effecting tagged-template hole evaluated at module scope. * - * @param {string} src raw module source + * @param {string} src module source, type-erased if it came from a `.ts` file */ /** * Constructors that produce inert DATA with no side effect, so a module-scope @@ -893,6 +903,47 @@ export function extractRenderedTags(src) { * @param {string} [appDir] app root; enables the helper-closure render rule * @returns {Promise>} absolute paths of elidable component files */ +/** Source extensions whose types must be erased before the scans below read them. */ +const TS_SOURCE_RE = /\.(?:m|c)?tsx?$/; + +/** + * Return `src` with its TYPE syntax erased, so every scan below reads the code + * that actually RUNS rather than the code as authored (#1423). + * + * The scans are lexical heuristics, and TypeScript annotations are syntax the + * heuristics were never designed for: `readonly (readonly [number, number, + * number])[]` is an identifier immediately followed by `(`, which the + * top-level-call matcher in `hasModuleScopeSideEffect` reads as a call, so a + * module holding nothing but a typed data literal was classified as running + * code at module scope and pinned every route module that imported it. Type + * syntax is erased before anything runs, so it can never be a runtime signal, + * and the only sound reading of it is none at all. + * + * This uses the framework's own stripper rather than a lexical annotation + * matcher: it is the same erasure the browser is served, so the analyser and + * the runtime agree by construction, where a hand-rolled matcher would meet + * generics, `as`, `satisfies`, and conditional types and get some of them + * wrong. It is position-preserving whitespace replacement, so every offset, + * line, and column the other scans depend on is unchanged. + * + * Non-TS files are returned untouched (there is nothing to erase, and running a + * TS parser over them buys only new ways to fail). A strip FAILURE also returns + * the source untouched, which is the pre-#1423 behaviour and therefore the + * conservative direction: the scans then over-detect at worst. + * + * @param {string} file absolute path, read for its extension only + * @param {string} src + * @returns {Promise} + */ +async function eraseTypesForScan(file, src) { + if (!TS_SOURCE_RE.test(file)) return src; + try { + return await stripTypeScript(src); + } catch { + return src; + } +} + export async function computeElidableComponents(components, moduleGraph, readFileFn, appDir) { const { elidableComponents } = await analyzeElision(components, [], moduleGraph, readFileFn, appDir); return elidableComponents; @@ -989,6 +1040,11 @@ export async function analyzeElision(components, routeModules, moduleGraph, read continue; } if (typeof src !== 'string') continue; + // Erase type syntax first, so every scan below reads the code that RUNS + // (#1423). This is the single point where the analyser reads a file, so + // doing it here covers the module-scope, template, import, and component + // scans at once. + src = await eraseTypesForScan(file, src); // Mask comments once for every signal scan below (#179): a ``, an // `@event`, a browser global, an `import`, or a `whenDefined` written in a // comment must not register as a real signal. String and template content diff --git a/packages/server/test/elision/type-annotations.test.js b/packages/server/test/elision/type-annotations.test.js new file mode 100644 index 000000000..0d22df1f6 --- /dev/null +++ b/packages/server/test/elision/type-annotations.test.js @@ -0,0 +1,167 @@ +/** + * A TypeScript TYPE ANNOTATION must never be read as runtime behaviour by the + * elision analyser (#1423). + * + * The scans in `component-elision.js` are lexical heuristics over source that + * has had comments and literals redacted, and type syntax was not on that list. + * `readonly (readonly [number, number, number])[]` is an identifier immediately + * followed by `(`, so `hasModuleScopeSideEffect`'s top-level-call matcher read + * it as a call and classified a module of pure typed data as running code at + * module scope. The cost was invisible: no error, no warning, identical + * behaviour, just every importing route module downgraded from inert to + * shipping whole, and any component carrying such an annotation forced to ship + * along with the display-only children it renders. + * + * These tests drive the REAL pipeline over a REAL `.ts` app on disk, because + * that is the only level the fix lives at: the analyser erases types before it + * scans, so a unit call on a source string would prove nothing about the path + * that decides what the browser downloads. The three positive cases are the + * issue's acceptance criteria, and the last one is the counterweight that + * keeps them honest, since blanket-erasing too much would satisfy the first + * three by simply never detecting anything. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { buildModuleGraph } from '../../src/module-graph.js'; +import { scanComponents } from '../../src/component-scanner.js'; +import { analyzeElision } from '../../src/component-elision.js'; + +/** The tic-tac-toe declaration from the report, verbatim in shape. */ +const LINES_TS = ` +export type Cell = 'X' | 'O' | null; +export interface Board { cells: Cell[]; } +export const LINES: readonly (readonly [number, number, number])[] = [ + [0, 1, 2], [3, 4, 5], [6, 7, 8], + [0, 3, 6], [1, 4, 7], [2, 5, 8], + [0, 4, 8], [2, 4, 6], +]; +export function winner(cells: Cell[]): Cell { + for (const [a, b, c] of LINES) { + if (cells[a] && cells[a] === cells[b] && cells[b] === cells[c]) return cells[a]; + } + return null; +} +`; + +/** + * Write a throwaway TypeScript app, run the real pipeline over it, and return + * the verdict plus the paths the assertions key on. + * + * @param {{ utilSrc: string, badgeExtra?: string }} spec + */ +async function analyseApp(spec) { + const dir = await mkdtemp(join(tmpdir(), 'webjs-type-annotations-')); + try { + await mkdir(join(dir, 'app'), { recursive: true }); + await mkdir(join(dir, 'components'), { recursive: true }); + await mkdir(join(dir, 'modules/game/utils'), { recursive: true }); + await writeFile(join(dir, 'modules/game/utils/game.ts'), spec.utilSrc); + await writeFile(join(dir, 'components/badge.ts'), ` +import { WebComponent, html } from '@webjsdev/core'; +import { LINES } from '../modules/game/utils/game.ts'; +export class Badge extends WebComponent { + ${spec.badgeExtra || ''} + render() { return html\`\${LINES.length}\`; } +} +Badge.register('my-badge'); +`); + await writeFile(join(dir, 'app/page.ts'), ` +import { html } from '@webjsdev/core'; +import '../components/badge.ts'; +export default () => html\`\`; +`); + + const graph = await buildModuleGraph(dir); + const components = await scanComponents(dir); + const pageFile = join(dir, 'app/page.ts'); + const badgeFile = join(dir, 'components/badge.ts'); + const verdict = await analyzeElision( + components, [pageFile], graph, (f) => readFile(f, 'utf8'), dir, + ); + return { verdict, pageFile, badgeFile }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('a parenthesised type annotation does not mark its module as running code at module scope', async () => { + const { verdict, pageFile } = await analyseApp({ utilSrc: LINES_TS }); + const shipped = verdict.shippedRouteModules.get(pageFile); + assert.equal(shipped, undefined, + `the page must not ship; it did, blocked by ${shipped && shipped.blocker} (${shipped && shipped.reason})`); +}); + +test('a page importing only such a module is inert', async () => { + const { verdict, pageFile } = await analyseApp({ utilSrc: LINES_TS }); + assert.ok(verdict.inertRouteModules.has(pageFile), + 'the whole client closure is display-only, so the page ships zero JavaScript'); +}); + +test('a component whose only module-scope construct is a parenthesised annotation is elided', async () => { + const { verdict, badgeFile } = await analyseApp({ utilSrc: LINES_TS }); + assert.ok(verdict.elidableComponents.has(badgeFile), + 'the badge renders static markup and the annotation is not a signal'); + assert.equal(verdict.componentVerdicts.get(badgeFile).shipped, false); +}); + +test('the pin is the ANNOTATION, not the data: an `as const` spelling was already inert', async () => { + // The report's own workaround, kept as a control. Both spellings describe the + // same runtime value, so a fix that only moved one of them would be reading + // something other than the annotation. + const asConst = LINES_TS.replace( + /export const LINES: readonly \(readonly \[number, number, number\]\)\[\] = \[/, + 'export const LINES = [', + ).replace(/\n\];/, '\n] as const;'); + assert.notEqual(asConst, LINES_TS, 'the control rewrite must actually apply'); + const { verdict, pageFile } = await analyseApp({ utilSrc: asConst }); + assert.ok(verdict.inertRouteModules.has(pageFile)); +}); + +test('real module-scope work in a .ts module still ships the page', async () => { + // The conservative direction, asserted per construct. Type erasure must not + // become a way for genuine client work to slip past: each of these is real + // code in the file the browser would run. + const cases = { + 'a top-level call': 'export const boot = init();\nfunction init() { return 1; }', + 'a non-data new': 'export const ws = new WebSocket("wss://x");', + 'a dynamic import': 'export const mod = import("./other.ts");', + 'a top-level await': 'export const v = await Promise.resolve(1);', + 'a browser global': 'export const w = window.innerWidth;', + }; + for (const [label, stmt] of Object.entries(cases)) { + const { verdict, pageFile } = await analyseApp({ + utilSrc: `export const LINES: readonly (readonly [number, number, number])[] = [[0, 1, 2]];\n${stmt}\n`, + }); + assert.ok(!verdict.inertRouteModules.has(pageFile), `${label} must keep the page shipping`); + } +}); + +test('a genuine top-level call named like a type keyword still ships', async () => { + // `readonly` is a legal function name in JavaScript, so exempting the bare + // identifier would have been a false NEGATIVE, the one direction this + // analyser may not take. Erasing types instead leaves the real call visible. + const { verdict, pageFile } = await analyseApp({ + utilSrc: 'function readonly(x: number) { return x; }\nexport const LINES = readonly(1);\n', + }); + assert.ok(!verdict.inertRouteModules.has(pageFile), + 'a call to a function named `readonly` is a call'); +}); + +test('a .ts module with non-erasable syntax falls back to scanning it as authored', async () => { + // The stripper throws on non-erasable TypeScript (invariant 10 forbids it and + // `webjs check` catches it at edit time, so this is a broken app rather than a + // supported one). The analyser must not throw with it: it keeps the source as + // authored, which is the pre-#1423 behaviour and therefore the conservative + // direction. The `init()` call is the proof it still SCANNED rather than + // silently gave up and called the module clean. + const { verdict, pageFile } = await analyseApp({ + utilSrc: 'export enum E { A, B }\nexport const LINES = init();\nfunction init() { return []; }\n', + }); + assert.ok(!verdict.inertRouteModules.has(pageFile), + 'an unstrippable module is still scanned, and its real call still ships the page'); +}); From cc216bebc03199d7145d0cd4e6a56474c4c20171 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 22:06:39 +0530 Subject: [PATCH 02/10] test: make the type-annotation elision cases discriminating The module-scope case asserted the page's shipped verdict while the page reached the util through a component, so a wrong verdict on the util was laundered into the component's own ship and the page came back import-only either way. Having the page import the util directly puts the util in the page's whole client closure, which is the shape the report hit and the one that fails when the erasure is reverted. --- .../test/elision/type-annotations.test.js | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/server/test/elision/type-annotations.test.js b/packages/server/test/elision/type-annotations.test.js index 0d22df1f6..c85efc827 100644 --- a/packages/server/test/elision/type-annotations.test.js +++ b/packages/server/test/elision/type-annotations.test.js @@ -15,15 +15,16 @@ * These tests drive the REAL pipeline over a REAL `.ts` app on disk, because * that is the only level the fix lives at: the analyser erases types before it * scans, so a unit call on a source string would prove nothing about the path - * that decides what the browser downloads. The three positive cases are the - * issue's acceptance criteria, and the last one is the counterweight that - * keeps them honest, since blanket-erasing too much would satisfy the first - * three by simply never detecting anything. + * that decides what the browser downloads. The first three are the issue's + * acceptance criteria, and they are the discriminating ones: reverting the + * erasure reds exactly those three (proven at 3b3cb6a5). The rest are the + * counterweight that keeps them honest, since erasing too much would satisfy + * the first three by simply never detecting anything, and they stay green in + * both directions on purpose. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; -import { readFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -52,7 +53,12 @@ export function winner(cells: Cell[]): Cell { * Write a throwaway TypeScript app, run the real pipeline over it, and return * the verdict plus the paths the assertions key on. * - * @param {{ utilSrc: string, badgeExtra?: string }} spec + * `direct: true` has the PAGE import the util and render no component, which is + * the shape the report hit: there the util is the page's whole client closure, + * so a wrong verdict on it shows up directly as the page's blocker instead of + * being laundered through a component that ships for its own reason. + * + * @param {{ utilSrc: string, direct?: boolean }} spec */ async function analyseApp(spec) { const dir = await mkdtemp(join(tmpdir(), 'webjs-type-annotations-')); @@ -61,16 +67,19 @@ async function analyseApp(spec) { await mkdir(join(dir, 'components'), { recursive: true }); await mkdir(join(dir, 'modules/game/utils'), { recursive: true }); await writeFile(join(dir, 'modules/game/utils/game.ts'), spec.utilSrc); - await writeFile(join(dir, 'components/badge.ts'), ` + if (!spec.direct) await writeFile(join(dir, 'components/badge.ts'), ` import { WebComponent, html } from '@webjsdev/core'; import { LINES } from '../modules/game/utils/game.ts'; export class Badge extends WebComponent { - ${spec.badgeExtra || ''} render() { return html\`\${LINES.length}\`; } } Badge.register('my-badge'); `); - await writeFile(join(dir, 'app/page.ts'), ` + await writeFile(join(dir, 'app/page.ts'), spec.direct ? ` +import { html } from '@webjsdev/core'; +import { LINES } from '../modules/game/utils/game.ts'; +export default () => html\`

\${LINES.length}

\`; +` : ` import { html } from '@webjsdev/core'; import '../components/badge.ts'; export default () => html\`\`; @@ -90,7 +99,7 @@ export default () => html\`\`; } test('a parenthesised type annotation does not mark its module as running code at module scope', async () => { - const { verdict, pageFile } = await analyseApp({ utilSrc: LINES_TS }); + const { verdict, pageFile } = await analyseApp({ utilSrc: LINES_TS, direct: true }); const shipped = verdict.shippedRouteModules.get(pageFile); assert.equal(shipped, undefined, `the page must not ship; it did, blocked by ${shipped && shipped.blocker} (${shipped && shipped.reason})`); @@ -118,7 +127,7 @@ test('the pin is the ANNOTATION, not the data: an `as const` spelling was alread 'export const LINES = [', ).replace(/\n\];/, '\n] as const;'); assert.notEqual(asConst, LINES_TS, 'the control rewrite must actually apply'); - const { verdict, pageFile } = await analyseApp({ utilSrc: asConst }); + const { verdict, pageFile } = await analyseApp({ utilSrc: asConst, direct: true }); assert.ok(verdict.inertRouteModules.has(pageFile)); }); @@ -136,6 +145,7 @@ test('real module-scope work in a .ts module still ships the page', async () => for (const [label, stmt] of Object.entries(cases)) { const { verdict, pageFile } = await analyseApp({ utilSrc: `export const LINES: readonly (readonly [number, number, number])[] = [[0, 1, 2]];\n${stmt}\n`, + direct: true, }); assert.ok(!verdict.inertRouteModules.has(pageFile), `${label} must keep the page shipping`); } @@ -147,6 +157,7 @@ test('a genuine top-level call named like a type keyword still ships', async () // analyser may not take. Erasing types instead leaves the real call visible. const { verdict, pageFile } = await analyseApp({ utilSrc: 'function readonly(x: number) { return x; }\nexport const LINES = readonly(1);\n', + direct: true, }); assert.ok(!verdict.inertRouteModules.has(pageFile), 'a call to a function named `readonly` is a call'); @@ -161,6 +172,7 @@ test('a .ts module with non-erasable syntax falls back to scanning it as authore // silently gave up and called the module clean. const { verdict, pageFile } = await analyseApp({ utilSrc: 'export enum E { A, B }\nexport const LINES = init();\nfunction init() { return []; }\n', + direct: true, }); assert.ok(!verdict.inertRouteModules.has(pageFile), 'an unstrippable module is still scanned, and its real call still ships the page'); From ebea628c447171b74b6f41d72c591d04522d1d6d Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 22:09:51 +0530 Subject: [PATCH 03/10] test: prove the type-annotation verdict matches on Bun The elision analysis now goes through the TypeScript stripper, which is the one seam that differs by runtime (Node's built-in module.stripTypeScriptTypes against amaro). A drift there would silently change what a Bun-served app downloads, so the cross-runtime fixture gains a .ts route whose util carries the parenthesised annotation. Its header claimed the analysis used no runtime-specific API, which is no longer true. Docs follow the same fact: the elidability blocker list in the skill and on the docs site now says a type annotation can never be a blocker, and the server package notes where the erasure happens and what a direct caller of the scan functions is still on the hook for. --- .agents/skills/webjs/references/components.md | 2 +- packages/server/AGENTS.md | 14 ++++++++++++ test/bun/elision-report.mjs | 22 +++++++++++++++---- website/app/docs/elision/page.ts | 2 +- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index dd193731d..d5a24dd2d 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -390,7 +390,7 @@ A component that does no client-side work renders the same SSR'd HTML with or wi - a factory-declared reactive property that is not `{ state: true }` - an overridden lifecycle hook (including `renderFallback` / `renderError`) - an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate` -- code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed +- code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed. TypeScript types are erased before the analyser reads a module, so an annotation can never be a blocker however call-shaped it looks (`readonly (readonly [number, number, number])[]` is fine) - the dynamic slot READ surface (`slotchange`, `assignedNodes` / `assignedElements` / `assignedSlot`); merely RENDERING a `` does not ship (the SSR output carries the placed children, so a display-only slotted wrapper is byte-identical without its JS; native-write liveness is consumer-driven and the consumer's tag reference forces the ship) - being rendered by a component that itself ships diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 33e7ba507..700bd97ec 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -463,6 +463,20 @@ conditional leaves a CSP-off document one newline shorter than a CSP-on one). map), so a `#` import is never sent to the resolver; the rare alias mapped to a real package (`"#x": "some-pkg"`) is consequently not vendored, an accepted limitation since the scaffold's catch-all `"#*": "./*"` is always local. + Every scan reads TYPE-ERASED source: `analyzeElision` runs a `.ts` / `.mts` / + `.tsx` module through the framework's own stripper (`eraseTypesForScan`) + before scanning it, because the scans are lexical and TS annotations are + call-shaped often enough to matter (`readonly (readonly [number, number, + number])[]` is an identifier immediately followed by `(`, which the + module-scope scan read as a top-level call, pinning every importing route + module, #1423). The stripper rather than a lexical annotation matcher, so the + analyser and the runtime agree by construction, and it is position-preserving, + so every offset the other scans depend on survives. A non-TS file, and a + source the stripper rejects (non-erasable syntax, which invariant 10 forbids), + are scanned as authored, the conservative direction. Consequence for a DIRECT + caller of `hasModuleScopeSideEffect` / `analyzeComponentSource`: both still + take source as given, so handing either one `.ts` as authored opts back into + that false positive. Import-only modules join the elision fingerprint (a verdict flip busts `?v`) and the bare-import scan exclusion (an SSR-only page import is no longer vendored), like inert modules. `collectRouteModules` (`dev.js`) feeds only diff --git a/test/bun/elision-report.mjs b/test/bun/elision-report.mjs index c6a093c38..ce3056e50 100644 --- a/test/bun/elision-report.mjs +++ b/test/bun/elision-report.mjs @@ -8,9 +8,13 @@ * WebJs runs on Node 24+ AND Bun (#508), and an app scaffolded with `--runtime * bun` runs `webjs elision` against a Bun-served app, so a verdict that drifted * between runtimes would mean the report told a Bun author something untrue - * about their own app. The analysis is filesystem reads plus regular - * expressions with no runtime-specific API, so there is nothing legitimate to - * skip and this file carries no DENYLIST entry. + * about their own app. The analysis is filesystem reads and regular expressions + * over source the framework's TypeScript stripper has erased (#1423), and that + * stripper IS runtime-specific: Node's built-in `module.stripTypeScriptTypes` + * on one side, `amaro` on the other. The two are meant to be byte-identical, + * and the `.ts` route below is what holds them to it, since a divergence there + * would silently change what a Bun-served app downloads. Nothing here is + * legitimate to skip, so this file carries no DENYLIST entry. * * The fixture covers one component of each verdict and one route module of each * class, so a divergence in ANY of the projection's moving parts (the tag sort, @@ -57,6 +61,15 @@ Counter.register('my-counter'); // Import-only: the page itself does no client work, and the only client work // its closure reaches is a shipping component. write('app/page.js', "import { html } from '@webjsdev/core';\nimport '../components/counter.js';\nexport default () => html``;"); + // Inert, and the row that exercises the STRIPPER seam: a parenthesised type + // annotation is call-shaped, so a runtime whose stripper left it behind would + // read this util as running code at module scope and report the page as + // shipping whole (#1423). + write('modules/game/utils/game.ts', ` +export type Cell = 'X' | 'O' | null; +export const LINES: readonly (readonly [number, number, number])[] = [[0, 1, 2], [3, 4, 5]]; +`); + write('app/typed/page.ts', "import { html } from '@webjsdev/core';\nimport { LINES } from '../../modules/game/utils/game.ts';\nexport default () => html`

${LINES.length}

`;"); const r = await analyzeAppElision(dir); @@ -81,13 +94,14 @@ Counter.register('my-counter'); [ ['app/about/page.js', 'inert', ''], ['app/page.js', 'import-only', 'components/counter.js'], + ['app/typed/page.ts', 'inert', ''], ], ); assert.deepEqual(r.orphans, []); assert.deepEqual(r.summary, { components: 2, elided: 1, shipped: 1, - routeModules: 2, inert: 1, importOnly: 1, shippedWhole: 0, + routeModules: 3, inert: 2, importOnly: 1, shippedWhole: 0, orphans: 0, }); diff --git a/website/app/docs/elision/page.ts b/website/app/docs/elision/page.ts index 6fd0b55cc..e38c2158a 100644 --- a/website/app/docs/elision/page.ts +++ b/website/app/docs/elision/page.ts @@ -33,7 +33,7 @@ export default function Elision() {
  • A factory-declared reactive property that is not { state: true }.
  • An overridden lifecycle hook, renderFallback() and renderError() included.
  • An imported signal / computed / watch / Task / ref or a streaming directive, or a call to addController / requestUpdate.
  • -
  • Code that runs at module load: a top-level call, a non-data new, a dynamic import(...), a top-level await. Only declarations and the register(...) call are inert.
  • +
  • Code that runs at module load: a top-level call, a non-data new, a dynamic import(...), a top-level await. Only declarations and the register(...) call are inert. TypeScript types are erased before the analyser reads a module, so an annotation is never a signal however call-shaped it looks: readonly (readonly [number, number, number])[] is inert data.
  • A browser global at module scope, or a side-effect import of an npm package.
  • The dynamic slot READ surface (slotchange, assignedNodes / assignedElements / assignedSlot). Merely rendering a <slot> does not ship, because the SSR output already carries the placed children.
  • Being rendered or imported by a component that itself ships.
  • From bb552e7689c8a9c44b9a90596c401d0337cd6236 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 22:15:55 +0530 Subject: [PATCH 04/10] refactor: keep the elidable-components doc block attached to its function The type-erasure helper landed between computeElidableComponents' JSDoc and the function it documents, which orphaned the block. Moved the helper above it. No behaviour change. --- packages/server/src/component-elision.js | 44 ++++++++++++------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/server/src/component-elision.js b/packages/server/src/component-elision.js index 7188b693b..de2f252b5 100644 --- a/packages/server/src/component-elision.js +++ b/packages/server/src/component-elision.js @@ -881,28 +881,6 @@ export function extractRenderedTags(src) { return tags; } -/** - * Compute the set of component FILES whose browser download can be - * elided. A file is elidable only when every component it defines is - * display-only AND it is not pulled into the client by an interactive - * component (rendered by, or imported by, a shipping module). - * - * Two propagation rules iterate to a fixpoint: - * - render rule: a shipping component that can emit `` on a - * client re-render forces the child to ship. The tags a component can - * emit are not only those in its own template but also those returned - * by the template helpers it imports (the documented `lib/utils/ui.ts` - * pattern), so the rule scans the component's transitive app-internal - * import closure, not just its own source. - * - import rule: a component that imports a shipping component module - * ships too (matches the issue's transitive criterion; conservative). - * - * @param {Array<{ tag: string, file: string }>} components - * @param {import('./module-graph.js').ModuleGraph} moduleGraph - * @param {(file: string) => Promise} readFileFn - * @param {string} [appDir] app root; enables the helper-closure render rule - * @returns {Promise>} absolute paths of elidable component files - */ /** Source extensions whose types must be erased before the scans below read them. */ const TS_SOURCE_RE = /\.(?:m|c)?tsx?$/; @@ -944,6 +922,28 @@ async function eraseTypesForScan(file, src) { } } +/** + * Compute the set of component FILES whose browser download can be + * elided. A file is elidable only when every component it defines is + * display-only AND it is not pulled into the client by an interactive + * component (rendered by, or imported by, a shipping module). + * + * Two propagation rules iterate to a fixpoint: + * - render rule: a shipping component that can emit `` on a + * client re-render forces the child to ship. The tags a component can + * emit are not only those in its own template but also those returned + * by the template helpers it imports (the documented `lib/utils/ui.ts` + * pattern), so the rule scans the component's transitive app-internal + * import closure, not just its own source. + * - import rule: a component that imports a shipping component module + * ships too (matches the issue's transitive criterion; conservative). + * + * @param {Array<{ tag: string, file: string }>} components + * @param {import('./module-graph.js').ModuleGraph} moduleGraph + * @param {(file: string) => Promise} readFileFn + * @param {string} [appDir] app root; enables the helper-closure render rule + * @returns {Promise>} absolute paths of elidable component files + */ export async function computeElidableComponents(components, moduleGraph, readFileFn, appDir) { const { elidableComponents } = await analyzeElision(components, [], moduleGraph, readFileFn, appDir); return elidableComponents; From f400f8650e933d8331b2ab5ad446a490264eef89 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 22:33:54 +0530 Subject: [PATCH 05/10] fix: match the erasure's extension set to the framework's TypeScript set The regex admitted .cts, .tsx, .mtsx and .ctsx, and the AGENTS.md sentence described a third set again (.ts / .mts / .tsx), so the code, the docs, and the framework all disagreed. WebJs treats exactly .ts and .mts as TypeScript: the MIME map and stripTs, the servable-extension test, the graph walker's file filter, and the router's name convention. There is no .cts and no JSX in any of them. The extra breadth was unreachable rather than harmful, since the graph walker and the router decide which files reach the analysis, but it read as support the framework does not have. The .tsx half of the doc claim was also backwards: real JSX is a parse error for a non-JSX TypeScript parse, so the catch would swallow it and the file would be scanned as authored, which is the opposite of what the sentence promised. --- packages/server/AGENTS.md | 4 ++-- packages/server/src/component-elision.js | 24 ++++++++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 700bd97ec..f4446be9a 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -463,8 +463,8 @@ conditional leaves a CSP-off document one newline shorter than a CSP-on one). map), so a `#` import is never sent to the resolver; the rare alias mapped to a real package (`"#x": "some-pkg"`) is consequently not vendored, an accepted limitation since the scaffold's catch-all `"#*": "./*"` is always local. - Every scan reads TYPE-ERASED source: `analyzeElision` runs a `.ts` / `.mts` / - `.tsx` module through the framework's own stripper (`eraseTypesForScan`) + Every scan reads TYPE-ERASED source: `analyzeElision` runs a `.ts` / `.mts` + module through the framework's own stripper (`eraseTypesForScan`) before scanning it, because the scans are lexical and TS annotations are call-shaped often enough to matter (`readonly (readonly [number, number, number])[]` is an identifier immediately followed by `(`, which the diff --git a/packages/server/src/component-elision.js b/packages/server/src/component-elision.js index de2f252b5..3a6c2bc98 100644 --- a/packages/server/src/component-elision.js +++ b/packages/server/src/component-elision.js @@ -881,8 +881,17 @@ export function extractRenderedTags(src) { return tags; } -/** Source extensions whose types must be erased before the scans below read them. */ -const TS_SOURCE_RE = /\.(?:m|c)?tsx?$/; +/** + * The TypeScript source extensions, which are exactly the ones WebJs treats as + * TypeScript everywhere else: the `MIME` map and `stripTs` in `dev/`, the + * servable-extension test in `dev/serve.js`, the graph walker's file filter in + * `module-graph.js`, and the router's `.` convention. + * Keep this set EQUAL to those rather than merely a superset. A wider one reads + * as support the framework does not have (there is no `.cts` and no JSX + * anywhere in it), and it cannot even be exercised, since the graph walker and + * the router are what decide which files reach this analysis at all. + */ +const TS_SOURCE_RE = /\.m?ts$/; /** * Return `src` with its TYPE syntax erased, so every scan below reads the code @@ -904,10 +913,13 @@ const TS_SOURCE_RE = /\.(?:m|c)?tsx?$/; * wrong. It is position-preserving whitespace replacement, so every offset, * line, and column the other scans depend on is unchanged. * - * Non-TS files are returned untouched (there is nothing to erase, and running a - * TS parser over them buys only new ways to fail). A strip FAILURE also returns - * the source untouched, which is the pre-#1423 behaviour and therefore the - * conservative direction: the scans then over-detect at worst. + * A `.js` / `.mjs` file is returned untouched (there is nothing to erase, and + * running a TS parser over them buys only new ways to fail). A strip FAILURE + * also returns the source untouched, which is the pre-#1423 behaviour and + * therefore the conservative direction: the scans then over-detect at worst. + * That is the path a non-erasable module takes, which invariant 10 forbids and + * `webjs check` catches at edit time, so it is a broken app rather than a + * supported one. * * @param {string} file absolute path, read for its extension only * @param {string} src From ca3d360de733ba473d9b5b3e343b8c4389dde590 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 22:44:32 +0530 Subject: [PATCH 06/10] fix: erase types for every extension that reaches the elision analysis The previous commit narrowed the erasure set to the SERVABLE extensions on the reasoning that the graph walker and the router decide which files reach the analysis. They do not. analyzeElision seeds allFiles from the COMPONENT set first, and scanComponents admits /\.m?[jt]sx?$/, which is wider than either. So a .tsx component arrives whatever those two admit, and narrowing put the #1423 verdict back for exactly the file class no other filter would have let through. The set is now every TypeScript-carrying extension in the union of the three seeding filters, and the comment says to derive it that way rather than from what the server serves. .cts stays out because no filter admits it. A .tsx holding real JSX still falls back to being scanned as authored, since the stripper parses as non-JSX TypeScript, and the fall-through paragraph now says so instead of naming a .js / .mjs set that was not the real one. Also reattaches hasModuleScopeSideEffect's JSDoc, which PURE_DATA_CONSTRUCTORS sat between. The direct-caller contract AGENTS.md sends readers to was in that block and reached neither hover nor param inference. --- packages/server/src/component-elision.js | 75 +++++++++++-------- .../test/elision/type-annotations.test.js | 31 ++++++++ 2 files changed, 74 insertions(+), 32 deletions(-) diff --git a/packages/server/src/component-elision.js b/packages/server/src/component-elision.js index 3a6c2bc98..bcd987aa3 100644 --- a/packages/server/src/component-elision.js +++ b/packages/server/src/component-elision.js @@ -268,6 +268,23 @@ const INSTANCEOF_RE = /\binstanceof\s+([A-Z][A-Za-z0-9_$]*)/g; * `customElements.define(...)` legitimately uses it and must not force ship). */ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\b/; +/** + * Constructors that produce inert DATA with no side effect, so a module-scope + * `export const X = new Set([...])` (a lookup table, a compiled RegExp, a + * parsed URL) is not client work and must not pin an importing page/layout + * (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`, + * `new EventSource()`, `new Audio()`) IS a side effect and still ships. + */ +const PURE_DATA_CONSTRUCTORS = new Set([ + 'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object', + 'Number', 'String', 'Boolean', 'BigInt', 'Symbol', + 'Error', 'TypeError', 'RangeError', 'SyntaxError', + 'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView', + 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array', + 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array', + 'BigInt64Array', 'BigUint64Array', +]); + /** * Module-scope client work, detected by an ALLOWLIST of safe top-level forms * rather than a denylist of browser globals. A module that runs ANY code when @@ -309,25 +326,10 @@ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage| * top-level object / array initializer or a destructuring default, and a * side-effecting tagged-template hole evaluated at module scope. * - * @param {string} src module source, type-erased if it came from a `.ts` file - */ -/** - * Constructors that produce inert DATA with no side effect, so a module-scope - * `export const X = new Set([...])` (a lookup table, a compiled RegExp, a - * parsed URL) is not client work and must not pin an importing page/layout - * (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`, - * `new EventSource()`, `new Audio()`) IS a side effect and still ships. + * @param {string} src module source, type-erased if it came from a TypeScript file + * @param {string[]} [literals] the redaction's literal bodies, when already computed + * @returns {boolean} */ -const PURE_DATA_CONSTRUCTORS = new Set([ - 'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object', - 'Number', 'String', 'Boolean', 'BigInt', 'Symbol', - 'Error', 'TypeError', 'RangeError', 'SyntaxError', - 'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView', - 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array', - 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array', - 'BigInt64Array', 'BigUint64Array', -]); - export function hasModuleScopeSideEffect(src, literals) { let redacted = src; if (!literals) { @@ -882,16 +884,23 @@ export function extractRenderedTags(src) { } /** - * The TypeScript source extensions, which are exactly the ones WebJs treats as - * TypeScript everywhere else: the `MIME` map and `stripTs` in `dev/`, the - * servable-extension test in `dev/serve.js`, the graph walker's file filter in - * `module-graph.js`, and the router's `.` convention. - * Keep this set EQUAL to those rather than merely a superset. A wider one reads - * as support the framework does not have (there is no `.cts` and no JSX - * anywhere in it), and it cannot even be exercised, since the graph walker and - * the router are what decide which files reach this analysis at all. + * The extensions that can carry TypeScript type syntax INTO this analysis. + * + * Derive it from what actually reaches `allFiles`, which is seeded from three + * places with three different filters: the component set from `scanComponents` + * (`/\.m?[jt]sx?$/`), the route modules from the router (`js|mjs|ts|mts`), and + * the module graph from its walker (`/\.(js|ts|mjs|mts)$/`). The COMPONENT set + * is the widest and it does not pass through the other two, so a `.tsx` + * component arrives here whatever the walker and the router admit. Hence + * `.tsx`, even though nothing in WebJs serves one: leaving it out is how the + * #1423 false positive comes back for exactly the file class no other filter + * would have let through. `.cts` is absent because no filter admits it. + * + * The rule to hold, if any of those three filters moves: this set is every + * TypeScript-carrying extension in their UNION. Narrowing it to the servable + * set instead is a change that looks tighter and silently un-erases files. */ -const TS_SOURCE_RE = /\.m?ts$/; +const TS_SOURCE_RE = /\.m?tsx?$/; /** * Return `src` with its TYPE syntax erased, so every scan below reads the code @@ -913,13 +922,15 @@ const TS_SOURCE_RE = /\.m?ts$/; * wrong. It is position-preserving whitespace replacement, so every offset, * line, and column the other scans depend on is unchanged. * - * A `.js` / `.mjs` file is returned untouched (there is nothing to erase, and - * running a TS parser over them buys only new ways to fail). A strip FAILURE + * A non-TypeScript file is returned untouched (there is nothing to erase, and + * running a TS parser over one buys only new ways to fail). A strip FAILURE * also returns the source untouched, which is the pre-#1423 behaviour and * therefore the conservative direction: the scans then over-detect at worst. - * That is the path a non-erasable module takes, which invariant 10 forbids and - * `webjs check` catches at edit time, so it is a broken app rather than a - * supported one. + * Two things take that path. A non-erasable module, which invariant 10 forbids + * and `webjs check` catches at edit time, so it is a broken app rather than a + * supported one. And a `.tsx` file holding real JSX, which the stripper parses + * as non-JSX TypeScript and rejects, so a JSX component is scanned as authored + * while a `.tsx` that is only TypeScript is erased normally. * * @param {string} file absolute path, read for its extension only * @param {string} src diff --git a/packages/server/test/elision/type-annotations.test.js b/packages/server/test/elision/type-annotations.test.js index c85efc827..9149c92d3 100644 --- a/packages/server/test/elision/type-annotations.test.js +++ b/packages/server/test/elision/type-annotations.test.js @@ -163,6 +163,37 @@ test('a genuine top-level call named like a type keyword still ships', async () 'a call to a function named `readonly` is a call'); }); +test('a .tsx component is erased too, because it reaches the analysis by a wider filter', async () => { + // `allFiles` is seeded from the COMPONENT set before the module graph, and + // `scanComponents` admits `/\.m?[jt]sx?$/`, which is wider than both the + // graph walker's file filter and the router's. So a `.tsx` component lands + // in the analysis whatever those two admit, and an erasure set narrowed to + // the SERVABLE extensions would leave exactly this file class un-erased, + // with the #1423 verdict intact and nothing else covering it. + const dir = await mkdtemp(join(tmpdir(), 'webjs-type-annotations-tsx-')); + try { + await mkdir(join(dir, 'components'), { recursive: true }); + await writeFile(join(dir, 'components/badge.tsx'), ` +import { WebComponent, html } from '@webjsdev/core'; +export const LINES: readonly (readonly [number, number, number])[] = [[0, 1, 2]]; +export class Badge extends WebComponent { + render() { return html\`\${LINES.length}\`; } +} +Badge.register('my-badge'); +`); + const graph = await buildModuleGraph(dir); + const components = await scanComponents(dir); + const badgeFile = join(dir, 'components/badge.tsx'); + assert.ok(components.some((c) => c.file === badgeFile), + 'precondition: the component scanner admits a .tsx file'); + const verdict = await analyzeElision(components, [], graph, (f) => readFile(f, 'utf8'), dir); + assert.ok(verdict.elidableComponents.has(badgeFile), + 'the annotation is not a signal in a .tsx file either'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test('a .ts module with non-erasable syntax falls back to scanning it as authored', async () => { // The stripper throws on non-erasable TypeScript (invariant 10 forbids it and // `webjs check` catches it at edit time, so this is a broken app rather than a From e914594923b252f9b7716b4e4f4825f3c7ec8d0a Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 23:16:17 +0530 Subject: [PATCH 07/10] docs: stop claiming WebJs serves .tsx The type-stripper seam listed `.ts` / `.tsx` as what the framework serves. It serves `.ts` / `.mts`: that is the MIME map, the servable-extension test, the graph walker's filter, and the router's name convention, and there is no JSX anywhere in the framework. Found while deriving the erasure set for #1423, so it is the same fact that change rests on. This file is copied into the scaffold at prepack, so the claim shipped to every generated app, where an agent following it would name a component .tsx and get a file the server will not serve. --- .agents/skills/webjs/references/runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/webjs/references/runtime.md b/.agents/skills/webjs/references/runtime.md index 1273ef945..55c1fcdea 100644 --- a/.agents/skills/webjs/references/runtime.md +++ b/.agents/skills/webjs/references/runtime.md @@ -20,7 +20,7 @@ Pick a runtime from the deploy target, not the code. Default to Node unless you Three seams pick a runtime-specific implementation, all inside the framework, none in your app: - **The listener.** `startServer` selects the `node:http` request shell on Node and a native `Bun.serve` shell on Bun. Both parse the request, run middleware, dispatch to your routes, and stream the response through the same downstream pipeline, so an SSR page, a server action RPC, and a route handler behave identically. -- **The type stripper.** WebJs serves `.ts` / `.tsx` as ES modules by erasing the types in place with no bundler. On Node that is the built-in `module.stripTypeScriptTypes`; on Bun it is `amaro` (the same engine, byte-identical and position-preserving so stack traces still point at the right line). Either way your TypeScript must be erasable (see `typescript.md`). +- **The type stripper.** WebJs serves `.ts` / `.mts` as ES modules by erasing the types in place with no bundler. Those two are the whole set: there is no JSX anywhere in the framework, so a `.tsx` file is not served. On Node that is the built-in `module.stripTypeScriptTypes`; on Bun it is `amaro` (the same engine, byte-identical and position-preserving so stack traces still point at the right line). Either way your TypeScript must be erasable (see `typescript.md`). - **A few built-ins.** SQLite, hot reload, and WebSockets each bind to the runtime's native primitive (see the table). ## Node vs Bun at a glance From 77c3c1b00313cd4210ace7276cd92df5ce825010 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 23:37:03 +0530 Subject: [PATCH 08/10] docs: point the doc-sync skill at the real docs-page extension It named the docs site as website/app/docs//page.tsx, and the step-5 note said a .tsx doc page. Every one of the 45 doc pages is page.ts and there are no .tsx files, so an agent following the skill would grep for a path that does not exist. Same class as the runtime.md claim in the previous commit, and found by the same sweep. --- .claude/skills/webjs-doc-sync/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/webjs-doc-sync/SKILL.md b/.claude/skills/webjs-doc-sync/SKILL.md index 316077413..b28c700c2 100644 --- a/.claude/skills/webjs-doc-sync/SKILL.md +++ b/.claude/skills/webjs-doc-sync/SKILL.md @@ -43,7 +43,7 @@ applies, then update or consciously skip each. relevant `references/` file. 2. **`README.md`** (repo root). Update when a headline capability changes (the feature list, the quickstart, the runtime/template matrix). -3. **The docs site: `website/app/docs//page.tsx`.** This is the +3. **The docs site: `website/app/docs//page.ts`.** This is the user-facing documentation at webjs.dev/docs. Find the topic page(s) that cover the area (`server-actions`, `routing`, `components`, `caching`, `configuration`, `client-router`, `data-fetching`, ...) and update them. `llms.txt` / @@ -102,7 +102,7 @@ The surface checks below are independent reads, so run them fanned out: one read 4. Verify: re-run the grep and confirm each applicable surface now describes the new behaviour, and no surface still describes the old one. 5. Respect the prose-punctuation invariant (#11) and run `webjs check` if any - code-shaped doc (a `.tsx` doc page) changed. + code-shaped doc (a `.ts` doc page) changed. ## Audit-mode procedure (sweep shipped work for drift) From 5cd4f495d176186e71552f5ca2d9c5f29e80f485 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sun, 16 Aug 2026 23:53:21 +0530 Subject: [PATCH 09/10] fix: report a missing stripper backend and memoize the erasure Three things the delta round surfaced. The per-package AGENTS.md still said the erasure covers .ts / .mts, which is the narrow set ca3d360d deliberately rejected. It now states the union rule the code follows, so the one doc surface asserting an extension set agrees with the regex. A missing stripper BACKEND was swallowed by the same catch as a per-file syntax rejection. Those are not the same failure: no backend means no TypeScript in the app is erased, so every module is scanned as authored and the #1423 verdict returns app-wide. ensureStripper had exactly one non-test caller, on the dev request path, so webjs check and webjs elision never resolved it and would degrade in silence. The backend is now resolved once per run and warns once when it cannot be, while a per-file failure stays silent because it is one file's problem. The erasure also ran on every file on every call, and analyzeElision is on the dev rebuild path. Measured on website, 179 files read in 2.0ms and stripped in 94ms, so it cost about 50x the read it was added beside on every save. Results are now memoized per file and validated against the exact source, which a same-mtime rewrite cannot defeat the way an mtime key could. Steady-state re-analysis drops from about 270ms to about 124ms. --- packages/server/AGENTS.md | 23 +++-- packages/server/src/component-elision.js | 98 ++++++++++++++++--- .../test/elision/type-annotations.test.js | 35 +++++++ 3 files changed, 132 insertions(+), 24 deletions(-) diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index f4446be9a..83caa9817 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -463,20 +463,27 @@ conditional leaves a CSP-off document one newline shorter than a CSP-on one). map), so a `#` import is never sent to the resolver; the rare alias mapped to a real package (`"#x": "some-pkg"`) is consequently not vendored, an accepted limitation since the scaffold's catch-all `"#*": "./*"` is always local. - Every scan reads TYPE-ERASED source: `analyzeElision` runs a `.ts` / `.mts` - module through the framework's own stripper (`eraseTypesForScan`) + Every scan reads TYPE-ERASED source: `analyzeElision` runs a `.ts` / `.mts` / + `.tsx` module through the framework's own stripper (`eraseTypesForScan`) before scanning it, because the scans are lexical and TS annotations are call-shaped often enough to matter (`readonly (readonly [number, number, number])[]` is an identifier immediately followed by `(`, which the module-scope scan read as a top-level call, pinning every importing route module, #1423). The stripper rather than a lexical annotation matcher, so the analyser and the runtime agree by construction, and it is position-preserving, - so every offset the other scans depend on survives. A non-TS file, and a - source the stripper rejects (non-erasable syntax, which invariant 10 forbids), - are scanned as authored, the conservative direction. Consequence for a DIRECT - caller of `hasModuleScopeSideEffect` / `analyzeComponentSource`: both still - take source as given, so handing either one `.ts` as authored opts back into - that false positive. + so every offset the other scans depend on survives. The extension set is the + union of the TypeScript-carrying extensions in the three filters that seed + `allFiles`, NOT the servable set: `scanComponents` admits `/\.m?[jt]sx?$/`, + wider than the graph walker and the router, so a `.tsx` component reaches the + analysis and narrowing to `.ts` / `.mts` silently un-erases it. A non-TS file, + and a source the stripper rejects (non-erasable syntax, which invariant 10 + forbids, or real JSX in a `.tsx`), are scanned as authored, the conservative + direction; a missing stripper BACKEND is the one failure that warns, since it + un-erases the whole app rather than one file. Results are memoized per file + and validated against the source, so a dev rebuild re-strips only what + changed. Consequence for a DIRECT caller of `hasModuleScopeSideEffect` / + `analyzeComponentSource`: both still take source as given, so handing either + one `.ts` as authored opts back into that false positive. Import-only modules join the elision fingerprint (a verdict flip busts `?v`) and the bare-import scan exclusion (an SSR-only page import is no longer vendored), like inert modules. `collectRouteModules` (`dev.js`) feeds only diff --git a/packages/server/src/component-elision.js b/packages/server/src/component-elision.js index bcd987aa3..5fd61a4ad 100644 --- a/packages/server/src/component-elision.js +++ b/packages/server/src/component-elision.js @@ -45,7 +45,7 @@ import { redactToPlaceholders, } from './js-scan.js'; import { transitiveDeps, expandImportAlias } from './module-graph.js'; -import { stripTypeScript } from './ts-strip.js'; +import { ensureStripper } from './ts-strip.js'; /** * Named imports from a `@webjsdev/core` specifier that imply the @@ -923,25 +923,86 @@ const TS_SOURCE_RE = /\.m?tsx?$/; * line, and column the other scans depend on is unchanged. * * A non-TypeScript file is returned untouched (there is nothing to erase, and - * running a TS parser over one buys only new ways to fail). A strip FAILURE - * also returns the source untouched, which is the pre-#1423 behaviour and - * therefore the conservative direction: the scans then over-detect at worst. - * Two things take that path. A non-erasable module, which invariant 10 forbids - * and `webjs check` catches at edit time, so it is a broken app rather than a - * supported one. And a `.tsx` file holding real JSX, which the stripper parses - * as non-JSX TypeScript and rejects, so a JSX component is scanned as authored - * while a `.tsx` that is only TypeScript is erased normally. + * running a TS parser over one buys only new ways to fail). A PER-FILE strip + * failure also returns the source untouched, which is the pre-#1423 behaviour + * and therefore the conservative direction: the scans then over-detect at + * worst. Two things take that path, both silent because both are one file's + * problem. A non-erasable module, which invariant 10 forbids and `webjs check` + * catches at edit time, so it is a broken app rather than a supported one. And + * a `.tsx` holding real JSX, which the stripper parses as non-JSX TypeScript + * and rejects, so a JSX component is scanned as authored while a `.tsx` that is + * only TypeScript is erased normally. * - * @param {string} file absolute path, read for its extension only + * A missing stripper BACKEND is the failure that does not stay silent, and it + * is why the backend is resolved once by the caller rather than per file. It + * means no TypeScript in the app can be erased (a runtime with neither the + * built-in nor `amaro`), so every module is scanned as authored and the #1423 + * verdict comes back app-wide. Swallowing that alongside a syntax rejection + * would make an app-wide regression indistinguishable from one broken file, so + * `resolveScanStripper` warns once and this returns the source untouched. + * + * Results are memoized per file and validated against the exact source, since + * this runs on every dev rebuild and stripping costs roughly 50x the read it + * sits beside. Validating on content rather than mtime means a same-mtime + * rewrite cannot serve a stale strip. + * + * @param {import('./ts-strip.js').Stripper | null} stripper resolved once per run, null when unavailable + * @param {string} file absolute path, the cache key and the extension source * @param {string} src - * @returns {Promise} + * @returns {string} */ -async function eraseTypesForScan(file, src) { - if (!TS_SOURCE_RE.test(file)) return src; +function eraseTypesForScan(stripper, file, src) { + if (!stripper || !TS_SOURCE_RE.test(file)) return src; + const hit = STRIP_CACHE.get(file); + if (hit && hit.src === src) return hit.out; + let out; try { - return await stripTypeScript(src); + out = stripper.fn(src); } catch { - return src; + out = src; + } + STRIP_CACHE.set(file, { src, out }); + return out; +} + +/** + * Per-file memo of {@link eraseTypesForScan}, holding the source it was derived + * from so a hit is proven rather than assumed. One entry per file, so it is + * bounded by the app's file count and needs no eviction policy; a deleted file + * leaves one dead entry until the process ends, which is cheaper than tracking + * liveness for it. + * + * @type {Map} + */ +const STRIP_CACHE = new Map(); + +/** + * Resolve the TypeScript stripper backend once for a whole analysis run, or + * `null` when this runtime has none. + * + * `analyzeElision` runs outside the dev request handler too (`webjs check`, + * `webjs elision`), and `ensureStripper` is called on the dev path only, so + * this is where the no-backend case surfaces for the other entry points. It + * warns once per process rather than per file: the condition is a property of + * the runtime, so repeating it once per module would bury it. + * + * @returns {Promise} + */ +let warnedNoStripper = false; +async function resolveScanStripper() { + try { + return await ensureStripper(); + } catch (e) { + if (!warnedNoStripper) { + warnedNoStripper = true; + console.warn( + '[webjs] elision analysis could not resolve a TypeScript stripper, so type syntax ' + + 'is not erased before the scans read a module. A type annotation can then read as ' + + 'module-scope work, which ships modules that would otherwise be elided. Underlying: ' + + (e && e.message ? e.message : String(e)), + ); + } + return null; } } @@ -1052,6 +1113,11 @@ export async function analyzeElision(components, routeModules, moduleGraph, read for (const v of vs) if (!appDir || v.startsWith(appDir)) allFiles.add(v); } + // Resolve the stripper ONCE for the run: the backend is a property of the + // runtime, not of a file, and the no-backend case must be reported rather + // than swallowed per module (see `eraseTypesForScan`). + const scanStripper = await resolveScanStripper(); + for (const file of allFiles) { if (SERVER_FILE_RE.test(file)) { serverFiles.add(file); continue; } let src; @@ -1067,7 +1133,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read // (#1423). This is the single point where the analyser reads a file, so // doing it here covers the module-scope, template, import, and component // scans at once. - src = await eraseTypesForScan(file, src); + src = eraseTypesForScan(scanStripper, file, src); // Mask comments once for every signal scan below (#179): a ``, an // `@event`, a browser global, an `import`, or a `whenDefined` written in a // comment must not register as a real signal. String and template content diff --git a/packages/server/test/elision/type-annotations.test.js b/packages/server/test/elision/type-annotations.test.js index 9149c92d3..3afb1ba12 100644 --- a/packages/server/test/elision/type-annotations.test.js +++ b/packages/server/test/elision/type-annotations.test.js @@ -194,6 +194,41 @@ Badge.register('my-badge'); } }); +test('the strip memo is validated against the source, so an edit is re-erased', async () => { + // The memo is keyed by PATH and holds the source it was derived from, because + // this runs on every dev rebuild and stripping costs roughly 50x the read it + // sits beside. Keying by path alone would serve a stale strip after an edit, + // and mtime would miss a same-mtime rewrite, so the check is on content. Same + // path, two different sources, two different verdicts is what proves it. + const dir = await mkdtemp(join(tmpdir(), 'webjs-type-annotations-memo-')); + try { + await mkdir(join(dir, 'app'), { recursive: true }); + await mkdir(join(dir, 'modules/game/utils'), { recursive: true }); + const util = join(dir, 'modules/game/utils/game.ts'); + const pageFile = join(dir, 'app/page.ts'); + await writeFile(pageFile, ` +import { html } from '@webjsdev/core'; +import { LINES } from '../modules/game/utils/game.ts'; +export default () => html\`

    \${LINES.length}

    \`; +`); + const analyse = async () => { + const graph = await buildModuleGraph(dir); + return analyzeElision(await scanComponents(dir), [pageFile], graph, (f) => readFile(f, 'utf8'), dir); + }; + + await writeFile(util, 'export const LINES: readonly (readonly [number, number, number])[] = [[0, 1, 2]];\n'); + assert.ok((await analyse()).inertRouteModules.has(pageFile), 'pure typed data is inert'); + + // Same path, new content that DOES do module-scope work. A memo keyed only + // by path would hand back the erased first version and call this inert too. + await writeFile(util, 'export const LINES = init();\nfunction init() { return [[0, 1, 2]]; }\n'); + assert.ok(!(await analyse()).inertRouteModules.has(pageFile), + 'the edit must be re-read and re-scanned, not served from the memo'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test('a .ts module with non-erasable syntax falls back to scanning it as authored', async () => { // The stripper throws on non-erasable TypeScript (invariant 10 forbids it and // `webjs check` catches it at edit time, so this is a broken app rather than a From 326d58f88690600c6b698ed5e4a8e1f3a0c87f82 Mon Sep 17 00:00:00 2001 From: Vivek Date: Mon, 17 Aug 2026 00:05:59 +0530 Subject: [PATCH 10/10] refactor: keep the new elision helpers' doc blocks attached resolveScanStripper's JSDoc had a let declaration between it and the function, which is the same detachment the previous commit fixed for hasModuleScopeSideEffect, reintroduced two hunks later. Moved the flag above the block and gave it its own line of doc. STRIP_CACHE moves above its only reader too, matching how every other constant in this file is ordered. No behaviour change. --- packages/server/src/component-elision.js | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/server/src/component-elision.js b/packages/server/src/component-elision.js index 5fd61a4ad..b780ad925 100644 --- a/packages/server/src/component-elision.js +++ b/packages/server/src/component-elision.js @@ -902,6 +902,17 @@ export function extractRenderedTags(src) { */ const TS_SOURCE_RE = /\.m?tsx?$/; +/** + * Per-file memo of {@link eraseTypesForScan}, holding the source it was derived + * from so a hit is proven rather than assumed. One entry per file, so it is + * bounded by the app's file count and needs no eviction policy; a deleted file + * leaves one dead entry until the process ends, which is cheaper than tracking + * liveness for it. + * + * @type {Map} + */ +const STRIP_CACHE = new Map(); + /** * Return `src` with its TYPE syntax erased, so every scan below reads the code * that actually RUNS rather than the code as authored (#1423). @@ -965,16 +976,8 @@ function eraseTypesForScan(stripper, file, src) { return out; } -/** - * Per-file memo of {@link eraseTypesForScan}, holding the source it was derived - * from so a hit is proven rather than assumed. One entry per file, so it is - * bounded by the app's file count and needs no eviction policy; a deleted file - * leaves one dead entry until the process ends, which is cheaper than tracking - * liveness for it. - * - * @type {Map} - */ -const STRIP_CACHE = new Map(); +/** Whether {@link resolveScanStripper} has already reported a missing backend. */ +let warnedNoStripper = false; /** * Resolve the TypeScript stripper backend once for a whole analysis run, or @@ -988,7 +991,6 @@ const STRIP_CACHE = new Map(); * * @returns {Promise} */ -let warnedNoStripper = false; async function resolveScanStripper() { try { return await ensureStripper();