@webjsdev/server 0.8.53 → 0.8.55

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.53",
3
+ "version": "0.8.55",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -118,9 +118,10 @@ export const CLIENT_LIFECYCLE_HOOKS = [
118
118
  ];
119
119
 
120
120
  /**
121
- * Method calls that only make sense on the client. `addController`
122
- * registers a ReactiveController (client lifecycle). `requestUpdate`
123
- * schedules a re-render. Either implies the component is not inert.
121
+ * Method calls that only make sense on the client. `addController` registers a
122
+ * ReactiveController (client lifecycle); `requestUpdate` schedules a re-render.
123
+ * Any of these implies the component is not inert. (The dynamic slot read
124
+ * surface lives in `SLOT_DYNAMIC_RE`, not here.)
124
125
  *
125
126
  * @type {readonly string[]}
126
127
  */
@@ -211,8 +212,22 @@ const EVENT_BINDING_RE = new RegExp(
211
212
  /** Match a `.onclick=${...}` (native event-handler property) binding. */
212
213
  const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*\$\{/;
213
214
 
214
- /** Match a rendered `<slot>` / `<slot ` / `<slot/>`, but not `<slot-machine>`. */
215
- const SLOT_RE = /<slot[\s/>]/;
215
+ /**
216
+ * Narrow dynamic-slot signals (#1015, retained under #1021's native-parity
217
+ * record model). The old blanket rule shipped ANY component that merely
218
+ * RENDERS a `<slot>`, but the SSR output already carries the placed children,
219
+ * so a display-only slotted wrapper is byte-identical with or without its JS
220
+ * and is elidable. What genuinely needs the client slot runtime is the
221
+ * DYNAMIC READ surface: a `slotchange` listener or an `assignedNodes` /
222
+ * `assignedElements` / `assignedSlot` read. Native WRITE liveness
223
+ * (appendChild, slot= flips) is consumer-driven and usually forces the ship
224
+ * through the consumer's own signals (a shipping component that renders the
225
+ * tag, or an observation form); the remaining carve-out, a shipped script
226
+ * reaching an elided host only via document.querySelector, is inert by
227
+ * design with the `static interactive = true` escape hatch (see the
228
+ * slot.js banner).
229
+ */
230
+ const SLOT_DYNAMIC_RE = /\bslotchange\b|\bassignedNodes\s*\(|\bassignedElements\s*\(|\bassignedSlot\b/;
216
231
 
217
232
  /** A `.server.{js,ts,mjs,mts}` file: a stub on the client, inert there. */
218
233
  const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
@@ -527,14 +542,13 @@ export function analyzeComponentSource(src) {
527
542
  return { interactive: true, reason: 'template sets a native event-handler property' };
528
543
  }
529
544
 
530
- // A rendered `<slot>` relies on webjs's client slot-projection runtime
531
- // for the slot API (assignedNodes, slotchange) and dynamic re-
532
- // projection. Shadow DOM slots are native via Declarative Shadow DOM,
533
- // but proving a given slot is purely native is beyond static analysis,
534
- // so any rendered slot ships. Tag names like `<slot-machine>` are
535
- // excluded by requiring a slot-closing character.
536
- if (SLOT_RE.test(src)) {
537
- return { interactive: true, reason: 'renders a <slot> (needs the projection runtime)' };
545
+ // Dynamic slot usage (#1015). Merely RENDERING a `<slot>` no longer
546
+ // ships: the SSR output carries the placed children, and with no
547
+ // observers in the runtime a display-only slotted wrapper is
548
+ // byte-identical with or without its JS. Only the dynamic slot surface
549
+ // (slotchange, the assigned* reads) needs the client runtime.
550
+ if (SLOT_DYNAMIC_RE.test(src)) {
551
+ return { interactive: true, reason: 'reads the dynamic slot surface (slotchange / assignedNodes / assignedElements / assignedSlot)' };
538
552
  }
539
553
 
540
554
  // Top-level client work the render/lifecycle checks would miss: a
package/src/ssr.js CHANGED
@@ -87,31 +87,53 @@ export async function ssrPage(route, params, url, opts) {
87
87
 
88
88
  try {
89
89
  const suspenseCtx = { pending: [], nextId: 1, usedComponents: new Set(), dev: opts.dev };
90
- // Parse the partial-nav "have" header from the client. The header
91
- // lists comma-separated marker paths the client already has rendered
92
- // in its DOM. The server walks the target route's layout chain
93
- // innermost → outermost and SHORT-CIRCUITS at the first match -
94
- // returning only the content below that layout, wrapped in the
95
- // matched layout's marker pair. Real wire-byte savings: the outer
96
- // layouts' HTML is never re-serialized for same-shell navigations.
90
+ // Parse the partial-nav "have" header from the client. The server walks
91
+ // the target route's layout chain innermost → outermost and
92
+ // SHORT-CIRCUITS at the first FULL match (segment AND route-key),
93
+ // returning only the content below that layout, wrapped in the matched
94
+ // layout's boundary pair. Real wire-byte savings: the outer layouts'
95
+ // HTML is never re-serialized for same-shell navigations.
97
96
  const haveHeader = opts.req?.headers.get('x-webjs-have') || '';
98
- const have = haveHeader
99
- ? new Set(haveHeader.split(',').map((s) => s.trim()).filter(Boolean))
100
- : null;
97
+ // Entries are `<segment>:<route-key>` (#1015). The route-key is required
98
+ // for a correct short-circuit: a dynamic layout the client holds for
99
+ // OTHER params ('/[org]' rendered for org-a on an org-b navigation) must
100
+ // be re-rendered and re-shipped, or the client's parent-anchored REPLACE
101
+ // has no fresh layout markup to swap in. Split at the LAST ':' (encoded
102
+ // route-keys contain no ':'; a hand-authored folder name that smuggles a
103
+ // delimiter through the SEGMENT half can only fail to match, which
104
+ // degrades to a full render: always correct). An entry with no key (a
105
+ // malformed or legacy client) is ignored, degrading the same way.
106
+ /** @type {Map<string, string> | null} */
107
+ let have = null;
108
+ if (haveHeader) {
109
+ have = new Map();
110
+ for (const entry of haveHeader.split(',')) {
111
+ const e = entry.trim();
112
+ if (!e) continue;
113
+ const cut = e.lastIndexOf(':');
114
+ if (cut <= 0 || cut === e.length - 1) continue;
115
+ have.set(e.slice(0, cut), e.slice(cut + 1));
116
+ }
117
+ if (have.size === 0) have = null;
118
+ }
101
119
  // SSR action-result seeding (#472). When enabled, run the whole render
102
120
  // inside an ambient seed collector so every `'use server'` action a
103
121
  // component awaits in `async render()` records its (args -> result) for the
104
122
  // hydration payload. Disabled -> the plain render, byte-identical to before.
105
123
  let seedCollector = null;
106
124
  let body;
125
+ let reduced = false;
107
126
  if (seedingEnabled()) {
108
127
  const seeded = await collectSeeds(() =>
109
128
  renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule),
110
129
  );
111
- body = seeded.value;
130
+ body = seeded.value.html;
131
+ reduced = seeded.value.reduced;
112
132
  seedCollector = seeded.collector;
113
133
  } else {
114
- body = await renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule);
134
+ const chain = await renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule);
135
+ body = chain.html;
136
+ reduced = chain.reduced;
115
137
  }
116
138
 
117
139
  // Frame subtree render (#253). A `<webjs-frame src>` self-load (or a
@@ -135,7 +157,14 @@ export async function ssrPage(route, params, url, opts) {
135
157
  if (frameId && suspenseCtx.pending.length === 0) {
136
158
  const subtree = extractFrameSubtree(body, frameId);
137
159
  if (subtree !== null) {
138
- return htmlResponse(subtree, opts.status || 200, opts.req, url);
160
+ const frameRes = htmlResponse(subtree, opts.status || 200, opts.req, url);
161
+ // The subtree is sliced by the x-webjs-frame REQUEST header, so a
162
+ // shared cache must never serve it to a request that did not send
163
+ // one (the same #1009 poisoning shape as the reduced-have case).
164
+ frameRes.headers.append('vary', 'X-Webjs-Frame');
165
+ // #1009: a subtree sliced from a REDUCED render inherits its variance.
166
+ if (reduced) frameRes.headers.append('vary', 'X-Webjs-Have');
167
+ return frameRes;
139
168
  }
140
169
  }
141
170
  // Module URLs for the page + every layout in its chain. These ride
@@ -273,6 +302,17 @@ export async function ssrPage(route, params, url, opts) {
273
302
  nonce,
274
303
  opts.dev,
275
304
  );
305
+ // REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the
306
+ // outer-layout chrome, so these bytes are only valid for a request that
307
+ // sent a matching `have`. Without `Vary: X-Webjs-Have`, a shared cache
308
+ // (CDN edge) could store the reduced body under the URL and serve a
309
+ // chrome-less fragment to a fresh full-page navigation (measured live:
310
+ // GET / was 73,534 bytes, GET / + have was 57,035, byte-identical headers
311
+ // otherwise). Scoped to genuinely reduced responses so a normal page's
312
+ // cache key is unchanged. The internal #241 revalidate cache is already
313
+ // safe by construction: `cacheEligible` excludes any request that carries
314
+ // x-webjs-have, so a reduced body is never stored under the URL-only key.
315
+ if (reduced) res.headers.append('vary', 'X-Webjs-Have');
276
316
  // Server HTML cache write (#241). The page opted in via `revalidate`, so
277
317
  // FLAG this candidate for the response funnel rather than writing here: the
278
318
  // store decision must see the FINAL response (after segment middleware,
@@ -564,36 +604,65 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
564
604
  } catch { /* loading file failed: skip, render page directly */ }
565
605
  }
566
606
 
567
- // Wrap each layout's `${children}` interpolation in
568
- // `<!--wj:children:<segment-path>-->...<!--/wj:children-->` comment
569
- // markers. The client router walks both old + new DOM for these
570
- // markers and swaps only the children-slot of the deepest shared
571
- // layout: preserving outer-layout DOM (and the scroll position of
572
- // anything inside it: sidenavs, sticky headers, inner scroll
573
- // containers). Auto-derived from folder structure: no opt-in
574
- // required from layout authors.
607
+ // Resolved route params drive every boundary's route-key. ctx.params is
608
+ // thenable (#848) but its `then` is non-enumerable, so a spread yields the
609
+ // plain param map the route-key derivation expects.
610
+ const params = { ...(/** @type {Record<string,string>} */ (ctx.params) || {}) };
611
+
612
+ // Page-level boundary (#1015). The page gets its own keyed boundary pair
613
+ // whose route-key is its full resolved path, so a dynamic-param change
614
+ // (`/blog/a` -> `/blog/b`) REPLACES (remounts) the page on the client (Next
615
+ // parity) while a shared parent layout (a shorter segment whose key did not
616
+ // change) is preserved. Skipped when the page's segment equals the innermost
617
+ // layout's: the layout's children-slot boundary already delimits that exact
618
+ // range, and two boundaries with one segment id would break keyed pairing.
619
+ const pageSeg = pageSegmentPath(route.file);
620
+ const innermostLayoutSeg = route.layouts && route.layouts.length
621
+ ? layoutSegmentPath(route.layouts[route.layouts.length - 1])
622
+ : null;
623
+ if (pageSeg !== innermostLayoutSeg) {
624
+ tree = wrapWithChildrenMarker(tree, pageSeg, params);
625
+ }
626
+
627
+ // Wrap each layout's `${children}` interpolation in the KEYED boundary
628
+ // comment pair (open `<!--wj:children:<segment>:<route-key>-->`, close
629
+ // `<!--/wj:children:<segment>-->`, #1015). The client router scans both the
630
+ // live and incoming DOM for these boundaries (strict id-matched pairing, no
631
+ // LIFO guessing) and applies the two-tier swap: a changed route-key
632
+ // REPLACES at the PARENT of the shallowest change, else the deepest shared
633
+ // boundary MORPHS. Outer
634
+ // layout DOM (and the scroll position of anything inside it: sidenavs,
635
+ // fixed headers, inner scroll containers) is preserved. Auto-derived from
636
+ // folder structure: no opt-in required from layout authors.
575
637
  // X-Webjs-Have optimization: iterate from innermost → outermost and
576
638
  // SHORT-CIRCUIT at the first layout whose segment path the client
577
639
  // already has rendered. Wrap the accumulated inner tree in that
578
- // layout's marker pair (so the client can identify the splice
640
+ // layout's boundary pair (so the client can identify the splice
579
641
  // target) and return: outer layouts are not rendered at all,
580
642
  // saving CPU and wire bytes.
581
643
  for (let i = route.layouts.length - 1; i >= 0; i--) {
582
644
  const segmentPath = layoutSegmentPath(route.layouts[i]);
583
- if (have && have.has(segmentPath)) {
584
- tree = wrapWithChildrenMarker(tree, segmentPath);
645
+ // Short-circuit ONLY when the client's copy of this layout was rendered
646
+ // for the SAME route-key: a param change at a dynamic layout must
647
+ // re-render the layout's own markup (#1015).
648
+ if (have && have.get(segmentPath) === regionRouteKey(segmentPath, params)) {
649
+ tree = wrapWithChildrenMarker(tree, segmentPath, params);
585
650
  const body = await renderToString(tree, { ssr: true, suspenseCtx });
586
- return body + (await loadingTemplates(route, ctx, dev));
651
+ // REDUCED response (#1009): the outer layouts were skipped, so these
652
+ // bytes are only valid for a client that already HAS them. The caller
653
+ // marks the response `Vary: X-Webjs-Have` so no shared cache can serve
654
+ // this fragment to a fresh full-page navigation.
655
+ return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: true };
587
656
  }
588
657
  const mod = await loadModule(route.layouts[i], dev);
589
658
  if (!mod.default) continue;
590
659
  tree = await mod.default({
591
660
  ...ctx,
592
- children: wrapWithChildrenMarker(tree, segmentPath),
661
+ children: wrapWithChildrenMarker(tree, segmentPath, params),
593
662
  });
594
663
  }
595
664
  const body = await renderToString(tree, { ssr: true, suspenseCtx });
596
- return body + (await loadingTemplates(route, ctx, dev));
665
+ return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: false };
597
666
  }
598
667
 
599
668
  /**
@@ -669,23 +738,142 @@ function layoutSegmentPath(layoutFile) {
669
738
  }
670
739
 
671
740
  /**
672
- * Wrap a TemplateResult-or-renderable child in the partial-nav children
673
- * marker pair. Returns a synthetic TemplateResult: server `renderToString`
674
- * walks `.strings` and `.values` exactly the same way as for the `html` tag.
741
+ * Like layoutSegmentPath but for the PAGE file. Strips the `page.ext`
742
+ * filename, yielding the page's own segment path (the full route pattern,
743
+ * dynamic tokens included):
744
+ *
745
+ * app/page.ts -> '/'
746
+ * app/blog/[slug]/page.ts -> '/blog/[slug]'
747
+ * app/files/[...rest]/page.ts -> '/files/[...rest]'
748
+ *
749
+ * This is the segment for the PAGE-level region (Pillar 1 / #1013): the page
750
+ * needs its own region keyed by the full resolved path so a dynamic-param
751
+ * change remounts the page (Next parity) while a shared parent LAYOUT (a
752
+ * shorter segment path whose route-key does not change) is preserved.
753
+ *
754
+ * @param {string} pageFile Absolute path to the page source file.
755
+ * @returns {string}
756
+ */
757
+ function pageSegmentPath(pageFile) {
758
+ const p = pageFile
759
+ .replace(/^.*\/app\//, '')
760
+ .replace(/\/?page\.[jt]sx?$/, '');
761
+ return p === '' ? '/' : '/' + p;
762
+ }
763
+
764
+ /**
765
+ * Derive a region's ROUTE-KEY from its segment path pattern and the render's
766
+ * resolved params. The route-key is the CONCRETE resolved URL path for the
767
+ * region: dynamic `[param]` / catch-all `[...param]` / optional-catch-all
768
+ * `[[...param]]` tokens are substituted with their param values, and `(group)`
769
+ * segments are dropped (they scope layouts but never appear in the URL).
770
+ * searchParams are excluded by construction (params carries route params only).
771
+ *
772
+ * The client router compares a region's OLD vs NEW route-key to pick the swap
773
+ * tier: route-key CHANGED -> wholesale replace (Next page-remount parity),
774
+ * route-key SAME -> bounded same-route morph (hydrated component state kept, the
775
+ * searchParams-only-nav case). A static segment (`/`, `/docs`) has a constant
776
+ * route-key, so that layout's region never remounts and its chrome always
777
+ * survives.
778
+ *
779
+ * PARAM VALUES ARE ENCODED (`encodeURIComponent`, per path piece). The
780
+ * route-key rides inside the boundary COMMENT, and param values are
781
+ * user-controlled, so an unencoded value could carry `-->` and terminate the
782
+ * comment mid-boundary. Encoding removes `<`, `>`, `:`, `/` from every
783
+ * substituted piece, which makes all three HTML-forbidden comment sequences
784
+ * (`<!--`, `-->`, `--!>`) impossible (each needs `<` or `>`) and keeps `:`
785
+ * unambiguous as the boundary-format delimiter. A catch-all's value is encoded
786
+ * per PIECE (split on `/`), so its literal separators stay readable. Static
787
+ * segments come from folder names and are emitted as-is. A bare `--` can
788
+ * survive encoding (`-` is unreserved) and is legal inside an HTML5 comment.
789
+ * The key is only ever compared for equality, so encoding does not affect the
790
+ * swap-tier decision.
791
+ *
792
+ * regionRouteKey('/', {}) -> '/'
793
+ * regionRouteKey('/docs', {}) -> '/docs'
794
+ * regionRouteKey('/blog/[slug]', {slug:'a'}) -> '/blog/a'
795
+ * regionRouteKey('/(marketing)/about', {}) -> '/about'
796
+ * regionRouteKey('/files/[...rest]', {rest:'a/b'}) -> '/files/a/b'
797
+ * regionRouteKey('/shop/[[...slug]]', {}) -> '/shop'
798
+ * regionRouteKey('/blog/[slug]', {slug:'a-->b'}) -> '/blog/a--%3Eb'
799
+ *
800
+ * @param {string} segmentPath Region segment pattern, e.g. '/blog/[slug]'.
801
+ * @param {Record<string,string>} params Resolved route params (values are
802
+ * strings; a catch-all value is already slash-joined, e.g. 'a/b/c').
803
+ * @returns {string}
804
+ */
805
+ function regionRouteKey(segmentPath, params) {
806
+ const p = params || {};
807
+ /** Encode one substituted value, per slash-piece (catch-alls keep their
808
+ * literal separators; each piece is comment-safe + delimiter-safe). */
809
+ const enc = (v) => String(v).split('/').map((s) => encodeURIComponent(s)).join('/');
810
+ /** A STATIC piece is a folder name emitted as-is EXCEPT the boundary/header
811
+ * delimiter characters (':' would mis-split the `segment:route-key` parses,
812
+ * ',' the have-entry list, '%' the decode); percent-encode just those so
813
+ * the no-delimiter invariant holds for every emitted route-key while
814
+ * normal folder names stay byte-identical. */
815
+ const encStatic = (v) => v.replace(/[%:,]/g, (c) => encodeURIComponent(c));
816
+ const out = [];
817
+ for (const seg of segmentPath.split('/')) {
818
+ if (!seg) continue;
819
+ // Route group `(marketing)`: scopes layouts, absent from the URL.
820
+ if (seg.startsWith('(') && seg.endsWith(')')) continue;
821
+ // Optional catch-all `[[...name]]` / catch-all `[...name]`: the value is
822
+ // the already-slash-joined tail (may be '' for an empty optional one).
823
+ if (seg.startsWith('[[...') && seg.endsWith(']]')) {
824
+ const v = p[seg.slice(5, -2)];
825
+ if (v) out.push(enc(v));
826
+ continue;
827
+ }
828
+ if (seg.startsWith('[...') && seg.endsWith(']')) {
829
+ const v = p[seg.slice(4, -1)];
830
+ if (v) out.push(enc(v));
831
+ continue;
832
+ }
833
+ // Dynamic `[name]`.
834
+ if (seg.startsWith('[') && seg.endsWith(']')) {
835
+ out.push(enc(p[seg.slice(1, -1)] ?? ''));
836
+ continue;
837
+ }
838
+ out.push(encStatic(seg));
839
+ }
840
+ return '/' + out.join('/');
841
+ }
842
+
843
+ /**
844
+ * Wrap a TemplateResult-or-renderable child in a KEYED partial-nav boundary
845
+ * comment pair (#1015). Returns a synthetic TemplateResult: server
846
+ * `renderToString` walks `.strings` and `.values` exactly the same way as for
847
+ * the `html` tag.
848
+ *
849
+ * Format:
850
+ * open <!--wj:children:<segment>:<route-key>-->
851
+ * close <!--/wj:children:<segment>-->
852
+ *
853
+ * The close carries the SEGMENT, so client-side pairing is deterministic
854
+ * id-matching instead of the LIFO reconstruction that produced the #994 class
855
+ * of silent mispair. The open additionally carries the resolved ROUTE-KEY
856
+ * (param values encoded, see `regionRouteKey`), which drives the client's
857
+ * two-tier swap decision: key changed -> wholesale replace (Next remount
858
+ * parity), key same -> bounded morph (hydrated state preserved). A comment is
859
+ * invisible to structural CSS (`>`, `:nth-child`, flex/grid item enumeration),
860
+ * so unlike a wrapper element this boundary is layout-free by construction.
675
861
  *
676
862
  * The marker text lives in `strings` (static template parts), NOT in
677
863
  * `values`: `values` get HTML-escaped on render, comments wouldn't survive.
678
864
  *
679
865
  * @param {unknown} tree A TemplateResult, string, array, or Promise.
680
- * @param {string} segmentPath The layout's segment path, used as marker id.
866
+ * @param {string} segmentPath The boundary's segment pattern (pairing id).
867
+ * @param {Record<string,string>} params Resolved route params for the key.
681
868
  * @returns {{ _$webjs: 'template', strings: string[], values: unknown[] }}
682
869
  */
683
- function wrapWithChildrenMarker(tree, segmentPath) {
870
+ function wrapWithChildrenMarker(tree, segmentPath, params) {
871
+ const routeKey = regionRouteKey(segmentPath, params);
684
872
  return {
685
873
  _$webjs: 'template',
686
874
  strings: [
687
- `<!--wj:children:${segmentPath}-->`,
688
- `<!--/wj:children-->`,
875
+ `<!--wj:children:${segmentPath}:${routeKey}-->`,
876
+ `<!--/wj:children:${segmentPath}-->`,
689
877
  ],
690
878
  values: [tree],
691
879
  };
@@ -694,6 +882,8 @@ function wrapWithChildrenMarker(tree, segmentPath) {
694
882
  // Re-export for unit testing.
695
883
  export {
696
884
  layoutSegmentPath as _layoutSegmentPath,
885
+ pageSegmentPath as _pageSegmentPath,
886
+ regionRouteKey as _regionRouteKey,
697
887
  wrapWithChildrenMarker as _wrapWithChildrenMarker,
698
888
  };
699
889