@webjsdev/server 0.8.53 → 0.8.54
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 +1 -1
- package/src/component-elision.js +25 -12
- package/src/ssr.js +224 -34
package/package.json
CHANGED
package/src/component-elision.js
CHANGED
|
@@ -120,11 +120,14 @@ export const CLIENT_LIFECYCLE_HOOKS = [
|
|
|
120
120
|
/**
|
|
121
121
|
* Method calls that only make sense on the client. `addController`
|
|
122
122
|
* registers a ReactiveController (client lifecycle). `requestUpdate`
|
|
123
|
-
* schedules a re-render.
|
|
123
|
+
* schedules a re-render. `setSlotContent` pushes dynamic slot values
|
|
124
|
+
* (#1015) and `hasSlot` conditions a render on the slot record, both of
|
|
125
|
+
* which need the client slot runtime to apply. Any of these implies the
|
|
126
|
+
* component is not inert.
|
|
124
127
|
*
|
|
125
128
|
* @type {readonly string[]}
|
|
126
129
|
*/
|
|
127
|
-
export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate'];
|
|
130
|
+
export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate', 'setSlotContent', 'hasSlot'];
|
|
128
131
|
|
|
129
132
|
/**
|
|
130
133
|
* Static class fields whose declaration (to a non-`false` value) marks a
|
|
@@ -211,8 +214,18 @@ const EVENT_BINDING_RE = new RegExp(
|
|
|
211
214
|
/** Match a `.onclick=${...}` (native event-handler property) binding. */
|
|
212
215
|
const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*\$\{/;
|
|
213
216
|
|
|
214
|
-
/**
|
|
215
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Narrow dynamic-slot signals (#1015). The old blanket rule shipped ANY
|
|
219
|
+
* component that merely RENDERS a `<slot>`, but under children-as-values the
|
|
220
|
+
* SSR output already carries the placed children, so a display-only slotted
|
|
221
|
+
* wrapper is byte-identical with or without its JS and is elidable. What
|
|
222
|
+
* genuinely needs the client slot runtime is the DYNAMIC surface: a
|
|
223
|
+
* `slotchange` listener, the assignedNodes/assignedElements/assignedSlot
|
|
224
|
+
* reads, a `this.slots` record read, or the setSlotContent/hasSlot calls
|
|
225
|
+
* (those two are also covered as CLIENT_METHOD_CALLS inside class bodies;
|
|
226
|
+
* this regex catches external callers too).
|
|
227
|
+
*/
|
|
228
|
+
const SLOT_DYNAMIC_RE = /\bslotchange\b|\bassignedNodes\s*\(|\bassignedElements\s*\(|\bassignedSlot\b|\.slots\b|\bsetSlotContent\s*\(|\bhasSlot\s*\(/;
|
|
216
229
|
|
|
217
230
|
/** A `.server.{js,ts,mjs,mts}` file: a stub on the client, inert there. */
|
|
218
231
|
const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
|
|
@@ -527,14 +540,14 @@ export function analyzeComponentSource(src) {
|
|
|
527
540
|
return { interactive: true, reason: 'template sets a native event-handler property' };
|
|
528
541
|
}
|
|
529
542
|
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
if (
|
|
537
|
-
return { interactive: true, reason: '
|
|
543
|
+
// Dynamic slot usage (#1015). Merely RENDERING a `<slot>` no longer
|
|
544
|
+
// ships: the SSR output carries the placed children, and with no
|
|
545
|
+
// observers in the runtime a display-only slotted wrapper is
|
|
546
|
+
// byte-identical with or without its JS. Only the dynamic slot surface
|
|
547
|
+
// (slotchange, the assigned* reads, the slots record, setSlotContent /
|
|
548
|
+
// hasSlot) needs the client runtime.
|
|
549
|
+
if (SLOT_DYNAMIC_RE.test(src)) {
|
|
550
|
+
return { interactive: true, reason: 'uses the dynamic slot API (slotchange / assignedNodes / slots record)' };
|
|
538
551
|
}
|
|
539
552
|
|
|
540
553
|
// 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
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
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
|
|
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
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
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
|
|
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
|
|