@webjsdev/cli 0.10.51 → 0.10.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/doctor.js CHANGED
@@ -52,7 +52,7 @@
52
52
 
53
53
  import { existsSync, statSync, readdirSync, readFileSync } from 'node:fs';
54
54
  import { readFile } from 'node:fs/promises';
55
- import { join, relative } from 'node:path';
55
+ import { dirname, join, relative } from 'node:path';
56
56
  import { createRequire } from 'node:module';
57
57
  import { checkNodeInline } from './node-preflight.js';
58
58
 
@@ -103,6 +103,7 @@ export const DOCTOR_CODES = {
103
103
  'importmap-coherence': 'IMPORTMAP_COHERENCE',
104
104
  'git-hook': 'GIT_HOOK',
105
105
  'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS',
106
+ 'Component elision (what the browser drops)': 'ELISION_COMPONENTS',
106
107
  'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
107
108
  'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS',
108
109
  };
@@ -836,13 +837,87 @@ async function checkImportmapCoherence(appDir, opts) {
836
837
  };
837
838
  }
838
839
 
840
+ /**
841
+ * Read a dependency's INSTALLED version as resolved FROM `appDir`, or null when
842
+ * it does not resolve there at all.
843
+ *
844
+ * Node's own resolver is the ground truth here, not a directory read. The check
845
+ * this serves asks "would this app resolve this dependency at runtime, and at
846
+ * what version", and Node's resolution algorithm IS that question's definition,
847
+ * so anything re-implementing it can only be a worse approximation. Asking Node
848
+ * handles workspace hoisting (the bug this fixes: under npm workspaces the
849
+ * `@webjsdev/*` deps hoist to the ROOT node_modules, so an app subdirectory has
850
+ * no local copy and a per-app `node_modules/<dep>/package.json` read reported
851
+ * every declared dep missing on a healthy install), symlinked workspace links,
852
+ * nested non-hoisted trees, and `package.json` `imports`, for free and for ever.
853
+ *
854
+ * The direct `<dep>/package.json` resolve is attempted FIRST because a package
855
+ * may declare no main entry at all: `@webjsdev/cli` is bin-only (no `main`, no
856
+ * `exports`), so `require.resolve('@webjsdev/cli')` throws MODULE_NOT_FOUND.
857
+ * The ERR_PACKAGE_PATH_NOT_EXPORTED fallback exists because a package may lock
858
+ * its manifest out of its `exports` map: `@webjsdev/server` exports only `.`,
859
+ * `./check`, `./testing`, and `./webjs-config.schema.json`, so the direct
860
+ * manifest resolve is refused and the main entry plus a bounded walk up to the
861
+ * package root is the way in. Neither strategy alone resolves all four
862
+ * `@webjsdev/*` packages; both halves are required.
863
+ *
864
+ * Local rather than `getPackageVersion` from `@webjsdev/server` for two reasons.
865
+ * Doctor must stay usable when the framework does not resolve from the app dir
866
+ * at all, which is the #954 fresh-worktree case doctor exists to diagnose, so
867
+ * this check cannot import the server (the same argument `frameworkResolves`
868
+ * below already follows). And `getPackageVersion` resolves the main entry only,
869
+ * so it returns null for a bin-only package, which would leave `@webjsdev/cli`
870
+ * reported missing: the same false positive with more machinery.
871
+ *
872
+ * Pinned by the workspace, bin-only, and exports-locked fixtures in
873
+ * `test/cli/doctor.test.mjs`.
874
+ * @param {string} dep package name, e.g. `@webjsdev/server`
875
+ * @param {string} appDir directory to anchor resolution at
876
+ * @returns {Promise<string|null>} the installed version, or null when unresolvable
877
+ */
878
+ async function readInstalledVersion(dep, appDir) {
879
+ // The base file need not exist; createRequire only uses it to anchor the
880
+ // node_modules lookup at appDir.
881
+ const require = createRequire(join(appDir, '__webjs_resolve_probe__.js'));
882
+ let manifestPath = null;
883
+ try {
884
+ manifestPath = require.resolve(dep + '/package.json');
885
+ } catch (err) {
886
+ if (err?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') return null;
887
+ let entry;
888
+ try {
889
+ entry = require.resolve(dep);
890
+ } catch {
891
+ return null;
892
+ }
893
+ let dir = dirname(entry);
894
+ for (let i = 0; i < 12; i++) {
895
+ const candidate = join(dir, 'package.json');
896
+ if (existsSync(candidate)) {
897
+ manifestPath = candidate;
898
+ break;
899
+ }
900
+ const parent = dirname(dir);
901
+ if (parent === dir) break;
902
+ dir = parent;
903
+ }
904
+ if (!manifestPath) return null;
905
+ }
906
+ try {
907
+ return JSON.parse(await readFile(manifestPath, 'utf8')).version || null;
908
+ } catch {
909
+ return null;
910
+ }
911
+ }
912
+
839
913
  /**
840
914
  * CHECK 5, @webjsdev/* version coherence. WARN-level only (a version drift is
841
915
  * not a crash). Reads the app package.json `@webjsdev/*` ranges across
842
- * dependencies + devDependencies, then for each reads the INSTALLED version from
843
- * `node_modules/@webjsdev/<pkg>/package.json` and checks it satisfies the
844
- * declared range. PASS when every @webjsdev dep is present + satisfied; WARN on
845
- * a missing install or a range drift.
916
+ * dependencies + devDependencies, then for each resolves the INSTALLED version
917
+ * through Node's own resolver anchored at the app dir (see
918
+ * `readInstalledVersion`, which is why a workspace-hoisted install resolves)
919
+ * and checks it satisfies the declared range. PASS when every @webjsdev dep is
920
+ * present + satisfied; WARN on a missing install or a range drift.
846
921
  * @param {string} appDir
847
922
  * @returns {Promise<DoctorResult>}
848
923
  */
@@ -880,15 +955,8 @@ async function checkWebjsVersions(appDir) {
880
955
  const missing = [];
881
956
  const drift = [];
882
957
  for (const dep of webjsDeps) {
883
- const installedPkg = join(appDir, 'node_modules', dep, 'package.json');
884
- if (!existsSync(installedPkg)) {
885
- missing.push(dep);
886
- continue;
887
- }
888
- let installedVersion = '';
889
- try {
890
- installedVersion = JSON.parse(await readFile(installedPkg, 'utf8')).version || '';
891
- } catch {
958
+ const installedVersion = await readInstalledVersion(dep, appDir);
959
+ if (!installedVersion) {
892
960
  missing.push(dep);
893
961
  continue;
894
962
  }
@@ -1002,43 +1070,104 @@ function checkGitHook(appDir) {
1002
1070
  * named line. WARN only: a page legitimately MAY ship, and the analyser is
1003
1071
  * biased toward shipping by design (server AGENTS invariant 7), so this is a
1004
1072
  * "you may not have intended this" hint, never a hard fail.
1005
- * @param {string} appDir
1073
+ * @param {Promise<any|null>} elisionPromise the ONE shared report (#1308)
1006
1074
  * @returns {Promise<DoctorResult>}
1007
1075
  */
1008
- async function checkElisionCarriers(appDir) {
1076
+ async function checkElisionCarriers(elisionPromise) {
1009
1077
  const name = 'Page/layout elision (carrier hygiene)';
1010
- let report;
1011
- try {
1012
- const { analyzeAppElision } = await import('@webjsdev/server');
1013
- report = await analyzeAppElision(appDir);
1014
- } catch {
1078
+ const report = await elisionPromise;
1079
+ if (!report) {
1015
1080
  // Analysis unavailable (no app, malformed, server import failed): no advice.
1016
1081
  return { name, status: 'pass', message: 'not analysed (no routable app or analysis unavailable)' };
1017
1082
  }
1018
1083
  if (!report.analysed) {
1019
1084
  return { name, status: 'pass', message: 'not analysed (no routable app, or elision is disabled)' };
1020
1085
  }
1021
- if (report.shipped.length === 0) {
1086
+ // Paths and reasons arrive app-relative from `analyzeAppElision` (#1308).
1087
+ const shipped = report.routeModules.filter((r) => r.verdict === 'shipped');
1088
+ if (shipped.length === 0) {
1022
1089
  return { name, status: 'pass', message: 'every page/layout is elided (a pure import-only or inert carrier)' };
1023
1090
  }
1024
- const rel = (f) => relative(appDir, f) || f;
1025
1091
  // Name the FIRST client-effecting blocker (there may be more than one; the
1026
1092
  // module stays shipped until every such blocker is moved out).
1027
- const lines = report.shipped.map(({ file, blocker, reason }) =>
1093
+ const lines = shipped.map(({ file, blocker, reason }) =>
1028
1094
  blocker
1029
- ? `${rel(file)} ships whole. Its first client-effecting blocker is ${rel(blocker)}, which ${reason} and is not a component`
1030
- : `${rel(file)} ships whole because it ${reason}`,
1095
+ ? `${file} ships whole. Its first client-effecting blocker is ${blocker}, which ${reason} and is not a component`
1096
+ : `${file} ships whole because it ${reason}`,
1031
1097
  );
1032
1098
  return {
1033
1099
  name,
1034
1100
  status: 'warn',
1035
1101
  message:
1036
- `${report.shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` +
1102
+ `${shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` +
1037
1103
  lines.map((l) => ` ${l}`).join('\n'),
1038
1104
  fix: 'Move the client work out of the page/layout closure (into a component, or a .server module reached through an action) so the carrier can be elided, or accept that it ships. See references/components.md in the skill.',
1039
1105
  };
1040
1106
  }
1041
1107
 
1108
+ /**
1109
+ * The OTHER direction of the elision verdict (#1308): which COMPONENT modules
1110
+ * the browser never downloads. `checkElisionCarriers` above reports the benign
1111
+ * over-ship direction; this one reports what was DROPPED, which is where a
1112
+ * wrong verdict silently costs an app its interactivity.
1113
+ *
1114
+ * Pass-only except for orphans, deliberately. An elided component is the
1115
+ * DESIRED outcome, so warning on one would fire on every healthy app and train
1116
+ * the reader to skip doctor output. The passing message carries the elided
1117
+ * inventory instead, which makes it the discovery surface, while `webjs
1118
+ * elision` is the detail surface. The one always-wrong condition is an ORPHAN:
1119
+ * a `class X extends WebComponent` with no literal-tag registration is
1120
+ * invisible to the scanner, so it gets no verdict at all and `static
1121
+ * interactive = true` cannot rescue it (nothing consults the component
1122
+ * analyser for a component the scanner never saw). Never `fail`:
1123
+ * an app that wants an orphan to break CI gates `ELISION_COMPONENTS` to
1124
+ * `error` via `webjs.doctor.gate`.
1125
+ *
1126
+ * @param {Promise<any|null>} elisionPromise the ONE shared report
1127
+ * @returns {Promise<DoctorResult>}
1128
+ */
1129
+ async function checkElisionComponents(elisionPromise) {
1130
+ const name = 'Component elision (what the browser drops)';
1131
+ const report = await elisionPromise;
1132
+ const notAnalysed = { name, status: /** @type {const} */ ('pass'), message: 'not analysed (no routable app or analysis unavailable)' };
1133
+ if (!report) return notAnalysed;
1134
+ if (!report.analysed) {
1135
+ return report.skipped === 'elide-off'
1136
+ ? { name, status: 'pass', message: 'elision is disabled (webjs.elide false or WEBJS_ELIDE), so every component module ships' }
1137
+ : notAnalysed;
1138
+ }
1139
+ if (report.orphans.length > 0) {
1140
+ const lines = report.orphans.map(({ file, className }) =>
1141
+ `${className} in ${file} is never registered with a literal tag`,
1142
+ );
1143
+ return {
1144
+ name,
1145
+ status: 'warn',
1146
+ message:
1147
+ `${report.orphans.length} component class(es) get NO elision verdict:\n` +
1148
+ lines.map((l) => ` ${l}`).join('\n') +
1149
+ '\n Either it has no registration call at all, or it registers a computed tag. The component '
1150
+ + 'scanner matches only a literal tag, so either way it never sees the class: no elision verdict, no '
1151
+ + 'registry entry, no preload hint, and `static interactive = true` cannot rescue it. With no '
1152
+ + 'registration call the element never upgrades at all; with a computed tag it upgrades only while '
1153
+ + 'its module still reaches the browser through an importer that ships.',
1154
+ fix: 'Register it with a literal tag, Class.register(\'my-tag\') (invariant 3 already requires one), or delete the class if nothing uses it.',
1155
+ };
1156
+ }
1157
+ const elided = report.components.filter((c) => c.verdict === 'elided');
1158
+ const tags = elided.flatMap((c) => c.tags);
1159
+ const shown = tags.slice(0, 8).join(', ');
1160
+ const tail = tags.length > 8 ? `, +${tags.length - 8} more` : '';
1161
+ return {
1162
+ name,
1163
+ status: 'pass',
1164
+ message:
1165
+ `${report.summary.elided} of ${report.summary.components} component module(s) are elided (never downloaded)` +
1166
+ (tags.length ? `: ${shown}${tail}` : '') +
1167
+ '. Run `webjs elision` for the full verdict.',
1168
+ };
1169
+ }
1170
+
1042
1171
  // Directories never worth walking for the CSS-freshness advisory (mirrors
1043
1172
  // dev-regenerate's IGNORE_DIRS): build output, deps, VCS + framework caches.
1044
1173
  const FRESHNESS_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
@@ -1517,6 +1646,16 @@ export function checkFrameworkResolves(appDir) {
1517
1646
 
1518
1647
  export async function runDoctorChecks(appDir, opts = {}) {
1519
1648
  const cliDir = opts.cliDir || new URL('.', import.meta.url).pathname;
1649
+ // ONE elision report for BOTH elision checks (#1308). Started before the
1650
+ // batch and awaited inside each check, so the module graph is built once per
1651
+ // doctor run and the two checks still run in parallel with everything else.
1652
+ // Fails soft to null, exactly as the carrier check's own try/catch did.
1653
+ const elision = (async () => {
1654
+ try {
1655
+ const { analyzeAppElision } = await import('@webjsdev/server');
1656
+ return await analyzeAppElision(appDir);
1657
+ } catch { return null; }
1658
+ })();
1520
1659
  const results = await Promise.all([
1521
1660
  checkNode(cliDir, opts),
1522
1661
  checkTsconfig(appDir),
@@ -1527,7 +1666,8 @@ export async function runDoctorChecks(appDir, opts = {}) {
1527
1666
  Promise.resolve(checkFrameworkResolves(appDir)),
1528
1667
  checkImportmapCoherence(appDir, opts),
1529
1668
  Promise.resolve(checkGitHook(appDir)),
1530
- checkElisionCarriers(appDir),
1669
+ checkElisionCarriers(elision),
1670
+ checkElisionComponents(elision),
1531
1671
  checkStaticAssetFreshness(appDir),
1532
1672
  checkUnmarkedAssetLinks(appDir),
1533
1673
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.51",
3
+ "version": "0.10.53",
4
4
  "type": "module",
5
5
  "description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
6
6
  "bin": {
@@ -38,7 +38,8 @@ Classify the task first, then load the smallest useful reference set. Each refer
38
38
  | Task involves... | Start with |
39
39
  | --------------------------------------------------------------------------- | --------------------------------------------- |
40
40
  | Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s | `references/routing-and-pages.md` |
41
- | Writing components: reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
41
+ | Writing components: what a component owns, reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
42
+ | Why a component's JS was or was not downloaded, `webjs elision`, `static interactive = true` | `references/components.md` |
42
43
  | Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
43
44
  | Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
44
45
  | Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` |
@@ -105,7 +106,7 @@ App-internal imports use the `#` root alias (`import { db } from '#db/connection
105
106
  9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s).
106
107
  10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators.
107
108
  11. Reactive properties are declared ONLY through the base-class factory `extends WebComponent({ count: Number })`. Never a `static properties` block, never a class-field initializer (it clobbers the reactive accessor).
108
- 12. A form that writes binds its action: `<form action=${importedAction}>`, or a per-button `<button formaction=${importedAction}>` inside a bound form. Quoted bindings, non-submit controls, `<input type="submit">` (the identity needs its `value`, which is also its label, so use a `<button>`), submitter `name` / `value` / `form` / static `formaction` attributes, a `.prop` spelling of any of those, `action=${fn}` off a `<form>`, a bound form with `method="get"`, `formmethod="get"` or an unparseable `formenctype` on ANY submitter in a bound form, and a non-action function all throw. A page has no `action` export, so a bare `<form method="post">` is a 405.
109
+ 12. A form that writes binds its action: `<form action=${importedAction}>`, or a per-button `<button formaction=${importedAction}>`. A bound submitter is SELF-SUFFICIENT (#1307): the renderer puts `formmethod="post"` and `formenctype` on the button itself, so it needs no bound form around it and works inside any form or none. Quoted bindings, non-submit controls, `<input type="submit">` (the identity needs its `value`, which is also its label, so use a `<button>`), submitter `name` / `value` / `form` / static `formaction` attributes, a `.prop` spelling of any of those, `action=${fn}` off a `<form>`, a bound form with `method="get"`, a BOUND submitter's own non-post `formmethod` or unparseable `formenctype`, and a non-action function all throw. A PLAIN button's own `formmethod` / `formenctype` is a legal native override and is left alone. A page has no `action` export, so a bare `<form method="post">` is a 405.
109
110
 
110
111
  ## Export Map
111
112
 
@@ -236,9 +237,11 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
236
237
  - Writing `fetch()` to call your own server instead of importing the action.
237
238
  - Writing a bare `<form method="post">` and expecting a page `action` export to catch it. There is no such export; bind the action with `action=${fn}` or the submission is a 405.
238
239
  - Putting a submitter's `formaction=${fn}` on anything that is not a submit control, or on a button carrying its own `name` / `value`. The identity IS the button's name/value pair, so both halves are spoken for.
239
- - Writing `formmethod="get"` or `formenctype="text/plain"` on any button inside a bound form. Neither can carry the action's body, so both are refused even when the button binds nothing.
240
+ - Writing `formmethod="get"` or `formenctype="text/plain"` on a button that BINDS an action. Neither can carry that action's body, so the pair contradicts itself and throws. On a button that binds nothing it is a legal native override and is honoured.
240
241
  - Binding an action whose file declares `export const method = 'GET'`. That is a 405 at runtime and a `webjs check` error.
241
242
  - Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
242
243
  - A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
243
244
  - A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
244
245
  - Interpolating into a component's `<style>` / `<script>` body. Use `static styles` or Tailwind.
246
+ - Driving a component's markup from a delegated `document` listener in a page or layout, coupled by a class selector. The markup, the state, and the listener belong in one component.
247
+ - Parking one component's UI state on `<body>` or `<html>`. The router's swap range never covers the document shell, so the flag outlives the markup it described. A document-wide SETTING such as the theme is the exception, and a transient effect such as a scroll lock is released in `disconnectedCallback`.
@@ -233,7 +233,7 @@ Wired at the single response funnel, covering pages, routes, actions, and assets
233
233
 
234
234
  - **Access log.** One structured `info` line per handled request (`method`, `path`, `status`, `durationMs`, `requestId`). Never logs bodies or secrets; framework `/__webjs/*` traffic is suppressed.
235
235
  - **Request id.** Each request gets a `crypto.randomUUID()` correlation id, set as `X-Request-Id` (honoring a trusted inbound one) and readable server-side with `requestId()` from `@webjsdev/server` (returns `null` outside a request scope).
236
- - **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM.
236
+ - **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM. It also carries two framework DIAGNOSTICS that are not request failures, each with an `err.code` to group or filter on, both under `phase: 'action'`: `WEBJS_FORM_SUBMITTED_AS_GET` (a page GET carrying the reserved `__webjs_action` field in its query string, so a submission holding a bound action's identity went out as a GET and the action never ran, #1307; a bound submitter carries its own `formmethod="post"`, so what reaches this is an explicit `formmethod="get"` / `method="get"` the author wrote and the renderer honours rather than refuses) and `WEBJS_FORM_ACTION_MISSING` (a PARSEABLE form body carrying no identity, the 405; an `enctype="text/plain"` submission is answered before its body is read, so it stays a bare 405). Both are detect-only, so the 200 and the 405 are unchanged; both carry `method`, `pathname`, and for the second the submitted field NAMES, never the values; and both are deduplicated per process on the code, the method, and the matched ROUTE (not the request pathname, so crafted urls on a dynamic route cannot exhaust the 256-entry cap and silence the diagnostics), since either is reachable by an unauthenticated request and an uncapped report would be a free amplifier into a paid sink.
237
237
 
238
238
  ```ts
239
239
  const app = await createRequestHandler({
@@ -56,6 +56,16 @@ revalidate(); // clear the entire snapshot cache
56
56
 
57
57
  The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward.
58
58
 
59
+ **Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). What follows for an app:
60
+
61
+ - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code.
62
+ - **An app that sets `overflow-anchor` on `<html>` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element.
63
+ - **A new PAGE navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so a page navigation or a page-level form submission starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. A FRAME-TARGETED navigation or submission is the exception, on exactly the rule that decides frame targeting everywhere else (the enclosing frame, an explicit `data-webjs-frame="<id>"` from anywhere, or the frame's own `src`; `_top` and an unresolvable id are page navigations and do close the window). It swaps one region and leaves the page, and so the restored offset, intact, so it leaves the restore running. Closing there would hand anchoring back mid-restore and bring the double count straight back, and it needs no user input to happen, since a component upgrading in the just-restored page can drive a frame on its own.
64
+ - **Suppression is conditional on the offset being reachable, and follows the chase onto it.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window. It is also time-boxed, and more tightly than the window a landed restore gets: a few hundred milliseconds from the RESTORE, not the 2s ceiling, and the suppression it installs on landing shares that same deadline rather than starting a fresh one. That bound is what keeps it from moving a reader who has landed and started reading, since such a reader generates no input to cancel it and the chase cannot tell the restore settling apart from any other growth. Anchoring is left on only WHILE the offset is out of reach, which is the part that heals the clamp. The moment the chase lands on the offset it suppresses anchoring too, because the growth that made the offset reachable is rarely all of it and every later stage would otherwise be added on top of what was just written. Both halves end together on the bound. After it, the router writes no more scroll and anchoring is back on, so a component that reaches its final height later than the bound (a chart, an embed measured from its content) has its growth added and the reader drifts BELOW the offset, the same way they would without this fix at all, rather than sitting at the clamp.
65
+ - **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over.
66
+
67
+ Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore.
68
+
59
69
  **Error recovery.** A 2xx/3xx swap applies in place, and an HTML error body of any status (a 422 re-rendered form, a 5xx error page) is ALSO applied in place with no reload. For a non-HTML error or a transport failure the router dispatches a cancelable `webjs:navigation-error` on `document` (detail `{ url, status, error }`). Call `preventDefault()` to own recovery, otherwise the router renders a minimal in-place alert into the layout slot.
60
70
 
61
71
  ```ts
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## What This Covers
4
4
 
5
+ - What a component owns (markup, state, listeners, styling), and the rules that follow from it: refs over selectors, no state on `<body>`, ARIA derived in `render()`
5
6
  - Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`)
6
7
  - Signals as the default state primitive for component-local and shared state, plus `effect` / `batch`
7
8
  - The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips
@@ -15,6 +16,91 @@
15
16
 
16
17
  Read this when you are authoring or reviewing a `WebComponent`. For styling a component (Tailwind, the tag-prefix rule, host sizing) see `styling.md`. For streaming a slow region or programmatic navigation see `client-router-and-streaming.md`. For Lit habits that break WebJs see `muscle-memory-gotchas.md`.
17
18
 
19
+ ## Ownership: what a component owns
20
+
21
+ A component owns four things together: its markup, its state, its listeners, and its styling. The moment one of them lives in a different file from the rest, the feature can no longer be read, tested, or deleted as a unit, and the parts drift apart. These are CONVENTIONS, judged by a reader. `webjs check` has no rule for any of them, and adding one would be wrong, because a sensible app can legitimately want a delegated listener to pass.
22
+
23
+ Most of what follows restates widely held component-model advice, ported. Lit's base class exists to hold reactive state, scoped styles, and a declarative template TOGETHER (the `lit` package README), and Lit documents a ref's value as `undefined` once the node "is no longer rendered", which is precisely the signal a selector lookup cannot give you. React frames the same ideas as lifting state to the closest common owner (react.dev, "Sharing State Between Components") and treating a ref as an escape hatch rather than the normal way to reach a node (react.dev, "Escape Hatches"). Where WebJs moves the boundary, the rule that needs it says so inline and names the mechanism.
24
+
25
+ **1. Markup and the code that drives it live in the same component.** A class selector is not an interface. `document.querySelector('.nav-toggle')` keeps compiling, keeps type-checking, and keeps passing `webjs check` after someone renames the class in the other file. It just starts returning `null` at runtime. If you are writing a selector to find markup that another file rendered, write the component that renders it instead. Where a value genuinely has to exist in two places (a layout's pre-paint inline script cannot import), the second place READS the first declaration rather than restating it.
26
+
27
+ **2. Reach your own rendered node with a ref, never with a selector.** `render()` already owns the node, so let the handle flow out of the template with `ref()` / `createRef()` from `@webjsdev/core/directives` (the directives table below carries the one-line summary). A ref is scoped to the component, so it cannot match a node some other component rendered, and it goes `undefined` when the node stops being rendered, which makes a stale handle visible instead of silent. Two reads a ref cannot express stay vanilla: `this.closest('parent-tag')` for compound-component ancestor lookup, and `assignedNodes()` for slotted content.
28
+
29
+ **3. State lives on the component, never on `<body>` or `<html>`.** The client router swaps a range INSIDE the document, so the document shell sits outside every swap. An open flag parked on `<body>` therefore survives a navigation that removed the markup it described, and it re-opens a panel over the next page or leaves scrolling locked on a page with nothing open. State held in a reactive property or an INSTANCE signal dies with the element, which is the behaviour you wanted in the first place. A module-scope signal deliberately outlives it, which is what rule 7 reaches for, so it is the right home for state genuinely shared between components and the wrong one for one element's own open flag. The carve-out is a document-level EFFECT rather than one component's state, and it comes in two shapes. A TRANSIENT effect, a scroll lock being the usual case, belongs to the element that opened it and must be released in `disconnectedCallback`. A PERSISTENT one is a document-wide SETTING, the theme being the case the framework itself ships: the scaffold's theme toggle writes `data-theme` on `<html>` and persists it, deliberately without releasing it on disconnect, because it describes the document rather than the element (`styling.md` carries that pattern). What the rule forbids is neither of those. It is one component's own open / selected / active flag parked on the shell because that was the convenient place to reach it from.
30
+
31
+ **4. ARIA state is a hole in `render()`, derived from the same state that drives behaviour.** `aria-expanded=${this.open ? 'true' : 'false'}` cannot disagree with `this.open`. A second function that re-finds the button and calls `setAttribute` can, and does, the first time someone adds a close path that forgets to call it. The same holds for `class`, `?disabled`, and any `.prop`. Two caveats ride this rule:
32
+
33
+ - Write the string explicitly for a tri-state ARIA attribute. A plain-attribute hole holding `false` serves `aria-expanded="false"` from the server and hydrates to NO attribute, because the client removes an attribute for `null` / `undefined` / `false` while the server stringifies it. `?attr=${bool}` is not a substitute, since a boolean binding omits the attribute in BOTH renderers.
34
+ - A hole commits on the next render, one microtask later. The one place a direct write is still correct is a synchronous snapshot read such as `webjs:before-cache`, where the router reads `outerHTML` in the same task. That is a documented exception, not the normal path.
35
+
36
+ **5. Behaviour needs an importable surface, or its test is a copy of it.** An inline `<script>` in a layout has no module identity, so a browser test cannot import it. It can only transcribe the listener into the test file and assert against the transcription, which then needs a SECOND test to grep the original for drift. Two tests, neither running shipping code. A component is importable, so its browser test mounts the real element and drives real events. A page or layout may still carry an inline `<script>`, but only for pre-paint boot work no module can do: reading a stored theme before first paint so the wrong palette never flashes, or measuring the header height into a CSS custom property. It must not be interactivity, and WHERE it sits decides how often it runs. The ROOT layout's markup sits OUTSIDE every swap range, so a soft navigation does not re-run its script, which is what makes it the right home for boot work and the wrong home for anything that has to respond to a later navigation. A page or a NESTED layout sits inside the swap range instead, so its script re-executes on every navigation that swaps that range (#1102), which means it has to be idempotent or guard on a flag it sets the first time. Neither shape gives you a listener that simply works, which is what a custom element is for. Under an opt-in CSP the script also needs the nonce from `cspNonce()`. `client-router-and-streaming.md` carries the full re-execution rule.
37
+
38
+ **6. Listening on `document` is legitimate. Querying `document` usually is not.** An outside-click dismissal or an Escape handler has no choice, because the event happens outside the element, so the listener has to be global. What decides whether that is ownership or a reach across the app is what the handler then READS. `this.contains(e.target)` is a decision about the component's own subtree. `document.querySelector('.other-thing')` is a decision about someone else's markup. Add the listener in `connectedCallback`, remove it in `disconnectedCallback`, and store the handler in a field so `removeEventListener` gets the same reference back (a function created inline at add time can never be removed).
39
+
40
+ **7. Talk to an ancestor with an event, and to a stranger with a module-scope signal.** A child telling its own ancestor something dispatches a `CustomEvent` with `bubbles: true`, and the ancestor binds `@my-event=${...}` in the template that rendered it. Add `composed: true` as well when the component sets `static shadow = true`, or the event stops at the shadow boundary. Two components with NO ancestor relationship share a module-scope `signal` that both import, which is typed, greppable, and owned by a module. What neither case is: a made-up event name on `document` used as a global bus, which is a global variable with extra steps. Framework events such as `webjs:navigate` ride `document` because the router has no element to dispatch from, and that is not a licence to add your own.
41
+
42
+ The shape to fix, all four pieces in different places:
43
+
44
+ ```js
45
+ // In a layout's inline script, driving markup that another file rendered.
46
+ document.addEventListener('click', (e) => {
47
+ if (e.target.closest('.nav-toggle')) document.body.toggleAttribute('data-nav-open');
48
+ });
49
+ function syncNav() {
50
+ const btn = document.querySelector('.nav-toggle'); // another file's markup
51
+ const open = document.body.hasAttribute('data-nav-open'); // outlives the markup
52
+ if (btn) btn.setAttribute('aria-expanded', String(open)); // a second home for the state
53
+ }
54
+ ```
55
+
56
+ The shape to write, one component owning all four:
57
+
58
+ ```ts
59
+ import { WebComponent, prop, html } from '@webjsdev/core';
60
+ import { createRef, ref } from '@webjsdev/core/directives';
61
+
62
+ class NavDrawer extends WebComponent({ open: prop(Boolean, { reflect: true }) }) {
63
+ private toggleRef = createRef<HTMLButtonElement>();
64
+ // Stored in a field, so removeEventListener gets the same reference back.
65
+ private onDocClick = (e: MouseEvent) => {
66
+ if (!this.contains(e.target as Node)) this.open = false; // reads its OWN subtree
67
+ };
68
+ private onDocKeydown = (e: KeyboardEvent) => {
69
+ if (e.key !== 'Escape' || !this.open) return;
70
+ this.open = false;
71
+ // The ref lands after the FIRST client commit, and `ref()` is a no-op at
72
+ // SSR, so read `.value` from a handler or `firstUpdated`, never from the
73
+ // constructor. This is the reach a selector would otherwise have done.
74
+ this.toggleRef.value?.focus();
75
+ };
76
+
77
+ constructor() { super(); this.open = false; } // SSR runs the constructor
78
+
79
+ connectedCallback() {
80
+ super.connectedCallback();
81
+ document.addEventListener('click', this.onDocClick); // listening globally is fine
82
+ document.addEventListener('keydown', this.onDocKeydown);
83
+ }
84
+ disconnectedCallback() {
85
+ super.disconnectedCallback();
86
+ document.removeEventListener('click', this.onDocClick); // the state dies with the element
87
+ document.removeEventListener('keydown', this.onDocKeydown);
88
+ }
89
+
90
+ render() {
91
+ return html`
92
+ <button ${ref(this.toggleRef)}
93
+ aria-expanded=${this.open ? 'true' : 'false'}
94
+ @click=${() => { this.open = !this.open; }}>Menu</button>
95
+ <nav ?hidden=${!this.open}><slot></slot></nav>
96
+ `;
97
+ }
98
+ }
99
+ NavDrawer.register('nav-drawer');
100
+ ```
101
+
102
+ This repo's own website is the worked example. Before commit `b80de906` the docs drawer and the header menu were exactly the first shape, and every accessibility bug their tests now pin came out of the split. `website/components/docs-drawer.ts` and `website/components/site-nav-menu.ts` are the second shape, and `website/AGENTS.md` records the app-level version of these rules under "What stays inline script in the root layout".
103
+
18
104
  ## Reactive properties: the base-class factory
19
105
 
20
106
  Reactive properties are declared by passing their shape into `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is NO `static properties` block and NO `declare` line (a `static properties` block throws at runtime, caught by `no-static-properties`).
@@ -48,7 +134,7 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
48
134
  | Option | Default | Meaning |
49
135
  |---|---|---|
50
136
  | `type` | `String` | Constructor feeding the default attribute converter |
51
- | `reflect` | `false` | Property changes write back to the HTML attribute (a function value removes it instead, see below) |
137
+ | `reflect` | `false` | Property changes write back to the HTML attribute (a value with no attribute representation removes it instead, see below) |
52
138
  | `state` | `false` | Internal-only. No attribute, not observed |
53
139
  | `attribute` | derived from name | The HTML attribute name the property rides |
54
140
  | `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
@@ -59,6 +145,8 @@ For an array-typed prop pass `Array`, not `Object` (`array-prop-uses-array-type`
59
145
 
60
146
  **A `reflect: true` property holding a FUNCTION drops its attribute instead of writing one, and so does one holding an array that carries a function, unless the prop is `Object` or `Array` typed.** A function has no HTML attribute representation, and the serializations it would otherwise get are both useless and dangerous. `String(fn)` is the function's SOURCE, so a reflected `'use server'` action would ship its whole body, closure secrets included, to every visitor, and `JSON.stringify(fn)` is `undefined`, which lands in the attribute as the literal four-character string. So the reflection path treats a function like `null`, removes the attribute, and warns naming the property, the tag, and the attribute. This holds on both sides, since SSR and the client-side setter run the same path, and it holds for every property name (the leak was never specific to one called `action`). Two exceptions. A property with a custom `converter.toAttribute` runs that converter first and is left alone, because an author who writes one has taken responsibility for serializing whatever they are handed. And an `Object` or `Array` typed property CARRYING a function keeps its data, because `JSON.stringify` drops the function to `null` and omits the key, so `[1, 2, fn]` reflects as `[1,2,null]` with no source and nothing else lost. If you need a function on a component, use a plain property or a signal and do not mark it `reflect`.
61
147
 
148
+ **An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value.
149
+
62
150
  **Never use a class-field declaration OR initializer** (`count = 0`, `student: Student = {...}`, `todos!: Todo[]`). Under `useDefineForClassFields` even a type-only `todos!: Todo[]` compiles to define an own property after `super()`, which clobbers the prototype's reactive accessor and silently breaks reactivity. Only declare props in the factory and read/write them off `this`. The `reactive-props-no-class-field` rule catches this.
63
151
 
64
152
  ## Signals are the default state primitive
@@ -183,7 +271,7 @@ The boundary also covers `watch(signal)` (its notify microtask) and `until()` (i
183
271
 
184
272
  **A commit that throws leaves the directive's own state consistent, so the NEXT valid render is correct.** This matters because the corruption is otherwise silent: the renders that expose it are fully valid and log nothing after the first throw. The hole whose commit threw is marked so the next render re-applies it rather than skipping it as unchanged (its recorded value is never advanced past a throw, and would otherwise match exactly what the recovering render supplies, leaving a child region blank for good). Both list reconcilers additionally repair their own bookkeeping so it describes the DOM again, and the next render is an ordinary reconcile rather than a rebuild of the region, which would discard the node identity the reconcilers exist to preserve. `repeat()` re-unites its key map and repositions every row (the failure was a permanently duplicated row). A plain `.map()` array splices the part of its slot list the failed pass never reached back on, which matters whenever a slot is REPLACED rather than updated in place (its template shape changed, its kind changed between text, template and empty, or the array grew past its old length), since that is the branch that inserts the replacement before removing what it replaced (the failure was a stranded row that outlived even a render of an empty array). `guard()` records its new deps only once the commit succeeds, so a later render with those same deps re-renders the region instead of short-circuiting past a region the throw had blanked; `until()` advances its resolved priority only after the commit succeeds, so a failed high-priority resolution does not refuse the lower-priority one behind it.
185
273
 
186
- **Teardown is total as well.** Removing a row is not a commit and has no retry, so a throw while tearing one down cannot be allowed to abandon the rest. Unbinding a `ref` during teardown can never abort the removal of the remaining rows, and `repeat()` drops each leftover key from its map before touching that row, so the map never describes a row that has already been removed (which used to leave the row the app DELETED on screen, reorder the survivors, and let a later render that re-added that key reinsert the disposed instance). To make that hold, a `ref` whose object `value` setter throws is now SWALLOWED on teardown, matching the ref CALLBACK, which was already swallowed everywhere. That is a deliberate divergence from lit, which guards neither and propagates from both. It applies to teardown only: on the COMMIT path a throwing object-ref setter still reaches `renderError()`, because there the boundary can report it and the next render can repair it.
274
+ **Teardown is total as well.** Removing a row is not a commit and has no retry, so a throw while tearing one down cannot be allowed to abandon the rest. Unbinding a `ref` during teardown can never abort the removal of the remaining rows, and `repeat()` drops each leftover key from its map before touching that row, so the map never describes a row that has already been removed (which used to leave the row the app DELETED on screen, reorder the survivors, and let a later render that re-added that key reinsert the disposed instance). To make that hold, a `ref` whose object `value` setter throws is now SWALLOWED on teardown, matching the ref CALLBACK, which was already swallowed everywhere. That is a deliberate divergence from lit, which guards neither and propagates from both. It applies to teardown only: on the COMMIT path a throwing object-ref setter still reaches `renderError()`, because there the boundary can report it and the next render can repair it. Total also means a removal takes the row's own boundary markers with it, so a list that grows and shrinks all day is net zero on the nodes the renderer added, rather than accruing one invisible comment per removed row for the life of the region.
187
275
 
188
276
  Decision rules. Use `async render()` for request-time server data that should be in the first paint (the default). Add `renderFallback()` when a client re-fetch's stale content would mislead. Use `Task` / signals for genuinely client-only data (a click, viewport, live updates). For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`Loading...\`}>` to stream it (the only way to show a first-paint fallback; see `client-router-and-streaming.md`). Do NOT fetch in `connectedCallback` for data knowable server-side, and do NOT prop-drill what a leaf can fetch itself.
189
277
 
@@ -264,7 +352,56 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
264
352
  - the dynamic slot READ surface (`slotchange`, `assignedNodes` / `assignedElements` / `assignedSlot`); merely RENDERING a `<slot>` does not ship (the SSR output carries the placed children, so a display-only slotted wrapper is byte-identical without its JS; native-write liveness is consumer-driven and the consumer's tag reference forces the ship)
265
353
  - being rendered by a component that itself ships
266
354
 
267
- A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis (a dynamically-built tag string, a `:defined` rule in an external stylesheet). `static shadow = true` always ships (Declarative Shadow DOM re-attaches only during parsing). Turn elision off app-wide with `{ "webjs": { "elide": false } }` or `WEBJS_ELIDE=0`.
355
+ A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis. `static shadow = true` always ships (Declarative Shadow DOM re-attaches only during parsing). Turn elision off app-wide with `{ "webjs": { "elide": false } }` or `WEBJS_ELIDE=0`.
356
+
357
+ ### What `static interactive = true` does and does not rescue
358
+
359
+ The analyser reads source lexically, so a few real shapes escape it. The override covers them:
360
+
361
+ - **An OBSERVER that computes the tag it waits for.** `customElements.whenDefined(TAG)` where `TAG` is a variable does not name a tag the analyser can resolve, so the observed component is elided, its `register` never runs, and the `await` never settles. Put `static interactive = true` on the OBSERVED component.
362
+ - **A `:defined` rule in an external stylesheet.** `public/app.css` is not in the module graph, so a `my-badge:defined { … }` rule is invisible. Same fix, on the component the rule names.
363
+ - **A consumer that reaches the element through a string selector.** The analyser matches `whenDefined` / `:defined` / `instanceof`, so a `document.querySelector('my-wrapper')` consumer escapes all three. Same fix, on the component being reached.
364
+
365
+ **It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs if the module reaches the browser, so what you ALWAYS lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades depends on one thing: the importing module has to ship WHOLE. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. A page rendering a real component alongside the orphan is import-only unless it ALSO does its own client work, so shipping whole is the narrower case: assume the element does not upgrade. Always pass a literal: `Badge.register('my-badge')`.
366
+
367
+ `webjs dev` warns, and `webjs elision` / `webjs doctor` report it, as an **orphan**. That name covers TWO shapes and they fail differently, so read the warning carefully: a computed tag is the case above, while a class with NO registration call anywhere in the app is the plainer one (someone forgot to register it), and that element never upgrades. The check is app-wide, so registering the class from a sibling module is fine and is not reported. Both lose the verdict, the registry entry, and the preload hint.
368
+
369
+ ### Inspecting and proving the verdict
370
+
371
+ Elision is the one thing WebJs decides about your code that you did not write down, so it is inspectable rather than something to reason about from the rules above.
372
+
373
+ ```sh
374
+ webjs elision # per-module verdict, and the evidence behind every ship
375
+ webjs elision --json # the same object, for a tool or an agent
376
+ webjs elision --verify # prove elision changed nothing your app serves
377
+ webjs elision --verify --routes /,/blog/hello # add paths (the only way to cover a dynamic route)
378
+ ```
379
+
380
+ **Reading the report.** Every component is `elided` or `shipped`. A shipped one carries the `evidence` that forced it, first match wins:
381
+
382
+ | `evidence` | Means | `by` |
383
+ |---|---|---|
384
+ | `own` | its own source carries a signal; `reason` is the exact one | null |
385
+ | `observed` | another module observes its registration (`whenDefined` / `:defined` / `instanceof`) | the observer |
386
+ | `closure` | something it imports does client work | the import |
387
+ | `render` | a shipping component can render its tag | that component |
388
+ | `import` | a shipping component imports it | that component |
389
+ | `unreadable` | its source could not be read, so it ships conservatively | null |
390
+
391
+ An elided row carries no reason on purpose: elision is the ABSENCE of every signal, so there is no positive fact to report.
392
+
393
+ **What to do with each verdict.** `elided` on a component you believe is interactive is the one result worth acting on: find the signal it is missing (the list above), and if the interactivity is genuinely invisible to static analysis, add `static interactive = true`. `shipped` with an `evidence` you did not expect is usually a `closure` row, and the fix is to move the client-effecting import out of that component's path. An `orphans` row is always a bug, and the fix depends on which shape it is: give the class a literal registration tag if its tag is computed, or add the missing `Class.register('my-tag')` call if there is none at all (delete the class instead if nothing uses it).
394
+
395
+ **What `--verify` proves.** It renders every static page route with elision on and off and diffs the bytes with the JS-loaded set masked out, which is the framework's own guard pointed at your app. So it proves elision did not change what your app SERVES. It does not prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Cover that half by running your own browser or e2e suite twice:
396
+
397
+ ```sh
398
+ WEBJS_ELIDE=1 npm run test:e2e
399
+ WEBJS_ELIDE=0 npm run test:e2e
400
+ ```
401
+
402
+ It exits non-zero on a divergence AND on a corpus where nothing could be compared, so it is safe to put in CI. The ON side is forced on rather than read from your config, so the comparison is a real one even in an app that has elision switched off, and the run reports how many modules elision actually dropped so a trivially-true pass is visible. Dynamic routes are skipped by name (rendering one would mean inventing param values); pass real ones with `--routes`. A route whose two same-side renders already differ is reported as nondeterministic and excluded, since a differential over live data proves nothing.
403
+
404
+ `webjs doctor` carries the same verdict as a one-line inventory, and warns only on an orphan.
268
405
 
269
406
  ## Members app code must not shadow
270
407