@vmz/core 0.3.6 → 0.3.8

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/README.md CHANGED
@@ -1,50 +1,50 @@
1
- # @vmz/core
2
-
3
- The browser should not have to rediscover an application's structure after every click. Yet that is the default cost of
4
- many UI systems: execute components again, compare a virtual tree, and infer which DOM work might be necessary.
5
-
6
- `@vmz/core` is the production runtime behind VMZ's alternative. It executes the creation, patch, SSR, resumption, HTTP,
7
- and lifetime schedules produced by the compiler. When VMZ can prove that a write affects one text binding, one control
8
- region, or one keyed item, the runtime can update that target directly rather than begin from a whole component tree.
9
-
10
- The same idea continues beyond a single browser update. SSR serializes planned regions; resumption attaches only
11
- reachable state and event entries instead of repeating completed server work; ownership regions give async tasks and
12
- resources a deterministic end; server and HTTP output stay tied to compiler-visible capabilities.
13
-
14
- | Instead of... | VMZ runtime work begins from... |
15
- |------------------------------------|-------------------------------------------------------|
16
- | Re-running a component tree | A known affected binding or region |
17
- | Reconciling a generic virtual tree | Direct create, patch, switch, or keyed-list work |
18
- | Hydrating every rendered component | The event and state entries that are actually reached |
19
- | Hoping cleanup follows callbacks | A compiler-known ownership and disposal boundary |
20
-
21
- For application authors, this mostly stays out of sight: write VMZ source and let the compiler generate the plan.
22
- `@vmz/core` matters when you care about the kind of runtime your product ships: small in responsibility, direct in
23
- execution, and able to account for why its work exists. 🌱
24
-
25
- ## The hot path VMZ is aiming for
26
-
27
- ```text
28
- state write
29
- -> known dependency edge
30
- -> affected computation or region
31
- -> direct patch / switch / reconcile
32
- ```
33
-
34
- There is no default detour through “execute every component that might matter, build virtual nodes, then compare them.”
35
- If analysis must widen, it widens to a safe region and should preserve the reason.
36
-
37
- ## More than DOM updates
38
-
39
- - **Transactions:** related writes can settle before dependent work runs.
40
- - **Ownership:** branches, list items, resources, and tasks have a known lifetime.
41
- - **Cancellation:** an obsolete navigation or async generation cannot write into new state.
42
- - **Resumption:** the browser attaches to server-produced work at the smallest reachable boundary.
43
- - **Zero-JS delivery:** pages with no interactive requirement do not need an eager framework shell.
44
-
45
- The runtime should not grow a second compiler made of reflection, string dependencies, or generic proxies. Its quality
46
- comes from faithfully executing the generated plan while keeping the production surface focused.
47
-
48
- ## License
49
-
50
- MIT
1
+ # @vmz/core
2
+
3
+ The browser should not have to rediscover an application's structure after every click. Yet that is the default cost of
4
+ many UI systems: execute components again, compare a virtual tree, and infer which DOM work might be necessary.
5
+
6
+ `@vmz/core` is the production runtime behind VMZ's alternative. It executes the creation, patch, SSR, resumption, HTTP,
7
+ and lifetime schedules produced by the compiler. When VMZ can prove that a write affects one text binding, one control
8
+ region, or one keyed item, the runtime can update that target directly rather than begin from a whole component tree.
9
+
10
+ The same idea continues beyond a single browser update. SSR serializes planned regions; resumption attaches only
11
+ reachable state and event entries instead of repeating completed server work; ownership regions give async tasks and
12
+ resources a deterministic end; server and HTTP output stay tied to compiler-visible capabilities.
13
+
14
+ | Instead of... | VMZ runtime work begins from... |
15
+ |------------------------------------|-------------------------------------------------------|
16
+ | Re-running a component tree | A known affected binding or region |
17
+ | Reconciling a generic virtual tree | Direct create, patch, switch, or keyed-list work |
18
+ | Hydrating every rendered component | The event and state entries that are actually reached |
19
+ | Hoping cleanup follows callbacks | A compiler-known ownership and disposal boundary |
20
+
21
+ For application authors, this mostly stays out of sight: write VMZ source and let the compiler generate the plan.
22
+ `@vmz/core` matters when you care about the kind of runtime your product ships: small in responsibility, direct in
23
+ execution, and able to account for why its work exists. 🌱
24
+
25
+ ## The hot path VMZ is aiming for
26
+
27
+ ```text
28
+ state write
29
+ -> known dependency edge
30
+ -> affected computation or region
31
+ -> direct patch / switch / reconcile
32
+ ```
33
+
34
+ There is no default detour through “execute every component that might matter, build virtual nodes, then compare them.”
35
+ If analysis must widen, it widens to a safe region and should preserve the reason.
36
+
37
+ ## More than DOM updates
38
+
39
+ - **Transactions:** related writes can settle before dependent work runs.
40
+ - **Ownership:** branches, list items, resources, and tasks have a known lifetime.
41
+ - **Cancellation:** an obsolete navigation or async generation cannot write into new state.
42
+ - **Resumption:** the browser attaches to server-produced work at the smallest reachable boundary.
43
+ - **Zero-JS delivery:** pages with no interactive requirement do not need an eager framework shell.
44
+
45
+ The runtime should not grow a second compiler made of reflection, string dependencies, or generic proxies. Its quality
46
+ comes from faithfully executing the generated plan while keeping the production surface focused.
47
+
48
+ ## License
49
+
50
+ MIT
@@ -5,6 +5,24 @@ export declare function findOwnedDefaultSlot(root: any): any;
5
5
  export declare function findOwnedDefaultSlotTarget(root: any): any;
