@vmz/core 0.3.13 → 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.
@@ -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,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
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;