@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.
@@ -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
- function hasModuleScopeSideEffect(src) {
219
- const redacted = redactStringsAndTemplates(src);
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` (a `new X()` constructor also trips the call check, but a
256
- // `new X` without parens would not) and top-level `await`.
257
- if (/(?<![.\w])(?:new|await)\s/.test(frame)) return true;
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
- if (CLIENT_ROUTER_SUBPATH_RE.test(src)) return true;
297
- for (const m of src.matchAll(CORE_IMPORT_RE)) {
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(src)) return true;
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(src)) return true;
312
- if (new RegExp(`\\b${ns}\\s*\\[`).test(src)) return true;
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
- for (const m of src.matchAll(SIDE_EFFECT_BARE_IMPORT_RE)) {
344
- const spec = m[2];
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\/[^'"]*)['"]/g;
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
- for (const m of src.matchAll(CORE_IMPORT_RE)) {
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(src)) return name;
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(src)) {
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(src)) {
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
- // Blank string / template bodies first. Redaction keeps quoted
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 declares any property that is NOT
576
- * marked `{ state: true }`.
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
- const objEnd = matchClosingBrace(factoryArg, objStart);
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.startsWith('{')) {
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
- if (importsReactivePrimitive(masked)) reactiveFiles.add(file);
774
- if (importsClientRouter(masked)) clientRouterFiles.add(file);
775
- if (EVENT_BINDING_RE.test(masked) || EVENT_PROP_RE.test(masked) ||
776
- importsSideEffectNonCorePackage(masked) || CLIENT_GLOBAL_RE.test(masked) ||
777
- hasModuleScopeSideEffect(masked)) {
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
- // Route modules: inert iff neither the module nor its effective client
927
- // closure (skipping elided components and server stubs, which never load)
928
- // is client-effecting.
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 inert set)
935
- if (isClientEffecting(file)) continue;
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
- if (closure.some(isClientEffecting)) continue;
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). */
@@ -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
- // Pattern A: Class.register('tag')
54
- const registerRe = /\b([A-Z][A-Za-z0-9_$]*)\.register\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g;
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(src)) !== null) {
59
+ while ((m = registerRe.exec(redacted)) !== null) {
57
60
  const className = m[1];
58
- const tag = m[2];
59
- if (tag.includes('-')) {
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*['"]([^'"\n]+)['"]\s*,\s*([A-Z][A-Za-z0-9_$]*)\b/g;
65
- while ((m = defineRe.exec(src)) !== null) {
66
- const tag = m[1];
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
  }