Skip to content

feat: make the elision verdict inspectable and provable per app #1308

Description

@vivek7405

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.

Verified state of the code at ddfc5547

Claim Verified Note
INTERACTIVITY_STATIC_FIELDS = ['shadow', 'interactive'] component-elision.js:154 as stated
the reason registry component-elision.js:157 (STATIC_FIELD_REASONS) the old body said L156
analyzeComponentSource(src) component-elision.js:528 as stated
always-ship static-field application component-elision.js:586-590 the old body said L584-590
analyzeAppElision(appDir) returns { analysed, shipped } elision-report.js:31, short-circuits at L34 and L36 as stated
doctor advisory 'Page/layout elision (carrier hygiene)' doctor.js:1008 (fn), analyzeAppElision imported at L1010-1011 the old body said L1009-1038 / L1012-1013
DOCTOR_CODES map doctor.js:94-107, ELISION_CARRIERS at L104 the old body said L100-108 / L105
dev warm path holds the verdicts dev.js:912-914 calls analyzeElision, dev.js:916-918 stores state.elidableComponents / state.inertRouteModules / state.importOnlyRouteModules #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:

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Invariants this work must not break

  • Auto-elision stays automatic. No annotation-based opt-in (research: fragility audit + production-readiness hardening (router is the hotspot, keep auto-elision) #976 records the full rationale).
  • 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:

    if (componentFiles.has(file) && analyzeComponentSource(masked).interactive) {
      mustShip.add(file);
    }

becomes:

    if (componentFiles.has(file)) {
      const v = analyzeComponentSource(masked);
      if (v.interactive) { mustShip.add(file); noteShip(file, 'own', null, v.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 }>} */
  const shipEvidence = new Map();
  /** Record WHY a component ships. First write wins. */
  const noteShip = (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:

      if (componentFiles.has(file)) mustShip.add(file);

becomes:

      if (componentFiles.has(file)) { mustShip.add(file); noteShip(file, 'unreadable', null, null); }

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 */
  const observedBy = new Map();

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).

Then at component-elision.js:1016:

  for (const f of observedComponentFiles) mustShip.add(f);

becomes:

  for (const f of observedComponentFiles) { mustShip.add(f); noteShip(f, 'observed', observedBy.get(f) ?? null, null); }

1e. The closure rule at component-elision.js:1066-1069:

        if (reachesClientWork.has(dep)) { mustShip.add(file); break; }

becomes:

        if (reachesClientWork.has(dep)) { mustShip.add(file); noteShip(file, 'closure', dep, null); break; }

1f. The fixpoint worklist at component-elision.js:1105-1121. Both arms record who pushed:

        if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); queue.push(childFile); }

becomes

        if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); noteShip(childFile, 'render', node, null); queue.push(childFile); }

and

        if (!mustShip.has(imp)) { mustShip.add(imp); queue.push(imp); }

