@webjsdev/server 0.8.59 → 0.8.60
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/component-elision.js +75 -16
- 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.60",
|
|
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.48",
|
|
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/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
|
*/
|
|
@@ -904,12 +908,12 @@ export async function computeElidableComponents(components, moduleGraph, readFil
|
|
|
904
908
|
* global (`window`, `document`, …), or a shipping component. Anything
|
|
905
909
|
* ambiguous or unreadable keeps shipping.
|
|
906
910
|
*
|
|
907
|
-
* @param {Array<{ tag: string, file: string }>} components
|
|
911
|
+
* @param {Array<{ tag: string, file: string, className?: string }>} components
|
|
908
912
|
* @param {string[]} routeModules absolute paths of page + layout files
|
|
909
913
|
* @param {import('./module-graph.js').ModuleGraph} moduleGraph
|
|
910
914
|
* @param {(file: string) => Promise<string>} readFileFn
|
|
911
915
|
* @param {string} [appDir]
|
|
912
|
-
* @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]>, shippedRouteModules: Map<string, { blocker: string|null, reason: string }> }>}
|
|
916
|
+
* @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
917
|
*/
|
|
914
918
|
export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) {
|
|
915
919
|
/** @type {Set<string>} */
|
|
@@ -926,6 +930,23 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
926
930
|
|
|
927
931
|
/** @type {Set<string>} */
|
|
928
932
|
const mustShip = new Set();
|
|
933
|
+
/**
|
|
934
|
+
* WHY each shipping component ships (#1308), so the app-level report can name
|
|
935
|
+
* the evidence instead of only the verdict. Written alongside every
|
|
936
|
+
* `mustShip.add` of a COMPONENT file; FIRST write wins, matching the
|
|
937
|
+
* first-match convention the analyser uses everywhere else.
|
|
938
|
+
* @type {Map<string, { evidence: string, by: string|null, reason: string|null }>}
|
|
939
|
+
*/
|
|
940
|
+
const shipEvidence = new Map();
|
|
941
|
+
/**
|
|
942
|
+
* @param {string} file the component file that ships
|
|
943
|
+
* @param {string} evidence one of own | observed | closure | render | import | unreadable
|
|
944
|
+
* @param {string|null} by the module that forced it, where one exists
|
|
945
|
+
* @param {string|null} [reason]
|
|
946
|
+
*/
|
|
947
|
+
const noteShip = (file, evidence, by, reason) => {
|
|
948
|
+
if (!shipEvidence.has(file)) shipEvidence.set(file, { evidence, by, reason: reason ?? null });
|
|
949
|
+
};
|
|
929
950
|
/** @type {Map<string, Set<string>>} */
|
|
930
951
|
const fileTags = new Map();
|
|
931
952
|
/** @type {Set<string>} modules importing a reactive primitive from core */
|
|
@@ -939,6 +960,8 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
939
960
|
/** @type {Set<string>} component files forced to ship because some module
|
|
940
961
|
* observes their registration (whenDefined / :defined / instanceof). */
|
|
941
962
|
const observedComponentFiles = new Set();
|
|
963
|
+
/** @type {Map<string, string>} observed component file -> the module observing it (#1308) */
|
|
964
|
+
const observedBy = new Map();
|
|
942
965
|
|
|
943
966
|
/** @type {Set<string>} */
|
|
944
967
|
const allFiles = new Set(componentFiles);
|
|
@@ -957,7 +980,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
957
980
|
catch {
|
|
958
981
|
// A component file we cannot read ships conservatively; a helper we
|
|
959
982
|
// cannot read simply contributes no tags.
|
|
960
|
-
if (componentFiles.has(file)) mustShip.add(file);
|
|
983
|
+
if (componentFiles.has(file)) { mustShip.add(file); noteShip(file, 'unreadable', null, null); }
|
|
961
984
|
continue;
|
|
962
985
|
}
|
|
963
986
|
if (typeof src !== 'string') continue;
|
|
@@ -989,8 +1012,9 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
989
1012
|
hasModuleScopeSideEffect(redacted, literals)) {
|
|
990
1013
|
clientGlobalOrBareFiles.add(file);
|
|
991
1014
|
}
|
|
992
|
-
if (componentFiles.has(file)
|
|
993
|
-
|
|
1015
|
+
if (componentFiles.has(file)) {
|
|
1016
|
+
const v = analyzeComponentSource(masked);
|
|
1017
|
+
if (v.interactive) { mustShip.add(file); noteShip(file, 'own', null, v.reason); }
|
|
994
1018
|
}
|
|
995
1019
|
// Cross-module registration observation (#169): if THIS module observes
|
|
996
1020
|
// another component's tag, that component must register client-side, so
|
|
@@ -999,13 +1023,13 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
999
1023
|
// (all components are known up front, but we collect here while we hold
|
|
1000
1024
|
// each source). Verdict-safe: only ever forces MORE components to ship.
|
|
1001
1025
|
for (const m of masked.matchAll(WHEN_DEFINED_RE)) {
|
|
1002
|
-
const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
1026
|
+
const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
|
|
1003
1027
|
}
|
|
1004
1028
|
for (const m of masked.matchAll(TAG_DEFINED_RE)) {
|
|
1005
|
-
const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
1029
|
+
const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
|
|
1006
1030
|
}
|
|
1007
1031
|
for (const m of masked.matchAll(INSTANCEOF_RE)) {
|
|
1008
|
-
const f = classToFile.get(m[1]); if (f) observedComponentFiles.add(f);
|
|
1032
|
+
const f = classToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
|
|
1009
1033
|
}
|
|
1010
1034
|
}
|
|
1011
1035
|
|
|
@@ -1013,7 +1037,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
1013
1037
|
// render/import rules propagate from it too. Dynamic tag strings and external
|
|
1014
1038
|
// (non graph-reachable) stylesheets remain an author-facing caveat, since
|
|
1015
1039
|
// static analysis cannot see them.
|
|
1016
|
-
for (const f of observedComponentFiles) mustShip.add(f);
|
|
1040
|
+
for (const f of observedComponentFiles) { mustShip.add(f); noteShip(f, 'observed', observedBy.get(f) ?? null, null); }
|
|
1017
1041
|
|
|
1018
1042
|
// Reverse import edges (who imports each file), built once from the graph.
|
|
1019
1043
|
// Drives both the closure-client-work reachability below and the fixpoint's
|
|
@@ -1065,7 +1089,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
1065
1089
|
if (!deps) continue;
|
|
1066
1090
|
for (const dep of deps) {
|
|
1067
1091
|
if (serverFiles.has(dep)) continue;
|
|
1068
|
-
if (reachesClientWork.has(dep)) { mustShip.add(file); break; }
|
|
1092
|
+
if (reachesClientWork.has(dep)) { mustShip.add(file); noteShip(file, 'closure', dep, null); break; }
|
|
1069
1093
|
}
|
|
1070
1094
|
}
|
|
1071
1095
|
}
|
|
@@ -1108,14 +1132,14 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
1108
1132
|
if (tags) {
|
|
1109
1133
|
for (const tag of tags) {
|
|
1110
1134
|
const childFile = tagToFile.get(tag);
|
|
1111
|
-
if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); queue.push(childFile); }
|
|
1135
|
+
if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); noteShip(childFile, 'render', node, null); queue.push(childFile); }
|
|
1112
1136
|
}
|
|
1113
1137
|
}
|
|
1114
1138
|
const importers = importersOf.get(node);
|
|
1115
1139
|
if (importers) {
|
|
1116
1140
|
for (const imp of importers) {
|
|
1117
1141
|
if (!componentFiles.has(imp)) continue; // import rule is component -> component
|
|
1118
|
-
if (!mustShip.has(imp)) { mustShip.add(imp); queue.push(imp); }
|
|
1142
|
+
if (!mustShip.has(imp)) { mustShip.add(imp); noteShip(imp, 'import', node, null); queue.push(imp); }
|
|
1119
1143
|
}
|
|
1120
1144
|
}
|
|
1121
1145
|
}
|
|
@@ -1146,6 +1170,41 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
1146
1170
|
return 'does client work';
|
|
1147
1171
|
};
|
|
1148
1172
|
|
|
1173
|
+
// Per-component verdict plus the evidence that produced it (#1308). Assembled
|
|
1174
|
+
// ENTIRELY from what the passes above already computed: nothing is re-read and
|
|
1175
|
+
// nothing is re-analysed, so this is a projection, not a second analysis. A
|
|
1176
|
+
// file may register more than one tag, so rows are keyed by FILE with a
|
|
1177
|
+
// sorted tag list.
|
|
1178
|
+
/** @type {Map<string, { tags: string[], className: string|null, shipped: boolean, evidence: string|null, by: string|null, reason: string|null }>} */
|
|
1179
|
+
const componentVerdicts = new Map();
|
|
1180
|
+
for (const c of components) {
|
|
1181
|
+
let row = componentVerdicts.get(c.file);
|
|
1182
|
+
if (!row) {
|
|
1183
|
+
const shipped = mustShip.has(c.file);
|
|
1184
|
+
const ev = shipped ? shipEvidence.get(c.file) : undefined;
|
|
1185
|
+
row = {
|
|
1186
|
+
tags: [], className: c.className ?? null, shipped,
|
|
1187
|
+
evidence: ev ? ev.evidence : null,
|
|
1188
|
+
by: ev ? ev.by : null,
|
|
1189
|
+
// An ELIDED component carries no reason on purpose: elision is the
|
|
1190
|
+
// ABSENCE of every signal, so there is no positive fact to report.
|
|
1191
|
+
// A shipping component with no evidence is only reachable if a future
|
|
1192
|
+
// rule adds to `mustShip` without calling `noteShip`, so report null
|
|
1193
|
+
// rather than a wrong claim (sigil-coverage guards against that drift).
|
|
1194
|
+
reason: !shipped || !ev ? null
|
|
1195
|
+
: ev.evidence === 'own' ? ev.reason
|
|
1196
|
+
: ev.evidence === 'observed' ? `its registration is observed by ${ev.by}`
|
|
1197
|
+
: ev.evidence === 'closure' ? `its import ${ev.by} ${clientEffectReason(/** @type {string} */ (ev.by))}`
|
|
1198
|
+
: ev.evidence === 'render' ? `${ev.by} ships and can render its tag`
|
|
1199
|
+
: ev.evidence === 'import' ? `${ev.by} ships and imports it`
|
|
1200
|
+
: 'its source could not be read (ships conservatively)',
|
|
1201
|
+
};
|
|
1202
|
+
componentVerdicts.set(c.file, row);
|
|
1203
|
+
}
|
|
1204
|
+
if (!row.tags.includes(c.tag)) row.tags.push(c.tag);
|
|
1205
|
+
}
|
|
1206
|
+
for (const row of componentVerdicts.values()) row.tags.sort();
|
|
1207
|
+
|
|
1149
1208
|
// Route modules fall into three classes by their effective client closure
|
|
1150
1209
|
// (skipping elided components and server stubs, which never load on the
|
|
1151
1210
|
// client):
|
|
@@ -1242,7 +1301,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
1242
1301
|
}
|
|
1243
1302
|
}
|
|
1244
1303
|
|
|
1245
|
-
return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules };
|
|
1304
|
+
return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules, componentVerdicts };
|
|
1246
1305
|
}
|
|
1247
1306
|
|
|
1248
1307
|
/** 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;
|