You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Line anchors in this issue were verified against HEAD ddfc5547. Where an anchor quoted in the previous body had drifted, the drift is noted inline.
Problem
Elision is the right default and stays automatic (settled in #976: the analyser is engineered fail-safe, every ambiguity ships JS, and a manual 'use client'-style annotation would invert the failure mode onto every app author with no compiler to catch a forgotten one). The production gap is not the inference. It is that only the benign direction is visible.
packages/server/src/elision-report.js (#646) tells an app which pages and layouts SHIP when they could have been elided. That is the over-ship direction, whose worst outcome is a few wasted kilobytes. Nothing reports the other direction: which components were ELIDED, and on what evidence. That is the direction where a wrong verdict silently loses interactivity in production, and an app author currently has no way to ask "what did you drop from my page?".
The framework protects itself with a differential on/off test (packages/server/test/elision/differential-elision.test.js). An APP has no equivalent, so every app inherits a guarantee it cannot verify locally.
#976 comment 4 said L882-884, which has drifted by about +34 lines
doctor tests live in packages/cli/test/
FALSE
they live at test/cli/doctor.test.mjs; packages/cli/test/ has no doctor directory
elision has no dedicated docs-site page
verified
described across data-fetching, client-router, components, no-build, progressive-enhancement, troubleshooting
the opt-out is on NO docs-site page
verified
grep -r WEBJS_ELIDE website/ and grep -r '"elide"' website/ both return nothing
no fixture app has a static interactive = true component
verified
the only in-repo occurrences are prose in packages/cli/templates/gallery/modules/async-render/components/server-clock.ts:20 and three docs-site paragraphs
The two documented residuals, measured
The previous body inherited #976's wording that residual (a) is "a runtime-computed .register(tagVar) tag string". That wording is wrong, and building tests against it would have tested a mechanism that does not exist. The real analyser was driven against six on-disk fixtures. Results:
Residual (a) is the OBSERVER's tag string, not the registration's. A shipping module writing customElements.whenDefined(TAG) where TAG is a variable misses WHEN_DEFINED_RE (component-elision.js:254), so the observed display-only component is ELIDED and the awaited registration never happens. Measured: the badge lands in elidableComponents. Adding static interactive = true to the badge rescues it (it drops out of elidableComponents). This is a genuine wrong-elide with a working escape hatch, and it is unasserted today.
Residual (b), an external-stylesheet :defined rule, is real as documented.TAG_DEFINED_RE (component-elision.js:255) only scans graph-reachable module source, so a public/app.css carrying my-badge:defined { ... } is invisible. Measured: the badge is elided AND the page is classified inert, so the module is dropped from the boot entirely. static interactive = true rescues it (the badge ships and the page becomes import-only). Also unasserted today.
A computed REGISTRATION tag is a different and worse defect, and the escape hatch does not rescue it.scanComponents requires a literal tag (component-scanner.js:57 and L68), so a component registered as Badge.register(TAG) is never in components at all. It therefore never enters componentFiles, analyzeComponentSource is never consulted for it, and analyzeElision sees a module whose only top-level statement is a register(...) call, which hasModuleScopeSideEffect explicitly exempts (component-elision.js:378, if (ident === 'register' || ident === 'define') continue;). Measured: the importing page is classified inert, so both the page module and the component module are dropped from the boot and the element never registers. Adding static interactive = true changes nothing (analyzeComponentSource on that source correctly returns interactive: true, but nothing calls it). The only thing standing between an author and a silently dead element is the dev-only orphan warning (findOrphanComponents, component-scanner.js:159, logged from dev.js:978-984), which fires in dev and is absent in prod.
Consequence: AGENTS.md, .agents/skills/webjs/references/components.md:267, website/app/docs/data-fetching/page.ts:66, and website/app/docs/components/page.ts:500 all name "a dynamically-computed tag string" as a case static interactive = true rescues. It does not. Invariant 3 already requires a literal tag, so the correct fix is documentary (say the tag must be a literal and point at the orphan warning), not a code change to support computed registration.
Design / approach
Five decisions, each with the constraint that settles it.
1. The app-level differential is a first-class command, webjs elision --verify
Settled: a new webjs elision command with a --verify mode that runs the SSR differential itself, plus a documented two-run recipe for apps that own a browser suite.
What decides it: a webjs test flag would require WebJs to re-run the app's own test command twice under a flipped env, and the proof would then be only as good as that app's coverage, which is exactly the thing an author cannot vouch for. A documented recipe alone is worse still, because a freshly scaffolded app has no e2e suite at all, so the recipe delivers nothing on day one. The framework's own SSR-layer guard needs no browser and no test suite: it boots two createRequestHandler instances in one process with WEBJS_ELIDE flipped, renders a route corpus, masks the JS-loaded set, and diffs bytes. That primitive is fully portable to any app, because the route corpus comes from buildRouteTable(appDir), which every app has. So the command an author actually types is:
npx webjs elision --verify
The masker is the one place the two guards could drift, so maskJsSet moves out of differential-elision.test.js:46 into a shared server leaf and both consumers import it. Literally "give apps the framework's own guard".
Rejected: folding --verify into webjs doctor. Doctor is a fast checklist an author runs constantly, and rendering every route twice through two warmed handlers is seconds to tens of seconds and would wreck that.
Rejected: webjs elision verify as a subcommand namespace (the webjs vendor shape). Two operations is thin for a namespace, and check / routes / doctor are all "one report plus mode flags", which is the shape this matches.
What --verify proves and what it does not.#976 recorded that the differential guard masks the whole JS-loaded set by construction, so OVER-ship is invisible to it. Confirmed at differential-elision.test.js:11-13 and in maskJsSet L46-70, which strips the importmap, every <script type="module">, every modulepreload, the vendor preconnect hints, and both content stamps. The mode therefore proves elision did not change the bytes the browser is served. It does not prove elision is optimal, and it does not prove post-hydration behaviour, because a wrongly dropped module manifests as a dead click, not as different bytes. The command must say so in its own success output, not only in docs.
2. The verdict artifact is JSON from analyzeAppElision, surfaced by CLI and MCP from one function
Settled: analyzeAppElision(appDir) is extended to return the whole verdict, webjs elision --json prints exactly that object, and a new MCP tool list_elision returns the same object.
What decides it: the webjs routes --json precedent. packages/mcp/src/routes-report.js exists as a shared projector only because buildRouteTable returns an internal table needing a projection step with injected effectful deps. analyzeAppElision has no such problem: it already owns the app-level contract and can return a plain JSON-serializable object directly. So there is no new projector leaf, both consumers import analyzeAppElision from @webjsdev/server, and a drift test asserts the two outputs are equal. That also honours the module header's rule (elision-report.js:2-18) that this file is a reporting layer over analyzeElision, not a second analysis and not a build.
Rejected: growing list_components (mcp.js:362) with an elided flag. That tool is scanComponents projected, a cheap lexical inventory that loads no module and builds no graph. Making it run buildModuleGraph plus analyzeElision would silently turn a cheap tool expensive, and it still could not carry the route-module verdicts or the elide-switch state.
Prior art. Next reports per-route First Load JS size from printTreeView (next.js/packages/next/src/build/utils.ts:286). It tells an author how much shipped and never why a given module shipped or what was dropped, because with 'use client' there is no inference to report. Astro's xray.ts (astro/packages/astro/src/runtime/client/dev-toolbar/apps/xray.ts) highlights <astro-island> elements in the browser, which is a locator rather than a verdict explainer for the same reason (the client:* directive is author-declared). Qwik is the closest analogue: QwikManifest (qwik/packages/qwik/src/optimizer/src/types.ts:217) is a machine-readable enumeration of every symbol, its bundle, and the import graph. But it is a BUILD artifact written to q-manifest.json. WebJs is no-build, so the same information has to come from a command that runs the same analysis pass on demand, never from a file on disk. That is the shape below.
The schema, field by field. Every path is app-relative (the routes-report.js:68 convention). Every array is sorted by file for determinism.
{
"analysed": true, // false when nothing could be analysed"skipped": null, // null | "no-app" | "elide-off" | "unanalysable""components": [
{
"file": "components/build-stamp.ts", // app-relative path of the component module"tags": ["build-stamp"], // every tag this file registers, sorted"verdict": "elided", // "elided" | "shipped""evidence": null, // null when elided"reason": null, // null when elided"by": null// null when elided
},
{
"file": "components/counter.ts",
"tags": ["my-counter"],
"verdict": "shipped",
"evidence": "own", // the component's own source carries a signal"reason": "template has an @event binding", // analyzeComponentSource's verbatim reason"by": null
},
{
"file": "components/observed-badge.ts",
"tags": ["observed-badge"],
"verdict": "shipped",
"evidence": "observed", // another module observes its registration"reason": "its registration is observed by components/observe-badge.ts",
"by": "components/observe-badge.ts"// app-relative path of the forcing module
}
],
"routeModules": [
{ "file": "app/about/page.ts", "verdict": "inert", "emits": [], "blocker": null, "reason": null },
{ "file": "app/blog/page.ts", "verdict": "import-only", "emits": ["components/counter.ts"], "blocker": null, "reason": null },
{ "file": "app/page.ts", "verdict": "shipped", "emits": [], "blocker": "lib/track.ts",
"reason": "references a browser global at module scope, runs code at module scope, or has a bare side-effect import" }
],
"orphans": [
{ "file": "components/dyn-badge.ts", "className": "DynBadge" }
],
"summary": {
"components": 3, "elided": 1, "shipped": 2,
"routeModules": 3, "inert": 1, "importOnly": 1, "shippedWhole": 1,
"orphans": 1
}
}
evidence is the closed set "own" | "observed" | "closure" | "render" | "import" | "unreadable", one per rule in analyzeElision that can force a ship. FIRST match wins, matching the analyser's own first-match convention. by names the module that forced it for observed / closure / render / import, and is null for own / unreadable.
An ELIDED row carries reason: null on purpose. Elision is the ABSENCE of every signal, so there is no positive fact to report, and inventing one would mean re-running the analysis to enumerate what was checked. The report says what it knows, and the docs say what the signal list is.
orphans is the answer to the measured case-3 defect. It reuses the already-exported findOrphanComponents (component-scanner.js:159), which the dev server already runs, so it is not a second elision analysis. It is the one shape that gets dropped with no verdict at all, which makes it exactly what a "what did you drop from my page" report must carry.
3. The new doctor check is gateable and warns only on orphans
Settled: check name 'Component elision (what the browser drops)', stable code ELISION_COMPONENTS, warn only when the report lists an orphan, pass otherwise.
What decides it: an elided component is the DESIRED outcome, so warning on one would fire on every healthy app and train the reader to skip doctor output. The check therefore carries the elided inventory as a passing message, which is the discovery surface, while webjs elision is the detail surface. But a pass-only check has no teeth, and the measurement above found exactly one condition that is always wrong and currently silent in prod: an orphan component, which is dropped with no escape hatch. That is the warn condition.
Elision being off reports pass with the message naming the switch, matching the sibling carrier check's existing treatment (doctor.js:1019-1021) rather than inventing a second policy for the same condition.
It needs its own explicit DOCTOR_CODES entry because webjs.doctor.gate (#1257) addresses a check by its stable code, and the name-derived fallback (codeForName, doctor.js:116) is a last resort the repo does not rely on for a shipped check. No webjs-config.schema.json change is needed: gate accepts any ^[A-Z][A-Z0-9_]*$ key by pattern and the doctor validates against the DOCTOR_CODES set, so adding the entry makes the code gateable automatically. No new webjs.* key is introduced anywhere in this work, so the three-place config lockstep is untouched.
Both elision checks must share ONE report. runDoctorChecks (doctor.js:1518) currently calls checkElisionCarriers(appDir) inside its Promise.all; two independent callers would mean two full module-graph builds per doctor run. The report is started as a single un-awaited promise before the array and both checks await it, so parallelism is preserved and the graph is built once.
4. The dev summary is a server-console line, with no browser push
Settled: one logger.info line at the end of the first warm analysis, gated if (dev), emitted from ensureReady beside the existing orphan warnings. No SSE event, no inline script.
What decides it, in order of weight:
An inline dev script would add JavaScript to a page whose verdict is that it needs none. An inert route ships zero application JS, and the single most useful manual check an author has is opening the network tab on that route and seeing nothing. A dev-only boot script corrupts exactly that observation, on exactly the pages the feature exists to prove.
The fact is app-wide, the channel is per-tab and per-page. The verdict is one set of numbers for the whole app, re-derived per rebuild. A browser push would repeat one app-wide fact on every navigation.
The webjs-error SSE channel exists because an error is tied to a request and must interrupt the author. A verdict summary is ambient and must not. Reusing that channel for ambient data devalues it.
The per-page detail already has a home in webjs elision, which prints the full verdict on demand.
Astro splits the same way and is the citation: xray.ts puts a POSITIONAL fact (where the islands are on this page) in the browser, while project-level facts stay in the CLI. The elision verdict is not positional.
Exact output, one line:
[webjs] elision: 7/12 components elided, 3 route modules inert, 4 import-only, 2 ship whole. Run `webjs elision` for the per-module verdict.
and with the switch off:
[webjs] elision: disabled (WEBJS_ELIDE / webjs.elide), every module ships.
5. Invalidation rides the rebuild, and nothing is cached
Settled: analyzeAppElision caches nothing, and the dev summary is invalidated by analysisDone = false in doRebuild.
What decides it: every out-of-process consumer (webjs doctor, webjs elision, the MCP tool) runs the analysis once and exits, so an intra-process cache would never see a hit and would only add a staleness bug. In dev the summary is emitted from inside the analysis stage of ensureReady, which runs only when analysisDone is false. doRebuild sets analysisDone = false at dev.js:1158 after waiting out any in-flight build, so the summary is re-emitted on the next request after each fs.watch rebuild, on exactly the signal that re-derives the verdict. That is the named signal.
The analyser stays biased toward shipping on any ambiguity. A reporting change must never become a reason to tighten a verdict.
No-build holds. The report is a server-side analysis pass, never a build artifact and never a file written to disk.
elision-report.js stays a reporting layer over analyzeElision. No second elision analysis pass.
Implementation plan
Work in a dedicated worktree (git worktree add -b feat/elision-inspectable ../webjs-elision-inspectable origin/main, then npm run worktree:link inside it). A fresh worktree has no node_modules and no packages/core/dist, and step 8 boots real handlers, so the link step is not optional.
Step 1. Thread the per-component verdict out of analyzeElision
File packages/server/src/component-elision.js. The verdict data already exists in memory. Do not recompute any of it.
1a. In the file loop, component-elision.js:992 currently discards the reason:
1b. Declare the recorder next to mustShip (component-elision.js:927-928). First write wins, so a component forced by several rules reports the rule that reached it first, matching the analyser's first-match convention everywhere else:
/** @type {Map<string, { evidence: string, by: string|null, reason: string|null }>} */constshipEvidence=newMap();/** Record WHY a component ships. First write wins. */constnoteShip=(file,evidence,by,reason)=>{if(!shipEvidence.has(file))shipEvidence.set(file,{ evidence, by,reason: reason??null});};
1c. The unreadable-component branch at component-elision.js:958-961:
1d. The cross-module observation loops at component-elision.js:1001-1009 collect into observedComponentFiles with no record of the observer. Add a sibling map declared beside it (component-elision.js:941), written in the same three loops:
/** @type {Map<string, string>} observed component file -> the module observing it */constobservedBy=newMap();
and in each of the three loops, after const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);, add if (f && !observedBy.has(f)) observedBy.set(f, file); (the INSTANCEOF_RE loop uses classToFile).
1g. Build the verdict map AFTER clientEffectReason is defined (component-elision.js:1141-1147), because the closure reason phrase reuses it. Insert immediately before the route-module loop at L1184:
// Per-component verdict + the evidence that produced it (#1308). Assembled// from data the passes above already computed, and nothing is re-analysed. A file// may register more than one tag, so rows are keyed by FILE with a tag list./** @type {Map<string, { tags: string[], className: string|null, shipped: boolean, evidence: string|null, by: string|null, reason: string|null }>} */constcomponentVerdicts=newMap();for(constcofcomponents){letrow=componentVerdicts.get(c.file);if(!row){constshipped=mustShip.has(c.file);constev=shipped ? shipEvidence.get(c.file) : undefined;row={tags: [],className: c.className??null, shipped,evidence: ev ? ev.evidence : null,by: ev ? ev.by : null,reason: !shipped||!ev ? null
: ev.evidence==='own' ? ev.reason
: ev.evidence==='observed' ? `its registration is observed by ${ev.by}`
: ev.evidence==='closure' ? `its import ${ev.by}${clientEffectReason(ev.by)}`
: ev.evidence==='render' ? `${ev.by} ships and can render its tag`
: ev.evidence==='import' ? `${ev.by} ships and imports it`
: 'its source could not be read (ships conservatively)',};componentVerdicts.set(c.file,row);}if(!row.tags.includes(c.tag))row.tags.push(c.tag);}for(constrowofcomponentVerdicts.values())row.tags.sort();
A shipped component with no shipEvidence entry is possible only if a future rule adds to mustShip without calling noteShip, so the !ev arm yields evidence: null, reason: null rather than a wrong claim. The sigil / lifecycle coverage guards do not cover this, so step 6 adds a drift assertion.
1h. Add componentVerdicts to the return at component-elision.js:1245 and to the JSDoc @returns at L912:
2b. Call findOrphanComponents alongside the existing three builders in the same try at L39-47 (add it to the import at L23), then replace the projection at L58-67 with the full report. shipped is REPLACED by routeModules, not kept alongside it: WebJs has no users and a parallel legacy key is pure drift surface.
relativizeReason is a small local that rewrites the absolute paths analyzeElision baked into the observed / closure phrases (step 1g) to app-relative form, so no absolute filesystem path ever reaches the JSON contract. Implement it as a single split(appDir + sep).join('') pass rather than a regex, so a path containing regex metacharacters is safe.
2c.analyzeAppElision is already exported from packages/server/index.js:46. Update its declaration in packages/server/index.d.ts:497 to the new return type. The drift test packages/server/test/types/exports-drift.test.mjs asserts the declared export SET, not the shape, so it stays green either way, but a stale declaration would break a typed consumer.
Step 3. Extract the shared differential masker
New file packages/server/src/elision-differential.js. It exports two things and imports nothing beyond node:path plus the server's own buildRouteTable.
maskJsSet(html): moved VERBATIM from differential-elision.test.js:46-70, comments included. It is the definition of "the JS-loaded set", and having two copies is the one way the app-facing guard and the framework's own guard can silently disagree.
staticPageRoutes(table): maps table.pages to URL paths via the same normalization routes-report.js:32 uses, keeping only pages with no dynamic segments (!r.paramNames || r.paramNames.length === 0), sorted, deduped. A dynamic route cannot be rendered without invented param values, so it is excluded and REPORTED as skipped rather than guessed at.
Then edit packages/server/test/elision/differential-elision.test.js to delete its local maskJsSet (L37-70) and import the shared one. Everything else in that file is unchanged, so its counterfactual at L208-217 still guards the masker, now on behalf of both consumers.
Export both from packages/server/index.js (next to the analyzeAppElision line at L46) and declare them in index.d.ts. maskJsSet is genuinely public API here, because the documented two-run recipe in step 10 lets an app author diff their own captures with it.
Step 4. Add the webjs elision command
File packages/cli/bin/webjs.js.
4a. Add the HELP entry between routes (L137-146) and doctor (L147). test/cli/help.test.mjs:160 reads the HELP object literal and requires a usage line plus at least one example for every entry, so both are mandatory:
elision: {usage: 'webjs elision [--json] [--verify] [--routes <paths>]',summary: 'Report the elision verdict: which component modules the browser never downloads, and why each one that ships does.',options: [{flag: '--json',description: 'Emit the verdict as JSON (byte-identical to the MCP list_elision tool).'},{flag: '--verify',description: 'Render every static page route with elision on and off and diff the observable SSR bytes. Exits non-zero on a divergence.'},{flag: '--routes <paths>',description: 'Comma-separated URL paths to add to the --verify corpus (the only way to cover a dynamic route).'},],examples: ['webjs elision','webjs elision --json','webjs elision --verify','webjs elision --verify --routes /,/blog/hello'],},
4b. Add one line to the USAGE banner (packages/cli/bin/webjs.js:52), directly after the webjs routes line at L58:
webjs elision [--json] [--verify] Report which component modules are elided and why each shipped one ships; --verify diffs SSR output with elision on vs off
4c. Add the case 'elision': block after the case 'routes': block (which ends around L889). Report mode:
The human default prints, in order, the skipped reason when analysed is false, then a one-line summary, the elided component list, the shipped component list with reason per row, the route-module list grouped by verdict, and the orphan list under a heading naming it as the one shape with no escape hatch. Sorted output only, so a diff of two runs is meaningful.
4d.--verify mode, in the same case. Structure, mirroring differential-elision.test.js:90-115 exactly:
Then, with process.env.WEBJS_ELIDE saved and restored in a finally (the test's L91-114 pattern is the reference):
Delete WEBJS_ELIDE, create handler A, await A.warmup(), render every route, capture { status, html }.
Render every route a SECOND time through the SAME warm handler A. A route whose two ON captures differ under maskJsSet is NONDETERMINISTIC (a rendered clock beyond what the masker normalizes, a random id, live data). Report it as skipped: nondeterministic and exclude it from the diff. A differential over a nondeterministic route proves nothing, and reporting it as a failure would be a false red the author cannot act on. The framework's own test does not need this because its corpus is fixed and known, while an arbitrary app's is not.
Set WEBJS_ELIDE=0, create handler B, warm it, render every route.
For each surviving route, assert equal status and equal maskJsSet(html). On a mismatch print the route, the first differing offset, and a 40-character window from each side (the differential-elision.test.js:124-131 failure shape).
Exit codes: 0 when every compared route matched; 1 on any divergence; 1 when ZERO routes were compared, because a vacuous pass is a failure. That posture is already the repo's (scripts/run-bun-tests.js:14 treats zero tests run as a failure), so it is a precedent, not a new rule.
The success output must state the boundary, in the command's own words:
webjs elision --verify: 7 route(s) identical with elision on vs off, 2 skipped (dynamic), 0 skipped (nondeterministic).
This proves elision did not change the bytes your app serves. It does NOT prove
post-hydration behaviour: a wrongly dropped module shows up as a dead click, not
as different bytes. Run your browser/e2e suite twice to cover that half:
WEBJS_ELIDE=1 <your e2e command>
WEBJS_ELIDE=0 <your e2e command>
Step 5. Add the MCP list_elision tool
File packages/mcp/src/mcp.js.
5a. Append a tool descriptor after the list_components entry (L162-167), inside the TOOLS array that closes at L172:
{name: 'list_elision',description:
'Report the display-only elision verdict: every component module with whether it is elided or shipped and the evidence that produced the verdict, every page/layout route module as inert / import-only / shipped (with its blocker), and any orphan component class that registers with no literal tag and is therefore dropped silently. Read-only.',inputSchema: APPDIR_SCHEMA,},
5b. Add analyzeAppElision to the injected deps of makeToolRunners (L304-312) and add the runner beside list_components (L362):
asynclist_elision(appDir){// The whole report IS the contract (analyzeAppElision already returns an// app-relative, sorted, JSON-serializable object), so there is no// projector leaf here. `webjs elision --json` prints this same object.returnanalyzeAppElision(appDir);},
5c. Wire the real analyzeAppElision into the default deps where scanComponents is wired today, and update the module map in packages/mcp/AGENTS.md (the mcp.js entry lists every introspection tool).
Step 6. Emit the dev summary
File packages/server/src/dev.js.
6a. The elision-off fallback literal at dev.js:915 is missing shippedRouteModules today, and now also needs componentVerdicts. Fix both in one edit:
6b. Emit the summary inside the existing if (dev) block at dev.js:977-985, after the orphan-warning loop, where components and r are both still in scope so nothing is recomputed:
constelideOn=r.componentVerdicts.size>0||r.elidableComponents.size>0||(awaitreadElideEnabled(appDir));logger.info?.(elideOn
? `[webjs] elision: ${r.elidableComponents.size}/${newSet(components.map((c)=>c.file)).size} components elided, `+`${r.inertRouteModules.size} route modules inert, ${r.importOnlyRouteModules.size} import-only, `+`${r.shippedRouteModules.size} ship whole. Run \`webjs elision\` for the per-module verdict.`
: `[webjs] elision: disabled (WEBJS_ELIDE / webjs.elide), every module ships.`,);
Read the switch ONCE into a local at L912 rather than calling readElideEnabled twice per warm. The snippet above is written against that local.
Because this sits inside the if (!analysisDone) stage, doRebuild's analysisDone = false (dev.js:1158) is what re-emits it after each fs.watch rebuild. That is the invalidation signal, and there is nothing else to invalidate.
Step 7. Add the doctor check
File packages/cli/lib/doctor.js.
7a. Add the DOCTOR_CODES entry immediately after ELISION_CARRIERS (L104):
'Component elision (what the browser drops)': 'ELISION_COMPONENTS',
7b. Change runDoctorChecks (L1518-1533) so the report is computed once. Before the Promise.all, start it without awaiting:
// ONE elision report for both elision checks (#1308). Started before the// batch and awaited inside each check, so the module graph is built once and// the checks still run in parallel. Fails soft to null, exactly as the// carrier check's own try/catch did.constelision=(async()=>{try{const{ analyzeAppElision }=awaitimport('@webjsdev/server');returnawaitanalyzeAppElision(appDir);}catch{returnnull;}})();
then replace checkElisionCarriers(appDir), at L1530 with:
7c. Rewrite checkElisionCarriers (L1008) to take the shared report and read the new key. The rel() helper at L1029 goes away (paths arrive app-relative), and report.shipped becomes report.routeModules.filter((r) => r.verdict === 'shipped'). Its message text and fix line stay as they are, so test/cli/doctor.test.mjs:1116-1132 keeps passing unchanged.
report is null or analysed is false with skipped === 'elide-off': pass, message elision is disabled (webjs.elide false or WEBJS_ELIDE), so every component module ships.
analysed false otherwise: pass, message not analysed (no routable app or analysis unavailable), matching the sibling.
orphans.length > 0: warn. Message names each orphan file and class and states that a class registered with a non-literal tag is invisible to the scanner, so its module is dropped with no verdict and static interactive = true cannot rescue it. fix: pass a literal tag to Class.register('my-tag') (invariant 3 already requires one).
otherwise: pass. Message is the inventory, ${summary.elided} of ${summary.components} component module(s) are elided (never downloaded) followed by up to eight tag names and a +N more tail, then Run webjs elision for the full verdict.
Never fail. This check reports an intended optimization, so the only non-pass condition is the orphan, which is a real defect. Gating it error via webjs.doctor.gate is available for an app that wants an orphan to break CI.
Step 8. Fix the docs claim the measurement disproved
The computed-registration-tag case is named on four surfaces as something static interactive = true rescues, and it does not. Correct each in place (these are the same files the docs step already touches, so this is not a separate change):
AGENTS.md, in the async-render paragraph, replace a dynamically-computed tag string with a tag string an external stylesheet's :defined rule names, or an observer that computes the tag it waits for.
.agents/skills/webjs/references/components.md:267, same replacement, plus one sentence: a component's OWN registration tag must be a literal (invariant 3); a computed one is invisible to the scanner, so the module is dropped with no verdict and the override cannot reach it. webjs dev warns, and webjs doctor now reports it as an orphan.
website/app/docs/data-fetching/page.ts:66 and website/app/docs/components/page.ts:500, same correction in prose.
The scaffold's copy of the skill is produced by cp from the repo-root canonical (packages/cli/lib/create.js:666-673), so editing the canonical file covers the scaffolded app with no second edit.
Step 9. The e2e fixture for the ship override
No fixture app has a static interactive = true component, so the override's ONLY coverage today stops at analyzeComponentSource(...).interactive (analyze.test.js:266). Nothing proves the boot script actually keeps the module.
Add examples/blog/components/forced-badge.ts: display-only in every respect (static markup, no events, no reactive props, no lifecycle hook, light DOM), plus static interactive = true. Follow examples/blog/components/observed-badge.ts for the doc-comment discipline, which matters here specifically: the elision analyser scans raw source including comments, so the prose must not contain a literal angle-bracket tag or a whenDefined call shape, or the fixture would ship for the wrong reason and the test would pass vacuously.
Render it from examples/blog/app/observed/page.ts, which already exists as the elision-residual route and already carries the #169 probe wiring, so no new route and no new server startup are needed.
Tests
Every layer this change touches, with the counterfactual for each.
Unit, analyser (packages/server/test/elision/)
New packages/server/test/elision/residual-contract.test.js. The two documented residuals have never been asserted. This file builds a REAL tmp app on disk and drives buildModuleGraph plus scanComponents plus analyzeElision, because Pattern B (the run({ files, edges, components, routeModules }) helper in route-elision.test.js:36) fakes the graph, and residual (b) is precisely about a file that is NOT in the graph, so a faked graph would make the assertion vacuous. Six cases, each measured against HEAD before being written:
a LITERAL whenDefined('my-badge') observer keeps the badge shipped (the control, so case 2 is known to be about the computed tag rather than about observation generally)
a COMPUTED whenDefined(TAG) observer leaves the badge in elidableComponents (residual (a), asserted as the documented limitation so a change in either direction is visible)
a public/app.css carrying my-badge:defined leaves the badge elided AND the importing page inert (residual (b))
case 4 plus static interactive = true ships the badge and reclassifies the page as import-only (the rescue)
a computed Badge.register(TAG) yields an empty scanComponents, an inert page, and a findOrphanComponents hit, AND adding static interactive = true changes none of that (the measured finding: the escape hatch cannot reach a component the scanner never saw)
Counterfactual: case 3 and case 5 fail if INTERACTIVITY_STATIC_FIELDS (component-elision.js:154) loses its interactive entry or the static-field loop at L586-590 is reverted. Case 6 fails if findOrphanComponents stops matching the factory form.
New packages/server/test/elision/elision-report.test.js.analyzeAppElision has NO test today (grep for it across packages/**/test and test/** returns nothing outside the source). Assert against tmp fixture apps: the full schema shape, that every path is app-relative and no absolute path survives into any reason string, that arrays are sorted by file, that the summary counts equal the array lengths, each skipped value (no-app, elide-off, unanalysable), and one row of each evidence value (own, observed, closure, render, import).
Counterfactual: drop the noteShip call from any one of steps 1a to 1f and the matching evidence case fails with evidence: null.
New packages/server/test/elision/elision-differential.test.js. Pure tests for staticPageRoutes(table): a dynamic page is excluded, a route group (group) and a _private segment are stripped, the root . maps to /, and the output is sorted and deduped.
Edit packages/server/test/elision/differential-elision.test.js. Delete the local maskJsSet (L37-70) and import the shared one from packages/server/src/elision-differential.js. Its existing counterfactual at L208-217 (a removed rendered element must still flag) now guards the masker on behalf of BOTH consumers, which is the point of the extraction.
Guard for step 1g. Add to packages/server/test/elision/sigil-coverage.test.js, which already owns the "a new signal cannot be added without being classified" contract: every file in mustShip must have a shipEvidence entry, so a future rule that adds to mustShip without calling noteShip reds a test instead of silently emitting evidence: null. Assert it over the examples/blog corpus, which exercises every rule.
Unit, CLI (test/cli/)
New test/cli/elision.test.mjs, following test/cli/routes.test.mjs (the sibling naming and the two-layer shape: the pure function against a tmp fixture, then the spawned binary).
analyzeAppElision(appDir) against a tmp fixture with one elided component, one shipped component, one inert page and one shipping page.
webjs elision --json output parses and deepEquals the direct analyzeAppElision call (the byte-identity contract).
the human default names the elided component, names the ship reason for the shipped one, and lists an orphan under its own heading.
--verify on an all-static fixture exits 0 and prints the post-hydration caveat block.
--verify COUNTERFACTUAL: a fixture whose page renders a wall-clock-free element removed under one setting cannot be produced honestly, so instead assert the divergence path directly by stubbing one capture, and separately assert that --verify on a fixture with ZERO static page routes exits 1 with a "nothing was compared" message. A vacuous pass is the failure mode this guards.
--routes /a,/b adds paths outside the static set, and a path that 404s is reported rather than silently passing.
Extend test/cli/doctor.test.mjs, in the block that starts at L1112 (CARRIER_CHECK, 'Page/layout elision (carrier hygiene)').
the existing three carrier cases must still pass unchanged after step 7c rewires the check to the shared report (that is the regression guard for the shipped to routeModules rename)
'Component elision (what the browser drops)' reports pass and names the elided component on a healthy app
it reports warn and names the class plus the file when an orphan exists, and never fail
it reports pass with the disabled message under webjs.elide: false
Counterfactual: remove the DOCTOR_CODES entry from step 7a and the gate case fails, because readDoctorPolicy rejects an unknown code as a hard config error.
test/cli/help.test.mjs:160 already iterates the HELP map and requires a usage line plus an example per entry, so the new command is covered with no edit to that file. It reds automatically if step 4a is incomplete.
Unit, MCP (packages/mcp/test/mcp.test.mjs)
tools/list includes list_elision with an appDir schema
tools/call on list_elision returns exactly what analyzeAppElision returns for the same fixture (the drift test, mirroring the list_routes versus projectRoutes assertion already in that file)
list_components output is UNCHANGED (the guard that the cheap inventory tool did not quietly grow a module-graph build)
e2e (test/e2e/e2e.test.mjs, WEBJS_E2E=1)
New case, modelled on the #169 observed-badge network probe at test/e2e/e2e.test.mjs:1842-1870. Navigate to /observed with the cache disabled, collect request URLs, and assert:
/components/forced-badge.(ts|js) IS downloaded (the override is honoured end to end, which no test proves today at any layer above the analyser unit)
/components/build-stamp.(ts|js) is still NOT downloaded on the same run (the negative control, so the assertion cannot pass because elision stopped working entirely)
the badge's SSR text is present before any JS (the progressive-enhancement half)
Counterfactual: delete static interactive = true from the fixture and the first assertion fails, because every other property of the component is display-only.
This is the layer that matters most here: packages/server/AGENTS.md invariant 7 and the root AGENTS.md both promise the override forces a ship, and until now that promise stopped at a unit boolean.
Bun parity (test/bun/)
Required, not optional. The pre-commit hook .claude/hooks/require-bun-parity-with-runtime-src.sh matches /dev\.js in its runtime-sensitive path regex (L62), and step 6 edits packages/server/src/dev.js, so a commit that stages no test/bun/** file is BLOCKED. Beyond the hook there is a real reason: an app scaffolded with --runtime bun runs webjs elision against a Bun-served app, so the verdict must not depend on the runtime.
New test/bun/elision-report.mjs (the plain assert script) plus test/bun/elision-report.test.mjs (the two-line node:test wrapper that imports it, the path-alias.mjs / path-alias.test.mjs pair being the model). The script builds a tmp app with one elided component, one shipped component, an inert page and a shipping page, calls analyzeAppElision, and asserts the exact verdict rows and every summary count, printing an OK line naming the runtime. It must pass identically under node test/bun/elision-report.mjs and bun test/bun/elision-report.mjs.
Run node scripts/run-bun-tests.js and report it green. Do NOT add a DENYLIST entry, because the analysis is filesystem plus regular expressions with no runtime-specific API, so there is nothing legitimate to skip.
Layers that do NOT apply
Browser (*/test/**/browser/*). This change ships no client-side code: no packages/core edit, no template, no hydration path, no client router surface. The browser-facing half of the elision guarantee is already covered by the differential elision (#181) block at test/e2e/e2e.test.mjs:2130, and the one new browser-observable behaviour (the override actually keeping a module on the wire) is asserted in a real browser by the e2e case above, which is the right layer for a network-request assertion.
Smoke (test/examples/*/smoke/*). No new smoke case. Run the blog suites after adding the fixture component, since packages/server/test/elision/comment-false-signals.test.js, differential-elision.test.js, and test/preload-subset.test.mjs all read the blog corpus. None asserts a fixed component COUNT (checked), so the fixture should be additive, but this is the set to re-run and confirm.
Docs
Every surface, with what changes on each.
Surface
Change
AGENTS.md, CLI reference block
add the webjs elision [--json] [--verify] [--routes <paths>] line beside webjs routes, and note in the webjs doctor line that it now also reports the component-elision verdict
AGENTS.md, the "Display-only components are elided" bullet and the async-render paragraph
name webjs elision as how to inspect the verdict, and apply the step-8 correction (a computed REGISTRATION tag is not an override case)
.agents/skills/webjs/references/components.md, the ## Display-only elision section at L257-267
add "Inspecting and proving the verdict": webjs elision, webjs elision --json, webjs elision --verify, what --verify proves and what it does not, and the two-run browser-suite recipe for the other half. Apply the step-8 correction. The scaffold copy is produced by cp from this canonical file (packages/cli/lib/create.js:666-673), so there is no second file to edit
.agents/skills/webjs/references/testing.md
add the two-run recipe to the testing reference, since that is where an author looks for "how do I prove this"
website/app/docs/configuration/page.ts
add ONE new <h2> section, Display-only elision, immediately after the Client router section (ending at L131 with its code-block) and before the Request limits section (L132). It documents { "webjs": { "elide": false } } and WEBJS_ELIDE=0, which appear on NO docs-site page today, and links to the new page below
website/app/docs/elision/page.ts (NEW)
the dedicated page. See the decision below
website/app/docs/layout.ts, NAV_SECTIONS
add { href: '/docs/elision', label: 'Display-Only Elision' } to the Core Concepts section, after Progressive Enhancement (L49), since elision is the mechanism that makes progressive enhancement pay
apply the step-8 correction, and link to the new page instead of restating the rules
packages/server/AGENTS.md
the component-elision.js and elision-report.js module-map rows gain the verdict/report change, the new elision-differential.js row is added, and invariant 7 gains a sentence naming webjs elision as the inspection surface
packages/mcp/AGENTS.md
the mcp.js module-map entry lists the introspection tools, so add list_elision to it
packages/cli/lib/doctor.js docblocks
the new check's docblock states why it is pass-only except for orphans
Decision: add a dedicated /docs/elision page rather than only extending existing pages. Verified: elision is currently described in six places (data-fetching, client-router, components, no-build, progressive-enhancement, troubleshooting) with no page owning it, and the opt-out appears on none of them. That scatter is exactly why the switch went undocumented. A capability with its own CLI command, its own config key, its own env override, its own doctor check, and its own MCP tool has outgrown being a paragraph inside five other topics. The page owns: what elision is and why it is safe, the full signal list (the one that keeps a component shipping), the two carve-outs, the two residuals with the measured escape hatch, how to inspect the verdict, how to prove it with --verify and what that does not prove, and the opt-out. The six existing pages keep their one-paragraph framing and link here instead of each carrying a partial rule list.
Acceptance criteria
npx webjs elision prints the per-module verdict for the current app: every component as elided or shipped, every shipped one with the evidence and the module that forced it, every page/layout as inert / import-only / shipped, and any orphan
npx webjs elision --json emits the documented schema, every path app-relative, every array sorted by file, and its output deepEquals the MCP list_elision tool for the same app
npx webjs elision --verify renders every static page route with elision on and off, diffs the masked SSR bytes, exits 0 on parity and non-zero on divergence or on a zero-route corpus, and its success output states in its own words that it does not prove post-hydration behaviour
--verify reports dynamic routes as skipped by name, accepts --routes to cover them, and reports a nondeterministic route as skipped rather than failed
maskJsSet has exactly one definition in the repo and both the framework's own differential test and the CLI import it
analyzeAppElision adds no second elision analysis pass, writes no file, and webjs doctor builds the module graph once for both elision checks
webjs doctor reports Component elision (what the browser drops) with the stable code ELISION_COMPONENTS, passes on a healthy app, warns only when an orphan exists, and is silenced by {"webjs":{"doctor":{"gate":{"ELISION_COMPONENTS":"off"}}}}
webjs dev prints one elision summary line on the first warm request and again after each fs.watch rebuild, and prints the disabled variant when the switch is off. No inline script and no SSE event is added
Contract tests cover both residuals as measured: the computed whenDefined observer (elided without the override, shipped with it) and the external-stylesheet :defined rule (elided without, shipped with)
A contract test records that a computed Class.register(tagVar) is NOT rescued by static interactive = true, and the four doc surfaces that claimed otherwise are corrected
examples/blog/components/forced-badge.ts exists, is display-only apart from static interactive = true, and test/e2e/e2e.test.mjs asserts under WEBJS_E2E=1 that the browser downloads its module while build-stamp on the same run is still not downloaded
test/bun/elision-report.mjs asserts an identical verdict under node and bun, and node scripts/run-bun-tests.js is green with no new DENYLIST entry
/docs/elision exists, is in the docs nav, and documents { "webjs": { "elide": false } } and WEBJS_ELIDE=0, which appear on no docs-site page today
npm test is green, webjs check is clean, and webjs doctor is run over examples/blog and website (the required conventions CI job runs both and fails on whatever each app gates error)
Out of scope
Do not widen into any of these.
Changing an elision verdict. This is a reporting change. The analyser stays biased toward shipping, and no rule is tightened, loosened, or added. If the report makes a wrong verdict visible, record it and stop.
Supporting a computed registration tag. Invariant 3 requires a literal tag. The fix here is documentary plus the orphan row in the report. Do not teach scanComponents to evaluate an expression, and do not add a runtime registration hook.
A webjs build, a manifest file on disk, or any bundler. Qwik's q-manifest.json is the shape being deliberately NOT copied. WebJs is no-build and the report is computed on demand.
Making the over-ship direction provable.--verify masks the JS-loaded set by construction, so it cannot see over-ship. That is a known and accepted limit, not a bug to fix in this issue.
Reworking the dev error overlay or the SSE channel. Decision 4 rules a browser push out. Do not add one, and do not add a dev toolbar.
Filing follow-up issues. Fold a small same-file tweak into this PR. Anything genuinely separate goes to the owner in conversation, not to the tracker.
Problem
Elision is the right default and stays automatic (settled in #976: the analyser is engineered fail-safe, every ambiguity ships JS, and a manual
'use client'-style annotation would invert the failure mode onto every app author with no compiler to catch a forgotten one). The production gap is not the inference. It is that only the benign direction is visible.packages/server/src/elision-report.js(#646) tells an app which pages and layouts SHIP when they could have been elided. That is the over-ship direction, whose worst outcome is a few wasted kilobytes. Nothing reports the other direction: which components were ELIDED, and on what evidence. That is the direction where a wrong verdict silently loses interactivity in production, and an app author currently has no way to ask "what did you drop from my page?".The framework protects itself with a differential on/off test (
packages/server/test/elision/differential-elision.test.js). An APP has no equivalent, so every app inherits a guarantee it cannot verify locally.Verified state of the code at
ddfc5547INTERACTIVITY_STATIC_FIELDS = ['shadow', 'interactive']component-elision.js:154component-elision.js:157(STATIC_FIELD_REASONS)analyzeComponentSource(src)component-elision.js:528component-elision.js:586-590analyzeAppElision(appDir)returns{ analysed, shipped }elision-report.js:31, short-circuits at L34 and L36'Page/layout elision (carrier hygiene)'doctor.js:1008(fn),analyzeAppElisionimported at L1010-1011DOCTOR_CODESmapdoctor.js:94-107,ELISION_CARRIERSat L104dev.js:912-914callsanalyzeElision,dev.js:916-918storesstate.elidableComponents/state.inertRouteModules/state.importOnlyRouteModulespackages/cli/test/test/cli/doctor.test.mjs;packages/cli/test/has no doctor directorydata-fetching,client-router,components,no-build,progressive-enhancement,troubleshootinggrep -r WEBJS_ELIDE website/andgrep -r '"elide"' website/both return nothingstatic interactive = truecomponentpackages/cli/templates/gallery/modules/async-render/components/server-clock.ts:20and three docs-site paragraphsThe two documented residuals, measured
The previous body inherited #976's wording that residual (a) is "a runtime-computed
.register(tagVar)tag string". That wording is wrong, and building tests against it would have tested a mechanism that does not exist. The real analyser was driven against six on-disk fixtures. Results:customElements.whenDefined(TAG)whereTAGis a variable missesWHEN_DEFINED_RE(component-elision.js:254), so the observed display-only component is ELIDED and the awaited registration never happens. Measured: the badge lands inelidableComponents. Addingstatic interactive = trueto the badge rescues it (it drops out ofelidableComponents). This is a genuine wrong-elide with a working escape hatch, and it is unasserted today.:definedrule, is real as documented.TAG_DEFINED_RE(component-elision.js:255) only scans graph-reachable module source, so apublic/app.csscarryingmy-badge:defined { ... }is invisible. Measured: the badge is elided AND the page is classified inert, so the module is dropped from the boot entirely.static interactive = truerescues it (the badge ships and the page becomes import-only). Also unasserted today.scanComponentsrequires a literal tag (component-scanner.js:57and L68), so a component registered asBadge.register(TAG)is never incomponentsat all. It therefore never enterscomponentFiles,analyzeComponentSourceis never consulted for it, andanalyzeElisionsees a module whose only top-level statement is aregister(...)call, whichhasModuleScopeSideEffectexplicitly exempts (component-elision.js:378,if (ident === 'register' || ident === 'define') continue;). Measured: the importing page is classified inert, so both the page module and the component module are dropped from the boot and the element never registers. Addingstatic interactive = truechanges nothing (analyzeComponentSourceon that source correctly returnsinteractive: true, but nothing calls it). The only thing standing between an author and a silently dead element is the dev-only orphan warning (findOrphanComponents,component-scanner.js:159, logged fromdev.js:978-984), which fires in dev and is absent in prod.Consequence: AGENTS.md,
.agents/skills/webjs/references/components.md:267,website/app/docs/data-fetching/page.ts:66, andwebsite/app/docs/components/page.ts:500all name "a dynamically-computed tag string" as a casestatic interactive = truerescues. It does not. Invariant 3 already requires a literal tag, so the correct fix is documentary (say the tag must be a literal and point at the orphan warning), not a code change to support computed registration.Design / approach
Five decisions, each with the constraint that settles it.
1. The app-level differential is a first-class command,
webjs elision --verifySettled: a new
webjs elisioncommand with a--verifymode that runs the SSR differential itself, plus a documented two-run recipe for apps that own a browser suite.What decides it: a
webjs testflag would require WebJs to re-run the app's own test command twice under a flipped env, and the proof would then be only as good as that app's coverage, which is exactly the thing an author cannot vouch for. A documented recipe alone is worse still, because a freshly scaffolded app has no e2e suite at all, so the recipe delivers nothing on day one. The framework's own SSR-layer guard needs no browser and no test suite: it boots twocreateRequestHandlerinstances in one process withWEBJS_ELIDEflipped, renders a route corpus, masks the JS-loaded set, and diffs bytes. That primitive is fully portable to any app, because the route corpus comes frombuildRouteTable(appDir), which every app has. So the command an author actually types is:The masker is the one place the two guards could drift, so
maskJsSetmoves out ofdifferential-elision.test.js:46into a shared server leaf and both consumers import it. Literally "give apps the framework's own guard".Rejected: folding
--verifyintowebjs doctor. Doctor is a fast checklist an author runs constantly, and rendering every route twice through two warmed handlers is seconds to tens of seconds and would wreck that.Rejected:
webjs elision verifyas a subcommand namespace (thewebjs vendorshape). Two operations is thin for a namespace, andcheck/routes/doctorare all "one report plus mode flags", which is the shape this matches.What
--verifyproves and what it does not. #976 recorded that the differential guard masks the whole JS-loaded set by construction, so OVER-ship is invisible to it. Confirmed atdifferential-elision.test.js:11-13and inmaskJsSetL46-70, which strips the importmap, every<script type="module">, everymodulepreload, the vendor preconnect hints, and both content stamps. The mode therefore proves elision did not change the bytes the browser is served. It does not prove elision is optimal, and it does not prove post-hydration behaviour, because a wrongly dropped module manifests as a dead click, not as different bytes. The command must say so in its own success output, not only in docs.2. The verdict artifact is JSON from
analyzeAppElision, surfaced by CLI and MCP from one functionSettled:
analyzeAppElision(appDir)is extended to return the whole verdict,webjs elision --jsonprints exactly that object, and a new MCP toollist_elisionreturns the same object.What decides it: the
webjs routes --jsonprecedent.packages/mcp/src/routes-report.jsexists as a shared projector only becausebuildRouteTablereturns an internal table needing a projection step with injected effectful deps.analyzeAppElisionhas no such problem: it already owns the app-level contract and can return a plain JSON-serializable object directly. So there is no new projector leaf, both consumers importanalyzeAppElisionfrom@webjsdev/server, and a drift test asserts the two outputs are equal. That also honours the module header's rule (elision-report.js:2-18) that this file is a reporting layer overanalyzeElision, not a second analysis and not a build.Rejected: growing
list_components(mcp.js:362) with anelidedflag. That tool isscanComponentsprojected, a cheap lexical inventory that loads no module and builds no graph. Making it runbuildModuleGraphplusanalyzeElisionwould silently turn a cheap tool expensive, and it still could not carry the route-module verdicts or the elide-switch state.Prior art. Next reports per-route First Load JS size from
printTreeView(next.js/packages/next/src/build/utils.ts:286). It tells an author how much shipped and never why a given module shipped or what was dropped, because with'use client'there is no inference to report. Astro'sxray.ts(astro/packages/astro/src/runtime/client/dev-toolbar/apps/xray.ts) highlights<astro-island>elements in the browser, which is a locator rather than a verdict explainer for the same reason (theclient:*directive is author-declared). Qwik is the closest analogue:QwikManifest(qwik/packages/qwik/src/optimizer/src/types.ts:217) is a machine-readable enumeration of every symbol, its bundle, and the import graph. But it is a BUILD artifact written toq-manifest.json. WebJs is no-build, so the same information has to come from a command that runs the same analysis pass on demand, never from a file on disk. That is the shape below.The schema, field by field. Every path is app-relative (the
routes-report.js:68convention). Every array is sorted byfilefor determinism.{ "analysed": true, // false when nothing could be analysed "skipped": null, // null | "no-app" | "elide-off" | "unanalysable" "components": [ { "file": "components/build-stamp.ts", // app-relative path of the component module "tags": ["build-stamp"], // every tag this file registers, sorted "verdict": "elided", // "elided" | "shipped" "evidence": null, // null when elided "reason": null, // null when elided "by": null // null when elided }, { "file": "components/counter.ts", "tags": ["my-counter"], "verdict": "shipped", "evidence": "own", // the component's own source carries a signal "reason": "template has an @event binding", // analyzeComponentSource's verbatim reason "by": null }, { "file": "components/observed-badge.ts", "tags": ["observed-badge"], "verdict": "shipped", "evidence": "observed", // another module observes its registration "reason": "its registration is observed by components/observe-badge.ts", "by": "components/observe-badge.ts" // app-relative path of the forcing module } ], "routeModules": [ { "file": "app/about/page.ts", "verdict": "inert", "emits": [], "blocker": null, "reason": null }, { "file": "app/blog/page.ts", "verdict": "import-only", "emits": ["components/counter.ts"], "blocker": null, "reason": null }, { "file": "app/page.ts", "verdict": "shipped", "emits": [], "blocker": "lib/track.ts", "reason": "references a browser global at module scope, runs code at module scope, or has a bare side-effect import" } ], "orphans": [ { "file": "components/dyn-badge.ts", "className": "DynBadge" } ], "summary": { "components": 3, "elided": 1, "shipped": 2, "routeModules": 3, "inert": 1, "importOnly": 1, "shippedWhole": 1, "orphans": 1 } }evidenceis the closed set"own" | "observed" | "closure" | "render" | "import" | "unreadable", one per rule inanalyzeElisionthat can force a ship. FIRST match wins, matching the analyser's own first-match convention.bynames the module that forced it forobserved/closure/render/import, and isnullforown/unreadable.An ELIDED row carries
reason: nullon purpose. Elision is the ABSENCE of every signal, so there is no positive fact to report, and inventing one would mean re-running the analysis to enumerate what was checked. The report says what it knows, and the docs say what the signal list is.orphansis the answer to the measured case-3 defect. It reuses the already-exportedfindOrphanComponents(component-scanner.js:159), which the dev server already runs, so it is not a second elision analysis. It is the one shape that gets dropped with no verdict at all, which makes it exactly what a "what did you drop from my page" report must carry.3. The new doctor check is gateable and warns only on orphans
Settled: check name
'Component elision (what the browser drops)', stable codeELISION_COMPONENTS,warnonly when the report lists an orphan,passotherwise.What decides it: an elided component is the DESIRED outcome, so warning on one would fire on every healthy app and train the reader to skip doctor output. The check therefore carries the elided inventory as a passing message, which is the discovery surface, while
webjs elisionis the detail surface. But a pass-only check has no teeth, and the measurement above found exactly one condition that is always wrong and currently silent in prod: an orphan component, which is dropped with no escape hatch. That is the warn condition.Elision being off reports
passwith the message naming the switch, matching the sibling carrier check's existing treatment (doctor.js:1019-1021) rather than inventing a second policy for the same condition.It needs its own explicit
DOCTOR_CODESentry becausewebjs.doctor.gate(#1257) addresses a check by its stable code, and the name-derived fallback (codeForName,doctor.js:116) is a last resort the repo does not rely on for a shipped check. Nowebjs-config.schema.jsonchange is needed:gateaccepts any^[A-Z][A-Z0-9_]*$key by pattern and the doctor validates against theDOCTOR_CODESset, so adding the entry makes the code gateable automatically. No newwebjs.*key is introduced anywhere in this work, so the three-place config lockstep is untouched.Both elision checks must share ONE report.
runDoctorChecks(doctor.js:1518) currently callscheckElisionCarriers(appDir)inside itsPromise.all; two independent callers would mean two full module-graph builds per doctor run. The report is started as a single un-awaited promise before the array and both checks await it, so parallelism is preserved and the graph is built once.4. The dev summary is a server-console line, with no browser push
Settled: one
logger.infoline at the end of the first warm analysis, gatedif (dev), emitted fromensureReadybeside the existing orphan warnings. No SSE event, no inline script.What decides it, in order of weight:
webjs-errorSSE channel exists because an error is tied to a request and must interrupt the author. A verdict summary is ambient and must not. Reusing that channel for ambient data devalues it.webjs elision, which prints the full verdict on demand.Astro splits the same way and is the citation:
xray.tsputs a POSITIONAL fact (where the islands are on this page) in the browser, while project-level facts stay in the CLI. The elision verdict is not positional.Exact output, one line:
and with the switch off:
5. Invalidation rides the rebuild, and nothing is cached
Settled:
analyzeAppElisioncaches nothing, and the dev summary is invalidated byanalysisDone = falseindoRebuild.What decides it: every out-of-process consumer (
webjs doctor,webjs elision, the MCP tool) runs the analysis once and exits, so an intra-process cache would never see a hit and would only add a staleness bug. In dev the summary is emitted from inside the analysis stage ofensureReady, which runs only whenanalysisDoneis false.doRebuildsetsanalysisDone = falseatdev.js:1158after waiting out any in-flight build, so the summary is re-emitted on the next request after eachfs.watchrebuild, on exactly the signal that re-derives the verdict. That is the named signal.Invariants this work must not break
elision-report.jsstays a reporting layer overanalyzeElision. No second elision analysis pass.Implementation plan
Work in a dedicated worktree (
git worktree add -b feat/elision-inspectable ../webjs-elision-inspectable origin/main, thennpm run worktree:linkinside it). A fresh worktree has nonode_modulesand nopackages/core/dist, and step 8 boots real handlers, so the link step is not optional.Step 1. Thread the per-component verdict out of
analyzeElisionFile
packages/server/src/component-elision.js. The verdict data already exists in memory. Do not recompute any of it.1a. In the file loop,
component-elision.js:992currently discards the reason:becomes:
1b. Declare the recorder next to
mustShip(component-elision.js:927-928). First write wins, so a component forced by several rules reports the rule that reached it first, matching the analyser's first-match convention everywhere else:1c. The unreadable-component branch at
component-elision.js:958-961:becomes:
1d. The cross-module observation loops at
component-elision.js:1001-1009collect intoobservedComponentFileswith no record of the observer. Add a sibling map declared beside it (component-elision.js:941), written in the same three loops:and in each of the three loops, after
const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);, addif (f && !observedBy.has(f)) observedBy.set(f, file);(theINSTANCEOF_REloop usesclassToFile).Then at
component-elision.js:1016:becomes:
1e. The closure rule at
component-elision.js:1066-1069:becomes:
1f. The fixpoint worklist at
component-elision.js:1105-1121. Both arms record who pushed:becomes
and
becomes
1g. Build the verdict map AFTER
clientEffectReasonis defined (component-elision.js:1141-1147), because theclosurereason phrase reuses it. Insert immediately before the route-module loop at L1184:A shipped component with no
shipEvidenceentry is possible only if a future rule adds tomustShipwithout callingnoteShip, so the!evarm yieldsevidence: null, reason: nullrather than a wrong claim. The sigil / lifecycle coverage guards do not cover this, so step 6 adds a drift assertion.1h. Add
componentVerdictsto the return atcomponent-elision.js:1245and to the JSDoc@returnsat L912:Paths in this map stay ABSOLUTE, consistent with every other key of that return. Relativization is the app-level layer's job (step 2).
Step 2. Rewrite
analyzeAppElisionto return the whole verdictFile
packages/server/src/elision-report.js. Keep the module header's contract intact and extend it to say the report now carries both directions.2a. The two short-circuits at L34 and L36 currently return the same opaque shape. Give a machine consumer the distinction:
becomes:
with the
catchat L43-47 returningempty('unanalysable'), and a local helper:2b. Call
findOrphanComponentsalongside the existing three builders in the sametryat L39-47 (add it to the import at L23), then replace the projection at L58-67 with the full report.shippedis REPLACED byrouteModules, not kept alongside it: WebJs has no users and a parallel legacy key is pure drift surface.relativizeReasonis a small local that rewrites the absolute pathsanalyzeElisionbaked into theobserved/closurephrases (step 1g) to app-relative form, so no absolute filesystem path ever reaches the JSON contract. Implement it as a singlesplit(appDir + sep).join('')pass rather than a regex, so a path containing regex metacharacters is safe.2c.
analyzeAppElisionis already exported frompackages/server/index.js:46. Update its declaration inpackages/server/index.d.ts:497to the new return type. The drift testpackages/server/test/types/exports-drift.test.mjsasserts the declared export SET, not the shape, so it stays green either way, but a stale declaration would break a typed consumer.Step 3. Extract the shared differential masker
New file
packages/server/src/elision-differential.js. It exports two things and imports nothing beyondnode:pathplus the server's ownbuildRouteTable.maskJsSet(html): moved VERBATIM fromdifferential-elision.test.js:46-70, comments included. It is the definition of "the JS-loaded set", and having two copies is the one way the app-facing guard and the framework's own guard can silently disagree.staticPageRoutes(table): mapstable.pagesto URL paths via the same normalizationroutes-report.js:32uses, keeping only pages with no dynamic segments (!r.paramNames || r.paramNames.length === 0), sorted, deduped. A dynamic route cannot be rendered without invented param values, so it is excluded and REPORTED as skipped rather than guessed at.Then edit
packages/server/test/elision/differential-elision.test.jsto delete its localmaskJsSet(L37-70) and import the shared one. Everything else in that file is unchanged, so its counterfactual at L208-217 still guards the masker, now on behalf of both consumers.Export both from
packages/server/index.js(next to theanalyzeAppElisionline at L46) and declare them inindex.d.ts.maskJsSetis genuinely public API here, because the documented two-run recipe in step 10 lets an app author diff their own captures with it.Step 4. Add the
webjs elisioncommandFile
packages/cli/bin/webjs.js.4a. Add the
HELPentry betweenroutes(L137-146) anddoctor(L147).test/cli/help.test.mjs:160reads theHELPobject literal and requires a usage line plus at least one example for every entry, so both are mandatory:4b. Add one line to the
USAGEbanner (packages/cli/bin/webjs.js:52), directly after thewebjs routesline at L58:4c. Add the
case 'elision':block after thecase 'routes':block (which ends around L889). Report mode:The human default prints, in order, the
skippedreason whenanalysedis false, then a one-line summary, the elided component list, the shipped component list withreasonper row, the route-module list grouped by verdict, and the orphan list under a heading naming it as the one shape with no escape hatch. Sorted output only, so a diff of two runs is meaningful.4d.
--verifymode, in the same case. Structure, mirroringdifferential-elision.test.js:90-115exactly:Then, with
process.env.WEBJS_ELIDEsaved and restored in afinally(the test's L91-114 pattern is the reference):WEBJS_ELIDE, create handler A,await A.warmup(), render every route, capture{ status, html }.maskJsSetis NONDETERMINISTIC (a rendered clock beyond what the masker normalizes, a random id, live data). Report it asskipped: nondeterministicand exclude it from the diff. A differential over a nondeterministic route proves nothing, and reporting it as a failure would be a false red the author cannot act on. The framework's own test does not need this because its corpus is fixed and known, while an arbitrary app's is not.WEBJS_ELIDE=0, create handler B, warm it, render every route.maskJsSet(html). On a mismatch print the route, the first differing offset, and a 40-character window from each side (thedifferential-elision.test.js:124-131failure shape).Exit codes:
0when every compared route matched;1on any divergence;1when ZERO routes were compared, because a vacuous pass is a failure. That posture is already the repo's (scripts/run-bun-tests.js:14treats zero tests run as a failure), so it is a precedent, not a new rule.The success output must state the boundary, in the command's own words:
Step 5. Add the MCP
list_elisiontoolFile
packages/mcp/src/mcp.js.5a. Append a tool descriptor after the
list_componentsentry (L162-167), inside theTOOLSarray that closes at L172:5b. Add
analyzeAppElisionto the injecteddepsofmakeToolRunners(L304-312) and add the runner besidelist_components(L362):5c. Wire the real
analyzeAppElisioninto the default deps wherescanComponentsis wired today, and update the module map inpackages/mcp/AGENTS.md(themcp.jsentry lists every introspection tool).Step 6. Emit the dev summary
File
packages/server/src/dev.js.6a. The elision-off fallback literal at
dev.js:915is missingshippedRouteModulestoday, and now also needscomponentVerdicts. Fix both in one edit:becomes
6b. Emit the summary inside the existing
if (dev)block atdev.js:977-985, after the orphan-warning loop, wherecomponentsandrare both still in scope so nothing is recomputed:Read the switch ONCE into a local at L912 rather than calling
readElideEnabledtwice per warm. The snippet above is written against that local.Because this sits inside the
if (!analysisDone)stage,doRebuild'sanalysisDone = false(dev.js:1158) is what re-emits it after eachfs.watchrebuild. That is the invalidation signal, and there is nothing else to invalidate.Step 7. Add the doctor check
File
packages/cli/lib/doctor.js.7a. Add the
DOCTOR_CODESentry immediately afterELISION_CARRIERS(L104):7b. Change
runDoctorChecks(L1518-1533) so the report is computed once. Before thePromise.all, start it without awaiting:then replace
checkElisionCarriers(appDir),at L1530 with:7c. Rewrite
checkElisionCarriers(L1008) to take the shared report and read the new key. Therel()helper at L1029 goes away (paths arrive app-relative), andreport.shippedbecomesreport.routeModules.filter((r) => r.verdict === 'shipped'). Its message text andfixline stay as they are, sotest/cli/doctor.test.mjs:1116-1132keeps passing unchanged.7d. Add
checkElisionComponents(appDir, elisionPromise)beside it:nulloranalysedis false withskipped === 'elide-off':pass, messageelision is disabled (webjs.elide false or WEBJS_ELIDE), so every component module ships.analysedfalse otherwise:pass, messagenot analysed (no routable app or analysis unavailable), matching the sibling.orphans.length > 0:warn. Message names each orphan file and class and states that a class registered with a non-literal tag is invisible to the scanner, so its module is dropped with no verdict andstatic interactive = truecannot rescue it.fix: pass a literal tag toClass.register('my-tag')(invariant 3 already requires one).pass. Message is the inventory,${summary.elided} of ${summary.components} component module(s) are elided (never downloaded)followed by up to eight tag names and a+N moretail, thenRun webjs elision for the full verdict.Never
fail. This check reports an intended optimization, so the only non-pass condition is the orphan, which is a real defect. Gating iterrorviawebjs.doctor.gateis available for an app that wants an orphan to break CI.Step 8. Fix the docs claim the measurement disproved
The computed-registration-tag case is named on four surfaces as something
static interactive = truerescues, and it does not. Correct each in place (these are the same files the docs step already touches, so this is not a separate change):AGENTS.md, in the async-render paragraph, replacea dynamically-computed tag stringwitha tag string an external stylesheet's :defined rule names, or an observer that computes the tag it waits for..agents/skills/webjs/references/components.md:267, same replacement, plus one sentence: a component's OWN registration tag must be a literal (invariant 3); a computed one is invisible to the scanner, so the module is dropped with no verdict and the override cannot reach it.webjs devwarns, andwebjs doctornow reports it as an orphan.website/app/docs/data-fetching/page.ts:66andwebsite/app/docs/components/page.ts:500, same correction in prose.The scaffold's copy of the skill is produced by
cpfrom the repo-root canonical (packages/cli/lib/create.js:666-673), so editing the canonical file covers the scaffolded app with no second edit.Step 9. The e2e fixture for the ship override
No fixture app has a
static interactive = truecomponent, so the override's ONLY coverage today stops atanalyzeComponentSource(...).interactive(analyze.test.js:266). Nothing proves the boot script actually keeps the module.Add
examples/blog/components/forced-badge.ts: display-only in every respect (static markup, no events, no reactive props, no lifecycle hook, light DOM), plusstatic interactive = true. Followexamples/blog/components/observed-badge.tsfor the doc-comment discipline, which matters here specifically: the elision analyser scans raw source including comments, so the prose must not contain a literal angle-bracket tag or awhenDefinedcall shape, or the fixture would ship for the wrong reason and the test would pass vacuously.Render it from
examples/blog/app/observed/page.ts, which already exists as the elision-residual route and already carries the#169probe wiring, so no new route and no new server startup are needed.Tests
Every layer this change touches, with the counterfactual for each.
Unit, analyser (
packages/server/test/elision/)New
packages/server/test/elision/residual-contract.test.js. The two documented residuals have never been asserted. This file builds a REAL tmp app on disk and drivesbuildModuleGraphplusscanComponentsplusanalyzeElision, because Pattern B (therun({ files, edges, components, routeModules })helper inroute-elision.test.js:36) fakes the graph, and residual (b) is precisely about a file that is NOT in the graph, so a faked graph would make the assertion vacuous. Six cases, each measured against HEAD before being written:whenDefined('my-badge')observer keeps the badge shipped (the control, so case 2 is known to be about the computed tag rather than about observation generally)whenDefined(TAG)observer leaves the badge inelidableComponents(residual (a), asserted as the documented limitation so a change in either direction is visible)static interactive = trueremoves the badge fromelidableComponents(the rescue, the pairing research: fragility audit + production-readiness hardening (router is the hotspot, keep auto-elision) #976 says is missing)public/app.csscarryingmy-badge:definedleaves the badge elided AND the importing page inert (residual (b))static interactive = trueships the badge and reclassifies the page as import-only (the rescue)Badge.register(TAG)yields an emptyscanComponents, an inert page, and afindOrphanComponentshit, AND addingstatic interactive = truechanges none of that (the measured finding: the escape hatch cannot reach a component the scanner never saw)Counterfactual: case 3 and case 5 fail if
INTERACTIVITY_STATIC_FIELDS(component-elision.js:154) loses itsinteractiveentry or the static-field loop at L586-590 is reverted. Case 6 fails iffindOrphanComponentsstops matching the factory form.New
packages/server/test/elision/elision-report.test.js.analyzeAppElisionhas NO test today (grep for it acrosspackages/**/testandtest/**returns nothing outside the source). Assert against tmp fixture apps: the full schema shape, that every path is app-relative and no absolute path survives into anyreasonstring, that arrays are sorted byfile, that thesummarycounts equal the array lengths, eachskippedvalue (no-app,elide-off,unanalysable), and one row of eachevidencevalue (own,observed,closure,render,import).Counterfactual: drop the
noteShipcall from any one of steps 1a to 1f and the matchingevidencecase fails withevidence: null.New
packages/server/test/elision/elision-differential.test.js. Pure tests forstaticPageRoutes(table): a dynamic page is excluded, a route group(group)and a_privatesegment are stripped, the root.maps to/, and the output is sorted and deduped.Edit
packages/server/test/elision/differential-elision.test.js. Delete the localmaskJsSet(L37-70) and import the shared one frompackages/server/src/elision-differential.js. Its existing counterfactual at L208-217 (a removed rendered element must still flag) now guards the masker on behalf of BOTH consumers, which is the point of the extraction.Guard for step 1g. Add to
packages/server/test/elision/sigil-coverage.test.js, which already owns the "a new signal cannot be added without being classified" contract: every file inmustShipmust have ashipEvidenceentry, so a future rule that adds tomustShipwithout callingnoteShipreds a test instead of silently emittingevidence: null. Assert it over theexamples/blogcorpus, which exercises every rule.Unit, CLI (
test/cli/)New
test/cli/elision.test.mjs, followingtest/cli/routes.test.mjs(the sibling naming and the two-layer shape: the pure function against a tmp fixture, then the spawned binary).analyzeAppElision(appDir)against a tmp fixture with one elided component, one shipped component, one inert page and one shipping page.webjs elision --jsonoutput parses anddeepEquals the directanalyzeAppElisioncall (the byte-identity contract).--verifyon an all-static fixture exits 0 and prints the post-hydration caveat block.--verifyCOUNTERFACTUAL: a fixture whose page renders a wall-clock-free element removed under one setting cannot be produced honestly, so instead assert the divergence path directly by stubbing one capture, and separately assert that--verifyon a fixture with ZERO static page routes exits 1 with a "nothing was compared" message. A vacuous pass is the failure mode this guards.--routes /a,/badds paths outside the static set, and a path that 404s is reported rather than silently passing.Extend
test/cli/doctor.test.mjs, in the block that starts at L1112 (CARRIER_CHECK,'Page/layout elision (carrier hygiene)').shippedtorouteModulesrename)'Component elision (what the browser drops)'reportspassand names the elided component on a healthy appwarnand names the class plus the file when an orphan exists, and neverfailpasswith the disabled message underwebjs.elide: falsecodeis exactlyELISION_COMPONENTS, and{"webjs":{"doctor":{"gate":{"ELISION_COMPONENTS":"off"}}}}is accepted byreadDoctorPolicyand silences the message (the gate contract from feat: let doctor gate CI without making every warning fatal #1257)Counterfactual: remove the
DOCTOR_CODESentry from step 7a and the gate case fails, becausereadDoctorPolicyrejects an unknown code as a hard config error.test/cli/help.test.mjs:160already iterates theHELPmap and requires a usage line plus an example per entry, so the new command is covered with no edit to that file. It reds automatically if step 4a is incomplete.Unit, MCP (
packages/mcp/test/mcp.test.mjs)tools/listincludeslist_elisionwith anappDirschematools/callonlist_elisionreturns exactly whatanalyzeAppElisionreturns for the same fixture (the drift test, mirroring thelist_routesversusprojectRoutesassertion already in that file)list_componentsoutput is UNCHANGED (the guard that the cheap inventory tool did not quietly grow a module-graph build)e2e (
test/e2e/e2e.test.mjs,WEBJS_E2E=1)New case, modelled on the
#169observed-badge network probe attest/e2e/e2e.test.mjs:1842-1870. Navigate to/observedwith the cache disabled, collect request URLs, and assert:/components/forced-badge.(ts|js)IS downloaded (the override is honoured end to end, which no test proves today at any layer above the analyser unit)/components/build-stamp.(ts|js)is still NOT downloaded on the same run (the negative control, so the assertion cannot pass because elision stopped working entirely)Counterfactual: delete
static interactive = truefrom the fixture and the first assertion fails, because every other property of the component is display-only.This is the layer that matters most here:
packages/server/AGENTS.mdinvariant 7 and the root AGENTS.md both promise the override forces a ship, and until now that promise stopped at a unit boolean.Bun parity (
test/bun/)Required, not optional. The pre-commit hook
.claude/hooks/require-bun-parity-with-runtime-src.shmatches/dev\.jsin its runtime-sensitive path regex (L62), and step 6 editspackages/server/src/dev.js, so a commit that stages notest/bun/**file is BLOCKED. Beyond the hook there is a real reason: an app scaffolded with--runtime bunrunswebjs elisionagainst a Bun-served app, so the verdict must not depend on the runtime.New
test/bun/elision-report.mjs(the plain assert script) plustest/bun/elision-report.test.mjs(the two-linenode:testwrapper that imports it, thepath-alias.mjs/path-alias.test.mjspair being the model). The script builds a tmp app with one elided component, one shipped component, an inert page and a shipping page, callsanalyzeAppElision, and asserts the exact verdict rows and everysummarycount, printing anOKline naming the runtime. It must pass identically undernode test/bun/elision-report.mjsandbun test/bun/elision-report.mjs.Run
node scripts/run-bun-tests.jsand report it green. Do NOT add aDENYLISTentry, because the analysis is filesystem plus regular expressions with no runtime-specific API, so there is nothing legitimate to skip.Layers that do NOT apply
*/test/**/browser/*). This change ships no client-side code: nopackages/coreedit, no template, no hydration path, no client router surface. The browser-facing half of the elision guarantee is already covered by thedifferential elision (#181)block attest/e2e/e2e.test.mjs:2130, and the one new browser-observable behaviour (the override actually keeping a module on the wire) is asserted in a real browser by the e2e case above, which is the right layer for a network-request assertion.test/examples/*/smoke/*). No new smoke case. Run the blog suites after adding the fixture component, sincepackages/server/test/elision/comment-false-signals.test.js,differential-elision.test.js, andtest/preload-subset.test.mjsall read the blog corpus. None asserts a fixed component COUNT (checked), so the fixture should be additive, but this is the set to re-run and confirm.Docs
Every surface, with what changes on each.
AGENTS.md, CLI reference blockwebjs elision [--json] [--verify] [--routes <paths>]line besidewebjs routes, and note in thewebjs doctorline that it now also reports the component-elision verdictAGENTS.md, the "Display-only components are elided" bullet and the async-render paragraphwebjs elisionas how to inspect the verdict, and apply the step-8 correction (a computed REGISTRATION tag is not an override case).agents/skills/webjs/references/components.md, the## Display-only elisionsection at L257-267webjs elision,webjs elision --json,webjs elision --verify, what--verifyproves and what it does not, and the two-run browser-suite recipe for the other half. Apply the step-8 correction. The scaffold copy is produced bycpfrom this canonical file (packages/cli/lib/create.js:666-673), so there is no second file to edit.agents/skills/webjs/references/testing.mdwebsite/app/docs/configuration/page.ts<h2>section,Display-only elision, immediately after theClient routersection (ending at L131 with itscode-block) and before theRequest limitssection (L132). It documents{ "webjs": { "elide": false } }andWEBJS_ELIDE=0, which appear on NO docs-site page today, and links to the new page belowwebsite/app/docs/elision/page.ts(NEW)website/app/docs/layout.ts,NAV_SECTIONS{ href: '/docs/elision', label: 'Display-Only Elision' }to theCore Conceptssection, afterProgressive Enhancement(L49), since elision is the mechanism that makes progressive enhancement paywebsite/app/docs/data-fetching/page.ts:66,website/app/docs/components/page.ts:500packages/server/AGENTS.mdcomponent-elision.jsandelision-report.jsmodule-map rows gain the verdict/report change, the newelision-differential.jsrow is added, and invariant 7 gains a sentence namingwebjs elisionas the inspection surfacepackages/mcp/AGENTS.mdmcp.jsmodule-map entry lists the introspection tools, so addlist_elisionto itpackages/cli/lib/doctor.jsdocblocksDecision: add a dedicated
/docs/elisionpage rather than only extending existing pages. Verified: elision is currently described in six places (data-fetching,client-router,components,no-build,progressive-enhancement,troubleshooting) with no page owning it, and the opt-out appears on none of them. That scatter is exactly why the switch went undocumented. A capability with its own CLI command, its own config key, its own env override, its own doctor check, and its own MCP tool has outgrown being a paragraph inside five other topics. The page owns: what elision is and why it is safe, the full signal list (the one that keeps a component shipping), the two carve-outs, the two residuals with the measured escape hatch, how to inspect the verdict, how to prove it with--verifyand what that does not prove, and the opt-out. The six existing pages keep their one-paragraph framing and link here instead of each carrying a partial rule list.Acceptance criteria
npx webjs elisionprints the per-module verdict for the current app: every component as elided or shipped, every shipped one with the evidence and the module that forced it, every page/layout as inert / import-only / shipped, and any orphannpx webjs elision --jsonemits the documented schema, every path app-relative, every array sorted byfile, and its outputdeepEquals the MCPlist_elisiontool for the same appnpx webjs elision --verifyrenders every static page route with elision on and off, diffs the masked SSR bytes, exits 0 on parity and non-zero on divergence or on a zero-route corpus, and its success output states in its own words that it does not prove post-hydration behaviour--verifyreports dynamic routes as skipped by name, accepts--routesto cover them, and reports a nondeterministic route as skipped rather than failedmaskJsSethas exactly one definition in the repo and both the framework's own differential test and the CLI import itanalyzeAppElisionadds no second elision analysis pass, writes no file, andwebjs doctorbuilds the module graph once for both elision checkswebjs doctorreportsComponent elision (what the browser drops)with the stable codeELISION_COMPONENTS, passes on a healthy app, warns only when an orphan exists, and is silenced by{"webjs":{"doctor":{"gate":{"ELISION_COMPONENTS":"off"}}}}webjs devprints one elision summary line on the first warm request and again after eachfs.watchrebuild, and prints the disabled variant when the switch is off. No inline script and no SSE event is addedwhenDefinedobserver (elided without the override, shipped with it) and the external-stylesheet:definedrule (elided without, shipped with)Class.register(tagVar)is NOT rescued bystatic interactive = true, and the four doc surfaces that claimed otherwise are correctedexamples/blog/components/forced-badge.tsexists, is display-only apart fromstatic interactive = true, andtest/e2e/e2e.test.mjsasserts underWEBJS_E2E=1that the browser downloads its module whilebuild-stampon the same run is still not downloadedtest/bun/elision-report.mjsasserts an identical verdict undernodeandbun, andnode scripts/run-bun-tests.jsis green with no newDENYLISTentry/docs/elisionexists, is in the docs nav, and documents{ "webjs": { "elide": false } }andWEBJS_ELIDE=0, which appear on no docs-site page todaynpm testis green,webjs checkis clean, andwebjs doctoris run overexamples/blogandwebsite(the requiredconventionsCI job runs both and fails on whatever each app gateserror)Out of scope
Do not widen into any of these.
scanComponentsto evaluate an expression, and do not add a runtime registration hook.webjs build, a manifest file on disk, or any bundler. Qwik'sq-manifest.jsonis the shape being deliberately NOT copied. WebJs is no-build and the report is computed on demand.'use client', no per-component directive.--verifymasks the JS-loaded set by construction, so it cannot see over-ship. That is a known and accepted limit, not a bug to fix in this issue.AGENTS.md(all three edit different sections) andwebsite/app/docs/configuration/page.ts, where THIS issue adds only theDisplay-only elisionsection betweenClient routerandRequest limitswhile feat: make SSR action seeding observable and assert determinism in dev #1309 adds theseedkill switch elsewhere on that page. Keep the diff confined to the named sections so the PRs merge without conflict.