Skip to content

fix: a parenthesised type annotation no longer reads as a top-level call - #1424

Merged
vivek7405 merged 10 commits into
mainfrom
fix/elision-parenthesised-type-annotation
Aug 16, 2026
Merged

fix: a parenthesised type annotation no longer reads as a top-level call#1424
vivek7405 merged 10 commits into
mainfrom
fix/elision-parenthesised-type-annotation

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #1423

A parenthesised TypeScript 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 (, which is exactly the shape hasModuleScopeSideEffect's call matcher looks for. 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.

What changed

The analyser now erases type syntax before it scans, at the single point where it reads a file (analyzeElision's loop), so the module-scope, template, import, and component scans all read the code that actually runs. A non-TypeScript file, and a source the stripper rejects, are scanned as authored, which is the previous behaviour and therefore the conservative direction.

Why the stripper and not the keyword

The issue offered two fixes. Adding readonly to NOT_A_CALL is one line and kills the common case, but it does not kill the class (as (readonly T[]) and every other identifier-before-paren in a type position still trip it), and it trades a false positive for a false NEGATIVE, since readonly is a legal function name and a real top-level readonly(x) call would then be missed. That is the one direction this analyser may not take. Erasing types removes the class outright and leaves a genuine call visible, so the keyword entry buys nothing once the erasure is in and is not included.

Erasure 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 the other scans depend on is unchanged.

Which extensions get erased, and why it is not the servable set

This is the part that took two passes to get right, so it is worth stating plainly. The erasure set is the union of the TypeScript-carrying extensions in the three filters that seed analyzeElision's allFiles, NOT the set the server serves.

allFiles starts from the COMPONENT set, and scanComponents admits /\.m?[jt]sx?$/, which is wider than both the module-graph walker's /\.(js|ts|mjs|mts)$/ and the router's js|mjs|ts|mts. So a .tsx component reaches the analysis whatever those two admit. Narrowing the erasure to the servable .ts / .mts therefore reinstates the #1423 verdict for exactly the file class no other filter would have let through, and it is the class with no other coverage. Hence /\.m?tsx?$/. .cts stays out because no filter admits it. A .tsx holding real JSX falls back to being scanned as authored, since the stripper parses as non-JSX TypeScript and rejects it.

Test plan

  • Unit / integration: new packages/server/test/elision/type-annotations.test.js, eight cases driving the real pipeline over a real app on disk. Four are the acceptance criteria (including the .tsx case above); four are the conservative-direction counterweight (a real call, a non-data new, a dynamic import(), a top-level await, a browser global, a genuine call to a function named readonly, and a module the stripper rejects). Full Node suite: 4468 tests, 0 failures.
  • Counterfactual: reverting the erasure reds exactly the acceptance cases and leaves the counterweights green (proven at 3b3cb6a). The .tsx case independently reds under a servable-set-only regex and passes under the union one, which is what makes the extension reasoning above testable rather than asserted.
  • Browser: green, 76 files on all three engines (Chromium 892, Firefox 882, Webkit 892 passed, 0 failed).
  • E2E: WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs green.
  • Bun: node scripts/run-bun-tests.js green, and test/bun/elision-report.mjs gains a .ts route carrying the annotation, run under both node and bun. This is the row that matters, since the erasure goes through the one seam that differs by runtime (Node's built-in stripper against amaro).
  • Verdict parity: webjs elision --json byte-identical to main on gallery, examples/blog, and website, compared on an identical working tree.
  • webjs elision --verify: examples/blog 26 routes identical, website 54 routes identical. gallery reports one divergence on /features/caching, which is that demo page rendering the current clock (22:10:33 against 22:10:39) rather than an elision difference. It reproduces identically on unmodified origin/main, so it is pre-existing and not from this PR.
  • Conventions: webjs check clean on all three apps; webjs doctor 11 passed, 2 warnings, 0 failed on each, matching main.
  • Dogfood: website boots in dist mode and serves 200 on /, /docs/elision, /docs/components, /ui, /ui/button with no broken modulepreload hints; gallery the same on / and /features/caching; examples/blog covered by the e2e run.

Docs surfaces

  • Updated: .agents/skills/webjs/references/components.md (the elidability blocker list), website/app/docs/elision/page.ts (the same list on the docs site), packages/server/AGENTS.md (where the erasure happens and what a direct caller of the scan functions is still on the hook for).
  • N/A: scaffold generators, since webjs create output is unchanged and the CLI copies the skill from the repo root at prepack (scripts/sync-scaffold-skill.mjs), so the one edited copy is what ships. MCP, since no tool projection changed. Editor plugins, marketing copy, and README, since nothing they describe moved. Changelog, since there is no version bump here.
  • Deferred, awaiting a call: .agents/skills/webjs/references/runtime.md:23 claims WebJs serves .ts / .tsx. The .tsx half is false. Pre-existing, in a file this PR does not touch. Recorded in a comment below.

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.
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.
@vivek7405 vivek7405 self-assigned this Aug 16, 2026
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.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design rationale: why the stripper, where it runs, and what it costs

The issue offered a narrow fix (add readonly to NOT_A_CALL) and a real one (strip annotations before the scan). I took the second alone, not both.

The keyword entry is not merely incomplete, it points the wrong way. readonly is a legal function name, so exempting the bare identifier turns a false positive into a false NEGATIVE, and a genuine top-level readonly(x) would stop shipping. This analyser is allowed to over-detect and is not allowed to under-detect, so a one-line fix that buys the common case by widening the exemption set is a worse trade than it looks. There is a test for exactly this: a module whose only statement is a call to a function named readonly still ships.

For the erasure itself I used the framework's own stripper rather than a lexical annotation matcher. A matcher would have to get generics, as, satisfies, and conditional types right to be sound, and it would be a second, private opinion about what TypeScript erases to, sitting next to the one the browser is actually served. The stripper makes the analyser and the runtime agree by construction. It is also position-preserving whitespace replacement, so every offset the other scans depend on is unchanged, which is what let this go in without touching them.

It runs at analyzeElision's file read, not inside hasModuleScopeSideEffect. That is the single point where the analyser reads a file, so one call covers the module-scope, template, import, and component scans at once, and it keeps the two exported scan functions synchronous for their existing callers. The consequence is that a DIRECT caller of hasModuleScopeSideEffect still gets the old behaviour on .ts source as authored, which is documented on the function and in packages/server/AGENTS.md. Today that is one purity canary in the ui registry, where a false positive is a test failure rather than shipped bytes, so it did not seem worth a second mechanism for the same bug.

The cost is real and small. Over website (92 modules), analyzeElision goes from about 180ms to about 270ms across five runs, so on the order of a millisecond per TypeScript module. It is warmup work, paid once per boot and again per dev rebuild, and nothing on the request path. I did not add a memo keyed by mtime for it: that is cache-invalidation surface to save 90ms of a cold start, and it can be added later if a large app makes it matter.

…tion

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.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the whole diff fresh. The change itself holds up: erasing types at analyzeElision's single file read is the right seam, position-preserving erasure means the redaction lexer and every brace-depth offset below it are untouched, and the strip-failure fallback lands on the over-ship side on every scan it feeds. I went looking for the way this could quietly go the WRONG direction, a signal that erasure makes invisible, and did not find one: CALL_RE and the lifecycle and prop() matchers all tolerate whitespace before (, so blanking <Options> out of createConfig<Options>(...) still leaves a detected call, and strip-only does no type-directed import elision, so a value import used only as a type is not dropped.

One thing to fix, and it is about the EXTENSION SET rather than the erasure. Inline.

Comment thread packages/server/src/component-elision.js Outdated
Comment thread packages/server/AGENTS.md
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.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scoped this round to f400f865 and traced what it touches outward. The extension narrowing does not hold up: it was made on a claim about which files reach the analysis that turns out to be false, and it reinstates the bug this PR exists to fix on one file class. Details inline.

One more, path-level because it is outside the diff. .agents/skills/webjs/references/runtime.md:23 says WebJs "serves .ts / .tsx as ES modules by erasing the types in place". It does not: dev/serve.js:259 serves js|mjs|ts|mts. Pre-existing, in a file this PR does not touch, and it ships into every scaffolded app through scripts/sync-scaffold-skill.mjs, so it is worth a decision rather than a silent pass.

Comment thread packages/server/src/component-elision.js Outdated
Comment thread packages/server/src/component-elision.js Outdated
Comment thread packages/server/src/component-elision.js Outdated
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.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Deferred: runtime.md claims WebJs serves .tsx, and it does not

Recording this so it is not lost with the round it came from.

.agents/skills/webjs/references/runtime.md:23 says the type stripper seam means WebJs "serves .ts / .tsx as ES modules by erasing the types in place". The .tsx half is wrong: dev/serve.js:259 serves js|mjs|ts|mts, the MIME map lists .ts and .mts, and nothing in the framework handles JSX. I confirmed this while deriving the erasure set for this PR, so the fact is the same one the change rests on.

Deferred rather than folded in because it is a pre-existing error in a file this PR does not touch, and the standing rule is that out-of-scope findings go to you rather than into the diff. Worth a decision either way: that file is copied into the scaffold at prepack by scripts/sync-scaffold-skill.mjs, so the claim ships to every generated app, and an agent reading it would reasonably name a component .tsx and get a file the server will not serve.

It is a one-line fix. Say the word and it goes in here, or it can be its own issue.

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.
It named the docs site as website/app/docs/<topic>/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.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scoped to the three commits nobody had read. Three things, and the first two are mine from ca3d360d rather than anything older.

Comment thread packages/server/AGENTS.md Outdated
Comment thread packages/server/src/component-elision.js Outdated
Comment thread packages/server/src/component-elision.js Outdated
Three things the delta round surfaced.

The per-package AGENTS.md still said the erasure covers .ts / .mts, which is
the narrow set ca3d360 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.
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.
@vivek7405
vivek7405 marked this pull request as ready for review August 16, 2026 19:39
@vivek7405
vivek7405 merged commit 720cadc into main Aug 16, 2026
10 checks passed
@vivek7405
vivek7405 deleted the fix/elision-parenthesised-type-annotation branch August 16, 2026 19:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dogfood: a parenthesised type annotation reads as a top-level call in elision

1 participant