@webjsdev/server 0.8.59 → 0.8.61

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/index.d.ts CHANGED
@@ -478,25 +478,114 @@ export declare function primeComponentRegistry(
478
478
  ): Promise<{ count: number }>;
479
479
  /** Extract `{ className, tag }` pairs from a component module's source. */
480
480
  export declare function extractComponents(src: string): Array<{ className: string; tag: string }>;
481
- /** Find component classes that no page / component imports (orphans). */
481
+ /**
482
+ * Find ORPHAN component classes: a `class X extends WebComponent` that its own
483
+ * file declares and NOTHING in the app registers with a LITERAL tag, either
484
+ * because there is no registration call anywhere or because the tag is
485
+ * computed. The registration cross-reference is app-wide, so a class a sibling
486
+ * module registers is not an orphan. Neither is visible to the
487
+ * scanner, so both always lose the elision verdict, the tag-to-module registry
488
+ * entry, and the preload hint; assume the element does not upgrade either.
489
+ * This is about REGISTRATION, never about import reachability. The full
490
+ * semantics, including the one case where a computed tag still upgrades, live
491
+ * on `findOrphanComponents` in `src/component-scanner.js`.
492
+ */
482
493
  export declare function findOrphanComponents(
483
494
  appDir: string,
484
495
  ): Promise<Array<{ className: string; file: string }>>;
485
496
 
486
497
  // ---------------------------------------------------------------------------
487
- // elision-report.js (#646)
498
+ // elision-report.js (#646, #1308)
488
499
  // ---------------------------------------------------------------------------
489
500
 
501
+ /** Which rule in the analyser forced a component to ship. First match wins. */
502
+ export type ElisionEvidence = 'own' | 'observed' | 'closure' | 'render' | 'import' | 'unreadable';
503
+
504
+ /** One component module's verdict. `evidence` / `reason` / `by` are null when elided. */
505
+ export interface ElisionComponentRow {
506
+ /** App-relative path of the component module. */
507
+ file: string;
508
+ /** Every tag this file registers, sorted. */
509
+ tags: string[];
510
+ verdict: 'elided' | 'shipped';
511
+ evidence: ElisionEvidence | null;
512
+ reason: string | null;
513
+ /** App-relative path of the module that forced the ship, where one did. */
514
+ by: string | null;
515
+ }
516
+
517
+ /** One page/layout route module's verdict. */
518
+ export interface ElisionRouteModuleRow {
519
+ file: string;
520
+ verdict: 'inert' | 'import-only' | 'shipped';
521
+ /** For `import-only`: the component modules the boot emits in its place. */
522
+ emits: string[];
523
+ /** For `shipped`: the first client-effecting blocker, or null when its own code is the cause. */
524
+ blocker: string | null;
525
+ reason: string | null;
526
+ }
527
+
528
+ /** A `class X extends WebComponent` with no literal-tag registration. */
529
+ export interface ElisionOrphanRow {
530
+ file: string;
531
+ className: string;
532
+ }
533
+
534
+ export interface ElisionSummary {
535
+ components: number;
536
+ elided: number;
537
+ shipped: number;
538
+ routeModules: number;
539
+ inert: number;
540
+ importOnly: number;
541
+ shippedWhole: number;
542
+ orphans: number;
543
+ }
544
+
545
+ /** The whole app-level elision verdict. Every path is app-relative; every array is sorted by `file`. */
546
+ export interface ElisionReport {
547
+ analysed: boolean;
548
+ /** Why nothing was analysed, when `analysed` is false. */
549
+ skipped: 'no-app' | 'elide-off' | 'unanalysable' | null;
550
+ components: ElisionComponentRow[];
551
+ routeModules: ElisionRouteModuleRow[];
552
+ orphans: ElisionOrphanRow[];
553
+ summary: ElisionSummary;
554
+ }
555
+
490
556
  /**
491
- * App-level elision report: the page/layout route modules that SHIP WHOLE to
492
- * the browser instead of being elided as carriers, each with the first
493
- * client-effecting blocker that pins it (or `null` when the module's own code
494
- * is the cause) plus a human reason. A reporting layer over `analyzeElision`,
495
- * consumed by the `webjs doctor` carrier-hygiene advisory (#646).
557
+ * App-level elision report, BOTH directions (#646, #1308): every component
558
+ * module as elided or shipped (a shipped one naming the evidence and the module
559
+ * that forced it), every page/layout as inert / import-only / shipped (a
560
+ * shipped one naming the first client-effecting blocker), and every orphan
561
+ * component class that gets no verdict at all. A reporting layer over
562
+ * `analyzeElision`, never a second analysis and never a build artifact.
563
+ * Consumed by `webjs elision`, the MCP `list_elision` tool, and both `webjs
564
+ * doctor` elision checks.
496
565
  */
