@vmz/core 0.3.12 → 0.3.14

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.
Files changed (35) hide show
  1. package/dist/browser/client-nav.types.d.ts +2 -5
  2. package/dist/browser/direct-api.types.d.ts +6 -0
  3. package/dist/browser/dom/attributes.js +142 -2
  4. package/dist/browser/dom/component.d.ts +10 -0
  5. package/dist/browser/dom/component.js +16 -4
  6. package/dist/browser/dom/direct-api.d.ts +43 -3
  7. package/dist/browser/dom/direct-api.js +421 -84
  8. package/dist/browser/dom-core.d.ts +1 -0
  9. package/dist/browser/dom-core.js +1 -0
  10. package/dist/browser/dom-core.types.d.ts +10 -2
  11. package/dist/browser/navigation/client-navigation.js +18 -30
  12. package/dist/browser/reactivity/write-barrier.js +38 -11
  13. package/dist/browser/resume/adopt.js +142 -10
  14. package/dist/browser/resume/hydrate.js +36 -5
  15. package/dist/browser/resume/resume.js +2 -5
  16. package/dist/host/deployment/deployment-registry.js +47 -1
  17. package/dist/host/render/render-host.js +2 -0
  18. package/dist/host/serve/bootstrap.js +8 -4
  19. package/dist/host/serve/hmr-dev.d.ts +16 -17
  20. package/dist/host/serve/hmr-dev.js +114 -35
  21. package/dist/host/serve/http.js +25 -8
  22. package/dist/host/serve/pack-entry.d.ts +31 -0
  23. package/dist/host/serve/pack-entry.js +98 -0
  24. package/dist/host/serve/page-render.js +12 -4
  25. package/dist/shared/dom-attr-normalize.d.ts +14 -0
  26. package/dist/shared/dom-attr-normalize.js +158 -19
  27. package/dist/shared/host.types.d.ts +9 -0
  28. package/dist/shared/html-digest.d.ts +15 -0
  29. package/dist/shared/html-digest.js +25 -0
  30. package/dist/ssr/onmount.d.ts +37 -0
  31. package/dist/ssr/onmount.js +94 -0
  32. package/dist/ssr/render.js +7 -8
  33. package/dist/ssr/serialize.d.ts +1 -1
  34. package/dist/ssr/serialize.js +309 -13
  35. package/package.json +7 -7
@@ -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
  }
@@ -71,6 +71,7 @@ export function __vmzReadPath(inst, root, segs) {
71
71
  return obj;
72
72
  }
