@vmz/core 0.3.11 → 0.3.13

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.
@@ -1,4 +1,14 @@
1
1
  export declare function mount(Component: any, container: any, props?: any): Promise<any>;
2
+ /**
3
+ * Root ownership for top-level creates (`mount` / `hydrate` / `resume`).
4
+ *
5
+ * Multi-root templates compile to a DocumentFragment; `appendChild` moves the
6
+ * children into the container and leaves the fragment empty and detached. The
7
+ * instance root must track the live container in that case — the same contract
8
+ * nested component hosts already follow (`nodeType === 11 ? host : node`) — or
9
+ * slot outlet searches and dispose walks run against a detached tree.
10
+ */
11
+ export declare function adoptTopLevelDomRoot(inst: any, node: any, container: any): void;
2
12
  export declare function settlePendingChildMounts(inst: any): Promise<void>;
3
13
  export declare function runDirectCreate(Component: any, inst: any): any;
4
14
  export declare function findOwnedDefaultSlot(root: any): any;
@@ -15,10 +15,7 @@ export async function mount(Component, container, props = {}) {
15
15
  inst.__vmzDepToBindings = Object.create(null);
16
16
  container.replaceChildren();
17
17
  const node = await createFromComponent(Component, inst);
18
- if (node) {
19
- inst.__vmzDomRoot = node;
20
- container.appendChild(node);
21
- }
18
+ adoptTopLevelDomRoot(inst, node, container);
22
19
  if (typeof inst.onMount === 'function') {
23
20
  await inst.onMount();
24
21
  }
@@ -26,6 +23,21 @@ export async function mount(Component, container, props = {}) {
26
23
  container.__vmzInst = inst;
27
24
  return inst;
28
25
  }
26
+ /**
27
+ * Root ownership for top-level creates (`mount` / `hydrate` / `resume`).
28
+ *
29
+ * Multi-root templates compile to a DocumentFragment; `appendChild` moves the
30
+ * children into the container and leaves the fragment empty and detached. The
31
+ * instance root must track the live container in that case — the same contract
32
+ * nested component hosts already follow (`nodeType === 11 ? host : node`) — or
33
+ * slot outlet searches and dispose walks run against a detached tree.
34
+ */
35
+ export function adoptTopLevelDomRoot(inst, node, container) {
36
+ if (!node)
37
+ return;
38
+ inst.__vmzDomRoot = node.nodeType === 11 ? container : node;
39
+ container.appendChild(node);
40
+ }
29
41
  export async function settlePendingChildMounts(inst) {
30
42
  if (!inst || !Array.isArray(inst.__vmzPendingChildMounts) || !inst.__vmzPendingChildMounts.length)
31
43
  return;
@@ -398,7 +398,7 @@ export const directApi = {
398
398
  host.replaceWith(err);
399
399
  return;
400
400
  }
401
- const { resume } = await import('../../ssr/dom-ssr.js');
401
+ const { resume } = await import('../resume/resume.js');
402
402
  await resume(Ctor, host, { props: resolved, state: {} });
403
403
  });
404
404
  return host;
