Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/webjs/references/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,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. 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)
- 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. A call inside a declaration does not run at load, so it is not a blocker: an arrow's expression body (`export const usd = (m) => Math.round(m) / MICROS`) and a function parameter default (`function relativeTime(when, now = Date.now())`) are both fine. 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 `<slot>` 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

Expand Down
8 changes: 7 additions & 1 deletion packages/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,13 @@ conditional leaves a CSP-off document one newline shorter than a CSP-on one).
if it does real client work). A module-scope pure-data constructor
(`new Set([...])` / `Map` / `Date` / `RegExp` / typed array / `URL`) is inert
data, not a side effect; any other constructor (`new WebSocket()` / `Worker()`)
still ships. The vendor bare-import scan (`extractPackageName`) treats a
still ships. A `function` declaration's parameter list and the expression
body of an arrow initializing a `const` / `let` / `var` declarator are
dropped from the depth-0 frame before the call scan
(`blankDeclarationOnlyCode`, #1567), so `now = Date.now()` and
`(m) => Math.round(m) / MICROS` are declarations, not load-time calls; the
arrow body ends early (at a newline) whenever continuation is not provable,
because ending late could swallow a real statement. The vendor bare-import scan (`extractPackageName`) treats a
`#`-alias specifier as local and skips it outright (it does not expand the
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
Expand Down
128 changes: 125 additions & 3 deletions packages/server/src/component-elision.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,118 @@ const PURE_DATA_CONSTRUCTORS = new Set([
'BigInt64Array', 'BigUint64Array',
]);

/**
* Marks where `hasModuleScopeSideEffect` dropped a top-level `{...}` body from
* its depth-0 frame, so an arrow followed by a block body is not mistaken for
* an expression body that runs on into the next statement. Removed again
* before the call scan.
*/
const BLOCK = '\x01';

/** `function`, `function*`, `async function name` up to its parameter list's `(`. */
const FUNCTION_PARAMS_RE = /(?<![\w$.])function\s*\*?\s*(?:[A-Za-z_$][\w$]*)?\s*\(/g;

/**
* The text before an arrow's `=>` when that arrow is a declarator initializer
* (`const usd = (m) =>`, `let f = async x =>`, `, g = (a) =>`). The parameter
* list must hold no parens, so a call in a parameter default
* (`(a = init()) =>`) keeps its arrow unrecognised and still ships.
*/
const ARROW_DECLARATOR_RE =
/(?:(?<![\w$.])(?:const|let|var)\s+|,\s*)[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?(?:\([^()]*\)|[A-Za-z_$][\w$]*)\s*$/;

/**
* An expression body that ends this way cannot stop at the newline (a binary
* operator, `?`, `:`, or a curried `=>`). Postfix `++` / `--` and a regex
* literal's closing `/` end a statement, so neither is listed.
*/
const BODY_CONTINUES_RE = /(?:=>|&&|\|\||\?\?|[?:=*%|&^<>]|(?<![+])\+|(?<![-])-)\s*$/;

/** A line starting this way continues the previous one: no statement starts with it. */
const LINE_CONTINUES_RE = /^\s*(?:\?|:|&&|\|\||\.(?![.\d])|\*|%|=(?!>)|\|)/;

/**
* Removes the two depth-0 shapes that look like calls and run nothing at module
* load: a `function` declaration's parameter list (a default such as
* `now = Date.now()` runs per call) and an arrow's expression body when the
* arrow initializes a declarator (`const usd = (m) => Math.round(m) / MICROS`).
*
* The arrow body ends at a `,` or `;` at its own paren/bracket depth 0, at a
* `)` / `]` closing something outside it, or at a newline once the body has
* content, unless `BODY_CONTINUES_RE` / `LINE_CONTINUES_RE` prove the
* expression carries on. Ending early is always safe, because whatever is left
* stays in the frame and is still scanned; ending late could swallow a real
* statement, so every rule errs towards ending.
*
* @param {string} frame depth-0 text, with `BLOCK` where a `{...}` was dropped
* @returns {string | null} the frame without those parts, or null when a
* parameter list does not close (the caller then ships)
*/
function blankDeclarationOnlyCode(frame) {
let out = '';
let last = 0;
for (const m of frame.matchAll(FUNCTION_PARAMS_RE)) {
if (m.index < last) continue;
const open = m.index + m[0].length - 1;
let depth = 0;
let close = -1;
for (let j = open; j < frame.length; j++) {
if (frame[j] === '(') depth++;
else if (frame[j] === ')' && --depth === 0) { close = j; break; }
}
if (close < 0) return null;
out += frame.slice(last, open + 1);
last = close;
}
frame = out + frame.slice(last);

out = '';
let depth = 0;
for (let i = 0; i < frame.length; i++) {
const c = frame[i];
if (c === '(' || c === '[') depth++;
else if ((c === ')' || c === ']') && depth > 0) depth--;
if (depth === 0 && c === '=' && frame[i + 1] === '>' && ARROW_DECLARATOR_RE.test(out)) {
out += '=>';
i = arrowBodyEnd(frame, i + 2) - 1;
continue;
}
out += c;
}
return out;
}

/**
* Index where an arrow's expression body starting at `start` ends (see
* `blankDeclarationOnlyCode`).
*
* @param {string} frame
* @param {number} start index just past the `=>`
* @returns {number}
*/
function arrowBodyEnd(frame, start) {
let depth = 0;
let i = start;
for (; i < frame.length; i++) {
const c = frame[i];
if (c === '(' || c === '[') { depth++; continue; }
if (c === ')' || c === ']') {
if (depth === 0) return i;
depth--;
continue;
}
if (depth > 0) continue;
if (c === ',' || c === ';') return i;
if (c === '\n') {
const body = frame.slice(start, i);
if (!body.trim() || BODY_CONTINUES_RE.test(body)) continue;
if (LINE_CONTINUES_RE.test(frame.slice(i + 1))) continue;
return i;
}
}
return i;
}

/**
* 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
Expand Down Expand Up @@ -320,8 +432,13 @@ const PURE_DATA_CONSTRUCTORS = new Set([
* 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
* A `function` declaration's parameter list and the expression body of an
* arrow that initializes a `const` / `let` / `var` declarator are dropped from
* the frame first (`blankDeclarationOnlyCode`): `now = Date.now()` and
* `(m) => Math.round(m) / MICROS` declare code, they do not run it.
*
* Over-detection is safe (a pure top-level helper call, or an arrow outside a
* declarator whose body calls something, only ships). The accepted residual misses, all
* contrived and structural (so they do not rot), are a call buried inside a
* top-level object / array initializer or a destructuring default, and a
* side-effecting tagged-template hole evaluated at module scope.
Expand Down Expand Up @@ -361,14 +478,19 @@ export function hasModuleScopeSideEffect(src, literals) {
if (depth === 0) frame += "''";
continue;
}
if (c === '{') { depth++; continue; }
if (c === '{') { if (depth === 0) frame += BLOCK; depth++; continue; }
if (c === '}') { if (depth > 0) depth--; continue; }
if (depth === 0) frame += c;
}
// Unbalanced braces mean a construct we could not lexically resolve (a regex
// literal with a stray brace is the common case). Ship rather than risk a
// hidden top-level statement.
if (depth !== 0) return true;
// Parameter lists and declarator arrow bodies are declarations, not code
// that runs at load.
const declared = blankDeclarationOnlyCode(frame);
if (declared === null) return true;
frame = declared.replaceAll(BLOCK, '');
// Optional-chaining call/index: `foo?.()`, `x?.[i]()` (the `?.` defeats the
// identifier-before-paren match below).
if (/\?\.\s*[([]/.test(frame)) return true;
Expand Down
91 changes: 91 additions & 0 deletions packages/server/test/elision/declaration-shapes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Declarations that LOOK like top-level calls must not read as module-load
* work (#1567).
*
* `hasModuleScopeSideEffect` keeps only the brace-depth-0 text of a module and
* treats any `ident(` left there as a call that runs when the module loads. Two
* declaration shapes leave such text behind without running anything: the
* expression body of an arrow that initializes a declarator
* (`export const usd = (m) => Math.round(m) / MICROS`) and a call in a
* `function` declaration's parameter default (`now = Date.now()`). Read as
* calls, they shipped every page that imported a formatting helper, along with
* that page's whole import graph.
*
* Every declaration shape is paired with real top-level calls that must keep
* shipping, because the cheap way to make the first half pass is to stop
* seeing calls at all. Reverting the frame blanking in `component-elision.js`
* reds every declaration test except the block-bodied arrow (which the
* depth-0 frame already dropped), plus the route test, and nothing else
* (proven at 015faf5c).
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { analyzeElision, hasModuleScopeSideEffect } from '../../src/component-elision.js';

const DECLARATIONS = {
'expression-bodied arrow': 'const MICROS = 1e6;\nexport const usd = (m) => Math.round(m) / MICROS;',
'parenthesised arrow body': 'const one = (v) => (Array.isArray(v) ? v[0] : v);',
'arrow body on the next line': 'export const one = (v) =>\n Array.isArray(v) ? v[0] : v;\nexport const two = 2;',
'leading-operator ternary body': 'const f = (v) =>\n isX(v)\n ? fmt(v)\n : other(v);\n',
'trailing-operator body': 'const ok = (v) => v.ok &&\n check(v);',
'async arrow and a second declarator': 'const a = async (v) => await load(v), b = (x) => g(x);',
'curried arrow': 'const add = (a) => (b) => sum(a, b);',
'arrow returning a constructor': 'const mk = (u) => new WebSocket(u);',
'type-erased arrow': 'export const usd = (m ) => Math.round(m);',
'function parameter default': 'export function relativeTime(when, now = Date.now()) { return now - when; }',
'parameter default calling a helper': 'export function buildSearchIndex(pages = livePages()) { return pages; }',
'async generator parameter default': 'async function* gen(a = x()) { yield a; }',
'block-bodied arrow': 'const f = (v) => {\n return g(v);\n}\nconst z = 1;',
};

const CALLS = {
'a declarator calling a helper': "const x = planById('free');",
'a method call on data': 'export const A = LIST.filter((p) => p.ok);',
'a bare call': 'foo();',
'a call after an arrow statement': 'const f = () => x; f();',
'a call on the line after an arrow (no semicolon)': 'const f = () => x\nf()',
'a call after a block-bodied arrow': 'const f = (v) => {\n return g(v);\n}\nfoo();',
'an arrow IIFE': '(() => { go(); })();',
'an immediately invoked arrow initializer': 'export const y = (() => 1)();',
'a call in the declarator after an arrow': 'const g = (a) => a, h = init();',
'a call in an arrow parameter default': 'const f = (a = init()) => a;',
'a call after a postfix increment ends the body': 'const f = (v) => v++\ninit();',
'a call after a regex literal ends the body': 'const r = () => /x/\ninit();',
'a call after a function declaration': 'function f() {}\ninit();',
'an invoked function expression': 'const x = function (a = 1) { return a; }();',
'a function IIFE with a parameter default': '(function (a = b()) {})();',
'a call after a conditional arrow': 'const k = cond ? (a) => f(a) : null;\nfoo();',
};

for (const [name, src] of Object.entries(DECLARATIONS)) {
test(`declaration, not a load-time call: ${name}`, () => {
assert.equal(hasModuleScopeSideEffect(src), false, src);
});
}

for (const [name, src] of Object.entries(CALLS)) {
test(`still a load-time call: ${name}`, () => {
assert.equal(hasModuleScopeSideEffect(src), true, src);
});
}

test('a page importing a formatting helper module does not ship for it', async () => {
const format = `const MICROS = 1_000_000;
export const usd = (m) => '$' + (Math.round(m / 10_000) / 100).toFixed(2);
export function relativeTime(when, now = Date.now()) {
return Math.round((now - when) / 1000) + 's ago';
}`;
const page = `import { html } from '@webjsdev/core';
import { usd, relativeTime } from './format.js';
export default ({ cost, at }) => html\`<p>\${usd(cost)} \${relativeTime(at)}</p>\`;`;
const files = { '/app/page.js': page, '/app/format.js': format };
const r = await analyzeElision(
[],
['/app/page.js'],
new Map([['/app/page.js', new Set(['/app/format.js'])]]),
async (f) => files[f],
'/app',
);
assert.ok(r.inertRouteModules.has('/app/page.js'), 'the page must be inert');
});
8 changes: 5 additions & 3 deletions packages/server/test/ssr/dropped-page-app-preload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ test('a SHIPPED page still preloads its transitive relative-helper app modules (
// The under-fetch guard for the roots change: this is what would break if the
// moduleUrls -> absolute-path round-trip failed to match the graph's node keys
// and the walk from `shippedRoots` silently dropped a genuinely-shipped dep.
// A page that imports a pure relative helper `./fmt.js` (used as a value) SHIPS,
// so `page.js` is a boot module. `fmt.js` and its own helper `./deep.js` are
// A page that runs code at module load (`Date.now()` at top level) SHIPS, so
// `page.js` is a boot module, while its pure relative helper `./fmt.js` does
// nothing at load (an arrow body is a declaration, #1567). `fmt.js` and its own helper `./deep.js` are
// NOT boot modules; their preloads come ONLY from `deduplicatedPreloads` walking
// the transitive closure from `shippedRoots` (the boot-module preload loop
// covers `page.js`, not its deps, and `deduplicatedPreloads` excludes
Expand All @@ -116,7 +117,8 @@ test('a SHIPPED page still preloads its transitive relative-helper app modules (
'app/page.js':
`import { html } from ${JSON.stringify(HTML_URL)};\n` +
`import { fmt } from './fmt.js';\n` +
`export default () => html\`<p>\${fmt(1)}</p>\`;\n`,
`const LOADED_AT = Date.now();\n` +
`export default () => html\`<p>\${fmt(LOADED_AT)}</p>\`;\n`,
});
const app = await createRequestHandler({ appDir, dev: false });
await app.warmup();
Expand Down
2 changes: 1 addition & 1 deletion website/app/docs/elision/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function Elision() {
<li>A factory-declared reactive property that is not <code>{ state: true }</code>.</li>
<li>An overridden lifecycle hook, <code>renderFallback()</code> and <code>renderError()</code> included.</li>
<li>An imported <code>signal</code> / <code>computed</code> / <code>watch</code> / <code>Task</code> / <code>ref</code> or a streaming directive, or a call to <code>addController</code> / <code>requestUpdate</code>.</li>
<li>Code that runs at module load: a top-level call, a non-data <code>new</code>, a dynamic <code>import(...)</code>, a top-level <code>await</code>. Only declarations and the <code>register(...)</code> 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: <code>readonly (readonly [number, number, number])[]</code> is inert data.</li>
<li>Code that runs at module load: a top-level call, a non-data <code>new</code>, a dynamic <code>import(...)</code>, a top-level <code>await</code>. Only declarations and the <code>register(...)</code> call are inert. A call inside a declaration does not run at load, so an arrow's expression body (<code>export const usd = (m) =&gt; Math.round(m) / MICROS</code>) and a function parameter default (<code>function relativeTime(when, now = Date.now())</code>) are inert too. TypeScript types are erased before the analyser reads a module, so an annotation is never a signal however call-shaped it looks: <code>readonly (readonly [number, number, number])[]</code> is inert data.</li>
<li>A browser global at module scope, or a side-effect import of an npm package.</li>
<li>The dynamic slot READ surface (<code>slotchange</code>, <code>assignedNodes</code> / <code>assignedElements</code> / <code>assignedSlot</code>). Merely rendering a <code>&lt;slot&gt;</code> does not ship, because the SSR output already carries the placed children.</li>
<li>Being rendered or imported by a component that itself ships.</li>
Expand Down
Loading