Skip to content

fix: treat arrow bodies and parameter defaults as declarations - #1568

Merged
vivek7405 merged 2 commits into
mainfrom
fix/elision-arrow-param-defaults
Oct 7, 2026
Merged

vivek7405 merged 2 commits into
mainfrom
fix/elision-arrow-param-defaults

Conversation

@vivek7405

@vivek7405 vivek7405 commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1567

The elision analyser no longer reads an arrow's expression body or a function parameter default as code that runs at module load. export const usd = (m) => Math.round(m) / MICROS and export function relativeTime(when, now = Date.now()) are declarations, so a page importing a helper module built from them stays elidable instead of shipping with its whole import graph.

What changed

  • hasModuleScopeSideEffect (packages/server/src/component-elision.js) now runs the depth-0 frame through blankDeclarationOnlyCode before its scans. That drops the parameter list of every function and the expression body of an arrow that initializes a const / let / var declarator.
  • The arrow body ends at a , / ; at its own depth 0, at a ) / ] closing something outside it, or at a newline once it has content, unless a trailing binary operator or a leading ? / : / . / && / || proves the expression carries on. Ending early is always safe, since the rest stays in the frame and is still scanned.
  • A dropped {...} block now leaves a marker in the frame while the blanking runs, so const f = (v) => {...} followed by foo() on the next line cannot be swallowed as an expression body. The marker is removed before the existing scans, so they see the same frame as before.

Deliberately left out: an arrow whose own parameter default calls something ((a = init()) =>) and an arrow outside a declarator (cond ? (a) => f(a) : null) still ship. Both stay conservative.

Test plan

  • packages/server/test/elision/declaration-shapes.test.js: 13 declaration shapes return false, 16 real top-level call shapes (bare call, declarator call, LIST.filter(...), arrow and function IIFEs, (() => 1)(), const g = (a) => a, h = init(), a call after a block-bodied arrow, after v++, after a regex literal, ...) return true, plus an analyzeElision case where a page importing the formatting helpers is inert.
  • packages/ui/test/utils-purity.test.js still green.
  • test/ssr/dropped-page-app-preload.test.js: the App-module modulepreload over-fetches a dropped page's SSR-only subtree #780 under-fetch guard made its page ship through an arrow helper the analyser used to misread, so the page now ships through its own top-level Date.now() call. That was the only test depending on the old reading.
  • Counterfactual: restoring the pre-fix analyser reds the 12 expression and parameter-default shapes plus the route test; the call shapes and the block-bodied arrow stay green.
  • npm test: 4953 pass. The 7 failures (differential elision on vs off, the scaffold server-export coverage entry for robots, the Bun listener test, a type fixture) fail identically with the pre-fix analyser in this worktree.
  • Dogfood: website boots 200 in prod mode on /, /docs/elision, /ui, /ui/button with no broken preloads; webjs check passes in website.
  • Browser / e2e / Bun: N/A, the change is a lexical analyser over source text with no runtime-sensitive surface; the route-level verdict is covered by the analyzeElision unit test.

Docs

  • .agents/skills/webjs/references/components.md (Display-only elision blockers)
  • website/app/docs/elision/page.ts (same list on the docs site)
  • packages/server/AGENTS.md (analyser notes)
  • README / scaffold templates / MCP / editors: N/A, no surface they describe changed.

https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo

The elision analyser read the depth-0 text of a module and counted any
`ident(` there as a call that runs at module load. An expression-bodied
arrow (`export const usd = (m) => Math.round(m) / MICROS`) and a call in a
function parameter default (`function relativeTime(when, now = Date.now())`)
both left such text behind without running anything, so every page that
imported a formatting helper shipped to the browser with its whole import
graph.

The frame now drops a function's parameter list and the expression body of
an arrow that initializes a declarator before the call scan. The arrow body
ends early whenever continuation is not provable, so a real call after it
is still seen.

Closes #1567

Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo
@vivek7405 vivek7405 self-assigned this Oct 7, 2026
@pilots-run

pilots-run Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Preview for ef5a69e was not built: compose file has unsupported keys

Next: fix the listed keys in the compose file

The #780 under-fetch guard made its page ship through an arrow helper
whose body the analyser used to read as a load-time call. That helper is
now correctly a declaration, so the page needs its own top-level call to
stay a boot module. Also dates the counterfactual claim in the new
declaration-shapes test.

Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo
@vivek7405
vivek7405 marked this pull request as ready for review October 7, 2026 10:26
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design rationale: blank the frame, end arrow bodies early

I kept this inside the existing lexical scan instead of reaching for a parser. The analyser already works on a redacted, brace-depth-0 frame, so the fix is just removing two more kinds of text from that frame before the call scan runs.

The one hard part is knowing where an expression-bodied arrow ends without a parser, because of ASI. The rule leans one way on purpose. Ending too early leaves the rest of the body in the frame, where it still gets scanned and at worst ships the module, which is the old behaviour. Ending too late could swallow a real statement on the next line, which would elide a module that does client work. So a newline ends the body unless continuation is certain: the line ends in a binary operator, ?, : or =>, or the next line starts with something no statement can start with (?, :, ., &&, ||). Postfix ++ / -- and a regex literal's closing / both end a statement, so neither counts as an operator here.

Since the frame drops {...} bodies, const f = (v) => {...} with foo() on the next line would look like an arrow whose body is foo(). A marker character now stands in for each dropped block while the blanking runs, and it is removed before the existing scans so they see exactly the frame they saw before.

@vivek7405
vivek7405 merged commit b56a2ad into main Oct 7, 2026
9 of 11 checks passed
@vivek7405
vivek7405 deleted the fix/elision-arrow-param-defaults branch October 7, 2026 10:37
vivek7405 added a commit that referenced this pull request Oct 7, 2026
Ships the elision analyser fix (#1568): an expression-bodied arrow and a
function parameter default are declarations, not load-time calls, so a
page importing helpers written that way no longer ships to the browser.

Claude-Session: https://claude.ai/code/session_01SZ72LSPAo4NvYvBDSD6RLo
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: elision reads arrow bodies and parameter defaults as load-time calls

1 participant