@@ -419,11 +419,17 @@ export const directApi = {
419
419
  // Enter the host's private child pool so nested create cannot see uncle siblings.
420
420
  const adopt = directApi._resumeAdopt;
421
421
  const entered = adopt && typeof adopt.enter === 'function' ? adopt.enter(host) : false;
422
+ // A child nested inside the parent's `v-for` must not feed the parent item
423
+ // patch sink: those patches belong to the child instance (`this`) and the
424
+ // parent's each replays them with the parent host, wiping child output.
425
+ const prevItemPatches = directApi._itemPatches;
426
+ directApi._itemPatches = null;
422
427
  let node;
423
428
  try {
424
429
  node = runDirectCreate(Ctor, child);
425
430
  }
426
431
  finally {
432
+ directApi._itemPatches = prevItemPatches;
427
433
  if (entered && adopt && typeof adopt.leave === 'function')
428
434
  adopt.leave();
429
435
  }
@@ -12,6 +12,7 @@ export { registerComponents } from './dom/registry.js';
12
12
  export { getRegisteredComponent } from './dom/registry.js';
13
13
  export { resolveComponent } from './dom/registry.js';
14
14
  export { mount } from './dom/component.js';
15
+ export { adoptTopLevelDomRoot } from './dom/component.js';
15
16
  export { settlePendingChildMounts } from './dom/component.js';
16
17
  export { runDirectCreate } from './dom/component.js';
17
18
  export { findOwnedDefaultSlot } from './dom/component.js';
@@ -12,6 +12,7 @@ export { registerComponents } from './dom/registry.js';
12
12
  export { getRegisteredComponent } from './dom/registry.js';
13
13
  export { resolveComponent } from './dom/registry.js';
14
14
  export { mount } from './dom/component.js';
15
+ export { adoptTopLevelDomRoot } from './dom/component.js';
15
16
  export { settlePendingChildMounts } from './dom/component.js';
16
17
  export { runDirectCreate } from './dom/component.js';
17
18
  export { findOwnedDefaultSlot } from './dom/component.js';
@@ -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;
@@ -192,8 +203,12 @@ export function __vmzWritePathItem(inst, root, idx, leaf, value) {
192
203
  if (!root || leaf == null)
193
204
  return value;
194
205
  const arr = inst[root];
195
- if (!Array.isArray(arr))
196
- return value;
206
+ if (!Array.isArray(arr)) {
207
+ // Computed member on a plain object root: same write, generic path.
208
+ if (arr == null || typeof arr !== 'object')
209
+ return value;
210
+ return __vmzWritePath(inst, root, [idx, leaf], value);
211
+ }
197
212
  const item = arr[idx];
198
213
  if (item == null || typeof item !== 'object')
199
214
  return value;
@@ -226,9 +241,10 @@ export function __vmzListTranspose(inst, root, ia, ib) {
226
241
  export function __vmzWritePathCompound(inst, root, segs, op, rhs) {
227
242
  if (!inst || inst.__vmzDestroyed)
228
243
  return undefined;
229
- if (!root || !Array.isArray(segs) || segs.length === 0)
244
+ assertWritePathSegs('__vmzWritePathCompound', root, segs);
245
+ if (!root)
230
246
  return undefined;
231
- if (segs.length === 2) {
247
+ if (segs.length === 2 && Array.isArray(inst[root])) {
232
248
  return __vmzWritePathCompoundItem(inst, root, segs[0], segs[1], op, rhs);
233
249
  }
234
250
  const cur = __vmzReadPath(inst, root, segs);
@@ -241,8 +257,12 @@ export function __vmzWritePathCompoundItem(inst, root, idx, leaf, op, rhs) {
241
257
  if (!root || leaf == null)
242
258
  return undefined;
243
259
  const arr = inst[root];
244
- if (!Array.isArray(arr))
245
- return undefined;
260
+ if (!Array.isArray(arr)) {
261
+ // Computed member on a plain object root: same write, generic path.
262
+ if (arr == null || typeof arr !== 'object')
263
+ return undefined;
264
+ return __vmzWritePathCompound(inst, root, [idx, leaf], op, rhs);
265
+ }
246
266
  const item = arr[idx];
247
267
  if (item == null || typeof item !== 'object')
248
268
  return undefined;
@@ -333,6 +353,9 @@ export function __vmzArrayMutate(inst, root, baseSegs, method, args) {
333
353
  }
334
354
  if (!Array.isArray(arr) || typeof arr[method] !== 'function')
335
355
  return undefined;
356
+ if (args !== undefined && !Array.isArray(args)) {
357
+ 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`);
358
+ }
336
359
  const list = Array.isArray(args) ? args : [];
337
360
  const ret = arr[method](...list);
338
361
  const rootObj = inst[root];
@@ -1,4 +1,4 @@
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
4
  export async function hydrateRoute(Page, container, props = {}, layoutCtors = [], opts = {}) {
@@ -82,10 +82,7 @@ export async function hydrate(Component, container, props = {}, opts = {}) {
82
82
  // onMount may still patch state / redirect afterwards (same end state as SSR order).
83
83
  if (!hasMeaningfulChild(container)) {
84
84
  const node = runDirectCreate(Component, inst);
85
- if (node) {
86
- inst.__vmzDomRoot = node;
87
- container.appendChild(node);
88
- }
85
+ adoptTopLevelDomRoot(inst, node, container);
89
86
  }
90
87
  else {
91
88
  // 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).
@@ -447,6 +447,20 @@ function contentType(filePath, nodePath) {
447
447
  return 'image/svg+xml';
448
448
  case '.map':
449
449
  return 'application/json; charset=utf-8';
450
+ case '.wasm':
451
+ return 'application/wasm';
452
+ case '.woff2':
453
+ return 'font/woff2';
454
+ case '.woff':
455
+ return 'font/woff';
456
+ case '.otf':
457
+ return 'font/otf';
458
+ case '.ttf':
459
+ return 'font/ttf';
460
+ case '.xml':
461
+ return 'application/xml';
462
+ case '.txt':
463
+ return 'text/plain; charset=utf-8';
450
464
  default:
451
465
  return 'application/octet-stream';
452
466
  }
@@ -97,10 +97,15 @@ 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
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.
108
+ const abs = packNode ? packNodeEntryAbs(distDir, entry, packNode) : path.join(distDir, entry.entry);
104
109
  let href = pathToFileURL(abs).href;
105
110
  if (opts.cacheBust != null && opts.cacheBust !== '') {
106
111
  href = `${href}?t=${encodeURIComponent(String(opts.cacheBust))}`;
@@ -114,6 +119,44 @@ export async function importAndRegisterComponentEntries(distDir, entries, regist
114
119
  registerComponents(map);
115
120
  return map;
116
121
  }
122
+ /** `vmz.pack.node-manifest.v0` 的唯一消费点:缺失即未打包(dev),调用方回退未打包模块。 */
123
+ function readPackNodeManifest(distDir) {
124
+ const manifestPath = path.join(distDir, '_vmz', 'pack-node-manifest.json');
125
+ if (!fs.existsSync(manifestPath))
126
+ return null;
127
+ // Freshness gate: `vmz build` writes the deployment document first and packs
128
+ // the node graph strictly after; a dev soft rebuild rewrites the deployment
129
+ // document without re-packing. A deployment newer than the manifest means the
130
+ // unpacked tree moved on — packed bytes are stale and must not be served.
131
+ try {
132
+ const deploymentMtime = fs.statSync(path.join(distDir, 'vmz-deployment.json')).mtimeMs;
133
+ const manifestMtime = fs.statSync(manifestPath).mtimeMs;
134
+ if (deploymentMtime > manifestMtime)
135
+ return null;
136
+ }
137
+ catch {
138
+ return null;
139
+ }
140
+ try {
141
+ const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
142
+ return Array.isArray(parsed.entryFiles) ? { entryFiles: parsed.entryFiles } : null;
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
148
+ /** Packed node chunk for one component entry; missing logical/file is an explicit error, never a fallback. */
149
+ function packNodeEntryAbs(distDir, entry, manifest) {
150
+ const hit = manifest.entryFiles.find((e) => e && e.logical === entry.chunkId);
151
+ if (!hit || typeof hit.file !== 'string' || !hit.file) {
152
+ throw new Error(`vmz: pack-node manifest has no entry file for "${entry.chunkId}" under ${distDir}`);
153
+ }
154
+ const abs = path.join(distDir, '_vmz', 'pack-node', ...hit.file.split('/'));
155
+ if (!fs.existsSync(abs)) {
156
+ throw new Error(`vmz: pack-node chunk missing on disk for "${entry.chunkId}": ${path.relative(distDir, abs)}`);
157
+ }
158
+ return abs;
159
+ }
117
160
  export async function bootstrapComponentRegistry(distDir, registerComponents, opts = {}) {
118
161
  const preload = opts.preload ?? (opts.closureRoots?.length ? 'closure' : 'all');
119
162
  if (preload === 'none')
@@ -7,6 +7,7 @@ import { listClientComponents } from '../list-client-components.js';
7
7
  import { LOCALE_LINK_PLAN_SCHEMA, linkRouteAliasesFromUnits } from '../localize-body-links.js';
8
8
  import { loadNativeAddon } from '../native-addon.js';
9
9
  import { createRenderHost } from '../render/render-host.js';
10
+ import { resolveRouteLayoutChain } from '../render/route-layout-chain.js';
10
11
  import { setRoutes } from '../../faces/vmz-runtime.js';
11
12
  import { LOCALE_LINK_PLAN_REL } from './constants.js';
12
13
  import { loadDeploymentStyle } from './locale-theme.js';
@@ -140,13 +141,13 @@ function emitEntryClient(eager, lazy, token) {
140
141
  }
141
142
  return native.generateServeEntryClient(eager, lazy, q);
142
143
  }
143
- function emitEntryEvent(token) {
144
+ function emitEntryEvent(eager, lazy, token) {
144
145
  const q = `?t=${token}`;
145
146
  const native = loadNativeAddon();
146
147
  if (typeof native.generateServeEntryEvent !== 'function') {
147
148
  throw new Error('vmz native addon missing generateServeEntryEvent — rebuild with `pnpm napi:build`');
148
149
  }
149
- return native.generateServeEntryEvent(q);
150
+ return native.generateServeEntryEvent(eager, lazy, q);
150
151
  }
151
152
  export async function softReload(opts = {}) {
152
153
  const prevToken = serveState.reloadToken;
@@ -237,10 +238,27 @@ export async function softReload(opts = {}) {
237
238
  .map((e) => e.component)
238
239
  .filter(Boolean);
239
240
  const lazySet = new Set(lazyEventNames);
240
- await writeFile(path.join(serveState.distDir, 'entry-client.js'), emitEntryClient(componentEntries.filter((e) => !lazySet.has(e.name)), componentEntries.filter((e) => lazySet.has(e.name)), skipEntryRewrite ? serveState.reloadToken : nextToken), 'utf8');
241
+ const eagerComponents = componentEntries.filter((e) => !lazySet.has(e.name));
242
+ const lazyComponents = componentEntries.filter((e) => lazySet.has(e.name));
243
+ // Bootstrap tables are literal maps keyed by chunkId / island name:
244
+ // page/layout units must be enumerated (name = chunkId, entry =
245
+ // `<chunkId>.client.js`), unknown names throw at runtime by design.
246
+ const pageEntries = serveState.pageCatalog.map((p) => ({ name: p.chunkId, entry: `${p.chunkId}.client.js` }));
247
+ const layoutNames = new Set();
248
+ for (const p of serveState.pageCatalog) {
249
+ try {
250
+ for (const id of resolveRouteLayoutChain(serveState.distDir, p.chunkId))
251
+ layoutNames.add(String(id));
252
+ }
253
+ catch {
254
+ /* page unit without layoutChain in a partial dev build */
255
+ }
256
+ }
257
+ const layoutEntries = [...layoutNames].sort().map((id) => ({ name: id, entry: `${id}.client.js` }));
258
+ await writeFile(path.join(serveState.distDir, 'entry-client.js'), emitEntryClient([...eagerComponents, ...pageEntries, ...layoutEntries], lazyComponents, skipEntryRewrite ? serveState.reloadToken : nextToken), 'utf8');
241
259
  const strategies = resumeEntries.map((e) => e.strategy);
242
260
  const eventOnlyShell = isEventOnlyShell(strategies);
243
- await writeFile(path.join(serveState.distDir, 'entry-event.js'), emitEntryEvent(skipEntryRewrite ? serveState.reloadToken : nextToken), 'utf8');
261
+ await writeFile(path.join(serveState.distDir, 'entry-event.js'), emitEntryEvent([], lazyComponents, skipEntryRewrite ? serveState.reloadToken : nextToken), 'utf8');
244
262
  serveState.lastDevError = null;
245
263
  const mode = islandHmr ? 'island' : eventOnlyShell ? 'event-shell' : 'full';
246
264
  notifySse(JSON.stringify({
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Resolve the browser URL of a packed bootstrap entry from
3
+ * `_vmz/pack-manifest.json`.
4
+ *
5
+ * The manifest is the only logical → physical map for browser delivery. A
6
+ * missing manifest or a missing logical name is an explicit error: serving the
7
+ * unpacked root module would hand the browser bare package specifiers that only
8
+ * `vmz-pack` rewrites.
9
+ */
10
+ export declare function packEntryHref(distDir: string, logical: string): string;
@@ -0,0 +1,27 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const PACK_MANIFEST_REL = ['_vmz', 'pack-manifest.json'];
4
+ const PACK_OUTPUT_DIR_REL = ['_vmz', 'pack'];
5
+ const DEFAULT_PUBLIC_BASE = '/';
6
+ /**
7
+ * Resolve the browser URL of a packed bootstrap entry from
8
+ * `_vmz/pack-manifest.json`.
9
+ *
10
+ * The manifest is the only logical → physical map for browser delivery. A
11
+ * missing manifest or a missing logical name is an explicit error: serving the
12
+ * unpacked root module would hand the browser bare package specifiers that only
13
+ * `vmz-pack` rewrites.
14
+ */
15
+ export function packEntryHref(distDir, logical) {
16
+ const manifestPath = path.join(distDir, ...PACK_MANIFEST_REL);
17
+ if (!fs.existsSync(manifestPath)) {
18
+ throw new Error(`packEntryHref: missing ${manifestPath} — run vmz build (pack) first`);
19
+ }
20
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
21
+ const hit = (manifest.entryFiles || []).find((entry) => entry && entry.logical === logical);
22
+ if (!hit || typeof hit.file !== 'string' || !hit.file) {
23
+ throw new Error(`packEntryHref: pack manifest has no entry file for "${logical}"`);
24
+ }
25
+ const base = String(manifest.publicBase || DEFAULT_PUBLIC_BASE).replace(/\/$/, '');
26
+ return `${base}/${PACK_OUTPUT_DIR_REL.join('/')}/${hit.file}`;
27
+ }
@@ -6,6 +6,7 @@ import { extractRouteParams, findRootCatchAll, isRootCatchAll, matchFileRoute }
6
6
  import { emitDevErrorHtml, normalizeDevError } from './hmr-dev.js';
7
7
  import { cssEntryWithBust, htmlThemeAttrPair, localeBootstrapScript, pageMetaAlternates, resolveLocalePath, resolveThemeId, siteFaviconHeadHtml, themeBootstrapScript, } from './locale-theme.js';
8
8
  import { isEventOnlyShell, loadPageCtor, loadPageResumeEntries, runRouteGate } from './page-catalog.js';
9
+ import { packEntryHref } from './pack-entry.js';
9
10
  import { serveState } from './state.js';
10
11
  export async function renderPage(pathname, opts = {}) {
11
12
  const rendered = await renderPageStream(pathname, opts);
@@ -393,7 +394,12 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
393
394
  if (typeof native.generatePageShell !== 'function') {
394
395
  throw new Error('vmz native addon missing generatePageShell — rebuild with `pnpm napi:build`');
395
396
  }
396
- const entrySrc = `/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${serveState.reloadToken}`;
397
+ // 生产 serve 必须注入打包条目:原始 `entry-client.js` 及其拉取的客户端模块保留
398
+ // 裸包说明符,浏览器无法解析。dev 仍走带 token 的原始 bootstrap。
399
+ const entryLogical = eventOnlyShell ? 'entry-event.js' : 'entry-client.js';
400
+ const entrySrc = serveState.isDev
401
+ ? `/${entryLogical}?t=${serveState.reloadToken}`
402
+ : packEntryHref(serveState.distDir, entryLogical);
397
403
  const cssHref = cssEntryWithBust(serveState.cssEntry);
398
404
  yield native.generatePageShell({
399
405
  bodyHtml,
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Shared class/style normalization for Direct SSR + client (0.1.33).
3
3
  * Vue-compatible: string | string[] | object map | falsy parts.
4
+ *
5
+ * Style keys are normalized to CSS property spelling so SSR text and client
6
+ * patches agree: camelCase becomes kebab-case, custom properties (`--x`) pass
7
+ * through untouched, and names that cannot be CSS properties (whitespace, `;`,
8
+ * `:`, leading digit) are dropped — the same fate invalid declarations have in
9
+ * the browser. Numeric values gain `px` except for unitless properties, `0`,
10
+ * and custom properties. Array values emit repeated declarations (vendor
11
+ * fallback chains) and nested arrays flatten.
4
12
  */
5
13
  /** Normalize one or more class fragments into a deduped class string. */
6
14
  export declare function mergeClassParts(...parts: unknown[]): string;
@@ -1,6 +1,14 @@
1
1
  /**
2
2
  * Shared class/style normalization for Direct SSR + client (0.1.33).
3
3
  * Vue-compatible: string | string[] | object map | falsy parts.
4
+ *
5
+ * Style keys are normalized to CSS property spelling so SSR text and client
6
+ * patches agree: camelCase becomes kebab-case, custom properties (`--x`) pass
7
+ * through untouched, and names that cannot be CSS properties (whitespace, `;`,
8
+ * `:`, leading digit) are dropped — the same fate invalid declarations have in
9
+ * the browser. Numeric values gain `px` except for unitless properties, `0`,
10
+ * and custom properties. Array values emit repeated declarations (vendor
11
+ * fallback chains) and nested arrays flatten.
4
12
  */
5
13
  function pushClassTokens(out, value) {
6
14
  if (value == null || value === false)
@@ -35,37 +43,168 @@ export function mergeClassParts(...parts) {
35
43
  pushClassTokens(tokens, part);
36
44
  return [...new Set(tokens)].join(' ');
37
45
  }
38
- function parseStyleObject(value) {
39
- const out = {};
46
+ /** CSS properties whose numeric values stay unitless (kebab-case names). */
47
+ const UNITLESS_STYLE_PROPS = new Set([
48
+ 'animation-iteration-count',
49
+ 'aspect-ratio',
50
+ 'border-image-outset',
51
+ 'border-image-slice',
52
+ 'border-image-width',
53
+ 'box-flex',
54
+ 'box-flex-group',
55
+ 'box-ordinal-group',
56
+ 'column-count',
57
+ 'columns',
58
+ 'flex',
59
+ 'flex-grow',
60
+ 'flex-positive',
61
+ 'flex-shrink',
62
+ 'flex-negative',
63
+ 'flex-order',
64
+ 'grid-row',
65
+ 'grid-row-end',
66
+ 'grid-row-span',
67
+ 'grid-row-start',
68
+ 'grid-column',
69
+ 'grid-column-end',
70
+ 'grid-column-span',
71
+ 'grid-column-start',
72
+ 'font-weight',
73
+ 'line-clamp',
74
+ 'line-height',
75
+ 'opacity',
76
+ 'order',
77
+ 'orphans',
78
+ 'scale',
79
+ 'tab-size',
80
+ 'widows',
81
+ 'z-index',
82
+ 'zoom',
83
+ 'fill-opacity',
84
+ 'flood-opacity',
85
+ 'stop-opacity',
86
+ 'stroke-dasharray',
87
+ 'stroke-dashoffset',
88
+ 'stroke-miterlimit',
89
+ 'stroke-opacity',
90
+ 'stroke-width',
91
+ ]);
92
+ /** camelCase → kebab-case, `--custom` preserved; `null` when not a CSS property name. */
93
+ function normalizeStyleKey(raw) {
94
+ const key = raw.trim();
95
+ if (!key)
96
+ return null;
97
+ if (key.startsWith('--')) {
98
+ return /^--[\w-]+$/.test(key) ? key : null;
99
+ }
100
+ const kebab = key.replace(/\B([A-Z])/g, '-$1').toLowerCase();
101
+ return /^[a-z][a-z0-9-]*$/.test(kebab) ? kebab : null;
102
+ }
103
+ /** One style value as declaration text; `null` drops the declaration. */
104
+ function normalizeStyleValue(key, value) {
105
+ if (value == null || value === false)
106
+ return null;
107
+ if (typeof value === 'number' && value !== 0 && !key.startsWith('--') && !isUnitlessStyleProp(key)) {
108
+ return `${value}px`;
109
+ }
110
+ return String(value);
111
+ }
112
+ /** Unitless lookup ignores vendor prefixes (`webkit-line-clamp` ≙ `line-clamp`). */
113
+ function isUnitlessStyleProp(key) {
114
+ const unprefixed = key.replace(/^(?:-?(?:webkit|moz|ms|o))-/, '');
115
+ return UNITLESS_STYLE_PROPS.has(unprefixed);
116
+ }
117
+ function pushStyleValues(out, key, value) {
118
+ if (Array.isArray(value)) {
119
+ for (const item of value)
120
+ pushStyleValues(out, key, item);
121
+ return;
122
+ }
123
+ const text = normalizeStyleValue(key, value);
124
+ if (text != null)
125
+ out.push(text);
126
+ }
127
+ /**
128
+ * Split a style fragment on top-level `;`. Semicolons inside `url(...)` /
129
+ * nested parens and inside quotes (e.g. `content: 'a;b'`, data URIs with
130
+ * `;base64,`) do not split — a naive split would truncate those values.
131
+ */
132
+ function splitStyleDeclarations(text) {
133
+ const chunks = [];
134
+ let depth = 0;
135
+ let quote = null;
136
+ let start = 0;
137
+ let i = 0;
138
+ while (i < text.length) {
139
+ const ch = text[i];
140
+ if (quote != null) {
141
+ if (ch === '\\') {
142
+ i += 2;
143
+ continue;
144
+ }
145
+ if (ch === quote)
146
+ quote = null;
147
+ }
148
+ else if (ch === '"' || ch === "'") {
149
+ quote = ch;
150
+ }
151
+ else if (ch === '(') {
152
+ depth += 1;
153
+ }
154
+ else if (ch === ')') {
155
+ if (depth > 0)
156
+ depth -= 1;
157
+ }
158
+ else if (ch === ';' && depth === 0) {
159
+ chunks.push(text.slice(start, i));
160
+ start = i + 1;
161
+ }
162
+ i += 1;
163
+ }
164
+ chunks.push(text.slice(start));
165
+ return chunks;
166
+ }
167
+ function collectStyleDeclarations(merged, value) {
40
168
  if (value == null || value === false)
41
- return out;
169
+ return;
42
170
  if (typeof value === 'string') {
43
- for (const chunk of value.split(';')) {
171
+ for (const chunk of splitStyleDeclarations(value)) {
44
172
  const idx = chunk.indexOf(':');
45
173
  if (idx < 0)
46
174
  continue;
47
- const key = chunk.slice(0, idx).trim();
48
- const val = chunk.slice(idx + 1).trim();
49
- if (key)
50
- out[key] = val;
175
+ const key = normalizeStyleKey(chunk.slice(0, idx));
176
+ if (!key)
177
+ continue;
178
+ merged.set(key, [chunk.slice(idx + 1).trim()]);
51
179
  }
52
- return out;
180
+ return;
53
181
  }
54
- if (typeof value === 'object' && !Array.isArray(value)) {
55
- for (const [key, raw] of Object.entries(value)) {
56
- if (raw == null || raw === false)
182
+ if (Array.isArray(value)) {
183
+ for (const item of value)
184
+ collectStyleDeclarations(merged, item);
185
+ return;
186
+ }
187
+ if (typeof value === 'object') {
188
+ for (const [rawKey, raw] of Object.entries(value)) {
189
+ const key = normalizeStyleKey(rawKey);
190
+ if (!key)
57
191
  continue;
58
- out[key] = String(raw);
192
+ const values = [];
193
+ pushStyleValues(values, key, raw);
194
+ if (values.length)
195
+ merged.set(key, values);
59
196
  }
60
197
  }
61
- return out;
62
198
  }
63
199
  /** Merge static + dynamic style fragments; later parts override same keys. */
64
200
  export function mergeStyleParts(...parts) {
65
- const merged = {};
201
+ const merged = new Map();
66
202
  for (const part of parts)
67
- Object.assign(merged, parseStyleObject(part));
68
- return Object.entries(merged)
69
- .map(([key, val]) => `${key}: ${val}`)
70
- .join('; ');
203
+ collectStyleDeclarations(merged, part);
204
+ const out = [];
205
+ for (const [key, values] of merged) {
206
+ for (const value of values)
207
+ out.push(`${key}: ${value}`);
208
+ }
209
+ return out.join('; ');
71
210
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * SSR `onMount` execution budget.
3
+ *
4
+ * Contract: every per-instance `onMount` await during SSR is bounded (default
5
+ * 5000ms, hosts may override via `opts.onMountBudgetMs`). Budget exhaustion and
6
+ * `signal` abort are the same event: the render terminates and fails — no
7
+ * half-baked HTML, no silent empty-string downgrade, no retry. An `onMount`
8
+ * that throws propagates its original error unchanged.
9
+ */
10
+ export declare const DEFAULT_SSR_ONMOUNT_BUDGET_MS = 5000;
11
+ /** `onMount` exceeded the SSR budget. Carries component, budget and elapsed. */
12
+ export declare class SsrOnMountTimeoutError extends Error {
13
+ readonly component: string;
14
+ readonly budgetMs: number;
15
+ readonly elapsedMs: number;
16
+ constructor(component: string, budgetMs: number, elapsedMs: number);
17
+ }
18
+ /** The host signal aborted a bounded `onMount` await. */
19
+ export declare class SsrRenderAbortError extends Error {
20
+ readonly component: string;
21
+ constructor(component: string);
22
+ }
23
+ export type SsrOnMountContext = {
24
+ budgetMs: number;
25
+ signal: AbortSignal | null;
26
+ };
27
+ /** Read the host options into a budget context (`opts.onMountBudgetMs`, `opts.signal`). */
28
+ export declare function ssrOnMountContext(opts: any): SsrOnMountContext;
29
+ /** Best-effort component label for diagnostics. */
30
+ export declare function ssrComponentName(Component: any, inst: any): string;
31
+ /**
32
+ * Await one instance's `onMount` under the budget. The race is settled exactly
33
+ * once; a late-resolving author promise after timeout/abort is dropped. The
34
+ * budget timer itself bounds the wait, so a hung `onMount` can never stall the
35
+ * host past `budgetMs`.
36
+ */
37
+ export declare function awaitSsrOnMount(inst: any, component: string, ctx: SsrOnMountContext): Promise<void>;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * SSR `onMount` execution budget.
3
+ *
4
+ * Contract: every per-instance `onMount` await during SSR is bounded (default
5
+ * 5000ms, hosts may override via `opts.onMountBudgetMs`). Budget exhaustion and
6
+ * `signal` abort are the same event: the render terminates and fails — no
7
+ * half-baked HTML, no silent empty-string downgrade, no retry. An `onMount`
8
+ * that throws propagates its original error unchanged.
9
+ */
10
+ export const DEFAULT_SSR_ONMOUNT_BUDGET_MS = 5000;
11
+ /** `onMount` exceeded the SSR budget. Carries component, budget and elapsed. */
12
+ export class SsrOnMountTimeoutError extends Error {
13
+ component;
14
+ budgetMs;
15
+ elapsedMs;
16
+ constructor(component, budgetMs, elapsedMs) {
17
+ super(`vmz:ssr onMount budget exceeded: component=${component} budget=${budgetMs}ms elapsed=${elapsedMs}ms`);
18
+ this.name = 'SsrOnMountTimeoutError';
19
+ this.component = component;
20
+ this.budgetMs = budgetMs;
21
+ this.elapsedMs = elapsedMs;
22
+ }
23
+ }
24
+ /** The host signal aborted a bounded `onMount` await. */
25
+ export class SsrRenderAbortError extends Error {
26
+ component;
27
+ constructor(component) {
28
+ super(`vmz:ssr onMount aborted by host signal: component=${component}`);
29
+ this.name = 'SsrRenderAbortError';
30
+ this.component = component;
31
+ }
32
+ }
33
+ /** Read the host options into a budget context (`opts.onMountBudgetMs`, `opts.signal`). */
34
+ export function ssrOnMountContext(opts) {
35
+ const raw = opts && opts.onMountBudgetMs;
36
+ const budgetMs = Number.isFinite(raw) && Number(raw) > 0 ? Number(raw) : DEFAULT_SSR_ONMOUNT_BUDGET_MS;
37
+ const signal = opts && opts.signal ? opts.signal : null;
38
+ return { budgetMs, signal };
39
+ }
40
+ /** Best-effort component label for diagnostics. */
41
+ export function ssrComponentName(Component, inst) {
42
+ return ((Component && (Component.__vmzTag || Component.name)) ||
43
+ (inst && inst.constructor && inst.constructor.name) ||
44
+ 'AnonymousComponent');
45
+ }
46
+ /**
47
+ * Await one instance's `onMount` under the budget. The race is settled exactly
48
+ * once; a late-resolving author promise after timeout/abort is dropped. The
49
+ * budget timer itself bounds the wait, so a hung `onMount` can never stall the
50
+ * host past `budgetMs`.
51
+ */
52
+ export async function awaitSsrOnMount(inst, component, ctx) {
53
+ const onMount = inst && inst.onMount;
54
+ if (typeof onMount !== 'function')
55
+ return;
56
+ if (ctx.signal && ctx.signal.aborted)
57
+ throw new SsrRenderAbortError(component);
58
+ const startedAt = Date.now();
59
+ let timer = null;
60
+ let onAbort = null;
61
+ try {
62
+ await new Promise((resolve, reject) => {
63
+ let settled = false;
64
+ const settle = (fn) => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ fn();
69
+ };
70
+ timer = setTimeout(() => {
71
+ settle(() => reject(new SsrOnMountTimeoutError(component, ctx.budgetMs, Date.now() - startedAt)));
72
+ }, ctx.budgetMs);
73
+ if (ctx.signal) {
74
+ onAbort = () => settle(() => reject(new SsrRenderAbortError(component)));
75
+ ctx.signal.addEventListener('abort', onAbort, { once: true });
76
+ }
77
+ let result;
78
+ try {
79
+ result = onMount.call(inst);
80
+ }
81
+ catch (err) {
82
+ settle(() => reject(err));
83
+ return;
84
+ }
85
+ Promise.resolve(result).then(() => settle(resolve), (err) => settle(() => reject(err)));
86
+ });
87
+ }
88
+ finally {
89
+ if (timer)
90
+ clearTimeout(timer);
91
+ if (ctx.signal && onAbort)
92
+ ctx.signal.removeEventListener('abort', onAbort);
93
+ }
94
+ }
@@ -1,5 +1,6 @@
1
1
  import { createInstance, destroy, } from '../browser/dom-core.js';
2
2
  import { ensureSsrDocument } from './document.js';
3
+ import { awaitSsrOnMount, ssrComponentName, ssrOnMountContext } from './onmount.js';
3
4
  import { flattenSerializeNode, runDirectSerializeTreeWithMounts, streamSerializeChunks } from './serialize.js';
4
5
  export async function renderToString(Component, props = {}, opts = {}) {
5
6
  ensureSsrDocument();
@@ -7,16 +8,15 @@ export async function renderToString(Component, props = {}, opts = {}) {
7
8
  if (signal && signal.aborted)
8
9
  return '';
9
10
  const inst = createInstance(Component, props);
10
- if (typeof inst.onMount === 'function') {
11
- await inst.onMount();
12
- }
11
+ const onMountCtx = ssrOnMountContext(opts);
12
+ await awaitSsrOnMount(inst, ssrComponentName(Component, inst), onMountCtx);
13
13
  if (signal && signal.aborted)
14
14
  return '';
15
15
  // production Direct emit: SSR only via Direct serialize schedule ??never `render`.
16
16
  if (!(Component && Component.__vmzDirect && typeof Component.__vmzCreate === 'function')) {
17
17
  throw new Error(`vmz:dom renderToString() requires __vmzCreate (Direct); blueprint render() removed (production Direct emit)`);
18
18
  }
19
- const root = await runDirectSerializeTreeWithMounts(Component, inst);
19
+ const root = await runDirectSerializeTreeWithMounts(Component, inst, onMountCtx);
20
20
  if (opts && opts.slotHtml != null)
21
21
  injectDefaultSlotHtml(root, opts.slotHtml);
22
22
  return flattenSerializeNode(root);
@@ -29,15 +29,14 @@ export async function* renderToStream(Component, props = {}, opts = {}) {
29
29
  return;
30
30
  const inst = createInstance(Component, props);
31
31
  try {
32
- if (typeof inst.onMount === 'function') {
33
- await inst.onMount();
34
- }
32
+ const onMountCtx = ssrOnMountContext(opts);
33
+ await awaitSsrOnMount(inst, ssrComponentName(Component, inst), onMountCtx);
35
34
  if (aborted())
36
35
  return;
37
36
  if (!(Component && Component.__vmzDirect && typeof Component.__vmzCreate === 'function')) {
38
37
  throw new Error(`vmz:dom renderToStream() requires __vmzCreate (Direct); blueprint render() removed (production Direct emit)`);
39
38
  }
40
- const root = await runDirectSerializeTreeWithMounts(Component, inst);
39
+ const root = await runDirectSerializeTreeWithMounts(Component, inst, onMountCtx);
41
40
  if (opts && opts.slotHtml != null)
42
41
  injectDefaultSlotHtml(root, opts.slotHtml);
43
42
  if (aborted())
@@ -1,3 +1,3 @@
1
- export declare function runDirectSerializeTreeWithMounts(Component: any, inst: any): Promise<any>;
1
+ export declare function runDirectSerializeTreeWithMounts(Component: any, inst: any, onMountCtx?: any): Promise<any>;
2
2
  export declare function flattenSerializeNode(node: any): any;
3
3
  export declare function streamSerializeChunks(node: any): any;
@@ -2,6 +2,7 @@ import { directHostBoxStyleAttr } from '../browser/direct-host-box.js';
2
2
  import { BOOLEAN_HTML_ATTRS, createInstance, eventPropHandlerName, getRegisteredComponent, isEventEntryStrategy, mergeClassParts, mergeStyleParts, snapshotInstanceState, stripFns, } from '../browser/dom-core.js';
3
3
  import { serializeUnknownComponentNode } from '../browser/unknown-component.js';
4
4
  import { _ssrDocumentLastError, ensureSsrDocument } from './document.js';
5
+ import { awaitSsrOnMount, ssrComponentName, ssrOnMountContext } from './onmount.js';
5
6
  function runDirectSerializeTree(Component, inst) {
6
7
  serializeApi._inst = inst;
7
8
  try {
@@ -11,7 +12,8 @@ function runDirectSerializeTree(Component, inst) {
11
12
  serializeApi._inst = null;
12
13
  }
13
14
  }
14
- export async function runDirectSerializeTreeWithMounts(Component, inst) {
15
+ export async function runDirectSerializeTreeWithMounts(Component, inst, onMountCtx) {
16
+ const ctx = onMountCtx || ssrOnMountContext(null);
15
17
  let preMounted = [];
16
18
  let tree = null;
17
19
  for (let round = 0; round < 32; round++) {
@@ -28,9 +30,7 @@ export async function runDirectSerializeTreeWithMounts(Component, inst) {
28
30
  if (!collected.length)
29
31
  return tree;
30
32
  for (const child of collected) {
31
- if (typeof child.onMount === 'function') {
32
- await child.onMount();
33
- }
33
+ await awaitSsrOnMount(child, ssrComponentName(null, child), ctx);
34
34
  }
35
35
  preMounted = preMounted.concat(collected);
36
36
  }
@@ -692,7 +692,13 @@ const serializeApi = {
692
692
  const child = serializeApi._ssrChildInstance(Ctor, resolved);
693
693
  if (Ctor.__vmzDirect && typeof Ctor.__vmzCreate === 'function') {
694
694
  const prev = serializeApi._inst;
695
+ // A child nested inside the parent's `v-for` must not feed the parent
696
+ // item patch sink: those patches belong to the child instance (`this`).
697
+ // Replaying them with the parent's each host clears child output
698
+ // (innerHTML / text) that was already correct at create time.
699
+ const prevItemPatches = serializeApi._itemPatches;
695
700
  serializeApi._inst = child;
701
+ serializeApi._itemPatches = null;
696
702
  try {
697
703
  const node = Ctor.__vmzCreate.call(child, serializeApi);
698
704
  const attrs = { 'data-vmz': name };
@@ -712,6 +718,7 @@ const serializeApi = {
712
718
  }
713
719
  finally {
714
720
  serializeApi._inst = prev;
721
+ serializeApi._itemPatches = prevItemPatches;
715
722
  }
716
723
  }
717
724
  throw new Error(`vmz:dom serialize component <${name}> requires __vmzCreate (rebuild child with Direct)`);
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.11",
72
- "@vmz/vmz-darwin-x64": "0.3.11",
73
- "@vmz/vmz-linux-arm64": "0.3.11",
74
- "@vmz/vmz-linux-x64": "0.3.11",
75
- "@vmz/vmz-win32-arm64": "0.3.11",
76
- "@vmz/vmz-win32-x64": "0.3.11"
71
+ "@vmz/vmz-darwin-arm64": "0.3.13",
72
+ "@vmz/vmz-darwin-x64": "0.3.13",
73
+ "@vmz/vmz-linux-arm64": "0.3.13",
74
+ "@vmz/vmz-linux-x64": "0.3.13",
75
+ "@vmz/vmz-win32-arm64": "0.3.13",
76
+ "@vmz/vmz-win32-x64": "0.3.13"
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.11"
91
+ "version": "0.3.13"
92
92
  }