497
- export declare function analyzeAppElision(
498
- appDir: string,
499
- ): Promise<{ analysed: boolean; shipped: Array<{ file: string; blocker: string | null; reason: string }> }>;
566
+ export declare function analyzeAppElision(appDir: string): Promise<ElisionReport>;
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // elision-differential.js (#1308)
570
+ // ---------------------------------------------------------------------------
571
+
572
+ /**
573
+ * Mask the JS-loaded set (importmap, boot script, modulepreload hints, vendor
574
+ * connection hints, content stamps) out of an SSR response so a diff sees only
575
+ * observable output. The ONE definition shared by the framework's own
576
+ * differential guard and `webjs elision --verify`, so an app can diff its own
577
+ * two captures with exactly the framework's comparator.
578
+ */
579
+ export declare function maskJsSet(html: string): string;
580
+
581
+ /**
582
+ * The URL paths of every STATIC page route in a `buildRouteTable` result,
583
+ * sorted and deduped. Dynamic routes are excluded (rendering one would mean
584
+ * inventing param values).
585
+ */
586
+ export declare function staticPageRoutes(table: {
587
+ pages?: Array<{ routeDir?: string; paramNames?: string[] }>;
588
+ }): string[];
500
589
 
501
590
  // ---------------------------------------------------------------------------
502
591
  // context.js (per-request context helpers)
package/index.js CHANGED
@@ -44,6 +44,7 @@ export {
44
44
  export { buildModuleGraph, transitiveDeps } from './src/module-graph.js';
45
45
  export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js';
46
46
  export { analyzeAppElision } from './src/elision-report.js';
47
+ export { maskJsSet, staticPageRoutes } from './src/elision-differential.js';
47
48
  export { headers, cookies, getRequest, withRequest, cspNonce, requestId } from './src/context.js';
48
49
  export { defaultLogger } from './src/logger.js';
49
50
  export { rateLimit, parseWindow, clientIp, stampRemoteIp } from './src/rate-limit.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.59",
3
+ "version": "0.8.61",
4
4
  "type": "module",
5
5
  "description": "The server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Provides the file-based router, SSR, server actions, route handlers, middleware, and live reload on Node 24+ or Bun.",
6
6
  "main": "index.js",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@webjsdev/core": "^0.7.47",
31
+ "@webjsdev/core": "^0.7.49",
32
32
  "ws": "^8.20.0"
33
33
  },