73
73
  export function __vmzWritePathLogical(inst, root, segs, kind, rhs) {
74
+ assertWritePathSegs('__vmzWritePathLogical', root, segs);
74
75
  const cur = __vmzReadPath(inst, root, segs);
75
76
  if (kind === '||') {
76
77
  if (cur)
@@ -139,16 +140,26 @@ function notifyArrayItemLeaf(inst, root, idx, leaf) {
139
140
  }
140
141
  scheduleRefresh(inst, { type: 'path', root, segs: [String(idx), leaf] });
141
142
  }
143
+ function assertWritePathSegs(fn, root, segs) {
144
+ if (!root)
145
+ return;
146
+ if (!Array.isArray(segs) || segs.length === 0) {
147
+ throw new Error(`vmz:dom ${fn} requires a non-empty path under root ${JSON.stringify(root)}; root field writes must stay plain assignments so the field setter reports them`);
148
+ }
149
+ }
142
150
  export function __vmzWritePath(inst, root, segs, value) {
143
151
  if (!inst || inst.__vmzDestroyed)
144
152
  return value;
145
- if (!root || !Array.isArray(segs) || segs.length === 0)
153
+ assertWritePathSegs('__vmzWritePath', root, segs);
154
+ if (!root)
146
155
  return value;
156
+ const normSegs = segs.map((s) => String(s));
147
157
  // Hot path: array item field write (`rows[i].label`) ??no map/slice, no shared-owner walk.
148
- if (segs.length === 2) {
149
- return __vmzWritePathItem(inst, root, segs[0], segs[1], value);
158
+ // Only a real array root takes the item fast path; a two-segment object path
159
+ // (`user.address.city`) must stay on the generic walk or the write is dropped.
160
+ if (normSegs.length === 2 && Array.isArray(inst[root])) {
161
+ return __vmzWritePathItem(inst, root, normSegs[0], normSegs[1], value);
150
162
  }
151
- const normSegs = segs.map((s) => String(s));
152
163
  let obj = inst[root];
153
164
  if (obj == null || typeof obj !== 'object')
154
165
  return value;
@@ -176,7 +187,11 @@ export function __vmzWritePath(inst, root, segs, value) {
176
187
  const rootObj = inst[root];
177
188
  const rootArr = rootObj;
178
189
  const isRootIndex = normSegs.length === 1 && Array.isArray(rootArr) && leaf !== 'length' && String(Number(leaf)) === leaf;
179
- 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) {
180
195
  if (!notifyWbShared(rootObj, null)) {
181
196
  scheduleRefresh(inst, { type: 'replace', root });
182
197
  }
@@ -192,8 +207,12 @@ export function __vmzWritePathItem(inst, root, idx, leaf, value) {
192
207
  if (!root || leaf == null)
193
208
  return value;
194
209
  const arr = inst[root];
195
- if (!Array.isArray(arr))
196
- return value;
210
+ if (!Array.isArray(arr)) {
211
+ // Computed member on a plain object root: same write, generic path.
212
+ if (arr == null || typeof arr !== 'object')
213
+ return value;
214
+ return __vmzWritePath(inst, root, [idx, leaf], value);
215
+ }
197
216
  const item = arr[idx];
198
217
  if (item == null || typeof item !== 'object')
199
218
  return value;
@@ -226,9 +245,10 @@ export function __vmzListTranspose(inst, root, ia, ib) {
226
245
  export function __vmzWritePathCompound(inst, root, segs, op, rhs) {
227
246
  if (!inst || inst.__vmzDestroyed)
228
247
  return undefined;
229
- if (!root || !Array.isArray(segs) || segs.length === 0)
248
+ assertWritePathSegs('__vmzWritePathCompound', root, segs);
249
+ if (!root)
230
250
  return undefined;
231
- if (segs.length === 2) {
251
+ if (segs.length === 2 && Array.isArray(inst[root])) {
232
252
  return __vmzWritePathCompoundItem(inst, root, segs[0], segs[1], op, rhs);
233
253
  }
234
254
  const cur = __vmzReadPath(inst, root, segs);
@@ -241,8 +261,12 @@ export function __vmzWritePathCompoundItem(inst, root, idx, leaf, op, rhs) {
241
261
  if (!root || leaf == null)
242
262
  return undefined;
243
263
  const arr = inst[root];
244
- if (!Array.isArray(arr))
245
- return undefined;
264
+ if (!Array.isArray(arr)) {
265
+ // Computed member on a plain object root: same write, generic path.
266
+ if (arr == null || typeof arr !== 'object')
267
+ return undefined;
268
+ return __vmzWritePathCompound(inst, root, [idx, leaf], op, rhs);
269
+ }
246
270
  const item = arr[idx];
247
271
  if (item == null || typeof item !== 'object')
248
272
  return undefined;
@@ -333,6 +357,9 @@ export function __vmzArrayMutate(inst, root, baseSegs, method, args) {
333
357
  }
334
358
  if (!Array.isArray(arr) || typeof arr[method] !== 'function')
335
359
  return undefined;
360
+ if (args !== undefined && !Array.isArray(args)) {
361
+ throw new Error(`vmz:dom __vmzArrayMutate expects the argument list as one array (root ${JSON.stringify(root)}, method ${JSON.stringify(method)}); stale artifacts must be rebuilt`);
362
+ }
336
363
  const list = Array.isArray(args) ? args : [];
337
364
  const ret = arr[method](...list);
338
365
  const rootObj = inst[root];
@@ -1,9 +1,12 @@
1
- import { directApi, noteDomCreate, runDirectCreate, } from '../dom-core.js';
1
+ import { directApi, findOwnedDefaultSlot, noteDomCreate, runDirectCreate, } from '../dom-core.js';
2
+ import { HTML_HASH_ATTR } from '../../shared/html-digest.js';
2
3
  const SVG_NS = 'http://www.w3.org/2000/svg';
3
4
  function createResumeAdopt(pool, rootEl) {
4
5
  const used = new WeakSet();
5
6
  const scopeStack = [pool];
6
7
  const currentScope = () => scopeStack[scopeStack.length - 1] || pool;
8
+ /** `rootEl`'s children were parked into `pool` before create — see bottom. */
9
+ let rootElTookPool = false;
7
10
  const markElement = (n) => {
8
11
  used.add(n);
9
12
  if (typeof n.getAttribute === 'function') {
@@ -11,11 +14,22 @@ function createResumeAdopt(pool, rootEl) {
11
14
  if (k != null && n.__vmzKey == null)
12
15
  n.__vmzKey = k;
13
16
  }
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);
17
+ // `runDirectResume` parks `rootEl`'s children into the top-level pool
18
+ // before create. Adopting `rootEl` must hand that same pool to it as the
19
+ // child scope: making a fresh (empty) child pool here would leave every
20
+ // SSR descendant unreachable and silently recreate the whole subtree.
21
+ let childPool;
22
+ if (n === rootEl && !rootElTookPool) {
23
+ rootElTookPool = true;
24
+ childPool = pool;
25
+ }
26
+ else {
27
+ // Private child pool ??never dump into the parent/global pool (that let
28
+ // if branches reclaim Drawer/sibling hosts parked as uncles).
29
+ childPool = document.createDocumentFragment();
30
+ while (n.firstChild)
31
+ childPool.appendChild(n.firstChild);
32
+ }
19
33
  n.__vmzResumePool = childPool;
20
34
  return n;
21
35
  };
@@ -44,14 +58,40 @@ function createResumeAdopt(pool, rootEl) {
44
58
  yield n;
45
59
  }
46
60
  }
47
- const adoptElement = (tag, svg = false) => {
61
+ const adoptElement = (tag, svg = false, key = null) => {
48
62
  const want = String(tag || 'div').toLowerCase();
63
+ // Keyed non-kernel rows (`component`, `v-once`, `v-memo`) carry their key on
64
+ // the root, so reclaim by identity. Falling back to tag order reuses a node
65
+ // that belongs to another key as soon as list order diverges from SSR.
66
+ if (key != null) {
67
+ const wanted = String(key);
68
+ for (const el of elementCandidates()) {
69
+ if (String(el.tagName).toLowerCase() !== want)
70
+ continue;
71
+ if (typeof el.getAttribute !== 'function')
72
+ continue;
73
+ if (el.getAttribute('data-vmz-key') !== wanted)
74
+ continue;
75
+ return markElement(el);
76
+ }
77
+ noteDomCreate();
78
+ const fresh = svg ? document.createElementNS(SVG_NS, tag || 'svg') : document.createElement(tag || 'div');
79
+ fresh.__vmzKey = wanted;
80
+ return fresh;
81
+ }
49
82
  for (const el of elementCandidates()) {
50
83
  if (String(el.tagName).toLowerCase() === want)
51
84
  return markElement(el);
52
85
  }
53
86
  noteDomCreate();
54
- return svg ? document.createElementNS(SVG_NS, tag || 'svg') : document.createElement(tag || 'div');
87
+ const fresh = svg ? document.createElementNS(SVG_NS, tag || 'svg') : document.createElement(tag || 'div');
88
+ // Direct SSR flattens `<slot/>` away and writes the projected children in
89
+ // place, so a rebuilt outlet has no SSR counterpart: its projection stays
90
+ // in the enclosing scope. Share that scope as the outlet pool so a later
91
+ // `adoptEnter(host)` can still reclaim the projected subtree.
92
+ if (want === 'slot')
93
+ fresh.__vmzResumePool = currentScope();
94
+ return fresh;
55
95
  };
56
96
  const adoptElementNS = (ns, tag) => {
57
97
  const want = String(tag || 'g');
@@ -62,6 +102,43 @@ function createResumeAdopt(pool, rootEl) {
62
102
  noteDomCreate();
63
103
  return document.createElementNS(ns || SVG_NS, want);
64
104
  };
105
+ /**
106
+ * Reclaim the SSR row root for one keyed-each key.
107
+ *
108
+ * Tag-order adoption cannot distinguish rows when the client list is already
109
+ * ahead of the SSR order (persisted state, re-filtered store): the first
110
+ * `<li>` would be reused for whichever key the client renders first, so the
111
+ * retained DOM node (focus, caret, typed value) belongs to another key.
112
+ * Keyed rows carry `data-vmz-key` on the server, so match it exactly and leave
113
+ * unmatched SSR rows for the pool teardown. `null` means "not present in SSR"
114
+ * and the caller creates a fresh row.
115
+ */
116
+ const claimKeyed = (tag, key) => {
117
+ if (key == null)
118
+ return null;
119
+ const want = String(tag || '').toLowerCase();
120
+ const wanted = String(key);
121
+ if (!want)
122
+ return null;
123
+ for (const el of elementCandidates()) {
124
+ if (String(el.tagName).toLowerCase() !== want)
125
+ continue;
126
+ if (typeof el.getAttribute !== 'function')
127
+ continue;
128
+ if (el.getAttribute('data-vmz-key') !== wanted)
129
+ continue;
130
+ // Row kernels own the whole row subtree (no nested each/component), and
131
+ // `hydrate` reads `root.firstChild` for its text slots. Do not stash the
132
+ // row's children into a resume pool here: the row must keep its SSR
133
+ // structure as-is.
134
+ used.add(el);
135
+ const k = el.getAttribute('data-vmz-key');
136
+ if (k != null && el.__vmzKey == null)
137
+ el.__vmzKey = k;
138
+ return el;
139
+ }
140
+ return null;
141
+ };
65
142
  const adoptText = (value) => {
66
143
  for (const n of textCandidates()) {
67
144
  used.add(n);
@@ -72,19 +149,45 @@ function createResumeAdopt(pool, rootEl) {
72
149
  noteDomCreate();
73
150
  return document.createTextNode(value == null ? '' : String(value));
74
151
  };
75
- const componentHost = (name) => {
152
+ const componentHost = (name, key = null) => {
76
153
  const want = String(name || '');
154
+ const wanted = key == null ? null : String(key);
77
155
  for (const el of elementCandidates()) {
78
156
  if (String(el.tagName).toLowerCase() !== 'div')
79
157
  continue;
80
158
  if (el.getAttribute('data-vmz') !== want)
81
159
  continue;
160
+ if (wanted != null && el.getAttribute('data-vmz-key') !== wanted)
161
+ continue;
82
162
  return markElement(el);
83
163
  }
84
164
  return null;
85
165
  };
166
+ /** Any parked SSR node in `frag` that no earlier create has claimed. */
167
+ const hasUnusedNode = (frag) => {
168
+ for (const n of frag.childNodes) {
169
+ if (!used.has(n))
170
+ return true;
171
+ }
172
+ return false;
173
+ };
86
174
  const enter = (node) => {
87
- if (!node || !node.__vmzResumePool)
175
+ if (!node)
176
+ return false;
177
+ // `adoptEnter(host)` in the parent template wraps the *projection*, not the
178
+ // component's own internals (its host pool was consumed by the component
179
+ // create). The parked projection lives under the default `<slot/>` outlet,
180
+ // so entering the host must push that outlet's pool.
181
+ const owner = node.__vmzInst;
182
+ if (owner) {
183
+ const slot = findOwnedDefaultSlot(owner.__vmzDomRoot);
184
+ const slotPool = slot && slot.__vmzResumePool;
185
+ if (slotPool && hasUnusedNode(slotPool)) {
186
+ scopeStack.push(slotPool);
187
+ return true;
188
+ }
189
+ }
190
+ if (!node.__vmzResumePool)
88
191
  return false;
89
192
  scopeStack.push(node.__vmzResumePool);
90
193
  return true;
@@ -121,9 +224,37 @@ function createResumeAdopt(pool, rootEl) {
121
224
  rewindScope(depth - 1);
122
225
  };
123
226
  };
227
+ /**
228
+ * `v-html` on a reclaimed element. `markElement` parked its SSR children, so
229
+ * the live element reads empty. SSR stamped the digest of the exact payload
230
+ * string it wrote on the host (`HTML_HASH_ATTR`), so compare that marker with
231
+ * the digest of the string this create would write: equal means the parked
232
+ * subtree already *is* that payload, and moving it back keeps every SSR node.
233
+ * A different digest drops the parked payload so the normal write replaces it.
234
+ */
235
+ const reclaimHtml = (el, digest) => {
236
+ if (!used.has(el))
237
+ return false;
238
+ const childPool = el.__vmzResumePool;
239
+ const stamp = String(el.getAttribute(HTML_HASH_ATTR) || '');
240
+ if (stamp !== digest) {
241
+ // A different payload replaces this host, so parked SSR children can
242
+ // never be adopted afterwards. Drop them now instead of leaving a
243
+ // stale pool for a later identical patch to resurrect.
244
+ if (childPool)
245
+ while (childPool.firstChild)
246
+ childPool.removeChild(childPool.firstChild);
247
+ return false;
248
+ }
249
+ if (childPool)
250
+ while (childPool.firstChild)
251
+ el.appendChild(childPool.firstChild);
252
+ return true;
253
+ };
124
254
  return {
125
255
  el: adoptElement,
126
256
  elNS: adoptElementNS,
257
+ claimKeyed,
127
258
  text: adoptText,
128
259
  componentHost,
129
260
  enter,
@@ -131,6 +262,7 @@ function createResumeAdopt(pool, rootEl) {
131
262
  scopeDepth,
132
263
  rewindScope,
133
264
  beginBranchScope,
265
+ reclaimHtml,
134
266
  };
135
267
  }
136
268
  export function runDirectResume(Component, inst, container) {
@@ -1,6 +1,31 @@
1
- import { applyPreservedState, createInstance, destroy, findOwnedDefaultSlot, hasMeaningfulChild, mount, runDirectCreate, settlePendingChildMounts, snapshotInstanceState, } from '../dom-core.js';
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);
@@ -82,10 +116,7 @@ export async function hydrate(Component, container, props = {}, opts = {}) {
82
116
  // onMount may still patch state / redirect afterwards (same end state as SSR order).
83
117
  if (!hasMeaningfulChild(container)) {
84
118
  const node = runDirectCreate(Component, inst);
85
- if (node) {
86
- inst.__vmzDomRoot = node;
87
- container.appendChild(node);
88
- }
119
+ adoptTopLevelDomRoot(inst, node, container);
89
120
  }
90
121
  else {
91
122
  // Deep adopt: park SSR children, rebuild schedule, reclaim nodes (no orphan siblings).
@@ -1,5 +1,5 @@
1
1
  import { applyDirectHostBox } from '../direct-host-box.js';
2
- import { applyPreservedState, createInstance, destroy, hasMeaningfulChild, isEventEntryStrategy, resolveComponent, runDirectCreate, scheduleClientOn, } from '../dom-core.js';
2
+ import { adoptTopLevelDomRoot, applyPreservedState, createInstance, destroy, hasMeaningfulChild, isEventEntryStrategy, resolveComponent, runDirectCreate, scheduleClientOn, } from '../dom-core.js';
3
3
  import { markUnknownComponentHost } from '../unknown-component.js';
4
4
  import { runDirectResume } from './adopt.js';
5
5
  export async function resume(Component, container, slice = null) {
@@ -42,10 +42,7 @@ export async function resume(Component, container, slice = null) {
42
42
  if (Component.__vmzDirect && typeof Component.__vmzCreate === 'function') {
43
43
  if (!hasMeaningfulChild(container)) {
44
44
  const node = runDirectCreate(Component, inst);
45
- if (node) {
46
- inst.__vmzDomRoot = node;
47
- container.appendChild(node);
48
- }
45
+ adoptTopLevelDomRoot(inst, node, container);
49
46
  }
50
47
  else {
51
48
  // Island leaf adopt: preserve Element identity (resume nodeIdentity).
@@ -97,10 +97,18 @@ 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 = opts.unpacked ? null : readPackNodeManifest(distDir);
100
101
  for (const entry of entries) {
101
102
  if (loaded && loaded.has(entry.chunkId))
102
103
  continue;
103
- const abs = path.join(distDir, entry.entry);
104
+ // Node/SSR consumes the packed unit bytes (`_vmz/pack-node/`) whenever the
105
+ // build packed the node graph — bare package imports in dependency
106
+ // components only exist in vendored form there. Dev sessions do not pack:
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.
111
+ const abs = packNode ? packNodeEntryAbs(distDir, entry, packNode) : path.join(distDir, entry.entry);
104
112
  let href = pathToFileURL(abs).href;
105
113
  if (opts.cacheBust != null && opts.cacheBust !== '') {
106
114
  href = `${href}?t=${encodeURIComponent(String(opts.cacheBust))}`;
@@ -114,6 +122,44 @@ export async function importAndRegisterComponentEntries(distDir, entries, regist
114
122
  registerComponents(map);
115
123
  return map;
116
124
  }
125
+ /** `vmz.pack.node-manifest.v0` 的唯一消费点:缺失即未打包(dev),调用方回退未打包模块。 */
126
+ function readPackNodeManifest(distDir) {
127
+ const manifestPath = path.join(distDir, '_vmz', 'pack-node-manifest.json');
128
+ if (!fs.existsSync(manifestPath))
129
+ return null;
130
+ // Freshness gate: `vmz build` writes the deployment document first and packs
131
+ // the node graph strictly after; a dev soft rebuild rewrites the deployment
132
+ // document without re-packing. A deployment newer than the manifest means the
133
+ // unpacked tree moved on — packed bytes are stale and must not be served.
134
+ try {
135
+ const deploymentMtime = fs.statSync(path.join(distDir, 'vmz-deployment.json')).mtimeMs;
136
+ const manifestMtime = fs.statSync(manifestPath).mtimeMs;
137
+ if (deploymentMtime > manifestMtime)
138
+ return null;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ try {
144
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
145
+ return Array.isArray(parsed.entryFiles) ? { entryFiles: parsed.entryFiles } : null;
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ }
151
+ /** Packed node chunk for one component entry; missing logical/file is an explicit error, never a fallback. */
152
+ function packNodeEntryAbs(distDir, entry, manifest) {
153
+ const hit = manifest.entryFiles.find((e) => e && e.logical === entry.chunkId);
154
+ if (!hit || typeof hit.file !== 'string' || !hit.file) {
155
+ throw new Error(`vmz: pack-node manifest has no entry file for "${entry.chunkId}" under ${distDir}`);
156
+ }
157
+ const abs = path.join(distDir, '_vmz', 'pack-node', ...hit.file.split('/'));
158
+ if (!fs.existsSync(abs)) {
159
+ throw new Error(`vmz: pack-node chunk missing on disk for "${entry.chunkId}": ${path.relative(distDir, abs)}`);
160
+ }
161
+ return abs;
162
+ }
117
163
  export async function bootstrapComponentRegistry(distDir, registerComponents, opts = {}) {
118
164
  const preload = opts.preload ?? (opts.closureRoots?.length ? 'closure' : 'all');
119
165
  if (preload === 'none')
@@ -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;