@vmz/core 0.3.12 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/browser/client-nav.types.d.ts +2 -5
  2. package/dist/browser/direct-api.types.d.ts +6 -0
  3. package/dist/browser/dom/attributes.js +142 -2
  4. package/dist/browser/dom/component.d.ts +10 -0
  5. package/dist/browser/dom/component.js +16 -4
  6. package/dist/browser/dom/direct-api.d.ts +43 -3
  7. package/dist/browser/dom/direct-api.js +421 -84
  8. package/dist/browser/dom-core.d.ts +1 -0
  9. package/dist/browser/dom-core.js +1 -0
  10. package/dist/browser/dom-core.types.d.ts +10 -2
  11. package/dist/browser/navigation/client-navigation.js +18 -30
  12. package/dist/browser/reactivity/write-barrier.js +38 -11
  13. package/dist/browser/resume/adopt.js +142 -10
  14. package/dist/browser/resume/hydrate.js +36 -5
  15. package/dist/browser/resume/resume.js +2 -5
  16. package/dist/host/deployment/deployment-registry.js +47 -1
  17. package/dist/host/render/render-host.js +2 -0
  18. package/dist/host/serve/bootstrap.js +8 -4
  19. package/dist/host/serve/hmr-dev.d.ts +16 -17
  20. package/dist/host/serve/hmr-dev.js +114 -35
  21. package/dist/host/serve/http.js +25 -8
  22. package/dist/host/serve/pack-entry.d.ts +31 -0
  23. package/dist/host/serve/pack-entry.js +98 -0
  24. package/dist/host/serve/page-render.js +12 -4
  25. package/dist/shared/dom-attr-normalize.d.ts +14 -0
  26. package/dist/shared/dom-attr-normalize.js +158 -19
  27. package/dist/shared/host.types.d.ts +9 -0
  28. package/dist/shared/html-digest.d.ts +15 -0
  29. package/dist/shared/html-digest.js +25 -0
  30. package/dist/ssr/onmount.d.ts +37 -0
  31. package/dist/ssr/onmount.js +94 -0
  32. package/dist/ssr/render.js +7 -8
  33. package/dist/ssr/serialize.d.ts +1 -1
  34. package/dist/ssr/serialize.js +309 -13
  35. package/package.json +7 -7
@@ -5,20 +5,17 @@ import type { ComponentCtorLike, DirectInstance } from './direct-api.types.js';
5
5
  export type HydrateFn = (Ctor: ComponentCtorLike, root: Element, props: object) => Promise<unknown>;
6
6
  export type HydrateRouteFn = (Page: ComponentCtorLike, root: Element, props: object, layouts?: ComponentCtorLike[]) => Promise<unknown>;
7
7
  export type HydrateRoutePageFn = (Page: ComponentCtorLike, root: Element, props: object) => Promise<unknown>;
8
- export type DomFallbackModule = {
9
- hydrate?: HydrateFn;
10
- hydrateRoute?: HydrateRouteFn;
11
- hydrateRoutePage?: HydrateRoutePageFn;
12
- };
13
8
  export type ClientNavOpts = {
14
9
  fetchImpl?: typeof fetch;
15
10
  document?: Document;
16
11
  history?: History;
17
12
  location?: Location;
13
+ /** Packed face 的 hydrate,由生成入口静态引入;页面导航缺失时显式报错,不回落到未打包路径。 */
18
14
  hydrate?: HydrateFn;
19
15
  hydrateRoute?: HydrateRouteFn;
20
16
  hydrateRoutePage?: HydrateRoutePageFn;
21
17
  destroy?: (inst: DirectInstance | object) => void;
18
+ /** Pack manifest 的 chunk 查表(生成入口的 import 表);缺失时页面导航显式报错。 */
22
19
  importPage?: (chunkId: string) => Promise<unknown>;
23
20
  };