34
34
  "publishConfig": {
@@ -75,6 +75,8 @@ const als = new AsyncLocalStorage();
75
75
 
76
76
  /** Whether seed COLLECTION is on (the `webjs.seed` switch). */
77
77
  let _seedEnabled = false;
78
+ /** Dev mode, threaded from `dev.js` at boot. Gates the determinism assertion. */
79
+ let _devMode = false;
78
80
  /** Whether the load hook is installed at all (so identity is available). */
79
81
  let _hookInstalled = false;
80
82
  /** Idempotency guard: `module.registerHooks` must run at most once. */
@@ -194,12 +196,50 @@ async function recordSeed(collector, file, fnName, args, value) {
194
196
  try {
195
197
  const hash = await actionFileHash(file);
196
198
  const argsKey = await stringify(args);
197
- collector.set(`${hash}/${fnName}/${argsKey}`, value);
199
+ const key = `${hash}/${fnName}/${argsKey}`;
200
+ // Dev-only determinism assertion (#1309). A duplicate key means the SAME
201
+ // action ran twice with the SAME arguments in ONE render; the collector
202
+ // keeps the LAST result, so a component that painted the first one hydrates
203
+ // with the second. Compared on the FULL key, never on `hash/fn`: a
204
+ // legitimate second call with different arguments has a different key and
205
+ // cannot false-fire. In its OWN try/catch so a diagnostic failure can never
206
+ // skip `collector.set` and turn observability into a dropped seed.
207
+ if (_devMode && collector.has(key)) {
208
+ try { await assertDeterministic(collector.get(key), value, hash, fnName); } catch { /* never affect the seed */ }
209
+ }
210
+ collector.set(key, value);
198
211
  } catch {
199
212
  // Drop the seed; the client stub falls back to a normal RPC.
200
213
  }
201
214
  }
202
215
 
216
+ /** Warned-once ids, keyed `hash/fn` so the Set is bounded by the action count. */
217
+ const _nonDeterministic = new Set();
218
+
219
+ /**
220
+ * Warn (once per action function) when one render recorded two DIFFERENT results
221
+ * for the same key. `Object.is` settles a memoized or cached return for free;
222
+ * the fallback compares through the SAME serializer the seed uses, so
223
+ * "different" means different on the wire, which is the only difference that can
224
+ * reach a client. Dev only, and only on a duplicate key.
225
+ * @param {unknown} prev
226
+ * @param {unknown} next
227
+ * @param {string} hash
228
+ * @param {string} fnName
229
+ */
230
+ async function assertDeterministic(prev, next, hash, fnName) {
231
+ if (Object.is(prev, next)) return;
232
+ const id = `${hash}/${fnName}`;
233
+ if (_nonDeterministic.has(id)) return;
234
+ if ((await stringify(prev)) === (await stringify(next))) return;
235
+ _nonDeterministic.add(id);
236
+ console.warn(
237
+ `[webjs] SSR action seeding: "${fnName}" returned two DIFFERENT results for the SAME arguments during one render. `
238
+ + 'The seed carries the LAST result, so a component that painted the first one hydrates with the second. '
239
+ + 'Make the action deterministic for a given argument list, or turn seeding off with "webjs": { "seed": false }.',
240
+ );
241
+ }
242
+
203
243
  /**
204
244
  * Register one exported action function's identity, and, when seeding is on,
205
245
  * wrap it so that a resolved result is recorded into an active collector.
@@ -599,11 +639,13 @@ function seedLoadHook(url, context, nextLoad) {
599
639
  * The hook installs whatever `seed` says, because action IDENTITY (#1155) rides
600
640
  * it and is not optional. `seed` gates only whether results are COLLECTED.
601
641
  *
602
- * @param {{ seed?: boolean }} [opts]
642
+ * @param {{ seed?: boolean, dev?: boolean }} [opts] `dev` gates the determinism
643
+ * assertion in `recordSeed` (a console warning has no place in production).
603
644
  * @returns {Promise<void>}
604
645
  */
605
646
  export async function registerActionHooks(opts = {}) {
606
647
  _seedEnabled = opts.seed !== false;
648
+ _devMode = opts.dev === true;
607
649
  if (_registered) return;
608
650
  _registered = true;
609
651
 
@@ -644,6 +686,15 @@ export async function collectSeeds(fn) {
644
686
  return { value, collector };
645
687
  }
646
688
 
689
+ /**
690
+ * The dev-only block emitted when the serializer THREW and the whole payload was
691
+ * dropped. Exported so the SSR emitter can tell it apart from a real block: it
692
+ * carries a marker so the browser can name the cause, but it ships NO seeds, so
693
+ * counting it as emitted would report a total success on the one failure the
694
+ * counts exist to expose.
695
+ */
696
+ export const SEED_DROP_BLOCK = '<script type="application/json" id="__webjs-seeds" data-webjs-dev="drop">{}</script>';
697
+
647
698
  /**
648
699
  * Serialize a collector into a `<script type="application/json">` block to embed
649
700
  * in the page. Returns '' for an empty collector (so a render with no seeded
@@ -651,14 +702,21 @@ export async function collectSeeds(fn) {
651
702
  * format the RPC stub's `parse` reads) and HTML-escaped so it can never break out
652
703
  * of the script element. A `type="application/json"` script is DATA, not
653
704
  * executable JS, so it needs no CSP nonce.
654
- * @param {Map<string, unknown>} collector
705
+ * @param {Map<string, unknown> | null} collector
706
+ * @param {{ dev?: boolean, reason?: string }} [opts] in DEV, stamp a
707
+ * `data-webjs-dev` marker and emit the block even when the collector is EMPTY.
708
+ * The marker is the only dev signal the browser gets (#1309): a
709
+ * `process.env.NODE_ENV` gate is a compile-time constant in the built core
710
+ * bundle, so the client cannot decide this for itself. Emitting an empty block
711
+ * in dev is what lets a page that seeded NOTHING still be reported on.
655
712
  * @returns {Promise<string>}
656
713
  */
657
- export async function buildSeedScript(collector) {
658
- if (!collector || collector.size === 0) return '';
714
+ export async function buildSeedScript(collector, opts = {}) {
715
+ const dev = opts.dev === true;
716
+ if ((!collector || collector.size === 0) && !dev) return '';
659
717
  try {
660
718
  const obj = {};
661
- for (const [k, v] of collector) obj[k] = v;
719
+ if (collector) for (const [k, v] of collector) obj[k] = v;
662
720
  const payload = await stringify(obj);
663
721
  const safe = payload
664
722
  .replace(/</g, '\\u003c')
@@ -666,8 +724,18 @@ export async function buildSeedScript(collector) {
666
724
  .replace(/&/g, '\\u0026')
667
725
  .replace(/\u2028/g, '\\u2028')
668
726
  .replace(/\u2029/g, '\\u2029');
669
- return `<script type="application/json" id="__webjs-seeds">${safe}</script>`;
727
+ // Attribute-safe by construction: `reason` is a framework literal ('ok' /
728
+ // 'streamed' / 'drop'), never app input, and is emitted only in dev.
729
+ const marker = dev ? ` data-webjs-dev="${opts.reason || 'ok'}"` : '';
730
+ return `<script type="application/json" id="__webjs-seeds"${marker}>${safe}</script>`;
670
731
  } catch {
732
+ // The serializer threw, so the whole block is dropped. This is the ONE
733
+ // failure the counting exists to expose (`collected` above `emitted`), and
734
+ // in prod it stays silent as before. In DEV emit a marker-only block so the
735
+ // browser can name it: with no marker at all the client opens no report
736
+ // window, so every missed hydration call on the page prints nothing, which
737
+ // is precisely the invisible failure this feature removes.
738
+ if (dev) return SEED_DROP_BLOCK;
671
739
  return '';
672
740
  }
673
741
  }
package/src/actions.js CHANGED
@@ -245,7 +245,7 @@ export async function isServerAction(file) {
245
245
 
246
246
  /**
247
247
  * @param {ActionIndex} idx
248
- * @param {string} urlPath - a browser-visible URL path like `/actions/foo.server.js`
248
+ * @param {string} urlPath A browser-visible URL path like `/actions/foo.server.js`
249
249
  */
250
250
  export function resolveServerModule(idx, urlPath) {
251
251
  const abs = join(idx.appDir, urlPath.split('/').join(sep));
package/src/check.js CHANGED
@@ -190,7 +190,7 @@ function isServerActionFile(filePath, content) {
190
190
  /**
191
191
  * Check whether a file resides under a components/ directory (shared or
192
192
  * module-scoped).
193
- * @param {string} relPath - path relative to appDir
193
+ * @param {string} relPath Path relative to appDir
194
194
  * @returns {boolean}
195
195
  */
196
196
  function isComponentFile(relPath) {
@@ -145,9 +145,13 @@ export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'reques
145
145
  * - `interactive`: `static interactive = true` is the explicit author
146
146
  * override that forces a component to ship even when the analyser would
147
147
  * elide it. It is the escape hatch for interactivity the analyser cannot
148
- * see statically (a dynamically-computed tag string, a `:defined` rule in
149
- * an external stylesheet outside the module graph). Context-free: any value
150
- * other than `false` ships.
148
+ * see statically (an OBSERVER that computes the tag it waits for, a
149
+ * `:defined` rule in an external stylesheet outside the module graph, a
150
+ * consumer reaching the element through a string selector). It
151
+ * cannot rescue a component whose OWN registration tag is computed: the
152
+ * scanner requires a literal tag (invariant 3), so such a component is
153
+ * never in `components` at all and nothing consults this analyser for it.
154
+ * Context-free: any value other than `false` ships.
151
155
  *
152
156
  * @type {readonly string[]}
153
157
  */
@@ -234,8 +238,13 @@ const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
234
238
 
235
239
  /** Side-effect or named import of the client router subpath. */
236
240
  const CLIENT_ROUTER_SUBPATH_RE = /['"]@webjsdev\/core\/client-router['"]/;
237
- /** Client-only named APIs from the `@webjsdev/core` main entry. */
238
- const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientRouter', 'revalidate'];
241
+ /**
242
+ * Client-only named APIs from the `@webjsdev/core` main entry. Exported so
243
+ * `test/elision/lifecycle-coverage.test.js` can guard it the way it guards
244
+ * REACTIVE_IMPORTS: a router function the barrel re-exports but this list omits
245
+ * is not recognized as client work, so a component using it is wrongly elided.
246
+ */
247
+ export const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientRouter', 'revalidate', 'loadFrame'];
239
248
 
240
249
  /** Identifiers that only exist in a browser; their presence means client work. */
241
250
  const CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|customElements|matchMedia|addEventListener)\b/;
@@ -904,12 +913,12 @@ export async function computeElidableComponents(components, moduleGraph, readFil
904
913
  * global (`window`, `document`, …), or a shipping component. Anything
905
914
  * ambiguous or unreadable keeps shipping.
906
915
  *
907
- * @param {Array<{ tag: string, file: string }>} components
916
+ * @param {Array<{ tag: string, file: string, className?: string }>} components
908
917
  * @param {string[]} routeModules absolute paths of page + layout files
909
918
  * @param {import('./module-graph.js').ModuleGraph} moduleGraph
910
919
  * @param {(file: string) => Promise<string>} readFileFn
911
920
  * @param {string} [appDir]
912
- * @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]>, shippedRouteModules: Map<string, { blocker: string|null, reason: string }> }>}
921
+ * @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]>, shippedRouteModules: Map<string, { blocker: string|null, reason: string }>, componentVerdicts: Map<string, { tags: string[], className: string|null, shipped: boolean, evidence: string|null, by: string|null, reason: string|null }> }>}
913
922
  */
914
923
  export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) {
915
924
  /** @type {Set<string>} */
@@ -926,6 +935,23 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
926
935
 
927
936
  /** @type {Set<string>} */
928
937
  const mustShip = new Set();
938
+ /**
939
+ * WHY each shipping component ships (#1308), so the app-level report can name
940
+ * the evidence instead of only the verdict. Written alongside every
941
+ * `mustShip.add` of a COMPONENT file; FIRST write wins, matching the
942
+ * first-match convention the analyser uses everywhere else.
943
+ * @type {Map<string, { evidence: string, by: string|null, reason: string|null }>}
944
+ */
945
+ const shipEvidence = new Map();
946
+ /**
947
+ * @param {string} file the component file that ships
948
+ * @param {string} evidence one of own | observed | closure | render | import | unreadable
949
+ * @param {string|null} by the module that forced it, where one exists
950
+ * @param {string|null} [reason]
951
+ */
952
+ const noteShip = (file, evidence, by, reason) => {
953
+ if (!shipEvidence.has(file)) shipEvidence.set(file, { evidence, by, reason: reason ?? null });
954
+ };
929
955
  /** @type {Map<string, Set<string>>} */
930
956
  const fileTags = new Map();
931
957
  /** @type {Set<string>} modules importing a reactive primitive from core */
@@ -939,6 +965,8 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
939
965
  /** @type {Set<string>} component files forced to ship because some module
940
966
  * observes their registration (whenDefined / :defined / instanceof). */
941
967
  const observedComponentFiles = new Set();
968
+ /** @type {Map<string, string>} observed component file -> the module observing it (#1308) */
969
+ const observedBy = new Map();
942
970
 
943
971
  /** @type {Set<string>} */
944
972
  const allFiles = new Set(componentFiles);
@@ -957,7 +985,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
957
985
  catch {
958
986
  // A component file we cannot read ships conservatively; a helper we
959
987
  // cannot read simply contributes no tags.
960
- if (componentFiles.has(file)) mustShip.add(file);
988
+ if (componentFiles.has(file)) { mustShip.add(file); noteShip(file, 'unreadable', null, null); }
961
989
  continue;
962
990
  }
963
991
  if (typeof src !== 'string') continue;
@@ -989,8 +1017,9 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
989
1017
  hasModuleScopeSideEffect(redacted, literals)) {
990
1018
  clientGlobalOrBareFiles.add(file);
991
1019
  }
992
- if (componentFiles.has(file) && analyzeComponentSource(masked).interactive) {
993
- mustShip.add(file);
1020
+ if (componentFiles.has(file)) {
1021
+ const v = analyzeComponentSource(masked);
1022
+ if (v.interactive) { mustShip.add(file); noteShip(file, 'own', null, v.reason); }
994
1023
  }
995
1024
  // Cross-module registration observation (#169): if THIS module observes
996
1025
  // another component's tag, that component must register client-side, so
@@ -999,13 +1028,13 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
999
1028
  // (all components are known up front, but we collect here while we hold
1000
1029
  // each source). Verdict-safe: only ever forces MORE components to ship.
1001
1030
  for (const m of masked.matchAll(WHEN_DEFINED_RE)) {
1002
- const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
1031
+ const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1003
1032
  }
1004
1033
  for (const m of masked.matchAll(TAG_DEFINED_RE)) {
1005
- const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
1034
+ const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1006
1035
  }
1007
1036
  for (const m of masked.matchAll(INSTANCEOF_RE)) {
1008
- const f = classToFile.get(m[1]); if (f) observedComponentFiles.add(f);
1037
+ const f = classToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1009
1038
  }
1010
1039
  }
1011
1040
 
@@ -1013,7 +1042,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1013
1042
  // render/import rules propagate from it too. Dynamic tag strings and external
1014
1043
  // (non graph-reachable) stylesheets remain an author-facing caveat, since
1015
1044
  // static analysis cannot see them.
1016
- for (const f of observedComponentFiles) mustShip.add(f);
1045
+ for (const f of observedComponentFiles) { mustShip.add(f); noteShip(f, 'observed', observedBy.get(f) ?? null, null); }
1017
1046
 
1018
1047
  // Reverse import edges (who imports each file), built once from the graph.
1019
1048
  // Drives both the closure-client-work reachability below and the fixpoint's
@@ -1065,7 +1094,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1065
1094
  if (!deps) continue;
1066
1095
  for (const dep of deps) {
1067
1096
  if (serverFiles.has(dep)) continue;
1068
- if (reachesClientWork.has(dep)) { mustShip.add(file); break; }
1097
+ if (reachesClientWork.has(dep)) { mustShip.add(file); noteShip(file, 'closure', dep, null); break; }
1069
1098
  }
1070
1099
  }
1071
1100
  }