6
6
  export declare function createInstance(Component: any, props?: any): any;
7
7
  export declare function destroy(inst: any): void;
8
+ /**
9
+ * DocumentFragment anchors are emptied by insertion (children move out, the
10
+ * fragment keeps no parentNode), so a projected fragment must anchor on its
11
+ * last child. Capture before inserting.
12
+ */
13
+ export declare function slotAnchorFor(node: any): any;
14
+ export declare function insertProjectedSlotNode(target: any, node: any): any;
15
+ /**
16
+ * Hold projected children until the component's default slot exists.
17
+ *
18
+ * A slot inside an unmounted `v-if` branch (closed drawer/dialog, loading
19
+ * skeleton) is absent at projection time. Appending to the component root would
20
+ * paint the child into the live page, so keep it detached and splice it in when
21
+ * the slot is mounted.
22
+ */
23
+ export declare function parkSlotNode(hostEl: any, node: any): void;
24
+ /** Move parked children into the now-mounted default slot. Returns true when drained. */
25
+ export declare function flushParkedSlotNodes(hostEl: any): boolean;
8
26
  export declare function disposeDomTree(root: any): void;
9
27
  export declare function snapshotInstanceState(inst: any): {};
10
28
  export declare function applyPreservedState(inst: any, state: any): void;
@@ -183,6 +183,97 @@ export function destroy(inst) {
183
183
  }
184
184
  }
185
185
  }