24
21
  export type TransitionToOpts = {
@@ -144,8 +144,14 @@ export type DirectApi = {
144
144
  projectDefaultSlot: (hostEl: HTMLElement, node: Node) => void;
145
145
  projectNamedSlot: (hostEl: HTMLElement, name: string, node: Node) => void;
146
146
  setHtml: (el: Element, value: unknown) => void;
147
+ /** `v-html` write that keeps the SSR subtree when its payload digest matches. */
148
+ html: (el: Element, value: unknown) => void;
147
149
  setShow: (el: HTMLElement, condition: unknown) => void;
148
150
  bindHtml: (inst: DirectInstance, bindingId: BindingId, deps: string[], get: PatchFn, el: Element) => void;
149
151
  bindComponentProp: (inst: DirectInstance, bindingId: BindingId, deps: string[], get: PatchFn, hostInst: DirectInstance, propName: string) => void;
152
+ bindComponentProps: (inst: DirectInstance, hostEl: HTMLElement, slot: string, deps: string[], get: PatchFn) => void;
153
+ bindComponentEvents: (inst: DirectInstance, hostEl: HTMLElement, slot: string, deps: string[], get: PatchFn) => void;
154
+ bindObjectListeners: (el: Element, slot: string, source: unknown, inst: DirectInstance | null, mode: 'bind' | 'on') => void;
155
+ spreadProps: (source: unknown) => Record<string, unknown>;
150
156
  component: (Ctor: ComponentCtorLike | string, props?: object) => HTMLElement;
151
157
  };
@@ -1,4 +1,10 @@
1
1
  import { mergeClassParts, mergeStyleParts } from '../../shared/dom-attr-normalize.js';
2
+ /**
3
+ * `xlink:href` parsed from SSR HTML lives in the XLink namespace. A plain
4
+ * `setAttribute` writes a non-namespaced attribute instead, so the SVG host
5
+ * (`<use>`) no longer resolves the reference after hydration or client create.
6
+ */
7
+ const XLINK_NS = 'http://www.w3.org/1999/xlink';
2
8
  export const BOOLEAN_HTML_ATTRS = new Set([
3
9
  'disabled',
4
10
  'checked',
@@ -21,6 +27,49 @@ export const BOOLEAN_HTML_ATTRS = new Set([
21
27
  'default',
22
28
  'inert',
23
29
  ]);
30
+ /**
31
+ * A `<select>` is patched before its `<option>` children are appended: emit
32
+ * writes the model attr while the select is still empty, so the first write
33
+ * selects nothing and the model never changes again. Remember the pending
34
+ * value and replay it once the synchronous create pass has finished.
35
+ */
36
+ const pendingSelectValues = new WeakMap();
37
+ /** First `value` write seeds a textarea's default value once. */
38
+ const seededTextareaDefaults = new WeakSet();
39
+ /**
40
+ * `form.reset()` restores the *default* state, which browsers derive from the
41
+ * `selected`, `value` and `checked` attributes. The first write therefore
42
+ * records the initial model there, and later model updates move only the IDL
43
+ * state (or `selected` on the options) instead of rewriting the defaults.
44
+ */
45
+ const seededSelectDefaults = new WeakSet();
46
+ const seededInputValueDefaults = new WeakSet();
47
+ const seededCheckedDefaults = new WeakSet();
48
+ const pendingSelectReplays = new WeakSet();
49
+ /** Checkbox and radio `value` attributes are submitted values, not defaults. */
50
+ function isCheckableInput(el) {
51
+ const type = String(el.type || (el.getAttribute && el.getAttribute('type')) || 'text').toLowerCase();
52
+ return type === 'checkbox' || type === 'radio';
53
+ }
54
+ function replaySelectValue(el) {
55
+ if (!pendingSelectValues.has(el))
56
+ return;
57
+ const value = pendingSelectValues.get(el);
58
+ const opts = el.options || el.querySelectorAll?.('option') || [];
59
+ if (!opts.length)
60
+ return;
61
+ pendingSelectValues.delete(el);
62
+ applyDomAttr(el, 'value', value);
63
+ }
64
+ function deferSelectValue(el) {
65
+ if (pendingSelectReplays.has(el))
66
+ return;
67
+ pendingSelectReplays.add(el);
68
+ queueMicrotask(() => {
69
+ pendingSelectReplays.delete(el);
70
+ replaySelectValue(el);
71
+ });
72
+ }
24
73
  export function applyDomAttr(el, name, value) {
25
74
  const key = name === 'className' ? 'class' : name;
26
75
  if (key === 'class') {
@@ -39,11 +88,36 @@ export function applyDomAttr(el, name, value) {
39
88
  el.removeAttribute('style');
40
89
  return;
41
90
  }
91
+ if (String(key).startsWith('xlink:') && el && typeof el.setAttributeNS === 'function') {
92
+ const local = String(key).slice('xlink:'.length);
93
+ if (value == null || value === false) {
94
+ if (typeof el.removeAttributeNS === 'function')
95
+ el.removeAttributeNS(XLINK_NS, local);
96
+ else
97
+ el.removeAttribute(key);
98
+ }
99
+ else {
100
+ el.setAttributeNS(XLINK_NS, `xlink:${local}`, value === true ? '' : String(value));
101
+ }
102
+ return;
103
+ }
42
104
  if (BOOLEAN_HTML_ATTRS.has(String(key).toLowerCase())) {
43
105
  const on = value === true || value === '';
44
- if (key === 'checked' && el && el.tagName === 'INPUT' && String(el.type || '').toLowerCase() === 'checkbox') {
106
+ // Reflect `checked` through its IDL property. Once the user has interacted
107
+ // with a radio, attribute writes only reach its default state, so a later
108
+ // model update would otherwise leave the visible control stale. Only the
109
+ // first write records the default; later ones stay on the IDL.
110
+ if (key === 'checked' && el && el.tagName === 'INPUT') {
111
+ if (!seededCheckedDefaults.has(el)) {
112
+ seededCheckedDefaults.add(el);
113
+ if (on)
114
+ el.setAttribute('checked', '');
115
+ else
116
+ el.removeAttribute('checked');
117
+ }
45
118
  if (el.checked !== on)
46
119
  el.checked = on;
120
+ return;
47
121
  }
48
122
  if (value === false || value == null || value === '') {
49
123
  el.removeAttribute(key);
@@ -57,16 +131,82 @@ export function applyDomAttr(el, name, value) {
57
131
  // also need the IDL `.value` property so controlled updates stay in sync after switches.
58
132
  // linkedom `<select>.value` is getter-only ??sync via `option.selected` instead of throwing.
59
133
  if (key === 'value' && el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT' || el.tagName === 'SELECT')) {
134
+ // A `multiple` select model is a list: every option in the model is
135
+ // selected and there is no single `value` attribute to reflect.
136
+ const listed = Array.isArray(value) ? value.map((v) => String(v)) : null;
60
137
  const next = value == null || value === false ? '' : String(value);
61
138
  if (el.tagName === 'SELECT') {
62
139
  const opts = el.options || el.querySelectorAll?.('option') || [];
140
+ if (!opts.length) {
141
+ pendingSelectValues.set(el, value);
142
+ deferSelectValue(el);
143
+ return;
144
+ }
145
+ pendingSelectValues.delete(el);
146
+ // The `selected` attribute is the option's default state, the value
147
+ // `form.reset()` restores. Seed it with the initial model once and
148
+ // move only `option.selected` afterwards. Attribute writes run after
149
+ // the IDL pass: setting selectedness marks the option dirty, so the
150
+ // later attribute write records the default without moving the
151
+ // visible selection.
152
+ const seed = !seededSelectDefaults.has(el);
153
+ if (seed)
154
+ seededSelectDefaults.add(el);
155
+ const selections = [];
63
156
  for (const opt of opts) {
64
- opt.selected = String(opt.value ?? '') === next;
157
+ // Match SSR `markSelectSelection`: an option without a `value`
158
+ // attribute contributes its trimmed text. Reading IDL `.value`
159
+ // here keeps the template's indentation and loses the selection
160
+ // right after hydration.
161
+ const optionValue = typeof opt.hasAttribute === 'function' && opt.hasAttribute('value')
162
+ ? String(opt.getAttribute('value') ?? '')
163
+ : String(opt.textContent ?? opt.value ?? '').trim();
164
+ selections.push([opt, listed ? listed.includes(optionValue) : optionValue === next]);
165
+ }
166
+ for (const [opt, on] of selections)
167
+ opt.selected = on;
168
+ if (seed) {
169
+ for (const [opt, on] of selections) {
170
+ if (on)
171
+ opt.setAttribute('selected', '');
172
+ else if (typeof opt.removeAttribute === 'function')
173
+ opt.removeAttribute('selected');
174
+ }
65
175
  }
66
176
  }
67
177
  else if (el.value !== next) {
68
178
  el.value = next;
69
179
  }
180
+ if (el.tagName === 'TEXTAREA' && !seededTextareaDefaults.has(el)) {
181
+ // SSR ships a textarea's initial value as its text child. Resume parks
182
+ // that child in the adopt pool, so `defaultValue` (the form-reset target)
183
+ // stayed empty. Seed it on the first write; later model updates leave it.
184
+ seededTextareaDefaults.add(el);
185
+ el.defaultValue = next;
186
+ }
187
+ if (el.tagName === 'INPUT') {
188
+ // For text-like inputs the `value` attribute *is* the default value:
189
+ // seed it once, then let the IDL follow the model alone. Checkbox and
190
+ // radio `value` attributes are submitted values, not defaults.
191
+ if (isCheckableInput(el)) {
192
+ if (value == null || value === false)
193
+ el.removeAttribute('value');
194
+ else
195
+ el.setAttribute('value', next);
196
+ }
197
+ else if (!seededInputValueDefaults.has(el)) {
198
+ seededInputValueDefaults.add(el);
199
+ if (value == null || value === false)
200
+ el.removeAttribute('value');
201
+ else
202
+ el.setAttribute('value', next);
203
+ }
204
+ return;
205
+ }
206
+ // A select carries its value as option selectedness: a `value` attribute
207
+ // on the select itself has no meaning and SSR never emits one.
208
+ if (listed || el.tagName === 'SELECT')
209
+ return;
70
210
  if (value == null || value === false)
71
211
  el.removeAttribute('value');
72
212
  else
@@ -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;
@@ -1,5 +1,5 @@
1
1
  import type { BindingId, DirectInstance, PatchFn } from '../direct-api.types.js';
2
- import type { EachCtx, ResumeAdoptCtx } from '../dom-core.types.js';
2
+ import type { EachCtx, ResumeAdoptCtx, VmzDomNode } from '../dom-core.types.js';
3
3
  export declare const directApi: {
4
4
  _inst: DirectInstance | null;
5
5
  _branchBinds: Array<{
@@ -10,9 +10,18 @@ export declare const directApi: {
10
10
  _itemPatches: PatchFn[] | null;
11
11
  _eachCtx: EachCtx | null;
12
12
  _resumeAdopt: ResumeAdoptCtx | null;
13
- el(tag: any): any;
13
+ el(tag: any, key?: any): any;
14
14
  elNS(ns: any, tag: any): Element;
15
15
  text(value: any): Text;
16
+ /**
17
+ * Emitted `v-html` write. Resume parks the SSR children of a reclaimed host,
18
+ * so the live `innerHTML` is still empty when the first patch runs. SSR stamps
19
+ * `HTML_HASH_ATTR` with the digest of the exact payload string it wrote: a
20
+ * matching digest proves the parked subtree is the same payload, so adopt
21
+ * moves it back and no text is ever re-parsed. A different digest writes the
22
+ * new payload and refreshes the marker for later identical runs.
23
+ */
24
+ html(el: any, value: any): void;
16
25
  nsScopeDepth(): number;
17
26
  rewindNsScope(depth: any): void;
18
27
  adoptEnter(node: any): boolean;
@@ -68,7 +77,14 @@ export declare const directApi: {
68
77
  /**
69
78
  * Nested component (sync Direct child or island schedule).
70
79
  */
71
- component(hostInst: any, nameOrCtor: any, props: any, client: any): any;
80
+ component(hostInst: any, nameOrCtor: any, props: any, client: any, key?: any): VmzDomNode;
81
+ /**
82
+ * `<component :is>` boundary. The host element is stable, the child instance
83
+ * is torn down and rebuilt whenever the evaluated `is` value changes. Props
84
+ * are re-evaluated per mount so the new type starts from current values, and
85
+ * the child listener bag is carried over so `@event` handlers keep working.
86
+ */
87
+ dynamicComponent(hostInst: any, bindingId: any, deps: any, getCtor: any, getProps: any, key?: any): any;
72
88
  /**
73
89
  * Subscribe to a child component event (`@submit` ??event name `submit`).
74
90
  * Orthogonal to function props (`:on-submit` ??prop `onSubmit`).
@@ -78,6 +94,30 @@ export declare const directApi: {
78
94
  * Keep nested Direct child props live with parent field writes.
79
95
  */
80
96
  bindComponentProp(hostInst: any, hostEl: any, propName: any, deps: any, get: any): void;
97
+ /**
98
+ * Object form of `bindComponentProp`: spread every key of the patch-time object
99
+ * across the child props and clear props whose keys disappear from the object.
100
+ */
101
+ bindComponentProps(hostInst: any, hostEl: any, slot: any, deps: any, get: any): void;
102
+ /**
103
+ * Object form of `onComponentEvent`: diff event names per call site and detach
104
+ * listeners whose keys disappear. No global `off` semantics — the bag entry
105
+ * belongs to this call site only.
106
+ */
107
+ bindComponentEvents(hostInst: any, hostEl: any, slot: any, deps: any, get: any): void;
108
+ /**
109
+ * `v-bind="obj"` / `v-on="obj"` listeners scoped to one call site.
110
+ *
111
+ * `mode: 'bind'` reads `onXxx` keys out of a bind object, `mode: 'on'` reads
112
+ * event names directly. Vue lowercases element listener names, so both modes
113
+ * normalize before touching `addEventListener`.
114
+ */
115
+ bindObjectListeners(el: any, slot: any, source: any, inst: any, mode: any): void;
116
+ /**
117
+ * Filter a `v-bind="obj"` source down to bindable keys before it reaches
118
+ * component props (`#slot` markers belong to the compiler, not the child).
119
+ */
120
+ spreadProps(source: any): Record<string, unknown>;
81
121
  /**
82
122
  * Project parent children into nested Direct component default `<slot>`.
83
123
  * Uses {@link findOwnedDefaultSlot} so nested Button/Link slots are not stolen.