@@ -1108,14 +1137,14 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1108
1137
  if (tags) {
1109
1138
  for (const tag of tags) {
1110
1139
  const childFile = tagToFile.get(tag);
1111
- if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); queue.push(childFile); }
1140
+ if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); noteShip(childFile, 'render', node, null); queue.push(childFile); }
1112
1141
  }
1113
1142
  }
1114
1143
  const importers = importersOf.get(node);
1115
1144
  if (importers) {
1116
1145
  for (const imp of importers) {
1117
1146
  if (!componentFiles.has(imp)) continue; // import rule is component -> component
1118
- if (!mustShip.has(imp)) { mustShip.add(imp); queue.push(imp); }
1147
+ if (!mustShip.has(imp)) { mustShip.add(imp); noteShip(imp, 'import', node, null); queue.push(imp); }
1119
1148
  }
1120
1149
  }
1121
1150
  }
@@ -1146,6 +1175,41 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1146
1175
  return 'does client work';
1147
1176
  };
1148
1177
 
1178
+ // Per-component verdict plus the evidence that produced it (#1308). Assembled
1179
+ // ENTIRELY from what the passes above already computed: nothing is re-read and
1180
+ // nothing is re-analysed, so this is a projection, not a second analysis. A
1181
+ // file may register more than one tag, so rows are keyed by FILE with a
1182
+ // sorted tag list.
1183
+ /** @type {Map<string, { tags: string[], className: string|null, shipped: boolean, evidence: string|null, by: string|null, reason: string|null }>} */
1184
+ const componentVerdicts = new Map();
1185
+ for (const c of components) {
1186
+ let row = componentVerdicts.get(c.file);
1187
+ if (!row) {
1188
+ const shipped = mustShip.has(c.file);
1189
+ const ev = shipped ? shipEvidence.get(c.file) : undefined;
1190
+ row = {
1191
+ tags: [], className: c.className ?? null, shipped,
1192
+ evidence: ev ? ev.evidence : null,
1193
+ by: ev ? ev.by : null,
1194
+ // An ELIDED component carries no reason on purpose: elision is the
1195
+ // ABSENCE of every signal, so there is no positive fact to report.
1196
+ // A shipping component with no evidence is only reachable if a future
1197
+ // rule adds to `mustShip` without calling `noteShip`, so report null
1198
+ // rather than a wrong claim (sigil-coverage guards against that drift).
1199
+ reason: !shipped || !ev ? null
1200
+ : ev.evidence === 'own' ? ev.reason
1201
+ : ev.evidence === 'observed' ? `its registration is observed by ${ev.by}`
1202
+ : ev.evidence === 'closure' ? `its import ${ev.by} ${clientEffectReason(/** @type {string} */ (ev.by))}`
1203
+ : ev.evidence === 'render' ? `${ev.by} ships and can render its tag`
1204
+ : ev.evidence === 'import' ? `${ev.by} ships and imports it`
1205
+ : 'its source could not be read (ships conservatively)',
1206
+ };
1207
+ componentVerdicts.set(c.file, row);
1208
+ }
1209
+ if (!row.tags.includes(c.tag)) row.tags.push(c.tag);
1210
+ }
1211
+ for (const row of componentVerdicts.values()) row.tags.sort();
1212
+
1149
1213
  // Route modules fall into three classes by their effective client closure
