@webjsdev/server 0.8.31 → 0.8.33

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
@@ -416,7 +504,7 @@ export function analyzeComponentSource(src) {
416
504
  return { interactive: true, reason: 'no parseable WebComponent class body' };
417
505
  }
418
506
 
419
- for (const body of bodies) {
507
+ for (const { body, factoryArg } of bodies) {
420
508
  // Shadow DOM always ships (#474). Declarative Shadow DOM attaches ONLY
421
509
  // during HTML parsing; an elided component that arrives via a client DOM
422
510
  // insertion (a soft-nav swap, a streamed <webjs-suspense> boundary's
@@ -447,7 +535,7 @@ export function analyzeComponentSource(src) {
447
535
  return { interactive: true, reason: `calls '${call}'` };
448
536
  }
449
537
  }
450
- if (hasNonStateReactiveProperty(body)) {
538
+ if (hasNonStateReactiveProperty(body) || hasNonStateFactoryProperty(factoryArg)) {
451
539
  return {
452
540
  interactive: true,
453
541
  reason: 'declares a reactive property that is not { state: true }',
@@ -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;
@@ -571,6 +667,73 @@ function hasNonStateReactiveProperty(classBody) {
571
667
  return false;
572
668
  }
573
669
 
670
+ /**
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).
685
+ *
686
+ * @param {string} factoryArg
687
+ * @returns {boolean}
688
+ */
689
+ function hasNonStateFactoryProperty(factoryArg) {
690
+ if (!factoryArg) return false;
691
+ const objStart = factoryArg.indexOf('{');
692
+ if (objStart === -1) return false;
693
+ // matchClosingBrace starts at depth 1, so pass the index AFTER the brace.
694
+ const objEnd = matchClosingBrace(factoryArg, objStart + 1);
695
+ if (objEnd === -1) return true;
696
+ const obj = factoryArg.slice(objStart + 1, objEnd);
697
+ if (/\.\.\./.test(obj)) return true; // spread: cannot prove all entries state
698
+ for (const entry of topLevelPropertyValues(obj)) {
699
+ if (!descriptorDeclaresState(entry)) return true;
700
+ }
701
+ return false;
702
+ }
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
+
574
737
  /**
575
738
  * Yield the trimmed VALUE text of each top-level `key: value` entry in a
576
739
  * properties object body, splitting on depth-0 commas and respecting
@@ -680,7 +843,7 @@ export async function computeElidableComponents(components, moduleGraph, readFil
680
843
  * @param {import('./module-graph.js').ModuleGraph} moduleGraph
681
844
  * @param {(file: string) => Promise<string>} readFileFn
682
845
  * @param {string} [appDir]
683
- * @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string> }>}
846
+ * @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]> }>}
684
847
  */
685
848
  export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) {
686
849
  /** @type {Set<string>} */
@@ -713,6 +876,8 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
713
876
 
714
877
  /** @type {Set<string>} */
715
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);
716
881
  for (const f of routeModules) allFiles.add(f);
717
882
  for (const [k, vs] of moduleGraph) {
718
883
  if (!appDir || k.startsWith(appDir)) allFiles.add(k);
@@ -740,11 +905,22 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
740
905
  // them on the comment-masked source just additionally drops comment prose.)
741
906
  const masked = maskComments(src);
742
907
  fileTags.set(file, extractRenderedTags(masked));
743
- if (importsReactivePrimitive(masked)) reactiveFiles.add(file);
744
- if (importsClientRouter(masked)) clientRouterFiles.add(file);
745
- if (EVENT_BINDING_RE.test(masked) || EVENT_PROP_RE.test(masked) ||
746
- importsSideEffectNonCorePackage(masked) || CLIENT_GLOBAL_RE.test(masked) ||
747
- 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)) {
748
924
  clientGlobalOrBareFiles.add(file);
749
925
  }
750
926
  if (componentFiles.has(file) && analyzeComponentSource(masked).interactive) {
@@ -893,22 +1069,53 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
893
1069
  clientRouterFiles.has(file) ||
894
1070
  clientGlobalOrBareFiles.has(file);
895
1071
 
896
- // Route modules: inert iff neither the module nor its effective client
897
- // closure (skipping elided components and server stubs, which never load)
898
- // is client-effecting.
1072
+ // Route modules fall into three classes by their effective client closure
1073
+ // (skipping elided components and server stubs, which never load on the
1074
+ // client):
1075
+ // - INERT: neither the module nor its closure does any client work. Dropped
1076
+ // from the boot script entirely (#179).
1077
+ // - IMPORT-ONLY (#605): the module itself does no client work, and the ONLY
1078
+ // client work its closure reaches is SHIPPING COMPONENTS. Such a page /
1079
+ // layout module is just the import-graph carrier for its components; the
1080
+ // boot can emit those component modules directly and drop the module. The
1081
+ // condition is a positive subset test (every client-effecting closure
1082
+ // member is a component), NOT a hand-listed block list: any client-
1083
+ // effecting NON-component in the closure (a self-executing helper, a
1084
+ // client-router import, a reactive helper) keeps the whole module, since
1085
+ // dropping it would lose that side effect.
1086
+ // - SHIP: anything else (the module itself is client-effecting, or its
1087
+ // closure reaches a client-effecting non-component). Unchanged behaviour.
899
1088
  /** @type {Set<string>} */
900
1089
  const skip = new Set([...elidableComponents, ...serverFiles]);
901
1090
  /** @type {Set<string>} */
902
1091
  const inertRouteModules = new Set();
1092
+ /** @type {Map<string, string[]>} route file -> component files to emit in its place */
1093
+ const importOnlyRouteModules = new Map();
903
1094
  for (const file of routeModules) {
904
- if (!fileTags.has(file)) continue; // unreadable / not analysed: ship (omit from inert set)
905
- if (isClientEffecting(file)) continue;
1095
+ if (!fileTags.has(file)) continue; // unreadable / not analysed: ship (omit from both sets)
1096
+ if (isClientEffecting(file)) continue; // the module itself ships whole
906
1097
  const closure = appDir ? transitiveDeps(moduleGraph, [file], appDir, skip) : [];
907
- if (closure.some(isClientEffecting)) continue;
908
- inertRouteModules.add(file);
1098
+ const effecting = closure.filter(isClientEffecting);
1099
+ if (effecting.length === 0) { inertRouteModules.add(file); continue; }
1100
+ // Import-only iff EVERY client-effecting closure member is a (shipping)
1101
+ // component. isClientEffecting for a component already implies mustShip, so
1102
+ // `effecting` is exactly the shipping components reachable from this module:
1103
+ // re-emit that STATIC set (what loading the module would have registered, so
1104
+ // a component imported but only conditionally rendered still registers).
1105
+ //
1106
+ // A `static lazy` component is NOT special-cased here: it only appears in
1107
+ // this STATIC closure when the route statically imports it, and in that case
1108
+ // loading the module already eager-loaded it before elision, so emitting it
1109
+ // directly preserves that exact behaviour. A normally-used lazy component is
1110
+ // tag-referenced (never statically imported), so it is absent from this
1111
+ // closure and still loads via the IntersectionObserver `observeLazy` path.
1112
+ if (effecting.every((f) => componentFiles.has(f))) {
1113
+ importOnlyRouteModules.set(file, effecting);
1114
+ }
1115
+ // else: a client-effecting non-component is reachable; ship the whole module.
909
1116
  }
910
1117
 
911
- return { elidableComponents, inertRouteModules };
1118
+ return { elidableComponents, inertRouteModules, importOnlyRouteModules };
912
1119
  }
913
1120
 
914
1121
  /** 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
  }
package/src/dev.js CHANGED
@@ -13,7 +13,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
13
13
 
14
14
  import { buildRouteTable, matchPage, matchApi } from './router.js';
15
15
  import { generateRouteTypes } from './route-types.js';
16
- import { ssrPage, ssrNotFound } from './ssr.js';
16
+ import { ssrPage, ssrNotFound, setClientRouterEnabled } from './ssr.js';
17
17
  import { loadPageAction, runPageAction } from './page-action.js';
18
18
  import { handleApi } from './api.js';
19
19
  import {
@@ -267,6 +267,28 @@ export async function readSeedEnabled(appDir) {
267
267
  return true;
268
268
  }
269
269
 
270
+ /**
271
+ * Read the client-router switch (`webjs.clientRouter`) from the app's
272
+ * package.json (#629). Default `true`: the client router auto-enables in the
273
+ * browser whenever `@webjsdev/core` loads (the automatic-navigation thesis).
274
+ * `{ "webjs": { "clientRouter": false } }` opts the WHOLE app out (pure MPA,
275
+ * full-page navigation), which the render path signals to the browser so the
276
+ * core bundle's module-end auto-enable is skipped. Any other value, or an
277
+ * absent key, keeps the router on. Re-read on every rebuild (alongside elide)
278
+ * so toggling it takes effect without a server restart.
279
+ * @param {string} appDir
280
+ * @returns {Promise<boolean>}
281
+ */
282
+ export async function readClientRouterEnabled(appDir) {
283
+ try {
284
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
285
+ if (pkg && pkg.webjs && pkg.webjs.clientRouter === false) return false;
286
+ } catch {
287
+ // No package.json, malformed JSON, or unreadable. Keep the default.
288
+ }
289
+ return true;
290
+ }
291
+
270
292
  /**
271
293
  * Read the per-path response-header config (`webjs.headers`) from the
272
294
  * app's package.json and compile it to URLPattern rules. A missing,
@@ -473,6 +495,12 @@ export async function createRequestHandler(opts) {
473
495
  const basePathValue = await readBasePathFromApp(appDir);
474
496
  await setBasePath(basePathValue);
475
497
 
498
+ // Client-router opt-out (#629): bind it eagerly at handler construction (the
499
+ // same timing as setBasePath above), so a handler's module-global is set
500
+ // before any render even if a request arrives before the first warm. The
501
+ // analysis pass re-reads it each rebuild so a dev toggle takes effect live.
502
+ setClientRouterEnabled(await readClientRouterEnabled(appDir));
503
+
476
504
  const coreDir = locateCoreDir(appDir);
477
505
  // Switch the importmap between dist/ bundles and src/ per-file
478
506
  // URLs depending on whether the resolved @webjsdev/core install
@@ -638,6 +666,7 @@ export async function createRequestHandler(opts) {
638
666
  moduleGraph: null,
639
667
  elidableComponents: new Set(),
640
668
  inertRouteModules: new Set(),
669
+ importOnlyRouteModules: new Map(),
641
670
  browserBoundFiles: null,
642
671
  // Transformed-source cache (stripped TS + applied elision). Per-handler,
643
672
  // NOT module-global: the cached bytes bake in THIS handler's elision
@@ -742,12 +771,16 @@ export async function createRequestHandler(opts) {
742
771
  t.actions = now() - m; m = now();
743
772
  state.middleware = await loadMiddleware(appDir, dev, logger);
744
773
  t.middleware = now() - m; m = now();
774
+ // Client-router opt-out (#629): re-read each pass so toggling
775
+ // `webjs.clientRouter` takes effect on rebuild without a restart.
776
+ setClientRouterEnabled(await readClientRouterEnabled(appDir));
745
777
  const r = (await readElideEnabled(appDir))
746
778
  ? await analyzeElision(components, collectRouteModules(state.routeTable),
747
779
  state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
748
- : { elidableComponents: new Set(), inertRouteModules: new Set() };
780
+ : { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map() };
749
781
  state.elidableComponents = r.elidableComponents;
750
782
  state.inertRouteModules = r.inertRouteModules;
783
+ state.importOnlyRouteModules = r.importOnlyRouteModules;
751
784
  // Fold the elision verdict into app-module content hashes (#243): an
752
785
  // app module's served body is elision-transformed, so a verdict flip
753
786
  // must bust its `?v` even when its source is byte-identical. A stable
@@ -764,6 +797,10 @@ export async function createRequestHandler(opts) {
764
797
  const elidedPaths = [
765
798
  ...state.elidableComponents,
766
799
  ...state.inertRouteModules,
800
+ // An import-only module is dropped from the boot (replaced by its
801
+ // component imports), so a flip in / out of that class must bust
802
+ // the importer's `?v` too.
803
+ ...state.importOnlyRouteModules.keys(),
767
804
  ].map(rel).sort();
768
805
  setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
769
806
  }
@@ -871,7 +908,7 @@ export async function createRequestHandler(opts) {
871
908
  if (vendorResolveInFlight) return vendorResolveInFlight;
872
909
  vendorResolveInFlight = (async () => {
873
910
  try {
874
- const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules]));
911
+ const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]));
875
912
  const v = await resolveVendorImports(appDir, scan);
876
913
  let { imports, integrity } = v;
877
914
  if (bootVendorPinned) {
@@ -1303,7 +1340,21 @@ export async function createRequestHandler(opts) {
1303
1340
  if (matchPathname === null) return null;
1304
1341
  const page = matchPage(state.routeTable, matchPathname);
1305
1342
  if (!page) return null;
1306
- const moduleUrls = [page.route.file, ...page.route.layouts].map((f) => {
1343
+ // Mirror ssr.js's boot assembly EXACTLY: drop inert route modules, and
1344
+ // splice an import-only module's component imports in place of the module
1345
+ // (#605). Otherwise the 103 Early Hints would preload the page / layout
1346
+ // modules the body no longer imports (a wasted hint) and miss the component
1347
+ // modules it actually fetches (a double-fetch on the real module).
1348
+ const inert = state.inertRouteModules;
1349
+ const importOnly = state.importOnlyRouteModules;
1350
+ const files = [];
1351
+ const seenFiles = new Set();
1352
+ for (const f of [page.route.file, ...page.route.layouts]) {
1353
+ if (inert && inert.has(f)) continue;
1354
+ const emit = importOnly && importOnly.get(f);
1355
+ for (const t of emit || [f]) if (!seenFiles.has(t)) { seenFiles.add(t); files.push(t); }
1356
+ }
1357
+ const moduleUrls = files.map((f) => {
1307
1358
  let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
1308
1359
  const url = rel.split('\\').join('/').replace(/^\/?/, '/');
1309
1360
  // Mirror ssr.js's emit (basePath THEN `?v`) so the 103 Early Hints preload
@@ -1880,6 +1931,7 @@ async function handleCore(req, ctx) {
1880
1931
  serverFiles: state.actionIndex.fileToHash,
1881
1932
  elidableComponents: state.elidableComponents,
1882
1933
  inertRouteModules: state.inertRouteModules,
1934
+ importOnlyRouteModules: state.importOnlyRouteModules,
1883
1935
  notFoundFile: state.routeTable.notFound,
1884
1936
  // Server HTML cache (#241): a CSP-enabled page emits a fresh
1885
1937
  // per-request nonce into its body, so its bytes vary per request and