becomes

        if (!mustShip.has(imp)) { mustShip.add(imp); noteShip(imp, 'import', node, null); queue.push(imp); }

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 }>} */
  const componentVerdicts = new Map();
  for (const c of components) {
    let row = componentVerdicts.get(c.file);
    if (!row) {
      const shipped = mustShip.has(c.file);
      const ev = 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 (const row of componentVerdicts.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:

  return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules, componentVerdicts };

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 analyzeAppElision to return the whole verdict

File 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:

  if (!(await pathExists(join(appDir, 'app')))) return { analysed: false, shipped: [] };
  if (!(await readElideEnabled(appDir))) return { analysed: false, shipped: [] };

becomes:

  if (!(await pathExists(join(appDir, 'app')))) return empty('no-app');
  if (!(await readElideEnabled(appDir))) return empty('elide-off');

with the catch at L43-47 returning empty('unanalysable'), and a local helper:

/** The no-verdict report, naming WHY nothing was analysed. */
function empty(skipped) {
  return {
    analysed: false, skipped,
    components: [], routeModules: [], orphans: [],
    summary: { components: 0, elided: 0, shipped: 0, routeModules: 0, inert: 0, importOnly: 0, shippedWhole: 0, orphans: 0 },
  };
}

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.

  const { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules, componentVerdicts } =
    await analyzeElision(components, [...routeModuleSet], moduleGraph, (f) => readFile(f, 'utf8'), appDir);

  const rel = (f) => (f == null ? null : relative(appDir, f) || f);
  const byFile = (a, b) => a.file.localeCompare(b.file);

  const componentRows = [...componentVerdicts.entries()].map(([file, v]) => ({
    file: rel(file), tags: v.tags,
    verdict: v.shipped ? 'shipped' : 'elided',
    evidence: v.shipped ? v.evidence : null,
    reason: v.shipped ? relativizeReason(v.reason, appDir) : null,
    by: v.shipped ? rel(v.by) : null,
  })).sort(byFile);

  const routeRows = [
    ...[...inertRouteModules].map((f) => ({ file: rel(f), verdict: 'inert', emits: [], blocker: null, reason: null })),
    ...[...importOnlyRouteModules].map(([f, emits]) => ({ file: rel(f), verdict: 'import-only', emits: emits.map(rel).sort(), blocker: null, reason: null })),
    ...[...shippedRouteModules].map(([f, v]) => ({ file: rel(f), verdict: 'shipped', emits: [], blocker: rel(v.blocker), reason: v.reason })),
  ].sort(byFile);

  const orphanRows = orphans.map((o) => ({ file: rel(o.file), className: o.className })).sort(byFile);

  return {
    analysed: true, skipped: null,
    components: componentRows, routeModules: routeRows, orphans: orphanRows,
    summary: {
      components: componentRows.length,
      elided: componentRows.filter((c) => c.verdict === 'elided').length,
      shipped: componentRows.filter((c) => c.verdict === 'shipped').length,
      routeModules: routeRows.length,
      inert: inertRouteModules.size,
      importOnly: importOnlyRouteModules.size,
      shippedWhole: shippedRouteModules.size,
      orphans: orphanRows.length,
    },
  };

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:

      const { analyzeAppElision } = await import('@webjsdev/server');
      const appDir = process.cwd();
      const report = await analyzeAppElision(appDir);
      if (rest.includes('--json')) { console.log(JSON.stringify(report)); break; }

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:

const { createRequestHandler, buildRouteTable, maskJsSet, staticPageRoutes } = await import('@webjsdev/server');
const table = await buildRouteTable(appDir);
const routes = [...new Set([...staticPageRoutes(table), ...extraRoutes])];

Then, with process.env.WEBJS_ELIDE saved and restored in a finally (the test's L91-114 pattern is the reference):

  1. Delete WEBJS_ELIDE, create handler A, await A.warmup(), render every route, capture { status, html }.
  2. 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.
  3. Set WEBJS_ELIDE=0, create handler B, warm it, render every route.
  4. 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):

    async list_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.
      return analyzeAppElision(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:

              : { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map() };

becomes

              : { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map(),
                  shippedRouteModules: new Map(), componentVerdicts: new Map() };

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:

              const elideOn = r.componentVerdicts.size > 0 || r.elidableComponents.size > 0 || (await readElideEnabled(appDir));
              logger.info?.(
                elideOn
                  ? `[webjs] elision: ${r.elidableComponents.size}/${new Set(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.
  const elision = (async () => {
    try {
      const { analyzeAppElision } = await import('@webjsdev/server');
      return await analyzeAppElision(appDir);
    } catch { return null; }
  })();

then replace checkElisionCarriers(appDir), at L1530 with:

    checkElisionCarriers(appDir, elision),
    checkElisionComponents(appDir, elision),

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.

7d. Add checkElisionComponents(appDir, elisionPromise) beside it:

  • 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:

  1. 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)
  2. 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)
  3. case 2 plus static interactive = true removes the badge from elidableComponents (the rescue, the pairing research: fragility audit + production-readiness hardening (router is the hotspot, keep auto-elision) #976 says is missing)
  4. a public/app.css carrying my-badge:defined leaves the badge elided AND the importing page inert (residual (b))
  5. case 4 plus static interactive = true ships the badge and reclassifies the page as import-only (the rescue)
  6. 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
  • its code is exactly ELISION_COMPONENTS, and {"webjs":{"doctor":{"gate":{"ELISION_COMPONENTS":"off"}}}} is accepted by readDoctorPolicy and silences the message (the gate contract from feat: let doctor gate CI without making every warning fatal #1257)

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
website/app/docs/data-fetching/page.ts:66, website/app/docs/components/page.ts:500 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.
  • An annotation-based opt-in. research: fragility audit + production-readiness hardening (router is the hotspot, keep auto-elision) #976 settled that auto-elision stays automatic. No 'use client', no per-component directive.
  • 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.
  • Doc files shared with sibling work. feat: resolve form-submitter boundness in webjs check and make the residual loud #1307 and feat: make SSR action seeding observable and assert determinism in dev #1309 are being planned in parallel. Code surfaces are disjoint, but two doc files are shared: AGENTS.md (all three edit different sections) and website/app/docs/configuration/page.ts, where THIS issue adds only the Display-only elision section between Client router and Request limits while feat: make SSR action seeding observable and assert determinism in dev #1309 adds the seed kill switch elsewhere on that page. Keep the diff confined to the named sections so the PRs merge without conflict.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions