@webjsdev/server 0.8.32 → 0.8.34
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 +15 -0
- package/index.js +1 -2
- package/package.json +1 -1
- package/src/actions.js +19 -13
- package/src/check.js +69 -0
- package/src/component-elision.js +265 -64
- package/src/component-scanner.js +15 -10
- package/src/csrf.js +101 -61
- package/src/dev.js +81 -7
- package/src/elision-report.js +89 -0
- package/src/html-cache.js +5 -31
- package/src/js-scan.js +177 -6
- package/src/listener-bun.js +1 -1
- package/src/ssr.js +44 -22
- package/src/testing.d.ts +8 -26
- package/src/testing.js +18 -72
- package/src/vendor.js +4 -1
- package/webjs-config.schema.json +11 -0
package/src/component-elision.js
CHANGED
|
@@ -42,8 +42,9 @@ import {
|
|
|
42
42
|
matchClosingBrace,
|
|
43
43
|
redactStringsAndTemplates,
|
|
44
44
|
maskComments,
|
|
45
|
+
redactToPlaceholders,
|
|
45
46
|
} from './js-scan.js';
|
|
46
|
-
import { transitiveDeps } from './module-graph.js';
|
|
47
|
+
import { transitiveDeps, expandImportAlias } from './module-graph.js';
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* Named imports from a `@webjsdev/core` specifier that imply the
|
|
@@ -215,8 +216,30 @@ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|
|
|
|
215
216
|
*
|
|
216
217
|
* @param {string} src raw module source
|
|
217
218
|
*/
|
|
218
|
-
|
|
219
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Constructors that produce inert DATA with no side effect, so a module-scope
|
|
221
|
+
* `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
|
|
222
|
+
* parsed URL) is not client work and must not pin an importing page/layout
|
|
223
|
+
* (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`,
|
|
224
|
+
* `new EventSource()`, `new Audio()`) IS a side effect and still ships.
|
|
225
|
+
*/
|
|
226
|
+
const PURE_DATA_CONSTRUCTORS = new Set([
|
|
227
|
+
'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object',
|
|
228
|
+
'Number', 'String', 'Boolean', 'BigInt', 'Symbol',
|
|
229
|
+
'Error', 'TypeError', 'RangeError', 'SyntaxError',
|
|
230
|
+
'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView',
|
|
231
|
+
'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
|
|
232
|
+
'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
|
|
233
|
+
'BigInt64Array', 'BigUint64Array',
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
function hasModuleScopeSideEffect(src, literals) {
|
|
237
|
+
let redacted = src;
|
|
238
|
+
if (!literals) {
|
|
239
|
+
const r = redactToPlaceholders(src);
|
|
240
|
+
redacted = r.redacted;
|
|
241
|
+
literals = r.literals;
|
|
242
|
+
}
|
|
220
243
|
// Keep only depth-0 text (outside every `{}`). Skip quoted-string bodies so
|
|
221
244
|
// braces/parens inside a string neither desync the depth nor read as a call.
|
|
222
245
|
let depth = 0;
|
|
@@ -252,9 +275,16 @@ function hasModuleScopeSideEffect(src) {
|
|
|
252
275
|
// Optional-chaining call/index: `foo?.()`, `x?.[i]()` (the `?.` defeats the
|
|
253
276
|
// identifier-before-paren match below).
|
|
254
277
|
if (/\?\.\s*[([]/.test(frame)) return true;
|
|
255
|
-
// Top-level `new
|
|
256
|
-
//
|
|
257
|
-
|
|
278
|
+
// Top-level `new X`: a pure-data builtin constructor (Set / Map / Date /
|
|
279
|
+
// RegExp / typed array / URL / ...) is inert module data, not client work, so
|
|
280
|
+
// it stays elidable (#623); any other constructor is a side effect. Catches
|
|
281
|
+
// both `new X` and `new X(...)` (the constructor's own `(`, if present, is
|
|
282
|
+
// then skipped in the call scan below).
|
|
283
|
+
for (const nm of frame.matchAll(/(?<![.\w])new\s+([A-Za-z_$][\w$]*)/g)) {
|
|
284
|
+
if (!PURE_DATA_CONSTRUCTORS.has(nm[1])) return true;
|
|
285
|
+
}
|
|
286
|
+
// Top-level `await`.
|
|
287
|
+
if (/(?<![.\w])await\s/.test(frame)) return true;
|
|
258
288
|
// A call: `(` preceded (ignoring whitespace) by an identifier, `)`, or `]`.
|
|
259
289
|
const CALL_RE = /(?:([A-Za-z_$][\w$]*)|[)\]])\s*\(/g;
|
|
260
290
|
// Identifiers that precede a `(` WITHOUT it being a call (keywords + a
|
|
@@ -274,6 +304,19 @@ function hasModuleScopeSideEffect(src) {
|
|
|
274
304
|
if (ident && /\bfunction\s*\*?\s*$/.test(frame.slice(0, m.index))) continue;
|
|
275
305
|
// The component registration call is the one permitted top-level call.
|
|
276
306
|
if (ident === 'register' || ident === 'define') continue;
|
|
307
|
+
// `extends WebComponent({ … })` is the declare-free reactive-prop DX (#597
|
|
308
|
+
// / #599): a class-declaration construct, not an independent side-effecting
|
|
309
|
+
// statement. Exempt it so a display-only factory-form component stays
|
|
310
|
+
// elidable; a genuinely interactive one still ships via the per-class and
|
|
311
|
+
// module-level signal checks below (events, slots, shadow, lifecycle, a
|
|
312
|
+
// non-state factory prop, a reactive import). Scoped to the `extends`
|
|
313
|
+
// position, so a top-level `WebComponent(...)` call used anywhere else
|
|
314
|
+
// still counts as a side effect (#604).
|
|
315
|
+
if (ident === 'WebComponent' && /\bextends\s+$/.test(frame.slice(0, m.index))) continue;
|
|
316
|
+
// A `new X(...)` constructor call: the `new`-prefixed cases were already
|
|
317
|
+
// vetted above (a non-pure constructor returned true; a pure-data one is
|
|
318
|
+
// inert), so its `(` here is not an independent side-effecting call.
|
|
319
|
+
if (ident && /(?<![.\w])new\s+$/.test(frame.slice(0, m.index))) continue;
|
|
277
320
|
return true; // any other top-level call
|
|
278
321
|
}
|
|
279
322
|
return false;
|
|
@@ -290,26 +333,38 @@ const SIDE_EFFECT_BARE_IMPORT_RE = /^\s*import\s*(['"])([^'"]+)\1\s*;?\s*(?:\/\/
|
|
|
290
333
|
* a router/nav API from the core main entry). A page or layout that does so
|
|
291
334
|
* is enabling client-side navigation and must ship.
|
|
292
335
|
* @param {string} src
|
|
336
|
+
* @param {string[]} [literals]
|
|
293
337
|
* @returns {boolean}
|
|
294
338
|
*/
|
|
295
|
-
function importsClientRouter(src) {
|
|
296
|
-
|
|
297
|
-
|
|
339
|
+
function importsClientRouter(src, literals) {
|
|
340
|
+
let redacted = src;
|
|
341
|
+
if (!literals) {
|
|
342
|
+
const r = redactToPlaceholders(src);
|
|
343
|
+
redacted = r.redacted;
|
|
344
|
+
literals = r.literals;
|
|
345
|
+
}
|
|
346
|
+
if (literals.includes('@webjsdev/core/client-router')) return true;
|
|
347
|
+
for (const m of redacted.matchAll(CORE_IMPORT_RE)) {
|
|
298
348
|
const clause = m[1];
|
|
349
|
+
const spec = m[2];
|
|
350
|
+
const idx = m[3];
|
|
351
|
+
if (idx !== undefined) {
|
|
352
|
+
const resolved = literals[parseInt(idx, 10)];
|
|
353
|
+
if (!resolved || (!resolved.startsWith('@webjsdev/core') && !resolved.includes('/__webjs/core/'))) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
299
357
|
if (clause.startsWith('{')) {
|
|
300
358
|
const names = clause.slice(1, -1).split(',').map((s) => s.trim().split(/\s+as\s+/)[0].trim());
|
|
301
359
|
if (names.some((n) => CLIENT_ROUTER_IMPORTS.includes(n))) return true;
|
|
302
360
|
} else if (clause.startsWith('*')) {
|
|
303
|
-
// Namespace import: a router/nav member reached through `ns.member`,
|
|
304
|
-
// a destructure of `ns`, or computed access. Mirrors the reactive
|
|
305
|
-
// primitive detection so the two stay symmetric.
|
|
306
361
|
const ns = clause.replace(/^\*\s+as\s+/, '').trim();
|
|
307
362
|
if (!ns || !/^\w+$/.test(ns)) continue;
|
|
308
363
|
for (const name of CLIENT_ROUTER_IMPORTS) {
|
|
309
|
-
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(
|
|
364
|
+
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(redacted)) return true;
|
|
310
365
|
}
|
|
311
|
-
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(
|
|
312
|
-
if (new RegExp(`\\b${ns}\\s*\\[`).test(
|
|
366
|
+
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(redacted)) return true;
|
|
367
|
+
if (new RegExp(`\\b${ns}\\s*\\[`).test(redacted)) return true;
|
|
313
368
|
}
|
|
314
369
|
}
|
|
315
370
|
return false;
|
|
@@ -336,13 +391,46 @@ function importsClientRouter(src) {
|
|
|
336
391
|
* the fix is `.server.{js,ts}` for genuinely server-only deps, or an
|
|
337
392
|
* interactivity signal on the consumer. (It is not caught by an SSR crash:
|
|
338
393
|
* the SSR `customElements` shim makes `define` a no-op server-side.)
|
|
394
|
+
*
|
|
395
|
+
* A `#` PATH-ALIAS specifier (#555, e.g. `import '#components/x.ts'`, the
|
|
396
|
+
* idiomatic way a page/layout registers a component) is NOT a bare npm
|
|
397
|
+
* package: Node's `package.json` "imports" resolves it, in the scaffold's
|
|
398
|
+
* catch-all `"#*": "./*"` to a LOCAL file. Expanding it through the same
|
|
399
|
+
* `expandImportAlias` the module graph uses, a local-resolving alias is
|
|
400
|
+
* treated like a relative import (skipped); only an alias mapped to a real
|
|
401
|
+
* bare package (`"#crypto": "crypto-browserify"`) falls through to the
|
|
402
|
+
* package check. Without this, every `#`-imported component side effect
|
|
403
|
+
* wrongly pinned its importing page/layout to the browser (#623). An
|
|
404
|
+
* unexpandable `#` (no `appDir`, or no matching key) is assumed local,
|
|
405
|
+
* since `#` is the local-subpath sigil and an unmapped `#` is a Node error,
|
|
406
|
+
* not a package.
|
|
339
407
|
* @param {string} src
|
|
408
|
+
* @param {string} [appDir] app root, used to expand `#` path aliases
|
|
409
|
+
* @param {string[]} [literals]
|
|
340
410
|
* @returns {boolean}
|
|
341
411
|
*/
|
|
342
|
-
function importsSideEffectNonCorePackage(src) {
|
|
343
|
-
|
|
344
|
-
|
|
412
|
+
function importsSideEffectNonCorePackage(src, appDir, literals) {
|
|
413
|
+
let redacted = src;
|
|
414
|
+
if (!literals) {
|
|
415
|
+
const r = redactToPlaceholders(src);
|
|
416
|
+
redacted = r.redacted;
|
|
417
|
+
literals = r.literals;
|
|
418
|
+
}
|
|
419
|
+
for (const m of redacted.matchAll(SIDE_EFFECT_BARE_IMPORT_RE)) {
|
|
420
|
+
let spec = m[2];
|
|
421
|
+
const match = /^__STR_(\d+)__$/.exec(spec);
|
|
422
|
+
if (match) {
|
|
423
|
+
const idx = parseInt(match[1], 10);
|
|
424
|
+
spec = literals[idx];
|
|
425
|
+
}
|
|
426
|
+
if (!spec) continue;
|
|
345
427
|
if (spec.startsWith('.') || spec.startsWith('/')) continue; // relative / absolute
|
|
428
|
+
if (spec.startsWith('#')) {
|
|
429
|
+
const expanded = appDir ? expandImportAlias(spec, appDir) : null;
|
|
430
|
+
if (expanded == null) continue; // unmapped alias: local
|
|
431
|
+
if (expanded.startsWith('.') || expanded.startsWith('/')) continue; // resolves to a local file
|
|
432
|
+
spec = expanded; // mapped to a bare spec: check it below
|
|
433
|
+
}
|
|
346
434
|
if (spec === '@webjsdev/core' || spec.startsWith('@webjsdev/core/')) continue; // inert framework / router handled separately
|
|
347
435
|
if (spec.startsWith('node:')) continue; // server-only builtins
|
|
348
436
|
return true;
|
|
@@ -350,9 +438,9 @@ function importsSideEffectNonCorePackage(src) {
|
|
|
350
438
|
return false;
|
|
351
439
|
}
|
|
352
440
|
|
|
353
|
-
/** Match a named-import clause from a `@webjsdev/core` specifier. */
|
|
441
|
+
/** Match a named-import clause from a `@webjsdev/core` specifier (or its placeholder). */
|
|
354
442
|
const CORE_IMPORT_RE =
|
|
355
|
-
/import\s+(?:type\s+)?(\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"](@webjsdev\/core[^'"]*|[^'"]*\/__webjs\/core\/[^'"]
|
|
443
|
+
/import\s+(?:type\s+)?(\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"`](@webjsdev\/core[^'"`]*|[^'"`]*\/__webjs\/core\/[^'"`]*|__STR_(\d+)__)['"`]/g;
|
|
356
444
|
|
|
357
445
|
/**
|
|
358
446
|
* Decide whether a component module is interactive (must ship) or
|
|
@@ -483,11 +571,26 @@ function declaresStaticTrue(classBody, name) {
|
|
|
483
571
|
|
|
484
572
|
/**
|
|
485
573
|
* @param {string} src
|
|
574
|
+
* @param {string[]} [literals]
|
|
486
575
|
* @returns {string | null} the offending imported name, or null
|
|
487
576
|
*/
|
|
488
|
-
function importsReactivePrimitive(src) {
|
|
489
|
-
|
|
577
|
+
function importsReactivePrimitive(src, literals) {
|
|
578
|
+
let redacted = src;
|
|
579
|
+
if (!literals) {
|
|
580
|
+
const r = redactToPlaceholders(src);
|
|
581
|
+
redacted = r.redacted;
|
|
582
|
+
literals = r.literals;
|
|
583
|
+
}
|
|
584
|
+
for (const m of redacted.matchAll(CORE_IMPORT_RE)) {
|
|
490
585
|
const clause = m[1];
|
|
586
|
+
const spec = m[2];
|
|
587
|
+
const idx = m[3];
|
|
588
|
+
if (idx !== undefined) {
|
|
589
|
+
const resolved = literals[parseInt(idx, 10)];
|
|
590
|
+
if (!resolved || (!resolved.startsWith('@webjsdev/core') && !resolved.includes('/__webjs/core/'))) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
491
594
|
if (clause.startsWith('{')) {
|
|
492
595
|
const names = clause
|
|
493
596
|
.slice(1, -1)
|
|
@@ -505,14 +608,14 @@ function importsReactivePrimitive(src) {
|
|
|
505
608
|
const ns = clause.replace(/^\*\s+as\s+/, '').trim();
|
|
506
609
|
if (!ns || !/^\w+$/.test(ns)) continue;
|
|
507
610
|
for (const name of REACTIVE_IMPORTS) {
|
|
508
|
-
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(
|
|
611
|
+
if (new RegExp(`\\b${ns}\\.${name}\\b`).test(redacted)) return name;
|
|
509
612
|
}
|
|
510
613
|
// Destructuring the namespace (`const { signal } = core`) or computed
|
|
511
614
|
// access (`core['signal']`) hides which members are pulled. Ship.
|
|
512
|
-
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(
|
|
615
|
+
if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(redacted)) {
|
|
513
616
|
return `${ns} (destructured namespace)`;
|
|
514
617
|
}
|
|
515
|
-
if (new RegExp(`\\b${ns}\\s*\\[`).test(
|
|
618
|
+
if (new RegExp(`\\b${ns}\\s*\\[`).test(redacted)) {
|
|
516
619
|
return `${ns} (computed namespace access)`;
|
|
517
620
|
}
|
|
518
621
|
}
|
|
@@ -551,18 +654,11 @@ function hasNonStateReactiveProperty(classBody) {
|
|
|
551
654
|
// see; ship rather than guess they are all { state: true }.
|
|
552
655
|
if (/\.\.\./.test(obj)) return true;
|
|
553
656
|
for (const entry of topLevelPropertyValues(obj)) {
|
|
554
|
-
// Object-literal descriptor: inert only when it carries state: true
|
|
657
|
+
// Object-literal descriptor: inert only when it carries `state: true` at
|
|
658
|
+
// its top level (descriptorDeclaresState blanks strings and ignores a
|
|
659
|
+
// nested state flag, e.g. inside a converter, so it cannot be forged).
|
|
555
660
|
if (entry.startsWith('{')) {
|
|
556
|
-
|
|
557
|
-
// string contents verbatim (so register('tag') stays readable), so
|
|
558
|
-
// a descriptor like `{ attribute: 'data-state: true' }` would
|
|
559
|
-
// otherwise forge the state flag. The real `state: true` is code,
|
|
560
|
-
// not a string, so it survives this blanking.
|
|
561
|
-
const code = entry
|
|
562
|
-
.replace(/'[^'\n]*'/g, "''")
|
|
563
|
-
.replace(/"[^"\n]*"/g, '""')
|
|
564
|
-
.replace(/`[^`]*`/g, '``');
|
|
565
|
-
if (!/\bstate\s*:\s*true\b/.test(code)) return true;
|
|
661
|
+
if (!descriptorDeclaresState(entry)) return true;
|
|
566
662
|
} else {
|
|
567
663
|
// Shorthand like `count: Number` rides an attribute, not state.
|
|
568
664
|
return true;
|
|
@@ -572,8 +668,20 @@ function hasNonStateReactiveProperty(classBody) {
|
|
|
572
668
|
}
|
|
573
669
|
|
|
574
670
|
/**
|
|
575
|
-
* True if the class factory argument
|
|
576
|
-
*
|
|
671
|
+
* True if the class factory argument (`extends WebComponent({ … })`) declares
|
|
672
|
+
* any reactive property that is NOT `{ state: true }`. A non-state property
|
|
673
|
+
* rides an HTML attribute or a `.prop` hydration binding, the channel that
|
|
674
|
+
* forces the component to ship; a `state: true` property is component-local
|
|
675
|
+
* reactive state with no such channel, so a component whose only signal is
|
|
676
|
+
* state props stays elidable.
|
|
677
|
+
*
|
|
678
|
+
* A property value is treated as state ONLY when its descriptor declares
|
|
679
|
+
* `state: true` at the TOP LEVEL (see `descriptorDeclaresState`), which covers
|
|
680
|
+
* the bare descriptor `{ state: true }` and the `prop()` helper forms
|
|
681
|
+
* `prop({ state: true })` / `prop(Type, { state: true })`. Anything else (a bare
|
|
682
|
+
* type `Number`, `prop(Number)`, an options object without `state: true`) is
|
|
683
|
+
* non-state and ships. Conservative on a spread or an unbalanced brace (cannot
|
|
684
|
+
* prove every entry is state, so ship).
|
|
577
685
|
*
|
|
578
686
|
* @param {string} factoryArg
|
|
579
687
|
* @returns {boolean}
|
|
@@ -582,25 +690,50 @@ function hasNonStateFactoryProperty(factoryArg) {
|
|
|
582
690
|
if (!factoryArg) return false;
|
|
583
691
|
const objStart = factoryArg.indexOf('{');
|
|
584
692
|
if (objStart === -1) return false;
|
|
585
|
-
|
|
693
|
+
// matchClosingBrace starts at depth 1, so pass the index AFTER the brace.
|
|
694
|
+
const objEnd = matchClosingBrace(factoryArg, objStart + 1);
|
|
586
695
|
if (objEnd === -1) return true;
|
|
587
696
|
const obj = factoryArg.slice(objStart + 1, objEnd);
|
|
588
|
-
if (/\.\.\./.test(obj)) return true; // spread
|
|
697
|
+
if (/\.\.\./.test(obj)) return true; // spread: cannot prove all entries state
|
|
589
698
|
for (const entry of topLevelPropertyValues(obj)) {
|
|
590
|
-
if (entry
|
|
591
|
-
const code = entry
|
|
592
|
-
.replace(/'[^'\n]*'/g, "''")
|
|
593
|
-
.replace(/"[^"\n]*"/g, '""')
|
|
594
|
-
.replace(/`[^`]*`/g, '``');
|
|
595
|
-
if (!/\bstate\s*:\s*true\b/.test(code)) return true;
|
|
596
|
-
} else {
|
|
597
|
-
// Shorthand like `count: Number` rides an attribute, not state
|
|
598
|
-
return true;
|
|
599
|
-
}
|
|
699
|
+
if (!descriptorDeclaresState(entry)) return true;
|
|
600
700
|
}
|
|
601
701
|
return false;
|
|
602
702
|
}
|
|
603
703
|
|
|
704
|
+
/**
|
|
705
|
+
* True if a reactive-property descriptor declares `state: true` at the TOP
|
|
706
|
+
* LEVEL of its options object (brace-depth 1). Restricting to depth 1 is a
|
|
707
|
+
* direction-of-safety fix: a `state: true` buried deeper (a converter /
|
|
708
|
+
* hasChanged body that happens to return `{ state: true }`) must NOT forge the
|
|
709
|
+
* flag, because wrongly treating an attribute-riding property as state would
|
|
710
|
+
* ELIDE an interactive component and break the page. Strings / templates are
|
|
711
|
+
* blanked first, so `attribute: 'data-state: true'` does not match, and the
|
|
712
|
+
* `\b` word boundary keeps a key like `firstate: true` from matching. In every
|
|
713
|
+
* legitimate shape (`{ state: true }`, `prop({ state: true })`,
|
|
714
|
+
* `prop(Type, { state: true })`) the descriptor object is the only brace group,
|
|
715
|
+
* so its `state` key sits at depth 1.
|
|
716
|
+
*
|
|
717
|
+
* @param {string} entry the property VALUE text
|
|
718
|
+
* @returns {boolean}
|
|
719
|
+
*/
|
|
720
|
+
function descriptorDeclaresState(entry) {
|
|
721
|
+
const code = entry
|
|
722
|
+
.replace(/'[^'\n]*'/g, "''")
|
|
723
|
+
.replace(/"[^"\n]*"/g, '""')
|
|
724
|
+
.replace(/`[^`]*`/g, '``');
|
|
725
|
+
// Keep only text at brace-depth <= 1 (the descriptor's own level); blank
|
|
726
|
+
// deeper nesting so a nested `state: true` cannot count.
|
|
727
|
+
let depth = 0;
|
|
728
|
+
let shallow = '';
|
|
729
|
+
for (const c of code) {
|
|
730
|
+
if (c === '{') { depth++; if (depth <= 1) shallow += c; continue; }
|
|
731
|
+
if (c === '}') { if (depth <= 1) shallow += c; if (depth > 0) depth--; continue; }
|
|
732
|
+
if (depth <= 1) shallow += c;
|
|
733
|
+
}
|
|
734
|
+
return /\bstate\s*:\s*true\b/.test(shallow);
|
|
735
|
+
}
|
|
736
|
+
|
|
604
737
|
/**
|
|
605
738
|
* Yield the trimmed VALUE text of each top-level `key: value` entry in a
|
|
606
739
|
* properties object body, splitting on depth-0 commas and respecting
|
|
@@ -710,7 +843,7 @@ export async function computeElidableComponents(components, moduleGraph, readFil
|
|
|
710
843
|
* @param {import('./module-graph.js').ModuleGraph} moduleGraph
|
|
711
844
|
* @param {(file: string) => Promise<string>} readFileFn
|
|
712
845
|
* @param {string} [appDir]
|
|
713
|
-
* @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string> }>}
|
|
846
|
+
* @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]>, shippedRouteModules: Map<string, { blocker: string|null, reason: string }> }>}
|
|
714
847
|
*/
|
|
715
848
|
export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) {
|
|
716
849
|
/** @type {Set<string>} */
|
|
@@ -743,6 +876,8 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
743
876
|
|
|
744
877
|
/** @type {Set<string>} */
|
|
745
878
|
const allFiles = new Set(componentFiles);
|
|
879
|
+
/** @type {Set<string>} page/layout modules: never hydrate, so template content is SSR output. */
|
|
880
|
+
const routeModuleSet = new Set(routeModules);
|
|
746
881
|
for (const f of routeModules) allFiles.add(f);
|
|
747
882
|
for (const [k, vs] of moduleGraph) {
|
|
748
883
|
if (!appDir || k.startsWith(appDir)) allFiles.add(k);
|
|
@@ -770,11 +905,22 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
770
905
|
// them on the comment-masked source just additionally drops comment prose.)
|
|
771
906
|
const masked = maskComments(src);
|
|
772
907
|
fileTags.set(file, extractRenderedTags(masked));
|
|
773
|
-
|
|
774
|
-
if (
|
|
775
|
-
if (
|
|
776
|
-
|
|
777
|
-
|
|
908
|
+
const { redacted, literals } = redactToPlaceholders(src);
|
|
909
|
+
if (importsReactivePrimitive(redacted, literals)) reactiveFiles.add(file);
|
|
910
|
+
if (importsClientRouter(redacted, literals)) clientRouterFiles.add(file);
|
|
911
|
+
// A page/layout NEVER hydrates (#605), so its `html` TEMPLATE content is
|
|
912
|
+
// SSR output, not module client work: an inline `<script>`'s browser
|
|
913
|
+
// globals run from the rendered HTML, and a page-template `@event` is
|
|
914
|
+
// dropped at SSR. For a route module, scan those two template-borne signals
|
|
915
|
+
// on the template-redacted source so they do not pin the module, while a
|
|
916
|
+
// genuine module-scope `document.x` OUTSIDE any template still flags (#623).
|
|
917
|
+
// The import-based checks stay on `redacted`: a real `import 'pkg'` side
|
|
918
|
+
// effect DOES run when a page/layout module loads in the browser, and
|
|
919
|
+
// `importsSideEffectNonCorePackage` itself skips local `#`-alias imports.
|
|
920
|
+
const templateScan = routeModuleSet.has(file) ? redacted : masked;
|
|
921
|
+
if (EVENT_BINDING_RE.test(templateScan) || EVENT_PROP_RE.test(templateScan) ||
|
|
922
|
+
importsSideEffectNonCorePackage(redacted, appDir, literals) || CLIENT_GLOBAL_RE.test(templateScan) ||
|
|
923
|
+
hasModuleScopeSideEffect(redacted, literals)) {
|
|
778
924
|
clientGlobalOrBareFiles.add(file);
|
|
779
925
|
}
|
|
780
926
|
if (componentFiles.has(file) && analyzeComponentSource(masked).interactive) {
|
|
@@ -923,22 +1069,77 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
923
1069
|
clientRouterFiles.has(file) ||
|
|
924
1070
|
clientGlobalOrBareFiles.has(file);
|
|
925
1071
|
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
1072
|
+
// A human reason for WHY a file does client work, for the advisory that
|
|
1073
|
+
// names why a page/layout ships (#646). Mirrors the isClientEffecting
|
|
1074
|
+
// branches, most specific first.
|
|
1075
|
+
const clientEffectReason = (file) => {
|
|
1076
|
+
if (componentFiles.has(file) && mustShip.has(file)) return 'is an interactive component';
|
|
1077
|
+
if (clientRouterFiles.has(file)) return 'imports the client router';
|
|
1078
|
+
if (reactiveFiles.has(file)) return 'imports a reactive primitive (signal / computed / watch)';
|
|
1079
|
+
if (clientGlobalOrBareFiles.has(file)) return 'references a browser global at module scope, runs code at module scope, or has a bare side-effect import';
|
|
1080
|
+
return 'does client work';
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
// Route modules fall into three classes by their effective client closure
|
|
1084
|
+
// (skipping elided components and server stubs, which never load on the
|
|
1085
|
+
// client):
|
|
1086
|
+
// - INERT: neither the module nor its closure does any client work. Dropped
|
|
1087
|
+
// from the boot script entirely (#179).
|
|
1088
|
+
// - IMPORT-ONLY (#605): the module itself does no client work, and the ONLY
|
|
1089
|
+
// client work its closure reaches is SHIPPING COMPONENTS. Such a page /
|
|
1090
|
+
// layout module is just the import-graph carrier for its components; the
|
|
1091
|
+
// boot can emit those component modules directly and drop the module. The
|
|
1092
|
+
// condition is a positive subset test (every client-effecting closure
|
|
1093
|
+
// member is a component), NOT a hand-listed block list: any client-
|
|
1094
|
+
// effecting NON-component in the closure (a self-executing helper, a
|
|
1095
|
+
// client-router import, a reactive helper) keeps the whole module, since
|
|
1096
|
+
// dropping it would lose that side effect.
|
|
1097
|
+
// - SHIP: anything else (the module itself is client-effecting, or its
|
|
1098
|
+
// closure reaches a client-effecting non-component). Unchanged behaviour.
|
|
929
1099
|
/** @type {Set<string>} */
|
|
930
1100
|
const skip = new Set([...elidableComponents, ...serverFiles]);
|
|
931
1101
|
/** @type {Set<string>} */
|
|
932
1102
|
const inertRouteModules = new Set();
|
|
1103
|
+
/** @type {Map<string, string[]>} route file -> component files to emit in its place */
|
|
1104
|
+
const importOnlyRouteModules = new Map();
|
|
1105
|
+
// Advisory (#646): for each route module that SHIPS WHOLE (neither inert nor
|
|
1106
|
+
// import-only), record the first client-effecting blocker so a tool can name
|
|
1107
|
+
// why it ships. blocker is null when the module's OWN code is the cause.
|
|
1108
|
+
/** @type {Map<string, { blocker: string|null, reason: string }>} */
|
|
1109
|
+
const shippedRouteModules = new Map();
|
|
933
1110
|
for (const file of routeModules) {
|
|
934
|
-
if (!fileTags.has(file)) continue; // unreadable / not analysed: ship (omit from
|
|
935
|
-
if (isClientEffecting(file))
|
|
1111
|
+
if (!fileTags.has(file)) continue; // unreadable / not analysed: ship (omit from both sets)
|
|
1112
|
+
if (isClientEffecting(file)) {
|
|
1113
|
+
// The module itself ships whole; its own signal is the blocker.
|
|
1114
|
+
shippedRouteModules.set(file, { blocker: null, reason: clientEffectReason(file) });
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
936
1117
|
const closure = appDir ? transitiveDeps(moduleGraph, [file], appDir, skip) : [];
|
|
937
|
-
|
|
938
|
-
inertRouteModules.add(file);
|
|
1118
|
+
const effecting = closure.filter(isClientEffecting);
|
|
1119
|
+
if (effecting.length === 0) { inertRouteModules.add(file); continue; }
|
|
1120
|
+
// Import-only iff EVERY client-effecting closure member is a (shipping)
|
|
1121
|
+
// component. isClientEffecting for a component already implies mustShip, so
|
|
1122
|
+
// `effecting` is exactly the shipping components reachable from this module:
|
|
1123
|
+
// re-emit that STATIC set (what loading the module would have registered, so
|
|
1124
|
+
// a component imported but only conditionally rendered still registers).
|
|
1125
|
+
//
|
|
1126
|
+
// A `static lazy` component is NOT special-cased here: it only appears in
|
|
1127
|
+
// this STATIC closure when the route statically imports it, and in that case
|
|
1128
|
+
// loading the module already eager-loaded it before elision, so emitting it
|
|
1129
|
+
// directly preserves that exact behaviour. A normally-used lazy component is
|
|
1130
|
+
// tag-referenced (never statically imported), so it is absent from this
|
|
1131
|
+
// closure and still loads via the IntersectionObserver `observeLazy` path.
|
|
1132
|
+
if (effecting.every((f) => componentFiles.has(f))) {
|
|
1133
|
+
importOnlyRouteModules.set(file, effecting);
|
|
1134
|
+
} else {
|
|
1135
|
+
// A client-effecting non-component is reachable; ship the whole module.
|
|
1136
|
+
// Name the FIRST such non-component as the blocker for the advisory.
|
|
1137
|
+
const blocker = effecting.find((f) => !componentFiles.has(f));
|
|
1138
|
+
shippedRouteModules.set(file, { blocker, reason: clientEffectReason(blocker) });
|
|
1139
|
+
}
|
|
939
1140
|
}
|
|
940
1141
|
|
|
941
|
-
return { elidableComponents, inertRouteModules };
|
|
1142
|
+
return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules };
|
|
942
1143
|
}
|
|
943
1144
|
|
|
944
1145
|
/** Match a whole-line side-effect import: `import './x.js';` (no bindings). */
|
package/src/component-scanner.js
CHANGED
|
@@ -22,6 +22,7 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
22
22
|
import { sep } from 'node:path';
|
|
23
23
|
import { walk } from './fs-walk.js';
|
|
24
24
|
import { primeModuleUrl } from '@webjsdev/core';
|
|
25
|
+
import { redactToPlaceholders } from './js-scan.js';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* mtime-keyed cache of extracted components per file, so a rebuild re-reads
|
|
@@ -50,22 +51,26 @@ export function _scanCacheHas(file) { return SCAN_CACHE.has(file); }
|
|
|
50
51
|
export function extractComponents(src) {
|
|
51
52
|
/** @type {Array<{ className: string, tag: string }>} */
|
|
52
53
|
const results = [];
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
const { redacted, literals } = redactToPlaceholders(src);
|
|
55
|
+
|
|
56
|
+
// Pattern A: Class.register('tag') -> matches Class.register('__STR_idx__')
|
|
57
|
+
const registerRe = /\b([A-Z][A-Za-z0-9_$]*)\.register\s*\(\s*['"`]__STR_(\d+)__['"`]\s*\)/g;
|
|
55
58
|
let m;
|
|
56
|
-
while ((m = registerRe.exec(
|
|
59
|
+
while ((m = registerRe.exec(redacted)) !== null) {
|
|
57
60
|
const className = m[1];
|
|
58
|
-
const
|
|
59
|
-
|
|
61
|
+
const idx = parseInt(m[2], 10);
|
|
62
|
+
const tag = literals[idx];
|
|
63
|
+
if (tag && tag.includes('-')) {
|
|
60
64
|
results.push({ className, tag });
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
|
-
// Pattern B: customElements.define('tag', Class)
|
|
64
|
-
const defineRe = /\bcustomElements\.define\s*\(\s*['"](
|
|
65
|
-
while ((m = defineRe.exec(
|
|
66
|
-
const
|
|
67
|
+
// Pattern B: customElements.define('tag', Class) -> matches customElements.define('__STR_idx__', Class)
|
|
68
|
+
const defineRe = /\bcustomElements\.define\s*\(\s*['"`]__STR_(\d+)__['"`]\s*,\s*([A-Z][A-Za-z0-9_$]*)\b/g;
|
|
69
|
+
while ((m = defineRe.exec(redacted)) !== null) {
|
|
70
|
+
const idx = parseInt(m[1], 10);
|
|
71
|
+
const tag = literals[idx];
|
|
67
72
|
const className = m[2];
|
|
68
|
-
if (tag.includes('-')) {
|
|
73
|
+
if (tag && tag.includes('-')) {
|
|
69
74
|
results.push({ className, tag });
|
|
70
75
|
}
|
|
71
76
|
}
|