@llui/vike 0.4.10 → 0.5.1

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.
@@ -1,13 +1,12 @@
1
- import { hydrateApp, mountApp, mountAtAnchor, hydrateAtAnchor } from '@llui/dom';
1
+ import { mountSignalComponent, hydrateSignalApp } from '@llui/dom';
2
2
  import { _consumePendingSlot, _resetPendingSlot } from './page-slot.js';
3
3
  // Re-exported so `@llui/vike/client` is a one-stop-shop for everything
4
- // a pages/+onRenderClient.ts / +Layout.ts file needs.
4
+ // a pages/+onRenderClient.ts / Layout.ts file needs.
5
5
  export { pageSlot } from './page-slot.js';
6
6
  /**
7
7
  * Resolves the layout chain for a given pageContext. A single layout
8
8
  * becomes a one-element chain; a function resolver gives callers full
9
- * control to return different chains for different routes (e.g. nested
10
- * layouts keyed on Vike's `pageContext.urlPathname`).
9
+ * control to return different chains for different routes.
11
10
  */
12
11
  function resolveLayoutChain(layoutOption, pageContext) {
13
12
  if (!layoutOption)
@@ -19,11 +18,14 @@ function resolveLayoutChain(layoutOption, pageContext) {
19
18
  return layoutOption;
20
19
  return [layoutOption];
21
20
  }
21
+ /** Resolve a layer's seed state — a present data slice IS the seed state
22
+ * (signal init() takes no data); an absent slice falls back to init(). */
23
+ function seedFor(data) {
24
+ return data === undefined ? undefined : data;
25
+ }
22
26
  /**
23
- * Adapt a `TransitionOptions` object (e.g. the output of
24
- * `routeTransition()` from `@llui/transitions`, or a preset like `fade`
25
- * / `slide`) into the `onLeave` / `onEnter` pair expected by
26
- * `createOnRenderClient`.
27
+ * Adapt a `TransitionOptions` object into the `onLeave` / `onEnter` pair
28
+ * expected by `createOnRenderClient`.
27
29
  *
28
30
  * ```ts
29
31
  * import { createOnRenderClient, fromTransition } from '@llui/vike/client'
@@ -36,9 +38,8 @@ function resolveLayoutChain(layoutOption, pageContext) {
36
38
  * ```
37
39
  *
38
40
  * The transition operates on the slot element — in a no-layout setup,
39
- * the root container; in a layout setup, the innermost surviving
40
- * layer's `pageSlot()` element. Opacity / transform fades apply to the
41
- * outgoing page content, then the new page fades in.
41
+ * the root container; in a layout setup, the innermost surviving layer's
42
+ * `pageSlot()` element.
42
43
  */
43
44
  export function fromTransition(t) {
44
45
  return {
@@ -58,33 +59,21 @@ export function fromTransition(t) {
58
59
  };
59
60
  }
60
61
  /**
61
- * Live chain of mounted layers — module-level singleton.
62
- *
63
- * Vike runs one client-side adapter per browser tab. Within one tab,
64
- * a single `chainHandles` array holds the AppHandle for every active
65
- * layer, indexed `[outermostLayout, ..., innerLayout, page]`. The
66
- * array mutates in place across navigations: shared layout layers
67
- * stay live, divergent suffix layers dispose, new layers append.
62
+ * Live chain of mounted layers — module-level singleton. Vike runs one
63
+ * client-side adapter per browser tab; within one tab a single
64
+ * `chainHandles` array holds the handle for every active layer, indexed
65
+ * `[outermostLayout, ..., innerLayout, page]`. It mutates in place across
66
+ * navigations: shared layout layers stay live, divergent suffix layers
67
+ * dispose, new layers append.
68
68
  *
69
- * **Module-level scope is correct for the browser**, where the
70
- * adapter has exactly one consumer per page load. It would be
71
- * INCORRECT in a long-running multi-tenant Node SSR worker that
72
- * imports `@llui/vike/client` and tries to render multiple requests
73
- * concurrently — every request would clobber the same array. That
74
- * usage isn't supported today (the client adapter assumes a browser
75
- * runtime; the SSR side lives in `@llui/vike/server`'s `_renderChain`
76
- * which keeps state per-call), but the constraint should be made
77
- * explicit if the adapter ever grows a Node SSR consumer. If you're
78
- * here to add such a consumer: convert `chainHandles` and the
79
- * pending-slot register to per-call locals threaded through the
80
- * adapter API instead of module state, and audit `getLayoutChain`
81
- * and `_resetChainForTest` for the same change.
69
+ * Module-level scope is correct for the browser (one consumer per page
70
+ * load); a multi-tenant Node SSR worker importing the client adapter
71
+ * would clobber it that usage isn't supported.
82
72
  */
83
73
  let chainHandles = [];
84
74
  /**
85
- * @internal — test helper. Disposes every layer in the current chain
86
- * and clears the module state so subsequent calls behave as a first
87
- * mount. Not part of the public API; subject to change without notice.
75
+ * @internal — test helper. Disposes every layer in the current chain and
76
+ * clears the module state so subsequent calls behave as a first mount.
88
77
  */
89
78
  export function _resetChainForTest() {
90
79
  // Dispose innermost-first to match the normal teardown path.
@@ -104,38 +93,19 @@ export function _resetCurrentHandleForTest() {
104
93
  }
105
94
  /**
106
95
  * Default onRenderClient hook — no layout, no animation hooks. Hydrates
107
- * on first load, mounts fresh on subsequent navs. Use `createOnRenderClient`
108
- * for the customizable factory form.
96
+ * on first load, mounts fresh on subsequent navs.
109
97
  */
110
98
  export async function onRenderClient(pageContext) {
111
99
  await renderClient(pageContext, {});
112
100
  }
113
101
  /**
114
- * Factory to create a customized onRenderClient hook. See `RenderClientOptions`
115
- * for the full option surface — this is the entry point for persistent
116
- * layouts, route transitions, and lifecycle hooks.
102
+ * Factory to create a customized onRenderClient hook. See
103
+ * `RenderClientOptions` for the full option surface.
117
104
  *
118
105
  * **Do not name your layout file `+Layout.ts`.** Vike reserves the `+`
119
- * prefix for its own framework config conventions, and `+Layout.ts` is
120
- * interpreted by `vike-react` / `vike-vue` / `vike-solid` framework
121
- * adapters as a native layout config. `@llui/vike` isn't a framework
122
- * adapter in that sense — it's a render adapter, and `createOnRenderClient`
123
- * consumes the layout component directly via the `Layout` option. Name
124
- * the file `Layout.ts`, `app-layout.ts`, or anywhere outside `/pages`
125
- * that Vike won't scan, and import it here by path.
126
- *
127
- * ```ts
128
- * // pages/+onRenderClient.ts
129
- * import { createOnRenderClient, fromTransition } from '@llui/vike/client'
130
- * import { routeTransition } from '@llui/transitions'
131
- * import { AppLayout } from './Layout.js' // ← NOT './+Layout'
132
- *
133
- * export const onRenderClient = createOnRenderClient({
134
- * Layout: AppLayout,
135
- * ...fromTransition(routeTransition({ duration: 200 })),
136
- * onMount: () => console.log('page rendered'),
137
- * })
138
- * ```
106
+ * prefix for its own framework config conventions. Name the file
107
+ * `Layout.ts`, `app-layout.ts`, or anywhere outside `/pages` that Vike
108
+ * won't scan, and import it here by path.
139
109
  */
140
110
  export function createOnRenderClient(options) {
141
111
  return (pageContext) => renderClient(pageContext, options);
@@ -147,16 +117,14 @@ async function renderClient(pageContext, options) {
147
117
  throw new Error(`@llui/vike: container "${selector}" not found in DOM`);
148
118
  }
149
119
  const rootEl = container;
150
- // Resolve the chain for this render. The page component is always
151
- // the innermost entry, regardless of layout configuration.
120
+ // Resolve the chain for this render. The page is always the innermost entry.
152
121
  const layoutChain = resolveLayoutChain(options.Layout, pageContext);
153
122
  const layoutData = pageContext.lluiLayoutData ?? [];
154
123
  const newChain = [...layoutChain, pageContext.Page];
155
124
  const newChainData = [...layoutData, pageContext.data];
156
125
  if (pageContext.isHydration) {
157
- // First load — the chain starts empty and we hydrate every layer
158
- // against server-rendered HTML. No onLeave/onEnter on hydration.
159
- await mountOrHydrateChain(newChain, newChainData, rootEl, {
126
+ // First load — hydrate every layer against server-rendered HTML.
127
+ mountChainSuffix(newChain, newChainData, 0, rootEl, undefined, {
160
128
  mode: 'hydrate',
161
129
  serverStateEnvelope: window.__LLUI_STATE__,
162
130
  runInitEffectsOnHydrate: options.runInitEffectsOnHydrate,
@@ -166,53 +134,22 @@ async function renderClient(pageContext, options) {
166
134
  }
167
135
  // Subsequent nav — diff the layout chain to find the divergent suffix.
168
136
  //
169
- // The page (innermost entry, always stored at chainHandles[length-1])
170
- // is NEVER considered a surviving layer: every client navigation
171
- // disposes the current page and mounts fresh, even when the incoming
172
- // `pageContext.Page` happens to resolve to the same `ComponentDef`
173
- // reference as the outgoing one.
174
- //
175
- // Rationale: the persistent-layout feature is about keeping app
176
- // *chrome* alive across navigation — headers, sidebars, focus traps,
177
- // session state. The page, by definition, is the thing that changes
178
- // per route. Content-driven sites routinely share one `ComponentDef`
179
- // across many routes (e.g. a docs site where every `+Page.ts`
180
- // re-exports the same `DocPage` and per-route `+data.ts` supplies
181
- // the content). Treating same-def page navs as no-ops would freeze
182
- // those sites visually while the URL advances — a regression that
183
- // shipped in 0.0.26 and was reported against the llui.dev site.
184
- //
185
- // The user-supplied `onLayerDataChange` callback fires for *layouts*
186
- // that want to react to nav-scoped data (pathname, session,
187
- // breadcrumbs) without remounting — see the loop below. The page is
188
- // deliberately excluded from that path because `init(data)` always
189
- // re-runs for it.
137
+ // The page (innermost entry) is NEVER a surviving layer: every client
138
+ // navigation disposes the current page and mounts fresh, even when the
139
+ // incoming Page resolves to the same def reference. The persistent-layout
140
+ // feature is about keeping app chrome alive; the page is the thing that
141
+ // changes per route.
190
142
  let firstMismatch = 0;
191
- // `chainHandles` stores `[...layouts, page]`, so the layout prefix
192
- // length is `chainHandles.length - 1` (or 0 on first fresh mount, when
193
- // the chain is still empty). Bounding `minLen` by this length keeps
194
- // `firstMismatch` from ever advancing into the page slot.
195
143
  const prevLayoutLen = chainHandles.length === 0 ? 0 : chainHandles.length - 1;
196
144
  const minLen = Math.min(prevLayoutLen, layoutChain.length);
197
145
  while (firstMismatch < minLen && chainHandles[firstMismatch].def === newChain[firstMismatch]) {
198
146
  firstMismatch++;
199
147
  }
200
- // Push fresh data into surviving layers (layers in the shared prefix).
201
- // Without this, persistent layouts can't react to nav-driven data
202
- // changes pathname, breadcrumbs, session, nav-highlight state all
203
- // belong to the layout but change on every client navigation.
204
- //
205
- // The user-supplied `onLayerDataChange` callback receives the layer
206
- // def, its AppHandle, the new data slice, and the previously-seen
207
- // data slice. The user typically dispatches a state-update message
208
- // through `handle.send`. Layers whose data slice is unchanged
209
- // (shallow-key Object.is on records, whole-value Object.is for
210
- // primitives) are skipped without calling the hook.
211
- //
212
- // This loop is layouts-only by construction: `firstMismatch` is
213
- // bounded by `layoutChain.length` above, so indices [0, firstMismatch)
214
- // never reach the page slot. If `onLayerDataChange` is undefined,
215
- // surviving layers retain their existing state — opt-in.
148
+ // Push fresh data into surviving layers (the shared prefix). The
149
+ // user-supplied `onLayerDataChange` receives the layer def, its handle,
150
+ // and the new + previous data slices; it typically dispatches a message
151
+ // through `handle.send`. Unchanged slices are skipped. Layouts-only by
152
+ // construction (firstMismatch is bounded by layoutChain.length).
216
153
  for (let i = 0; i < firstMismatch; i++) {
217
154
  const entry = chainHandles[i];
218
155
  const newData = newChainData[i];
@@ -221,23 +158,14 @@ async function renderClient(pageContext, options) {
221
158
  const prevData = entry.data;
222
159
  entry.data = newData;
223
160
  if (options.onLayerDataChange) {
224
- options.onLayerDataChange({
225
- def: entry.def,
226
- handle: entry.handle,
227
- newData,
228
- prevData,
229
- });
161
+ options.onLayerDataChange({ def: entry.def, handle: entry.handle, newData, prevData });
230
162
  }
231
163
  }
232
- // Determine whether this nav replaces the entire root or only a suffix.
233
- // For the root swap, the outermost layer mounts/hydrates via mountApp/
234
- // hydrateApp on rootEl. For a deeper swap, the mount target is an
235
- // anchor comment owned by the surviving layer's slot. `firstMismatch
236
- // === 0` covers two cases: no layouts configured (page-only chain,
237
- // every nav is a root swap) and all layouts diverging (full re-render).
164
+ // `firstMismatch === 0` means a root swap (no layouts, or all diverging);
165
+ // otherwise the surviving layer at firstMismatch-1 owns the slot we mount into.
238
166
  const isRootSwap = firstMismatch === 0;
239
- // onLeave runs BEFORE any teardown. Outgoing DOM still mounted here.
240
- // Skip on the very first mount — there's no outgoing page to leave.
167
+ // onLeave runs BEFORE any teardown outgoing DOM still mounted. Skip on the
168
+ // very first mount (no outgoing page to leave).
241
169
  const isFirstMount = chainHandles.length === 0;
242
170
  if (options.onLeave && !isFirstMount) {
243
171
  const leaveTargetEl = isRootSwap
@@ -245,23 +173,20 @@ async function renderClient(pageContext, options) {
245
173
  : (chainHandles[firstMismatch - 1].slotAnchor?.parentElement ?? rootEl);
246
174
  await options.onLeave(leaveTargetEl);
247
175
  }
248
- // Dispose the divergent suffix, innermost first. Each handle.dispose()
249
- // calls disposeLifetime on that layer's rootLifetime, which cascades through
250
- // every child scope the layer owned (bindings, portals, onMount
251
- // cleanups, dialog focus traps, etc.). The surviving layers are
252
- // untouched because their scopes live above the disposal roots.
253
- // For anchor-based mounts, dispose() also removes the owned DOM region
254
- // between the anchor and end sentinel — no additional textContent clear needed.
176
+ // Dispose the divergent suffix, innermost first. Each handle.dispose() runs
177
+ // the layer's teardowns; anchor-mounted layers also remove their owned DOM
178
+ // region (anchor end sentinel). For a root swap the container is cleared
179
+ // explicitly below since a container mount's dispose doesn't remove DOM.
255
180
  for (let i = chainHandles.length - 1; i >= firstMismatch; i--) {
256
181
  chainHandles[i].handle.dispose();
257
182
  }
258
183
  chainHandles = chainHandles.slice(0, firstMismatch);
184
+ if (isRootSwap && !isFirstMount)
185
+ rootEl.replaceChildren();
259
186
  // Mount the new suffix starting at firstMismatch.
260
- // For a root swap, the target is the container HTMLElement.
261
- // For a deeper swap, the target is the surviving layer's slot anchor (Comment).
262
- const parentLifetime = firstMismatch === 0 ? undefined : (chainHandles[firstMismatch - 1].slotLifetime ?? undefined);
263
- const mountTargetArg = firstMismatch === 0 ? rootEl : chainHandles[firstMismatch - 1].slotAnchor;
264
- mountChainSuffix(newChain, newChainData, firstMismatch, mountTargetArg, parentLifetime, {
187
+ const mountTarget = firstMismatch === 0 ? rootEl : chainHandles[firstMismatch - 1].slotAnchor;
188
+ const mountContexts = firstMismatch === 0 ? undefined : (chainHandles[firstMismatch - 1].slotContexts ?? undefined);
189
+ mountChainSuffix(newChain, newChainData, firstMismatch, mountTarget, mountContexts, {
265
190
  mode: 'mount',
266
191
  });
267
192
  // onEnter fires after the new suffix is in place. Fire-and-forget.
@@ -274,18 +199,8 @@ async function renderClient(pageContext, options) {
274
199
  options.onMount?.(snapshotLayoutChain());
275
200
  }
276
201
  /**
277
- * Public read of the current layout chain. Returns the live
278
- * `AppHandle`s for `[...layouts, page]`, outermost first. Empty array
279
- * before the first mount; updates after every navigation.
280
- *
281
- * Returns a fresh array each call, but the AppHandle references are
282
- * shared with the live chain — calling `.send()` / `.dispose()` /
283
- * `.subscribe()` operates on the same instance the framework manages.
284
- *
285
- * Prefer the `onMount(chain)` callback for lifecycle-coupled wiring
286
- * (the framework guarantees the chain is fully populated when it
287
- * fires); use this getter for ad-hoc reads where the caller can't
288
- * thread state through `onMount`.
202
+ * Public read of the current layout chain live `LayerHandle`s for
203
+ * `[...layouts, page]`, outermost first. Empty before the first mount.
289
204
  */
290
205
  export function getLayoutChain() {
291
206
  return snapshotLayoutChain();
@@ -294,81 +209,57 @@ function snapshotLayoutChain() {
294
209
  return chainHandles.map((entry) => entry.handle);
295
210
  }
296
211
  /**
297
- * Walk the full chain for the first mount or hydration. Starts from
298
- * depth 0 at the root container, threads each layer's slot into the
299
- * next layer's mount target + parentLifetime.
300
- */
301
- async function mountOrHydrateChain(chain, chainData, rootEl, opts) {
302
- mountChainSuffix(chain, chainData, 0, rootEl, undefined, opts);
303
- }
304
- /**
305
- * Mount (or hydrate) `chain[startAt..end]` into `initialTarget`, with
306
- * the initial layer's rootLifetime parented at `initialParentLifetime`.
307
- * Threads slot → next-target → next-parentLifetime through the chain.
212
+ * Mount (or hydrate) `chain[startAt..end]` into `initialTarget`, replaying
213
+ * `initialContexts` into the first layer's build. Threads each layer's slot
214
+ * (anchor + captured contexts) into the next layer's target + contexts.
308
215
  *
309
- * `initialTarget` is `HTMLElement` for the outermost layer (container-
310
- * based mount/hydrate) and `Comment` for inner layers that mount relative
311
- * to a `pageSlot()` anchor.
216
+ * `initialTarget` is an `HTMLElement` for the outermost layer (container mount/
217
+ * hydrate) and a `Comment` for inner layers mounting relative to a `pageSlot()`
218
+ * anchor.
312
219
  *
313
- * Fails loudly if a non-innermost layer forgot to call `pageSlot()`,
314
- * or if the innermost layer called `pageSlot()` unnecessarily.
220
+ * Fails loudly if a non-innermost layer forgot to call `pageSlot()`, or if the
221
+ * innermost layer called `pageSlot()` unnecessarily.
315
222
  *
316
- * @internal — test helper. Exported so `client-page-slot.test.ts` can
317
- * test anchor-mount/dispose contracts directly with hand-built DOM.
318
- * Not part of the public API.
223
+ * @internal — test helper. Exported so `client-page-slot.test.ts` can exercise
224
+ * anchor-mount/dispose contracts directly with hand-built DOM.
319
225
  */
320
- export function _mountChainSuffix(chain, chainData, startAt, initialTarget, initialParentLifetime, opts) {
226
+ export function _mountChainSuffix(chain, chainData, startAt, initialTarget, initialContexts, opts) {
321
227
  let mountTarget = initialTarget;
322
- let parentLifetime = initialParentLifetime;
228
+ let contexts = initialContexts;
323
229
  for (let i = startAt; i < chain.length; i++) {
324
230
  const def = chain[i];
325
231
  const layerData = chainData[i];
326
232
  const isInnermost = i === chain.length - 1;
327
233
  // Defensive: clear any stale slot from a prior failed mount.
328
234
  _resetPendingSlot();
235
+ const isContainer = mountTarget.nodeType === 1;
236
+ const target = isContainer
237
+ ? mountTarget
238
+ : { anchor: mountTarget, mode: opts.mode === 'hydrate' ? 'replace' : 'append' };
329
239
  let handle;
330
240
  if (opts.mode === 'hydrate') {
331
- // Hydration envelope: each layer pulls its own state slice. The
332
- // envelope shape is `{ layouts: [...], page: {...} }` with each
333
- // entry carrying `{ name, state }`. We match by name so a server/
334
- // client mismatch throws with a clear error instead of silently
335
- // hydrating the wrong state into the wrong instance.
241
+ // Each layer pulls its own state slice from the envelope, matched by name
242
+ // so a server/client mismatch throws clearly instead of binding wrong state.
336
243
  const layerState = extractHydrationState(opts.serverStateEnvelope, i, chain.length, def);
337
- if (mountTarget.nodeType === 1) {
338
- // HTMLElement — outermost layer, use hydrateApp (container-based).
339
- // Cross from the type-erased AnyComponentDef back into a concrete
340
- // ComponentDef<unknown, unknown, unknown, unknown> for the mount
341
- // primitive's signature. The cast is safe — mountApp / hydrateApp
342
- // don't use the type parameters at runtime.
343
- handle = hydrateApp(mountTarget, def, layerState, { parentLifetime, runInitEffectsOnHydrate: opts.runInitEffectsOnHydrate });
344
- }
345
- else {
346
- // Comment anchor — inner layer, use hydrateAtAnchor.
347
- handle = hydrateAtAnchor(mountTarget, def, layerState, { parentLifetime, runInitEffectsOnHydrate: opts.runInitEffectsOnHydrate });
348
- }
244
+ handle = hydrateSignalApp(target, def, layerState, {
245
+ runInitEffects: opts.runInitEffectsOnHydrate,
246
+ contexts,
247
+ });
349
248
  }
350
249
  else {
351
- if (mountTarget.nodeType === 1) {
352
- // HTMLElement — outermost layer, use mountApp (container-based).
353
- handle = mountApp(mountTarget, def, layerData, { parentLifetime });
354
- }
355
- else {
356
- // Comment anchor — inner layer, use mountAtAnchor.
357
- handle = mountAtAnchor(mountTarget, def, layerData, { parentLifetime });
358
- }
250
+ handle = mountSignalComponent(target, def, {
251
+ initialState: seedFor(layerData),
252
+ contexts,
253
+ });
359
254
  }
360
255
  const slot = _consumePendingSlot();
361
256
  if (isInnermost && slot !== null) {
362
- // Innermost layer declared a slot with nothing to fill it —
363
- // probably a misuse of pageSlot() in the page component itself.
364
257
  handle.dispose();
365
258
  throw new Error(`[llui/vike] <${def.name}> is the innermost component in the chain ` +
366
259
  `but called pageSlot(). pageSlot() only belongs in layout components ` +
367
260
  `that wrap a nested page or layout — not in the page itself.`);
368
261
  }
369
262
  if (!isInnermost && slot === null) {
370
- // Non-innermost layer didn't declare a slot — there's nowhere to
371
- // mount the remaining chain.
372
263
  handle.dispose();
373
264
  throw new Error(`[llui/vike] <${def.name}> is a layout layer at depth ${i} but did not ` +
374
265
  `call pageSlot() in its view(). There are ${chain.length - i - 1} more ` +
@@ -379,47 +270,28 @@ export function _mountChainSuffix(chain, chainData, startAt, initialTarget, init
379
270
  def,
380
271
  handle,
381
272
  slotAnchor: slot?.anchor ?? null,
382
- slotLifetime: slot?.slotLifetime ?? null,
273
+ slotContexts: slot?.contexts ?? null,
383
274
  data: layerData,
384
275
  });
385
276
  if (slot !== null) {
386
- // Next layer mounts relative to the slot's comment anchor.
277
+ // Next layer mounts relative to this slot's anchor, replaying the
278
+ // contexts captured at the slot so providers above it stay reachable.
387
279
  mountTarget = slot.anchor;
388
- parentLifetime = slot.slotLifetime;
280
+ contexts = slot.contexts;
389
281
  }
390
282
  }
391
283
  }
392
- // Internal alias used by renderClient and mountOrHydrateChain.
393
- // The public-named export above carries the @internal doc.
284
+ // Internal alias used by renderClient. The public-named export above carries
285
+ // the @internal doc.
394
286
  const mountChainSuffix = _mountChainSuffix;
395
287
  /**
396
- * Pull the per-layer state from the hydration envelope. Supports both
397
- * the new chain-aware shape (`{ layouts: [...], page: {...} }`) and the
398
- * legacy flat shape (`window.__LLUI_STATE__` is the state object itself)
399
- * for backward compatibility with pages written against 0.0.15 or earlier.
400
- *
401
- * Throws on envelope shape mismatch — missing entries, wrong component
402
- * name at a given index — so server/client drift fails loud instead of
403
- * silently binding the wrong state to the wrong instance.
404
- */
405
- /**
406
- * Shallow-key data diff for the persistent-layer prop-update path.
407
- * Returns true when `next` differs from `prev` enough to warrant
408
- * dispatching the user's `onLayerDataChange` hook:
409
- *
410
- * - `Object.is(prev, next)` short-circuits identical references.
411
- * - For two plain-object records, walks the union of keys and returns
412
- * true on the first `Object.is` mismatch.
413
- * - For anything else (primitives, arrays, class instances), falls
414
- * back to the top-level `Object.is` result — covers the cases where
415
- * the host populates `lluiLayoutData[i]` with a primitive or a
416
- * referentially-stable object.
288
+ * Shallow-key data diff for the surviving-layer prop-update path. Returns true
289
+ * when `next` differs from `prev` enough to warrant dispatching the user's
290
+ * `onLayerDataChange` hook.
417
291
  */
418
292
  function hasDataChanged(prev, next) {
419
293
  if (Object.is(prev, next))
420
294
  return false;
421
- // Both must be plain object records to do a key walk; otherwise the
422
- // Object.is above is the only signal.
423
295
  if (prev === null ||
424
296
  next === null ||
425
297
  typeof prev !== 'object' ||
@@ -442,9 +314,15 @@ function hasDataChanged(prev, next) {
442
314
  }
443
315
  return false;
444
316
  }
317
+ /**
318
+ * Pull the per-layer state from the hydration envelope. Supports the chain-aware
319
+ * shape (`{ layouts: [...], page: {...} }`) and the legacy flat shape (the state
320
+ * object itself) for a single-layer page-only render.
321
+ *
322
+ * Throws on envelope shape mismatch — missing entries, wrong component name at a
323
+ * given index — so server/client drift fails loud.
324
+ */
445
325
  function extractHydrationState(envelope, layerIndex, chainLength, def) {
446
- // Legacy flat envelope — no layout chain at render time. Only valid
447
- // when the chain has a single layer (the page).
448
326
  const isLegacyFlat = envelope !== null &&
449
327
  typeof envelope === 'object' &&
450
328
  !('layouts' in envelope) &&