@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 +99 -10
- package/index.js +1 -0
- package/package.json +2 -2
- package/src/action-seed.js +75 -7
- package/src/actions.js +1 -1
- package/src/check.js +1 -1
- package/src/component-elision.js +82 -18
- package/src/component-scanner.js +82 -26
- package/src/dev.js +62 -9
- package/src/elision-differential.js +101 -0
- package/src/elision-report.js +133 -30
- package/src/form-dispatch.js +169 -3
- package/src/js-scan.js +41 -13
- package/src/ssr.js +50 -14
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
|
-
/**
|
|
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
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
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
|
-
|
|
499
|
-
|
|
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.
|
|
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.
|
|
31
|
+
"@webjsdev/core": "^0.7.49",
|
|
32
32
|
"ws": "^8.20.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/action-seed.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
193
|
+
* @param {string} relPath Path relative to appDir
|
|
194
194
|
* @returns {boolean}
|
|
195
195
|
*/
|
|
196
196
|
function isComponentFile(relPath) {
|
package/src/component-elision.js
CHANGED
|
@@ -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 (
|
|
149
|
-
* an external stylesheet outside the module graph
|
|
150
|
-
*
|
|
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
|
-
/**
|
|
238
|
-
|
|
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)
|
|
993
|
-
|
|
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). */
|
package/src/component-scanner.js
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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`
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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(
|
|
181
|
-
if (declared.size === 0) continue;
|
|
231
|
+
while ((m = classRe.exec(redacted)) !== null) declared.add(m[1]);
|
|
182
232
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 (!
|
|
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;
|