@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.
- package/dist/browser/client-nav.types.d.ts +2 -5
- package/dist/browser/direct-api.types.d.ts +6 -0
- package/dist/browser/dom/attributes.js +142 -2
- package/dist/browser/dom/component.d.ts +10 -0
- package/dist/browser/dom/component.js +16 -4
- package/dist/browser/dom/direct-api.d.ts +43 -3
- package/dist/browser/dom/direct-api.js +421 -84
- package/dist/browser/dom-core.d.ts +1 -0
- package/dist/browser/dom-core.js +1 -0
- package/dist/browser/dom-core.types.d.ts +10 -2
- package/dist/browser/navigation/client-navigation.js +18 -30
- package/dist/browser/reactivity/write-barrier.js +38 -11
- package/dist/browser/resume/adopt.js +142 -10
- package/dist/browser/resume/hydrate.js +36 -5
- package/dist/browser/resume/resume.js +2 -5
- package/dist/host/deployment/deployment-registry.js +47 -1
- package/dist/host/render/render-host.js +2 -0
- package/dist/host/serve/bootstrap.js +8 -4
- package/dist/host/serve/hmr-dev.d.ts +16 -17
- package/dist/host/serve/hmr-dev.js +114 -35
- package/dist/host/serve/http.js +25 -8
- package/dist/host/serve/pack-entry.d.ts +31 -0
- package/dist/host/serve/pack-entry.js +98 -0
- package/dist/host/serve/page-render.js +12 -4
- package/dist/shared/dom-attr-normalize.d.ts +14 -0
- package/dist/shared/dom-attr-normalize.js +158 -19
- package/dist/shared/host.types.d.ts +9 -0
- package/dist/shared/html-digest.d.ts +15 -0
- package/dist/shared/html-digest.js +25 -0
- package/dist/ssr/onmount.d.ts +37 -0
- package/dist/ssr/onmount.js +94 -0
- package/dist/ssr/render.js +7 -8
- package/dist/ssr/serialize.d.ts +1 -1
- package/dist/ssr/serialize.js +309 -13
- 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 =
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
157
|
-
await
|
|
149
|
+
else if (typeof opts.hydrate === 'function') {
|
|
150
|
+
await opts.hydrate(Page, fresh, props);
|
|
158
151
|
}
|
|
159
152
|
else {
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
18
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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;
|