@vmz/core 0.3.13 → 0.3.15

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.
@@ -7,7 +7,15 @@ export type ComponentCtor = ComponentCtorLike & {
7
7
  [key: string]: unknown;
8
8
  };
9
9
  export type VmzDomElement = Element & {
10
+ /** `v-pre` subtree: attributes are written exactly as authored. */
11
+ __vmzVerbatim?: boolean;
10
12
  __vmzEvt?: Record<string, EventListener>;
13
+ __vmzObjEvt?: Record<string, {
14
+ types: Record<string, {
15
+ handlers: Function[];
16
+ wrapper: EventListener;
17
+ }>;
18
+ }>;
11
19
  __vmzInst?: DirectInstance;
12
20
  __vmzPropBindSeq?: number;
13
21
  __vmzDispose?: (() => void) | null;
@@ -69,15 +77,24 @@ export type TraceBuffer = {
69
77
  events: TraceEvent[];
70
78
  };
71
79
  export type ResumeAdoptCtx = {
72
- el: (tag: string, svg?: boolean) => Element;
80
+ el: (tag: string, svg?: boolean, key?: unknown) => Element;
73
81
  elNS?: (ns: string, tag: string) => Element;
74
82
  text: (value?: unknown) => Text;
75
- componentHost?: (name: string) => HTMLElement | null;
83
+ componentHost?: (name: string, key?: unknown) => HTMLElement | null;
76
84
  enter?: (node: Element) => boolean;
85
+ /** Open a named `<slot name="…">` projection scope inside a component host. */
86
+ enterSlot?: (hostEl: Element, name: string) => boolean;
77
87
  leave?: () => void;
78
88
  scopeDepth?: () => number;
79
89
  rewindScope?: (depth: number) => void;
80
90
  beginBranchScope?: () => () => void;
91
+ /** Open a multi-root `<template v-for>` row scope (claim the SSR marker pair). */
92
+ rowStart?: (key: unknown) => boolean;
93
+ rowEnd?: () => void;
94
+ /** Keep the parked SSR subtree when its SSR payload digest equals `digest`. */
95
+ reclaimHtml?: (el: Element, digest: string) => boolean;
96
+ /** Keep the parked SSR subtree when this create has no payload yet. */
97
+ keepHtml?: (el: Element) => boolean;
81
98
  _enterBalance?: number;
82
99
  inst?: DirectInstance;
83
100
  };
@@ -39,9 +39,6 @@ export function installClientNavigation(opts = {}) {
39
39
  else if (win?.vmzDestroy)
40
40
  win.vmzDestroy(inst);
41
41
  }
42
- async function loadDomFallback() {
43
- return import(/* @vite-ignore */ '/dom.browser.js');
44
- }
45
42
  async function transitionTo(url, { replace = false, fromPop = false, softFail = false } = {}) {
46
43
  const target = new URL(url, loc.href);
47
44
  if (target.origin !== loc.origin) {
@@ -98,7 +95,12 @@ export function installClientNavigation(opts = {}) {
98
95
  else
99
96
  hist.pushState({ vmzClientNav: true }, '', target.href);
100
97
  }
101
- const importChunk = opts.importPage || (async (id) => (await import(/* @vite-ignore */ `/${id}.client.js`)).default);
98
+ const importChunk = (id) => {
99
+ if (typeof opts.importPage !== 'function') {
100
+ throw new Error('vmz:client-nav missing importPage — the generated entry must pass the pack manifest table');
101
+ }
102
+ return opts.importPage(id);
103
+ };
102
104
  let liveRoot = root;
103
105
  let retainedLayout = false;
104
106
  applyLocaleRealization(doc, nextApp);
@@ -106,28 +108,21 @@ export function installClientNavigation(opts = {}) {
106
108
  applyAppAttrs(root, nextApp);
107
109
  if (chunkId) {
108
110
  const Page = await importChunk(chunkId);
109
- let hydrateRoutePage = opts.hydrateRoutePage;
110
- if (typeof hydrateRoutePage !== 'function') {
111
- const dom = await loadDomFallback();
112
- hydrateRoutePage = dom.hydrateRoutePage;
113
- }
114
- if (typeof hydrateRoutePage === 'function') {
115
- await hydrateRoutePage(Page, root, props);
111
+ if (typeof opts.hydrateRoutePage === 'function') {
112
+ await opts.hydrateRoutePage(Page, root, props);
116
113
  }
117
- else {
118
- let hydrate = opts.hydrate;
119
- if (typeof hydrate !== 'function') {
120
- const dom = await loadDomFallback();
121
- hydrate = dom.hydrate;
122
- }
114
+ else if (typeof opts.hydrate === 'function') {
123
115
  const pageHost = root.__vmzPageHost || root;
124
116
  if (pageHost.__vmzInst)
125
117
  destroyInst(pageHost.__vmzInst);
126
- await hydrate(Page, pageHost, props);
118
+ await opts.hydrate(Page, pageHost, props);
127
119
  root.__vmzPageHost = pageHost;
128
120
  if (!root.__vmzLayoutInsts?.length)
129
121
  root.__vmzInst = pageHost.__vmzInst;
130
122
  }
123
+ else {
124
+ throw new Error('vmz:client-nav missing hydrateRoutePage and hydrate — the generated entry must pass the packed face');
125
+ }
131
126
  }
132
127
  retainedLayout = true;
133
128
  }
@@ -148,21 +143,14 @@ export function installClientNavigation(opts = {}) {
148
143
  for (const id of nextLayout) {
149
144
  layoutCtors.push(await importChunk(id));
150
145
  }
151
- let hydrateRoute = opts.hydrateRoute;
152
- if (typeof hydrateRoute !== 'function') {
153
- const dom = await loadDomFallback();
154
- hydrateRoute = dom.hydrateRoute;
146
+ if (typeof opts.hydrateRoute === 'function') {
147
+ await opts.hydrateRoute(Page, fresh, props, layoutCtors);
155
148
  }
156
- if (typeof hydrateRoute === 'function') {
157
- await hydrateRoute(Page, fresh, props, layoutCtors);
149
+ else if (typeof opts.hydrate === 'function') {
150
+ await opts.hydrate(Page, fresh, props);
158
151
  }
159
152
  else {
160
- let hydrate = opts.hydrate;
161
- if (typeof hydrate !== 'function') {
162
- const dom = await loadDomFallback();
163
- hydrate = dom.hydrate;
164
- }
165
- await hydrate(Page, fresh, props);
153
+ throw new Error('vmz:client-nav missing hydrateRoute and hydrate — the generated entry must pass the packed face');
166
154
  }
167
155
  }
168
156
  }
@@ -187,7 +187,11 @@ export function __vmzWritePath(inst, root, segs, value) {
187
187
  const rootObj = inst[root];
188
188
  const rootArr = rootObj;
189
189
  const isRootIndex = normSegs.length === 1 && Array.isArray(rootArr) && leaf !== 'length' && String(Number(leaf)) === leaf;
190
- if (isRootIndex) {
190
+ // Root `rows.length = n` truncates or grows the list itself: the bare `rows`
191
+ // structure depot only matches a replace notice, so a path notice on
192
+ // `rows.length` leaves the rendered rows stale.
193
+ const isRootLength = normSegs.length === 1 && Array.isArray(rootArr) && leaf === 'length';
194
+ if (isRootIndex || isRootLength) {
191
195
  if (!notifyWbShared(rootObj, null)) {
192
196
  scheduleRefresh(inst, { type: 'replace', root });
193
197
  }
@@ -1,9 +1,13 @@
1
- import { directApi, noteDomCreate, runDirectCreate, } from '../dom-core.js';
1
+ import { directApi, findOwnedDefaultSlot, findOwnedNamedSlot, noteDomCreate, runDirectCreate, } from '../dom-core.js';
2
+ import { HTML_HASH_ATTR } from '../../shared/html-digest.js';
3
+ import { ROW_END_MARKER_PREFIX, ROW_MARKER_PREFIX, rowEndMarker, rowMarker } from '../../shared/row-key.js';
2
4
  const SVG_NS = 'http://www.w3.org/2000/svg';
3
5
  function createResumeAdopt(pool, rootEl) {
4
6
  const used = new WeakSet();
5
7
  const scopeStack = [pool];
6
8
  const currentScope = () => scopeStack[scopeStack.length - 1] || pool;
9
+ /** `rootEl`'s children were parked into `pool` before create — see bottom. */
10
+ let rootElTookPool = false;
7
11
  const markElement = (n) => {
8
12
  used.add(n);
9
13
  if (typeof n.getAttribute === 'function') {
@@ -11,14 +15,32 @@ function createResumeAdopt(pool, rootEl) {
11
15
  if (k != null && n.__vmzKey == null)
12
16
  n.__vmzKey = k;
13
17
  }
14
- // Private child pool ??never dump into the parent/global pool (that let
15
- // if branches reclaim Drawer/sibling hosts parked as uncles).
16
- const childPool = document.createDocumentFragment();
17
- while (n.firstChild)
18
- childPool.appendChild(n.firstChild);
18
+ // `runDirectResume` parks `rootEl`'s children into the top-level pool
19
+ // before create. Adopting `rootEl` must hand that same pool to it as the
20
+ // child scope: making a fresh (empty) child pool here would leave every
21
+ // SSR descendant unreachable and silently recreate the whole subtree.
22
+ let childPool;
23
+ if (n === rootEl && !rootElTookPool) {
24
+ rootElTookPool = true;
25
+ childPool = pool;
26
+ }
27
+ else {
28
+ // Private child pool ??never dump into the parent/global pool (that let
29
+ // if branches reclaim Drawer/sibling hosts parked as uncles).
30
+ childPool = document.createDocumentFragment();
31
+ while (n.firstChild)
32
+ childPool.appendChild(n.firstChild);
33
+ }
19
34
  n.__vmzResumePool = childPool;
20
35
  return n;
21
36
  };
37
+ /**
38
+ * Component hosts are claimed only by `componentHost`, never by plain element
39
+ * adoption. Hosts may be `<span>` (phrasing inline chips) or `<div>` (block
40
+ * surfaces), so tag matching alone would let a page `<span>` steal a chip host
41
+ * and force a rebuild.
42
+ */
43
+ const isComponentHost = (el) => typeof el.getAttribute === 'function' && el.getAttribute('data-vmz') != null;
22
44
  function* elementCandidates() {
23
45
  const scope = currentScope();
24
46
  if (scope === pool && rootEl && !used.has(rootEl))
@@ -44,14 +66,40 @@ function createResumeAdopt(pool, rootEl) {
44
66
  yield n;
45
67
  }
46
68
  }
47
- const adoptElement = (tag, svg = false) => {
69
+ const adoptElement = (tag, svg = false, key = null) => {
48
70
  const want = String(tag || 'div').toLowerCase();
71
+ // Keyed non-kernel rows (`component`, `v-once`, `v-memo`) carry their key on
72
+ // the root, so reclaim by identity. Falling back to tag order reuses a node
73
+ // that belongs to another key as soon as list order diverges from SSR.
74
+ if (key != null) {
75
+ const wanted = String(key);
76
+ for (const el of elementCandidates()) {
77
+ if (String(el.tagName).toLowerCase() !== want)
78
+ continue;
79
+ if (typeof el.getAttribute !== 'function')
80
+ continue;
81
+ if (el.getAttribute('data-vmz-key') !== wanted)
82
+ continue;
83
+ return markElement(el);
84
+ }
85
+ noteDomCreate();
86
+ const fresh = svg ? document.createElementNS(SVG_NS, tag || 'svg') : document.createElement(tag || 'div');
87
+ fresh.__vmzKey = wanted;
88
+ return fresh;
89
+ }
49
90
  for (const el of elementCandidates()) {
50
- if (String(el.tagName).toLowerCase() === want)
91
+ if (String(el.tagName).toLowerCase() === want && !isComponentHost(el))
51
92
  return markElement(el);
52
93
  }
53
94
  noteDomCreate();
54
- return svg ? document.createElementNS(SVG_NS, tag || 'svg') : document.createElement(tag || 'div');
95
+ const fresh = svg ? document.createElementNS(SVG_NS, tag || 'svg') : document.createElement(tag || 'div');
96
+ // Direct SSR flattens `<slot/>` away and writes the projected children in
97
+ // place, so a rebuilt outlet has no SSR counterpart: its projection stays
98
+ // in the enclosing scope. Share that scope as the outlet pool so a later
99
+ // `adoptEnter(host)` can still reclaim the projected subtree.
100
+ if (want === 'slot')
101
+ fresh.__vmzResumePool = currentScope();
102
+ return fresh;
55
103
  };
56
104
  const adoptElementNS = (ns, tag) => {
57
105
  const want = String(tag || 'g');
@@ -62,6 +110,43 @@ function createResumeAdopt(pool, rootEl) {
62
110
  noteDomCreate();
63
111
  return document.createElementNS(ns || SVG_NS, want);
64
112
  };
113
+ /**
114
+ * Reclaim the SSR row root for one keyed-each key.
115
+ *
116
+ * Tag-order adoption cannot distinguish rows when the client list is already
117
+ * ahead of the SSR order (persisted state, re-filtered store): the first
118
+ * `<li>` would be reused for whichever key the client renders first, so the
119
+ * retained DOM node (focus, caret, typed value) belongs to another key.
120
+ * Keyed rows carry `data-vmz-key` on the server, so match it exactly and leave
121
+ * unmatched SSR rows for the pool teardown. `null` means "not present in SSR"
122
+ * and the caller creates a fresh row.
123
+ */
124
+ const claimKeyed = (tag, key) => {
125
+ if (key == null)
126
+ return null;
127
+ const want = String(tag || '').toLowerCase();
128
+ const wanted = String(key);
129
+ if (!want)
130
+ return null;
131
+ for (const el of elementCandidates()) {
132
+ if (String(el.tagName).toLowerCase() !== want)
133
+ continue;
134
+ if (typeof el.getAttribute !== 'function')
135
+ continue;
136
+ if (el.getAttribute('data-vmz-key') !== wanted)
137
+ continue;
138
+ // Row kernels own the whole row subtree (no nested each/component), and
139
+ // `hydrate` reads `root.firstChild` for its text slots. Do not stash the
140
+ // row's children into a resume pool here: the row must keep its SSR
141
+ // structure as-is.
142
+ used.add(el);
143
+ const k = el.getAttribute('data-vmz-key');
144
+ if (k != null && el.__vmzKey == null)
145
+ el.__vmzKey = k;
146
+ return el;
147
+ }
148
+ return null;
149
+ };
65
150
  const adoptText = (value) => {
66
151
  for (const n of textCandidates()) {
67
152
  used.add(n);
@@ -72,21 +157,78 @@ function createResumeAdopt(pool, rootEl) {
72
157
  noteDomCreate();
73
158
  return document.createTextNode(value == null ? '' : String(value));
74
159
  };
75
- const componentHost = (name) => {
160
+ const componentHost = (name, key = null) => {
76
161
  const want = String(name || '');
162
+ const wanted = key == null ? null : String(key);
77
163
  for (const el of elementCandidates()) {
78
- if (String(el.tagName).toLowerCase() !== 'div')
79
- continue;
80
164
  if (el.getAttribute('data-vmz') !== want)
81
165
  continue;
166
+ if (wanted != null && el.getAttribute('data-vmz-key') !== wanted)
167
+ continue;
82
168
  return markElement(el);
83
169
  }
84
170
  return null;
85
171
  };
172
+ /** Any parked SSR node in `frag` that no earlier create has claimed. */
173
+ const hasUnusedNode = (frag) => {
174
+ for (const n of frag.childNodes) {
175
+ if (!used.has(n))
176
+ return true;
177
+ }
178
+ return false;
179
+ };
86
180
  const enter = (node) => {
87
- if (!node || !node.__vmzResumePool)
181
+ if (!node)
88
182
  return false;
89
- scopeStack.push(node.__vmzResumePool);
183
+ // `adoptEnter(host)` in the parent template wraps the *projection*, not the
184
+ // component's own internals (its host pool was consumed by the component
185
+ // create). The parked projection lives under the default `<slot/>` outlet,
186
+ // so entering the host must push that outlet's pool.
187
+ const owner = node.__vmzInst;
188
+ if (owner) {
189
+ const slot = findOwnedDefaultSlot(owner.__vmzDomRoot);
190
+ const slotPool = slot && slot.__vmzResumePool;
191
+ if (slotPool && hasUnusedNode(slotPool)) {
192
+ scopeStack.push(slotPool);
193
+ return true;
194
+ }
195
+ }
196
+ // Emitted `adoptEnter` is always paired with `adoptLeave`, but a node that
197
+ // has no SSR counterpart (fresh element or fresh component host) has no
198
+ // parked pool. Returning without pushing made that paired leave pop the
199
+ // caller's scope instead, so each rebuilt node drifted the stack one level
200
+ // up and later projection siblings (slot roots 2..n) lost their pool.
201
+ // Push an isolated empty scope: the rebuilt subtree has no SSR counterpart
202
+ // to reclaim, and its children must not steal the enclosing siblings.
203
+ scopeStack.push(node.__vmzResumePool || document.createDocumentFragment());
204
+ return true;
205
+ };
206
+ /**
207
+ * Push the parked pool of an owned named `<slot name="…">` outlet.
208
+ *
209
+ * SSR inlines named projections exactly like the default outlet, so the nodes
210
+ * a page projects into `#header` sit under the named outlet and stay
211
+ * unreachable from the default pool that `enter(host)` pushes. Without this
212
+ * scope the named projection is rebuilt on every load while the default
213
+ * projection is reclaimed. A host without that outlet (fresh component or a
214
+ * name the component never declares) gets an isolated empty scope so the
215
+ * emitted enter/leave pair always stays balanced.
216
+ */
217
+ const enterSlot = (hostEl, name) => {
218
+ if (!hostEl)
219
+ return false;
220
+ const wanted = name == null ? '' : String(name);
221
+ if (!wanted || wanted === 'default')
222
+ return enter(hostEl);
223
+ const owner = hostEl.__vmzInst;
224
+ const root = (owner && owner.__vmzDomRoot) || hostEl;
225
+ const slot = root && root.nodeType === 1 ? findOwnedNamedSlot(root, wanted) : null;
226
+ const slotPool = slot && slot.__vmzResumePool;
227
+ if (slotPool && hasUnusedNode(slotPool)) {
228
+ scopeStack.push(slotPool);
229
+ return true;
230
+ }
231
+ scopeStack.push(document.createDocumentFragment());
90
232
  return true;
91
233
  };
92
234
  const leave = () => {
@@ -121,16 +263,144 @@ function createResumeAdopt(pool, rootEl) {
121
263
  rewindScope(depth - 1);
122
264
  };
123
265
  };
266
+ /**
267
+ * `v-html` on a reclaimed element. `markElement` parked its SSR children, so
268
+ * the live element reads empty. SSR stamped the digest of the exact payload
269
+ * string it wrote on the host (`HTML_HASH_ATTR`), so compare that marker with
270
+ * the digest of the string this create would write: equal means the parked
271
+ * subtree already *is* that payload, and moving it back keeps every SSR node.
272
+ * A different digest drops the parked payload so the normal write replaces it.
273
+ */
274
+ const reclaimHtml = (el, digest) => {
275
+ if (!used.has(el))
276
+ return false;
277
+ const childPool = el.__vmzResumePool;
278
+ const stamp = String(el.getAttribute(HTML_HASH_ATTR) || '');
279
+ if (stamp !== digest) {
280
+ // A different payload replaces this host, so parked SSR children can
281
+ // never be adopted afterwards. Drop them now instead of leaving a
282
+ // stale pool for a later identical patch to resurrect.
283
+ if (childPool)
284
+ while (childPool.firstChild)
285
+ childPool.removeChild(childPool.firstChild);
286
+ return false;
287
+ }
288
+ if (childPool)
289
+ while (childPool.firstChild)
290
+ el.appendChild(childPool.firstChild);
291
+ return true;
292
+ };
293
+ /**
294
+ * `v-html` create with no payload yet (e.g. an async highlighter that has
295
+ * not produced one). The host may still own the SSR payload: hand the parked
296
+ * subtree back and leave the SSR stamp untouched so a later authoritative
297
+ * patch can still match it. The host keeps that payload until a real payload
298
+ * lands; once one does, a null patch clears it like `v-html` always has.
299
+ */
300
+ const keepHtml = (el) => {
301
+ if (!used.has(el))
302
+ return false;
303
+ // The emitted `v-html` create and its tracked patch both run with a null
304
+ // payload (the patch applies once right after create), so the hold must
305
+ // outlive the resume pass: one restore is enough until a payload lands.
306
+ if (el.__vmzHtmlKeep)
307
+ return true;
308
+ const childPool = el.__vmzResumePool;
309
+ if (!childPool || !childPool.firstChild)
310
+ return false;
311
+ while (childPool.firstChild)
312
+ el.appendChild(childPool.firstChild);
313
+ el.__vmzResumePool = null;
314
+ el.__vmzHtmlKeep = true;
315
+ return true;
316
+ };
317
+ /**
318
+ * Scope adoption to one multi-root `<template v-for>` row.
319
+ *
320
+ * The row has no element root to carry `data-vmz-key`, so SSR wrote a
321
+ * `<!--vmz-row:KEY-->…<!--vmz-row-end:KEY-->` pair around its nodes. Claim
322
+ * exactly that pair, move its inner nodes into a private scope, and drop the
323
+ * markers so the resumed DOM matches a client-created row. A missing or
324
+ * unbalanced pair means "this row is not in SSR": push an empty scope so the
325
+ * row is built fresh instead of stealing a sibling row's nodes.
326
+ */
327
+ const rowStart = (key) => {
328
+ const scope = currentScope();
329
+ const rowPool = document.createDocumentFragment();
330
+ let claimed = false;
331
+ if (key != null) {
332
+ const wanted = rowMarker(key);
333
+ const wantedEnd = rowEndMarker(key);
334
+ const nodes = [...scope.childNodes];
335
+ let start = -1;
336
+ for (let i = 0; i < nodes.length; i++) {
337
+ const n = nodes[i];
338
+ if (n.nodeType !== 8)
339
+ continue;
340
+ if (String(n.nodeValue || '') === wanted) {
341
+ start = i;
342
+ break;
343
+ }
344
+ }
345
+ let end = -1;
346
+ if (start >= 0) {
347
+ // Nested rows directly inside this row contribute their own pair at
348
+ // the same sibling level, so pairing is depth-counted.
349
+ let depth = 0;
350
+ for (let i = start + 1; i < nodes.length; i++) {
351
+ const n = nodes[i];
352
+ if (n.nodeType !== 8)
353
+ continue;
354
+ const data = String(n.nodeValue || '');
355
+ if (data.startsWith(ROW_MARKER_PREFIX)) {
356
+ depth += 1;
357
+ }
358
+ else if (data.startsWith(ROW_END_MARKER_PREFIX)) {
359
+ if (depth === 0) {
360
+ if (data === wantedEnd)
361
+ end = i;
362
+ break;
363
+ }
364
+ depth -= 1;
365
+ }
366
+ }
367
+ }
368
+ if (end > start) {
369
+ const startMarker = nodes[start];
370
+ const endMarker = nodes[end];
371
+ for (let i = start + 1; i < end; i++)
372
+ rowPool.appendChild(nodes[i]);
373
+ if (startMarker.parentNode === scope)
374
+ scope.removeChild(startMarker);
375
+ if (endMarker.parentNode === scope)
376
+ scope.removeChild(endMarker);
377
+ claimed = true;
378
+ }
379
+ }
380
+ scopeStack.push(rowPool);
381
+ return claimed;
382
+ };
383
+ const rowEnd = () => {
384
+ if (scopeStack.length <= 1)
385
+ return;
386
+ scopeStack.pop();
387
+ };
124
388
  return {
125
389
  el: adoptElement,
126
390
  elNS: adoptElementNS,
391
+ claimKeyed,
392
+ rowStart,
393
+ rowEnd,
127
394
  text: adoptText,
128
395
  componentHost,
129
396
  enter,
397
+ enterSlot,
130
398
  leave,
131
399
  scopeDepth,
132
400
  rewindScope,
133
401
  beginBranchScope,
402
+ reclaimHtml,
403
+ keepHtml,
134
404
  };
135
405
  }
136
406
  export function runDirectResume(Component, inst, container) {
@@ -1,6 +1,31 @@
1
1
  import { adoptTopLevelDomRoot, applyPreservedState, createInstance, destroy, findOwnedDefaultSlot, hasMeaningfulChild, mount, runDirectCreate, settlePendingChildMounts, snapshotInstanceState, } from '../dom-core.js';
2
2
  import { runDirectResume } from './adopt.js';
3
3
  import { resumeIslands } from './resume.js';
4
+ /**
5
+ * A layout whose whole template is a default `<slot/>` contributes no DOM of its
6
+ * own: SSR renders the page straight into the route container. Mounting such a
7
+ * layout would `replaceChildren()` the SSR tree, so the page hydrate that follows
8
+ * always sees an empty host and recreates every node (verified on the homepage and
9
+ * charts route: 0 of 336 / 0 of 18743 SSR nodes were reused).
10
+ *
11
+ * Only trusted for layouts with no state, props, or lifecycle, so dropping the
12
+ * instance cannot change behavior — a pass-through layout has nothing to run.
13
+ */
14
+ function isTransparentSlotLayout(Layout) {
15
+ const plan = Layout && Layout.__vmzPlan;
16
+ if (!plan || !Array.isArray(plan.root_ids) || !Array.isArray(plan.nodes) || plan.root_ids.length !== 1) {
17
+ return false;
18
+ }
19
+ const root = plan.nodes.find((n) => n && n.id === plan.root_ids[0]);
20
+ if (!root || root.kind !== 'slot')
21
+ return false;
22
+ if (Array.isArray(Layout.__vmzState) && Layout.__vmzState.length)
23
+ return false;
24
+ if (Array.isArray(Layout.__vmzProps) && Layout.__vmzProps.length)
25
+ return false;
26
+ const proto = Layout.prototype;
27
+ return !(proto && (typeof proto.onMount === 'function' || typeof proto.onDestroy === 'function'));
28
+ }
4
29
  export async function hydrateRoute(Page, container, props = {}, layoutCtors = [], opts = {}) {
5
30
  if (typeof document === 'undefined') {
6
31
  throw new Error('vmz:dom hydrateRoute() requires a document (browser)');
@@ -14,6 +39,15 @@ export async function hydrateRoute(Page, container, props = {}, layoutCtors = []
14
39
  const layoutInsts = [];
15
40
  let host = container;
16
41
  const ctors = Array.isArray(layoutCtors) ? layoutCtors.filter(Boolean) : [];
42
+ // Pass-through layout chain: hydrate the page into the SSR container directly so
43
+ // the rendered subtree becomes the adopt pool instead of being wiped by mount().
44
+ if (ctors.length && ctors.every(isTransparentSlotLayout) && hasMeaningfulChild(container)) {
45
+ const pageInst = await hydrate(Page, container, props, opts);
46
+ container.__vmzPageHost = container;
47
+ container.__vmzLayoutInsts = [];
48
+ container.__vmzInst = pageInst;
49
+ return pageInst;
50
+ }
17
51
  for (const Layout of ctors) {
18
52
  const inst = await mount(Layout, host, {});
19
53
  layoutInsts.push(inst);
@@ -97,14 +97,17 @@ export async function loadComponentEntries(distDir, opts = {}) {
97
97
  export async function importAndRegisterComponentEntries(distDir, entries, registerComponents, opts = {}) {
98
98
  const map = {};
99
99
  const loaded = opts.loaded ?? null;
100
- const packNode = readPackNodeManifest(distDir);
100
+ const packNode = opts.unpacked ? null : readPackNodeManifest(distDir);
101
101
  for (const entry of entries) {
102
102
  if (loaded && loaded.has(entry.chunkId))
103
103
  continue;
104
104
  // Node/SSR consumes the packed unit bytes (`_vmz/pack-node/`) whenever the
105
105
  // build packed the node graph — bare package imports in dependency
106
106
  // components only exist in vendored form there. Dev sessions do not pack:
107
- // the unpacked `${chunkId}.client.js` remains the module identity.
107
+ // the unpacked `${chunkId}.client.js` remains the module identity. Client
108
+ // hosts pass `unpacked` and stay on that tree: units they mount come from
109
+ // the unpacked client graph, including client-only fixtures that no page
110
+ // reaches and the node graph therefore excludes.
108
111
  const abs = packNode ? packNodeEntryAbs(distDir, entry, packNode) : path.join(distDir, entry.entry);
109
112
  let href = pathToFileURL(abs).href;
110
113
  if (opts.cacheBust != null && opts.cacheBust !== '') {
@@ -18,6 +18,7 @@ export async function createRenderHost(distDir, opts = {}) {
18
18
  loaded: loadedChunkIds,
19
19
  preload: opts.preload ?? 'none',
20
20
  closureRoots: opts.closureRoots,
21
+ unpacked: opts.unpackedEntries === true,
21
22
  };
22
23
  if (bootstrapOpts.preload !== 'none') {
23
24
  await bootstrapComponentRegistry(distDir, dom.registerComponents, bootstrapOpts);
@@ -36,6 +37,7 @@ export async function createRenderHost(distDir, opts = {}) {
36
37
  return importAndRegisterComponentEntries(distDir, entries, dom.registerComponents, {
37
38
  cacheBust: opts.cacheBust,
38
39
  loaded: loadedChunkIds,
40
+ unpacked: opts.unpackedEntries === true,
39
41
  });
40
42
  }
41
43
  /**
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { pathToFileURL } from 'node:url';
3
3
  import { setServerModuleResolver } from '../../faces/vmz-runtime.js';
4
- import { installDevImportTokenHooks, normalizeDevError, softReload } from './hmr-dev.js';
4
+ import { commitReload, installDevImportTokenHooks, normalizeDevError, prepareReload } from './hmr-dev.js';
5
5
  import { bustUrl } from './page-catalog.js';
6
6
  import { installAppModuleResolveHooks } from './paths.js';
7
7
  import { serveState } from './state.js';
@@ -13,13 +13,17 @@ export async function bootstrapServeHost() {
13
13
  const rel = moduleId.replace(/^#server\//, '') + '.js';
14
14
  return bustUrl(pathToFileURL(path.join(serveState.distDir, '#server', rel)).href);
15
15
  });
16
+ // dev:入口模块写好后必须由 CLI 侧 pack 再 commit,host 自己不能置 ready。
17
+ // 生产 serve:pack 已在构建期完成,直接 prepare + commit。
18
+ if (serveState.isDev)
19
+ return;
16
20
  try {
17
- await softReload({ quiet: true });
18
- serveState.ready = true;
21
+ await prepareReload({ quiet: true });
22
+ commitReload();
19
23
  }
20
24
  catch (err) {
21
25
  serveState.lastDevError = normalizeDevError(err);
22
26
  serveState.ready = true;
23
- console.error('vmz serve: initial load failed (dev host stays up)', serveState.lastDevError.message);
27
+ console.error('vmz serve: initial load failed (host stays up)', serveState.lastDevError.message);
24
28
  }
25
29
  }
@@ -13,22 +13,21 @@ export declare function emitDevErrorHtml(err: {
13
13
  message?: string;
14
14
  stack?: string;
15
15
  }): AsyncGenerator<any, void, unknown>;
16
- export declare function softReload(opts?: {
16
+ /**
17
+ * 第一阶段:加载构建产物、写客户端入口模块,但**不**发布 SSE、不置 ready。
18
+ *
19
+ * dev 的浏览器交付必须经 `vmz-pack`,而 pack 由 CLI 侧执行(runtime 不反向
20
+ * 依赖 `@vmz/vmz`)。因此先把入口写到 outDir,让调用方 pack,再 `commitReload`。
21
+ */
22
+ export declare function prepareReload(opts?: {
17
23
  quiet?: boolean;
18
24
  payload?: Record<string, unknown>;
19
- }): Promise<{
20
- affectedChunks: string[];
21
- seedChunks: string[];
22
- full: boolean;
23
- islandHmr: boolean;
24
- mode: string;
25
- eventOnlyShell: boolean;
26
- pageCount: number;
27
- reloadedPages: number;
28
- reloadAllPages: boolean;
29
- buildId: string;
30
- sourceRevision: string;
31
- bundleRevision: string;
32
- serveRevision: string;
33
- token: number;
34
- }>;
25
+ }): Promise<Record<string, unknown>>;
26
+ /**
27
+ * 第二阶段:发布 SSE 并置 ready。island 热更新的地址表从 pack manifest 精确
28
+ * 查得(逻辑 `chunkId` → chunk 文件、含 `dom.browser` 的共享 chunk),不做后缀
29
+ * 猜测;查不到即整页 reload。
30
+ */
31
+ export declare function commitReload(): Record<string, unknown>;
32
+ /** pack 失败:回滚到上一轮已发布状态,并以 SSE error 显式告知浏览器。 */
33
+ export declare function abortReload(err: unknown): void;