1150
1214
  // (skipping elided components and server stubs, which never load on the
1151
1215
  // client):
@@ -1242,7 +1306,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1242
1306
  }
1243
1307
  }
1244
1308
 
1245
- return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules };
1309
+ return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules, componentVerdicts };
1246
1310
  }
1247
1311
 
1248
1312
  /** Match a whole-line side-effect import: `import './x.js';` (no bindings). */
@@ -7,15 +7,13 @@
7
7
  * renders a component tag, `lookupModuleUrl(tag)` already has the URL
8
8
  * ready for `<link rel="modulepreload">` hints.
9
9
  *
10
- * The convention WebJs uses is the web-standard one:
11
- *
12
- * class Counter extends WebComponent { … }
13
- * customElements.define('my-counter', Counter);
14
- *
15
- * The scanner looks for `customElements.define('<tag>', <ClassName>)`
16
- * calls: static text patterns that are cheap to regex-match without
17
- * a full TS parse. A full parse would be ~50× slower for no payoff;
18
- * we only need `{ tag, className, moduleUrl }` tuples.
10
+ * The idiomatic WebJs registration is `Class.register('my-counter')`; the
11
+ * web-standard `customElements.define('my-counter', Counter)` is equally
12
+ * supported. The scanner matches BOTH, as static text patterns that are cheap
13
+ * to regex-match without a full TS parse. A full parse would be ~50× slower
14
+ * for no payoff; we only need `{ tag, className, moduleUrl }` tuples. Either
15
+ * way the tag must be a LITERAL (invariant 3): a computed one is invisible
16
+ * here, which is what makes it an orphan (see `findOrphanComponents`).
19
17
  */