186
+ /**
187
+ * DocumentFragment anchors are emptied by insertion (children move out, the
188
+ * fragment keeps no parentNode), so a projected fragment must anchor on its
189
+ * last child. Capture before inserting.
190
+ */
191
+ export function slotAnchorFor(node) {
192
+ return node && node.nodeType === 11 ? node.lastChild : node;
193
+ }
194
+ export function insertProjectedSlotNode(target, node) {
195
+ if (!target || !node)
196
+ return null;
197
+ const anchor = slotAnchorFor(node);
198
+ if (String(target.tagName || '').toLowerCase() === 'slot') {
199
+ target.replaceWith(node);
200
+ return anchor;
201
+ }
202
+ target.appendChild(node);
203
+ return anchor;
204
+ }
205
+ /**
206
+ * Hold projected children until the component's default slot exists.
207
+ *
208
+ * A slot inside an unmounted `v-if` branch (closed drawer/dialog, loading
209
+ * skeleton) is absent at projection time. Appending to the component root would
210
+ * paint the child into the live page, so keep it detached and splice it in when
211
+ * the slot is mounted.
212
+ */
213
+ export function parkSlotNode(hostEl, node) {
214
+ if (!hostEl || !node)
215
+ return;
216
+ if (typeof MutationObserver === 'undefined') {
217
+ // Non-browser DOM (unit hosts): preserve the legacy root fallback.
218
+ const child = hostEl.__vmzInst;
219
+ const root = (child && child.__vmzDomRoot) || hostEl;
220
+ if (root && root.nodeType === 1 && typeof root.appendChild === 'function')
221
+ root.appendChild(node);
222
+ else if (typeof hostEl.appendChild === 'function')
223
+ hostEl.appendChild(node);
224
+ return;
225
+ }
226
+ const parked = hostEl.__vmzSlotParked || (hostEl.__vmzSlotParked = []);
227
+ parked.push(node);
228
+ if (hostEl.__vmzSlotObserver)
229
+ return;
230
+ hostEl.__vmzSlotObserver = new MutationObserver(() => {
231
+ flushParkedSlotNodes(hostEl);
232
+ });
233
+ const scope = (hostEl.__vmzInst && hostEl.__vmzInst.__vmzDomRoot) || hostEl;
234
+ if (scope && scope.nodeType === 1) {
235
+ hostEl.__vmzSlotObserver.observe(scope, { childList: true, subtree: true });
236
+ }
237
+ }
238
+ /** Move parked children into the now-mounted default slot. Returns true when drained. */
239
+ export function flushParkedSlotNodes(hostEl) {
240
+ if (!hostEl)
241
+ return false;
242
+ const parked = hostEl.__vmzSlotParked;
243
+ if (!parked || !parked.length)
244
+ return false;
245
+ const child = hostEl.__vmzInst;
246
+ const root = (child && child.__vmzDomRoot) || hostEl;
247
+ if (!root || root.nodeType !== 1)
248
+ return false;
249
+ const target = findOwnedDefaultSlotTarget(root);
250
+ if (!target || !target.parentNode)
251
+ return false;
252
+ if (hostEl.__vmzSlotObserver) {
253
+ hostEl.__vmzSlotObserver.disconnect();
254
+ hostEl.__vmzSlotObserver = null;
255
+ }
256
+ hostEl.__vmzSlotParked = [];
257
+ const anchor = slotAnchorFor(parked[parked.length - 1]);
258
+ const tag = String(target.tagName || '').toLowerCase();
259
+ if (tag === 'slot') {
260
+ const frag = typeof document !== 'undefined' ? document.createDocumentFragment() : null;
261
+ if (!frag) {
262
+ target.replaceWith(parked[0]);
263
+ hostEl.__vmzSlotAnchor = anchor;
264
+ return true;
265
+ }
266
+ for (const node of parked)
267
+ frag.appendChild(node);
268
+ target.replaceWith(frag);
269
+ hostEl.__vmzSlotAnchor = anchor;
270
+ return true;
271
+ }
272
+ for (const node of parked)
273
+ target.appendChild(node);
274
+ hostEl.__vmzSlotAnchor = anchor;
275
+ return true;
276
+ }
186
277
  export function disposeDomTree(root) {
187
278
  if (!root)
188
279
  return;
@@ -205,6 +296,14 @@ export function disposeDomTree(root) {
205
296
  node.__vmzInst = null;
206
297
  destroy(child);
207
298
  }
299
+ if (node.__vmzSlotObserver) {
300
+ node.__vmzSlotObserver.disconnect();
301
+ node.__vmzSlotObserver = null;
302
+ }
303
+ if (node.__vmzSlotParked)
304
+ node.__vmzSlotParked = [];
305
+ if (node.__vmzSlotAnchor)
306
+ node.__vmzSlotAnchor = null;
208
307
  let child = node.firstChild;
209
308
  while (child) {
210
309
  const next = child.nextSibling;
@@ -13,6 +13,8 @@ export declare const directApi: {
13
13
  el(tag: any): any;
14
14
  elNS(ns: any, tag: any): Element;
15
15
  text(value: any): Text;
16
+ nsScopeDepth(): number;
17
+ rewindNsScope(depth: any): void;
16
18
  adoptEnter(node: any): boolean;
17
19
  adoptLeave(): void;
18
20
  frag(): DocumentFragment;
@@ -2,11 +2,34 @@ import { mergeClassParts, mergeStyleParts } from '../../shared/dom-attr-normaliz
2
2
  import { applyDirectHostBox } from '../direct-host-box.js';
3
3
  import { createUnknownComponentElement } from '../unknown-component.js';
4
4
  import { applyDomAttr, eventPropHandlerName } from './attributes.js';
5
- import { createInstance, disposeDomTree, findOwnedDefaultSlotTarget, runDirectCreate, stripFns } from './component.js';
5
+ import { createInstance, disposeDomTree, findOwnedDefaultSlotTarget, insertProjectedSlotNode, parkSlotNode, runDirectCreate, slotAnchorFor, stripFns, } from './component.js';
6
6
  import { isEventEntryStrategy, noteDomCreate, noteDomRemove, scheduleClientOn } from './lifecycle.js';
7
7
  import { components, resolveComponent } from './registry.js';
8
8
  import { beginEventFlush, endEventFlush, inferHandlerMethod, methodAllowsSyncEventFlush, registerBind, runDomEventHandler, runPatch, scheduleRefresh, unregisterBind, } from '../reactivity/patch-scheduler.js';
9
9
  let directPropBindSeq = 0;
10
+ const SVG_NS = 'http://www.w3.org/2000/svg';
11
+ /**
12
+ * Nesting depth of inline `<svg>` subtrees during create/resume.
13
+ *
14
+ * Template emit creates every element through `api.el(tag)` and brackets each
15
+ * subtree with `adoptEnter`/`adoptLeave`. Elements inside `<svg>` must be built
16
+ * with `createElementNS`, otherwise the browser returns an HTMLUnknownElement:
17
+ * attribute names lose their case (`viewBox` → `viewbox`) and the node lays out
18
+ * at 0×0. Track the depth here so the generator stays namespace-agnostic.
19
+ */
20
+ const nsStack = [];
21
+ function inSvgScope() {
22
+ return nsStack.length > 0 && nsStack[nsStack.length - 1] === true;
23
+ }
24
+ function isSvgNode(node) {
25
+ if (!node || typeof node !== 'object')
26
+ return false;
27
+ const ns = node.namespaceURI;
28
+ if (ns === SVG_NS)
29
+ return true;
30
+ const tag = node.tagName;
31
+ return tag != null && String(tag).toLowerCase() === 'svg' && ns == null;
32
+ }
10
33
  export const directApi = {
11
34
  _inst: null,
12
35
  _branchBinds: null,
@@ -14,17 +37,19 @@ export const directApi = {
14
37
  _eachCtx: null,
15
38
  _resumeAdopt: null,
16
39
  el(tag) {
40
+ const name = tag || 'div';
41
+ const svg = inSvgScope() || name === 'svg';
17
42
  if (directApi._resumeAdopt)
18
- return directApi._resumeAdopt.el(tag);
43
+ return directApi._resumeAdopt.el(name, svg);
19
44
  noteDomCreate();
20
- return document.createElement(tag || 'div');
45
+ return svg ? document.createElementNS(SVG_NS, name) : document.createElement(name);
21
46
  },
22
47
  elNS(ns, tag) {
23
48
  if (directApi._resumeAdopt && typeof directApi._resumeAdopt.elNS === 'function') {
24
49
  return directApi._resumeAdopt.elNS(ns, tag);
25
50
  }
26
51
  noteDomCreate();
27
- return document.createElementNS(ns || 'http://www.w3.org/2000/svg', tag || 'g');
52
+ return document.createElementNS(ns || SVG_NS, tag || 'g');
28
53
  },
29
54
  text(value) {
30
55
  if (directApi._resumeAdopt)
@@ -32,7 +57,16 @@ export const directApi = {
32
57
  noteDomCreate();
33
58
  return document.createTextNode(value == null ? '' : String(value));
34
59
  },
60
+ nsScopeDepth() {
61
+ return nsStack.length;
62
+ },
63
+ rewindNsScope(depth) {
64
+ const target = Math.max(0, Number(depth) || 0);
65
+ while (nsStack.length > target)
66
+ nsStack.pop();
67
+ },
35
68
  adoptEnter(node) {
69
+ nsStack.push(isSvgNode(node));
36
70
  const adopt = directApi._resumeAdopt;
37
71
  if (adopt && typeof adopt.enter === 'function') {
38
72
  const ok = adopt.enter(node);
@@ -43,6 +77,7 @@ export const directApi = {
43
77
  return false;
44
78
  },
45
79
  adoptLeave() {
80
+ nsStack.pop();
46
81
  const adopt = directApi._resumeAdopt;
47
82
  // Emit always pairs leave after enter; if enter failed (fresh node, no
48
83
  // pool), do not pop ??unbalanced leave previously stole parent scopes
@@ -431,16 +466,21 @@ export const directApi = {
431
466
  root = hostEl;
432
467
  const target = root && root.nodeType === 1 ? findOwnedDefaultSlotTarget(root) : null;
433
468
  if (target && target.parentNode) {
434
- if (String(target.tagName || '').toLowerCase() === 'slot') {
435
- target.replaceWith(node);
436
- return;
437
- }
438
- target.appendChild(node);
469
+ hostEl.__vmzSlotAnchor = insertProjectedSlotNode(target, node);
470
+ return;
471
+ }
472
+ // The `<slot>` was already replaced by an earlier projected child, so this
473
+ // call appends after the last one instead of re-searching for a slot.
474
+ const anchor = hostEl.__vmzSlotAnchor;
475
+ if (anchor && anchor.parentNode) {
476
+ const nextAnchor = slotAnchorFor(node);
477
+ anchor.parentNode.insertBefore(node, anchor.nextSibling);
478
+ hostEl.__vmzSlotAnchor = nextAnchor;
439
479
  return;
440
480
  }
441
- if (root && root.nodeType === 1 && typeof root.appendChild === 'function')
442
- root.appendChild(node);
443
- else
444
- hostEl.appendChild(node);
481
+ // Component slot may live inside an unmounted `v-if` branch (closed overlay,
482
+ // loading skeleton). Parking the child until the slot appears keeps it out of
483
+ // the component root, where it would otherwise leak into the rendered page.
484
+ parkSlotNode(hostEl, node);
445
485
  },
446
486
  };
@@ -69,7 +69,7 @@ export type TraceBuffer = {
69
69
  events: TraceEvent[];
70
70
  };
71
71
  export type ResumeAdoptCtx = {
72
- el: (tag: string) => Element;
72
+ el: (tag: string, svg?: boolean) => Element;
73
73
  elNS?: (ns: string, tag: string) => Element;
74
74
  text: (value?: unknown) => Text;
75
75
  componentHost?: (name: string) => HTMLElement | null;
@@ -53,6 +53,32 @@ export function scheduleRefresh(inst, notice) {
53
53
  }
54
54
  });
55
55
  }
56
+ const ARROW_SIGNATURE = /^(?:async\s*)?(?:\(([^)]*)\)|([A-Za-z_$][\w$]*))\s*=>\s*([\s\S]*)$/;
57
+ const EVENT_PASSTHROUGH_CALL = /^this\.([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*;?$/;
58
+ /**
59
+ * Direct-bind hint for `(ev) => this.method(ev)` handlers only. The closure is
60
+ * replaced by `method.call(inst, ev)`, so the shape must forward exactly the
61
+ * event: a handler that passes its own literal arguments (`() => this.pick("dark")`)
62
+ * must keep running as a closure, otherwise the event would overwrite the argument.
63
+ */
64
+ function inferEventPassthroughMethod(src) {
65
+ const arrow = src.match(ARROW_SIGNATURE);
66
+ if (!arrow)
67
+ return null;
68
+ const params = (arrow[1] ?? arrow[2] ?? '')
69
+ .split(',')
70
+ .map((p) => p.trim())
71
+ .filter(Boolean);
72
+ if (params.length !== 1)
73
+ return null;
74
+ let body = arrow[3].trim();
75
+ if (body.startsWith('{') && body.endsWith('}'))
76
+ body = body.slice(1, -1).trim();
77
+ const call = body.match(EVENT_PASSTHROUGH_CALL);
78
+ if (!call || call[2].trim() !== params[0])
79
+ return null;
80
+ return call[1];
81
+ }
56
82
  export function inferHandlerMethod(handler) {
57
83
  if (typeof handler !== 'function')
58
84
  return null;
@@ -62,8 +88,7 @@ export function inferHandlerMethod(handler) {
62
88
  let name = null;
63
89
  try {
64
90
  const src = Function.prototype.toString.call(handler);
65
- const m = src.match(/this\.([A-Za-z_$][\w$]*)\s*\(/);
66
- name = m ? m[1] : null;
91
+ name = inferEventPassthroughMethod(src);
67
92
  }
68
93
  catch {
69
94
  name = null;
@@ -1,19 +1,8 @@
1
1
  import { directApi, noteDomCreate, runDirectCreate, } from '../dom-core.js';
2
+ const SVG_NS = 'http://www.w3.org/2000/svg';
2
3
  function createResumeAdopt(pool, rootEl) {
3
4
  const used = new WeakSet();
4
5
  const scopeStack = [pool];
5
- const walkTexts = (root, visit) => {
6
- if (!root)
7
- return;
8
- if (root.nodeType === 3) {
9
- visit(root);
10
- return;
11
- }
12
- if (root.nodeType !== 1)
13
- return;
14
- for (let c = root.firstChild; c; c = c.nextSibling)
15
- walkTexts(c, visit);
16
- };
17
6
  const currentScope = () => scopeStack[scopeStack.length - 1] || pool;
18
7
  const markElement = (n) => {
19
8
  used.add(n);
@@ -42,27 +31,36 @@ function createResumeAdopt(pool, rootEl) {
42
31
  }
43
32
  }
44
33
  }
34
+ /**
35
+ * Text bindings always belong to the element whose scope is active, so only
36
+ * direct text children are candidates. Recursing into element children
37
+ * lets an earlier binding steal the text of a sibling that has not been
38
+ * adopted yet (soft navigation lost the landing CTA label this way).
39
+ */
45
40
  function* textCandidates() {
46
41
  const scope = currentScope();
47
- const kids = [...scope.childNodes];
48
- for (const n of kids) {
49
- const found = [];
50
- walkTexts(n, (t) => {
51
- if (!used.has(t))
52
- found.push(t);
53
- });
54
- for (const t of found)
55
- yield t;
42
+ for (const n of [...scope.childNodes]) {
43
+ if (n.nodeType === 3 && !used.has(n))
44
+ yield n;
56
45
  }
57
46
  }
58
- const adoptElement = (tag) => {
47
+ const adoptElement = (tag, svg = false) => {
59
48
  const want = String(tag || 'div').toLowerCase();
60
49
  for (const el of elementCandidates()) {
61
50
  if (String(el.tagName).toLowerCase() === want)
62
51
  return markElement(el);
63
52
  }
64
53
  noteDomCreate();
65
- return document.createElement(tag || 'div');
54
+ return svg ? document.createElementNS(SVG_NS, tag || 'svg') : document.createElement(tag || 'div');
55
+ };
56
+ const adoptElementNS = (ns, tag) => {
57
+ const want = String(tag || 'g');
58
+ for (const el of elementCandidates()) {
59
+ if (el.namespaceURI === ns && String(el.localName) === want)
60
+ return markElement(el);
61
+ }
62
+ noteDomCreate();
63
+ return document.createElementNS(ns || SVG_NS, want);
66
64
  };
67
65
  const adoptText = (value) => {
68
66
  for (const n of textCandidates()) {
@@ -125,6 +123,7 @@ function createResumeAdopt(pool, rootEl) {
125
123
  };
126
124
  return {
127
125
  el: adoptElement,
126
+ elNS: adoptElementNS,
128
127
  text: adoptText,
129
128
  componentHost,
130
129
  enter,
@@ -148,6 +147,7 @@ export function runDirectResume(Component, inst, container) {
148
147
  const prevItems = directApi._itemPatches;
149
148
  const prevEach = directApi._eachCtx;
150
149
  const prevAdopt = directApi._resumeAdopt;
150
+ const prevNsDepth = directApi.nsScopeDepth();
151
151
  directApi._inst = inst;
152
152
  directApi._branchBinds = null;
153
153
  directApi._itemPatches = null;
@@ -157,6 +157,7 @@ export function runDirectResume(Component, inst, container) {
157
157
  return Component.__vmzCreate.call(inst, directApi);
158
158
  }
159
159
  finally {
160
+ directApi.rewindNsScope(prevNsDepth);
160
161
  directApi._resumeAdopt = prevAdopt;
161
162
  directApi._inst = prevInst;
162
163
  directApi._branchBinds = prevBranch;
@@ -605,32 +605,32 @@ function wantsBrowserHtml(req) {
605
605
  function browserErrorHtml(status, title, message, stack) {
606
606
  const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
607
607
  const stackBlock = stack ? `<pre class="stack">${esc(stack)}</pre>` : '';
608
- return `<!DOCTYPE html>
609
- <html lang="en">
610
- <head>
611
- <meta charset="utf-8" />
612
- <meta name="viewport" content="width=device-width, initial-scale=1" />
613
- <title>${esc(title)}</title>
614
- <style>
615
- body{margin:0;background:#0f1115;color:#f4f4f5;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
616
- main{max-width:56rem;margin:0 auto;padding:2rem 1.25rem}
617
- h1{margin:0 0 .75rem;color:#f87171;font-size:1.1rem}
618
- .code{opacity:.65;font-size:12px;margin-bottom:.75rem}
619
- pre{white-space:pre-wrap;margin:0 0 1rem}
620
- .stack{opacity:.7;font-size:12px}
621
- .hint{opacity:.65;font-size:12px}
622
- </style>
623
- </head>
624
- <body data-vmz-error="${status}">
625
- <main>
626
- <p class="code">HTTP ${status}</p>
627
- <h1>${esc(title)}</h1>
628
- <pre>${esc(message)}</pre>
629
- ${stackBlock}
630
- <p class="hint">Document navigation returns HTML errors — not API JSON.</p>
631
- </main>
632
- </body>
633
- </html>
608
+ return `<!DOCTYPE html>
609
+ <html lang="en">
610
+ <head>
611
+ <meta charset="utf-8" />
612
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
613
+ <title>${esc(title)}</title>
614
+ <style>
615
+ body{margin:0;background:#0f1115;color:#f4f4f5;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
616
+ main{max-width:56rem;margin:0 auto;padding:2rem 1.25rem}
617
+ h1{margin:0 0 .75rem;color:#f87171;font-size:1.1rem}
618
+ .code{opacity:.65;font-size:12px;margin-bottom:.75rem}
619
+ pre{white-space:pre-wrap;margin:0 0 1rem}
620
+ .stack{opacity:.7;font-size:12px}
621
+ .hint{opacity:.65;font-size:12px}
622
+ </style>
623
+ </head>
624
+ <body data-vmz-error="${status}">
625
+ <main>
626
+ <p class="code">HTTP ${status}</p>
627
+ <h1>${esc(title)}</h1>
628
+ <pre>${esc(message)}</pre>
629
+ ${stackBlock}
630
+ <p class="hint">Document navigation returns HTML errors — not API JSON.</p>
631
+ </main>
632
+ </body>
633
+ </html>
634
634
  `;
635
635
  }
636
636
  function sendJson(res, status, body) {
@@ -87,29 +87,29 @@ export function normalizeDevError(err) {
87
87
  export async function* emitDevErrorHtml(err) {
88
88
  const msg = escapeHtml(err.message || 'Unknown error');
89
89
  const stack = err.stack ? escapeHtml(err.stack) : '';
90
- const style = `<style>
91
- body{margin:0;background:#0f1115;color:#f4f4f5;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
92
- main{max-width:56rem;margin:0 auto;padding:2rem 1.25rem}
93
- h1{margin:0 0 .75rem;color:#f87171;font-size:1.1rem}
94
- pre{white-space:pre-wrap;margin:0 0 1rem}
95
- .hint{opacity:.65;font-size:12px}
90
+ const style = `<style>
91
+ body{margin:0;background:#0f1115;color:#f4f4f5;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
92
+ main{max-width:56rem;margin:0 auto;padding:2rem 1.25rem}
93
+ h1{margin:0 0 .75rem;color:#f87171;font-size:1.1rem}
94
+ pre{white-space:pre-wrap;margin:0 0 1rem}
95
+ .hint{opacity:.65;font-size:12px}
96
96
  </style>`;
97
- const body = `${style}
98
- <main data-vmz-error="500">
99
- <h1>Dev Error</h1>
100
- <pre>${msg}</pre>
101
- ${stack ? `<pre style="opacity:.7;font-size:12px">${stack}</pre>` : ''}
102
- <p class="hint">Dev host stayed up. Fix the source and save — soft reload will recover.</p>
103
- </main>
104
- <script>
105
- (() => {
106
- const es = new EventSource("/__vmz/events");
107
- es.onmessage = (ev) => {
108
- let msg = null;
109
- try { msg = JSON.parse(ev.data); } catch {}
110
- if (msg && msg.type === "hmr") location.reload();
111
- };
112
- })();
97
+ const body = `${style}
98
+ <main data-vmz-error="500">
99
+ <h1>Dev Error</h1>
100
+ <pre>${msg}</pre>
101
+ ${stack ? `<pre style="opacity:.7;font-size:12px">${stack}</pre>` : ''}
102
+ <p class="hint">Dev host stayed up. Fix the source and save — soft reload will recover.</p>
103
+ </main>
104
+ <script>
105
+ (() => {
106
+ const es = new EventSource("/__vmz/events");
107
+ es.onmessage = (ev) => {
108
+ let msg = null;
109
+ try { msg = JSON.parse(ev.data); } catch {}
110
+ if (msg && msg.type === "hmr") location.reload();
111
+ };
112
+ })();
113
113
  </script>`;
114
114
  try {
115
115
  const native = loadNativeAddon();
@@ -222,11 +222,11 @@ async function* emitAccessShell(marker) {
222
222
  bodyAttrs: [],
223
223
  });
224
224
  }
225
- function resolvePageDocumentMeta(Page) {
225
+ function resolvePageDocumentMeta(Page, ctx = {}) {
226
226
  try {
227
227
  let raw = {};
228
228
  if (typeof Page?.meta === 'function')
229
- raw = Page.meta() || {};
229
+ raw = Page.meta(ctx) || {};
230
230
  else if (Page?.meta && typeof Page.meta === 'object')
231
231
  raw = Page.meta;
232
232
  const title = String(raw.title || '').trim();
@@ -240,79 +240,79 @@ function resolvePageDocumentMeta(Page) {
240
240
  async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {}, layoutChain = [], localeCtx = {}) {
241
241
  const signal = opts.signal;
242
242
  const live = serveState.isDev
243
- ? `\n <script>
244
- (() => {
245
- const es = new EventSource("/__vmz/events");
246
- let sawDisconnect = false;
247
- es.onerror = () => { sawDisconnect = true; };
248
- es.onopen = () => {
249
- if (sawDisconnect) location.reload();
250
- };
251
- function showOverlay(err) {
252
- let el = document.getElementById("vmz-dev-overlay");
253
- if (!el) {
254
- el = document.createElement("div");
255
- el.id = "vmz-dev-overlay";
256
- el.setAttribute("role", "alert");
257
- Object.assign(el.style, {
258
- position: "fixed", inset: "0", zIndex: "2147483646",
259
- background: "rgba(15,17,21,0.92)", color: "#f4f4f5",
260
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
261
- padding: "2rem", overflow: "auto",
262
- });
263
- document.documentElement.appendChild(el);
264
- }
265
- const msg = (err && err.message) || String(err || "Unknown error");
266
- const stack = (err && err.stack) || "";
267
- const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
268
- el.innerHTML = "<div style=\\"max-width:56rem;margin:0 auto\\">"
269
- + "<p style=\\"margin:0 0 .5rem;color:#f87171;font-weight:700\\">Dev Error</p>"
270
- + "<pre style=\\"white-space:pre-wrap;margin:0 0 1rem;font-size:13px;line-height:1.45\\">" + esc(msg) + "</pre>"
271
- + (stack ? "<pre style=\\"white-space:pre-wrap;opacity:.7;font-size:12px\\">" + esc(stack) + "</pre>" : "")
272
- + "<p style=\\"opacity:.65;font-size:12px\\">Fix the file and save — soft reload will clear this overlay.</p>"
273
- + "</div>";
274
- }
275
- function hideOverlay() {
276
- const el = document.getElementById("vmz-dev-overlay");
277
- if (el) el.remove();
278
- }
279
- es.onmessage = async (ev) => {
280
- let msg = null;
281
- try { msg = JSON.parse(ev.data); } catch { /* plain string */ }
282
- if (msg && msg.type === "error") {
283
- showOverlay(msg);
284
- return;
285
- }
286
- if (!msg || msg.type !== "hmr") {
287
- if (ev.data === "reload") location.reload();
288
- return;
289
- }
290
- hideOverlay();
291
- if (msg.mode === "island") {
292
- try {
293
- const { hydrate } = await import("/dom.browser.js?t=" + msg.token);
294
- const root = document.getElementById("app");
295
- const pageChunk = root && root.getAttribute("data-vmz-page");
296
- if (root && pageChunk) {
297
- const pageMod = await import("/" + pageChunk + ".client.js?t=" + msg.token);
298
- let hmrProps = {};
299
- try {
300
- const raw = root.getAttribute("data-vmz-props");
301
- if (raw) hmrProps = JSON.parse(raw);
302
- } catch { /* ignore */ }
303
- await hydrate(pageMod.default, root, hmrProps, { preserveState: true, skipOnMount: true });
304
- } else {
305
- location.reload();
306
- }
307
- } catch (err) {
308
- console.error("vmz island HMR failed", err);
309
- showOverlay({ message: String(err && err.message || err), stack: err && err.stack });
310
- }
311
- return;
312
- }
313
- location.reload();
314
- };
315
- })();
243
+ ? `\n <script>
244
+ (() => {
245
+ const es = new EventSource("/__vmz/events");
246
+ let sawDisconnect = false;
247
+ es.onerror = () => { sawDisconnect = true; };
248
+ es.onopen = () => {
249
+ if (sawDisconnect) location.reload();
250
+ };
251
+ function showOverlay(err) {
252
+ let el = document.getElementById("vmz-dev-overlay");
253
+ if (!el) {
254
+ el = document.createElement("div");
255
+ el.id = "vmz-dev-overlay";
256
+ el.setAttribute("role", "alert");
257
+ Object.assign(el.style, {
258
+ position: "fixed", inset: "0", zIndex: "2147483646",
259
+ background: "rgba(15,17,21,0.92)", color: "#f4f4f5",
260
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
261
+ padding: "2rem", overflow: "auto",
262
+ });
263
+ document.documentElement.appendChild(el);
264
+ }
265
+ const msg = (err && err.message) || String(err || "Unknown error");
266
+ const stack = (err && err.stack) || "";
267
+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
268
+ el.innerHTML = "<div style=\\"max-width:56rem;margin:0 auto\\">"
269
+ + "<p style=\\"margin:0 0 .5rem;color:#f87171;font-weight:700\\">Dev Error</p>"
270
+ + "<pre style=\\"white-space:pre-wrap;margin:0 0 1rem;font-size:13px;line-height:1.45\\">" + esc(msg) + "</pre>"
271
+ + (stack ? "<pre style=\\"white-space:pre-wrap;opacity:.7;font-size:12px\\">" + esc(stack) + "</pre>" : "")
272
+ + "<p style=\\"opacity:.65;font-size:12px\\">Fix the file and save — soft reload will clear this overlay.</p>"
273
+ + "</div>";
274
+ }
275
+ function hideOverlay() {
276
+ const el = document.getElementById("vmz-dev-overlay");
277
+ if (el) el.remove();
278
+ }
279
+ es.onmessage = async (ev) => {
280
+ let msg = null;
281
+ try { msg = JSON.parse(ev.data); } catch { /* plain string */ }
282
+ if (msg && msg.type === "error") {
283
+ showOverlay(msg);
284
+ return;
285
+ }
286
+ if (!msg || msg.type !== "hmr") {
287
+ if (ev.data === "reload") location.reload();
288
+ return;
289
+ }
290
+ hideOverlay();
291
+ if (msg.mode === "island") {
292
+ try {
293
+ const { hydrate } = await import("/dom.browser.js?t=" + msg.token);
294
+ const root = document.getElementById("app");
295
+ const pageChunk = root && root.getAttribute("data-vmz-page");
296
+ if (root && pageChunk) {
297
+ const pageMod = await import("/" + pageChunk + ".client.js?t=" + msg.token);
298
+ let hmrProps = {};
299
+ try {
300
+ const raw = root.getAttribute("data-vmz-props");
301
+ if (raw) hmrProps = JSON.parse(raw);
302
+ } catch { /* ignore */ }
303
+ await hydrate(pageMod.default, root, hmrProps, { preserveState: true, skipOnMount: true });
304
+ } else {
305
+ location.reload();
306
+ }
307
+ } catch (err) {
308
+ console.error("vmz island HMR failed", err);
309
+ showOverlay({ message: String(err && err.message || err), stack: err && err.stack });
310
+ }
311
+ return;
312
+ }
313
+ location.reload();
314
+ };
315
+ })();
316
316
  </script>`
317
317
  : '';
318
318
  const bootOverlay = serveState.isDev && serveState.lastDevError
@@ -349,7 +349,7 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
349
349
  htmlExtraAttrs.push('data-vmz-locale-hrefs', JSON.stringify(hrefTable));
350
350
  }
351
351
  }
352
- const pageDocMeta = resolvePageDocumentMeta(Page);
352
+ const pageDocMeta = resolvePageDocumentMeta(Page, { localeId });
353
353
  const prevLocaleHint = globalThis.__vmzLocaleIdHint;
354
354
  globalThis.__vmzLocaleIdHint = localeId;
355
355
  if (!serveState.ssrRenderHost) {
@@ -58,8 +58,12 @@ export function ensureSsrDocument() {
58
58
  const detail = errors.length ? `\n${errors.join('\n')}` : '';
59
59
  throw new Error(`linkedom unresolved for SSR document${detail}`);
60
60
  }
61
- const { window, document } = parseHTML('<!DOCTYPE html><html><body></body></html>');
62
- globalThis.window = window;
61
+ // SSR host contract: expose `document` only. Installing the linkedom
62
+ // `window` would make `typeof window !== "undefined"` true on the server
63
+ // while still having no browser `location`, so client code would take
64
+ // browser branches and diverge from the browser lifecycle. The window is
65
+ // still reachable as `document.defaultView` when DOM APIs need it.
66
+ const { document } = parseHTML('<!DOCTYPE html><html><body></body></html>');
63
67
  globalThis.document = document;
64
68
  _ssrDocumentLastError = null;
65
69
  return typeof document.createElement === 'function';
@@ -548,8 +548,9 @@ const serializeApi = {
548
548
  target.children.push(node);
549
549
  return;
550
550
  }
551
- if (root)
552
- root.appendChild(node);
551
+ // No default slot in this render: children belong to a branch that is not
552
+ // mounted (closed overlay, loading skeleton). Mirror the browser runtime and
553
+ // drop them instead of painting them into the component root.
553
554
  },
554
555
  setHtml(el, value) {
555
556
  if (!el || el.__kind !== 'el')
package/package.json CHANGED
@@ -68,12 +68,12 @@
68
68
  "main": "./dist/faces/server.js",
69
69
  "name": "@vmz/core",
70
70
  "optionalDependencies": {
71
- "@vmz/vmz-darwin-arm64": "0.3.6",
72
- "@vmz/vmz-darwin-x64": "0.3.6",
73
- "@vmz/vmz-linux-arm64": "0.3.6",
74
- "@vmz/vmz-linux-x64": "0.3.6",
75
- "@vmz/vmz-win32-arm64": "0.3.6",
76
- "@vmz/vmz-win32-x64": "0.3.6"
71
+ "@vmz/vmz-darwin-arm64": "0.3.8",
72
+ "@vmz/vmz-darwin-x64": "0.3.8",
73
+ "@vmz/vmz-linux-arm64": "0.3.8",
74
+ "@vmz/vmz-linux-x64": "0.3.8",
75
+ "@vmz/vmz-win32-arm64": "0.3.8",
76
+ "@vmz/vmz-win32-x64": "0.3.8"
77
77
  },
78
78
  "publishConfig": {
79
79
  "access": "public"
@@ -88,5 +88,5 @@
88
88
  },
89
89
  "type": "module",
90
90
  "types": "./dist/faces/index.d.ts",
91
- "version": "0.3.6"
91
+ "version": "0.3.8"
92
92
  }