20
18
 
21
19
  import { readFile, stat } from 'node:fs/promises';
@@ -148,10 +146,36 @@ export async function primeComponentRegistry(appDir, components) {
148
146
  }
149
147
 
150
148
  /**
151
- * Find `class X extends WebComponent` (or its subclasses) declarations
152
- * that are NOT accompanied by a `customElements.define(tag, X)` call in
153
- * the same file. Lets the dev server warn authors early when they
154
- * forget the registration step.
149
+ * Find ORPHAN components: a `class X extends WebComponent` that NOTHING in the
150
+ * app registers with a LITERAL tag, via either `X.register('tag')` or
151
+ * `customElements.define('tag', X)`. The declaration is per-file, the
152
+ * registration cross-reference is app-wide.
153
+ *
154
+ * TWO shapes land here and they fail differently, so any message about this
155
+ * must cover both:
156
+ *
157
+ * - No registration call ANYWHERE in the app (the forgot-to-register case).
158
+ * Nothing ever registers the tag, so the element NEVER upgrades. The
159
+ * cross-reference is app-wide precisely so a class registered by a sibling
160
+ * module is not accused of this.
161
+ * - A registration whose tag is COMPUTED (`X.register(TAG)`). That call is
162
+ * ordinary code, so it runs IF the module reaches the browser, which
163
+ * requires the importing module to ship WHOLE. An inert, import-only, or
164
+ * elided importer is dropped from the boot and takes the import with it,
165
+ * and then the element does not upgrade either. Assume it does not. An
166
+ * orphan is not in `componentFiles`, so it never joins the frontier an
167
+ * import-only page emits in its place, and a page that renders a real
168
+ * component alongside the orphan is import-only unless it ALSO does its
169
+ * own client work (which ships it whole, #963). Shipping whole is the
170
+ * narrower case, so treat the upgrade as lost until proven otherwise.
171
+ *
172
+ * Lost in EVERY case, whichever shape: the elision verdict, the tag-to-module
173
+ * registry entry, and the modulepreload hint. That, not the upgrade, is the
174
+ * part that is always true, and it is what every message about this should
175
+ * lead with.
176
+ *
177
+ * Matches the literal `extends WebComponent` only, so a class extending a
178
+ * component SUBCLASS is not reported.
155
179
  *
156
180
  * @param {string} appDir
157
181
  * @returns {Promise<Array<{ className: string, file: string }>>}
@@ -164,30 +188,62 @@ export async function findOrphanComponents(appDir) {
164
188
  !/\.(test|spec)\.m?[jt]sx?$/.test(p) &&
165
189
  !/\.server\.m?[jt]s$/.test(p);
166
190
 
191
+ // TWO passes, because registration is an APP-WIDE fact while the declaration
192
+ // is per-file. A class may legitimately be declared in one module and
193
+ // registered by a sibling (`customElements.define('my-badge', Badge)` in a
194
+ // separate file), which the scanner header calls equally supported and which
195
+ // `extractComponents` already picks up as a real component. Reporting it as
196
+ // an orphan is a FALSE positive, and a false warning on a legitimate pattern
197
+ // is exactly what makes an author stop reading the warnings.
198
+ //
199
+ // Trade-off, deliberate: the cross-reference is by class NAME, so two
200
+ // same-named classes in different files, one registered and one genuinely
201
+ // orphaned, hide the real orphan. That is rarer than the sibling-registration
202
+ // pattern and errs toward silence rather than toward a wrong accusation.
203
+ /** @type {Array<{ file: string, declared: Set<string> }>} */
204
+ const declaredPerFile = [];
205
+ /** @type {Set<string>} every class name registered ANYWHERE in the app */
206
+ const registeredAnywhere = new Set();
207
+
167
208
  for await (const file of walk(appDir, filter)) {
168
209
  let src;
169
210
  try { src = await readFile(file, 'utf8'); } catch { continue; }
211
+ // Scan REDACTED source, the same way `extractComponents` above does. A
212
+ // `class X extends WebComponent` written inside an `html` template or a
213
+ // string is a CODE SAMPLE (every docs page is full of them), not a real
214
+ // declaration, and reporting it as an unregistered component is a false
215
+ // orphan. Redaction blanks comments and swaps each string / template body
216
+ // for a `__STR_<idx>__` placeholder, so a genuine top-level declaration
217
+ // still matches while a sample inside a template does not. It does NOT
218
+ // preserve offsets (a placeholder is a different length than the body it
219
+ // replaces), which is fine here because an orphan is reported by class
220
+ // name and file, never by position. If this scan ever needs a line or
221
+ // column, reach for `redactStringsAndTemplates(src, true)`, WITH the
222
+ // blank-strings argument: the default form keeps plain-string bodies and
223
+ // single-line untagged templates verbatim, so a sample written either way
224
+ // would match again and the false orphan would be back.
225
+ const { redacted } = redactToPlaceholders(src);
170
226
  // Find every class that extends WebComponent (exact name: we trust
171
227
  // the framework convention).
172
228
  const classRe = /\b(?:export\s+)?(?:default\s+)?class\s+([A-Z][A-Za-z0-9_$]*)\s+extends\s+WebComponent\b/g;
173
- // A class counts as "registered" if either Class.register('tag') or
174
- // customElements.define('tag', Class) appears in the file.
175
- const registerRe = /\b([A-Z][A-Za-z0-9_$]*)\.register\s*\(\s*['"][^'"]+['"]\s*\)/g;
176
- const defineRe = /\bcustomElements\.define\s*\(\s*['"][^'"]+['"]\s*,\s*([A-Z][A-Za-z0-9_$]*)\b/g;
177
-
178
229
  const declared = new Set();
179
230
  let m;
180
- while ((m = classRe.exec(src)) !== null) declared.add(m[1]);
181
- if (declared.size === 0) continue;
231
+ while ((m = classRe.exec(redacted)) !== null) declared.add(m[1]);
182
232
 
183
- const registered = new Set();
184
- while ((m = registerRe.exec(src)) !== null) registered.add(m[1]);
185
- while ((m = defineRe.exec(src)) !== null) registered.add(m[1]);
233
+ // "Registered" is whatever `extractComponents` accepts, reused rather than
234
+ // re-expressed. The two scans MUST agree on what a literal tag is: when
235
+ // this used its own looser pattern, a redacted interpolated template
236
+ // (`Class.register(\`${p}__STR_1__\`)`) satisfied the orphan scan but not
237
+ // `extractComponents`, so a computed template-literal tag was neither a
238
+ // component NOR an orphan and vanished from every surface, which is the
239
+ // exact shape the orphan report exists to catch.
240
+ for (const c of extractComponents(src)) registeredAnywhere.add(c.className);
241
+ if (declared.size) declaredPerFile.push({ file, declared });
242
+ }
186
243
 
244
+ for (const { file, declared } of declaredPerFile) {
187
245
  for (const cls of declared) {
188
- if (!registered.has(cls)) {
189
- orphans.push({ className: cls, file });
190
- }
246
+ if (!registeredAnywhere.has(cls)) orphans.push({ className: cls, file });
191
247
  }
192
248
  }
193
249
  return orphans;