@vmz/core 0.3.13 → 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  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
+ import { splitStyleDeclarations } from '../shared/dom-attr-normalize.js';
5
+ import { HTML_HASH_ATTR, htmlDigest } from '../shared/html-digest.js';
4
6
  import { _ssrDocumentLastError, ensureSsrDocument } from './document.js';
5
7
  import { awaitSsrOnMount, ssrComponentName, ssrOnMountContext } from './onmount.js';
6
8
  function runDirectSerializeTree(Component, inst) {
@@ -56,17 +58,116 @@ const VOID_TAGS = new Set([
56
58
  function isVoidTag(tag) {
57
59
  return VOID_TAGS.has(String(tag).toLowerCase());
58
60
  }
61
+ /**
62
+ * `textarea` carries its initial value as text content and `select` carries it
63
+ * as a selected option, so neither can travel as a `value` attribute. `input`
64
+ * keeps the attribute, which browsers honor for the default value.
65
+ */
66
+ function controlValue(node, tag) {
67
+ if (tag !== 'textarea' && tag !== 'select')
68
+ return null;
69
+ const v = node.attrs ? node.attrs.value : null;
70
+ if (v == null || v === false)
71
+ return null;
72
+ // A `multiple` select model is a list; keep every entry addressable.
73
+ if (tag === 'select' && Array.isArray(v))
74
+ return v.map((item) => String(item));
75
+ return String(v);
76
+ }
77
+ function nodeText(node) {
78
+ if (!node || typeof node !== 'object')
79
+ return '';
80
+ if (node.__kind === 'text')
81
+ return String(node.value ?? '');
82
+ if (node.__kind === 'el' || node.__kind === 'frag') {
83
+ return (node.children || []).map(nodeText).join('');
84
+ }
85
+ return '';
86
+ }
87
+ /** Mark the `<option>` whose value (or text) equals one of the model values. */
88
+ function markSelectSelection(node, wanted) {
89
+ const wants = Array.isArray(wanted) ? wanted.map((item) => String(item)) : [String(wanted)];
90
+ const visit = (n) => {
91
+ if (!n || typeof n !== 'object')
92
+ return;
93
+ if (n.__kind === 'el') {
94
+ const tag = String(n.tag || '').toLowerCase();
95
+ if (tag === 'option') {
96
+ const raw = n.attrs && n.attrs.value != null ? String(n.attrs.value) : nodeText(n).trim();
97
+ if (wants.includes(raw)) {
98
+ if (!n.attrs)
99
+ n.attrs = {};
100
+ n.attrs.selected = '';
101
+ }
102
+ else if (n.attrs && Object.prototype.hasOwnProperty.call(n.attrs, 'selected')) {
103
+ delete n.attrs.selected;
104
+ }
105
+ return;
106
+ }
107
+ }
108
+ for (const c of n.children || [])
109
+ visit(c);
110
+ };
111
+ for (const c of node.children || [])
112
+ visit(c);
113
+ }
114
+ /**
115
+ * Inline style as the browser would serialize it: the `style` attribute merged
116
+ * with the live `style.*` writes that Direct patches and `v-show` perform
117
+ * (`el.style.display = …`). SSR virtual elements only kept the attribute, so a
118
+ * server-first response showed elements that hydration hid.
119
+ */
120
+ function serializeStyleAttr(node) {
121
+ const declared = node.attrs && node.attrs.style != null ? String(node.attrs.style) : '';
122
+ const live = node.style;
123
+ if (!live || typeof live !== 'object')
124
+ return declared;
125
+ const display = live.display;
126
+ if (display === undefined)
127
+ return declared;
128
+ // `el.style.display = ''` removes the declaration instead of writing it.
129
+ if (display === '' || display == null) {
130
+ return splitStyleDeclarations(declared)
131
+ .filter((chunk) => {
132
+ const idx = chunk.indexOf(':');
133
+ return idx < 0 || chunk.slice(0, idx).trim() !== 'display';
134
+ })
135
+ .map((chunk) => chunk.trim())
136
+ .filter(Boolean)
137
+ .join('; ');
138
+ }
139
+ return mergeStyleParts(declared, { display });
140
+ }
59
141
  function serializeOpenTag(node) {
60
142
  const tag = node.tag || 'div';
143
+ const lowerTag = String(tag).toLowerCase();
61
144
  let attrs = '';
62
145
  for (const [k, v] of Object.entries(node.attrs || {})) {
63
146
  if (v == null || v === false)
64
147
  continue;
148
+ // `textarea` value renders as text content and `select` value as a
149
+ // selected option, so the raw attribute must not be emitted.
150
+ if (k === 'value' && (lowerTag === 'textarea' || lowerTag === 'select'))
151
+ continue;
152
+ if (k === 'style')
153
+ continue;
154
+ // Always replaced by the digest of the payload written below, so an
155
+ // author-supplied marker can never mask a different payload.
156
+ if (k === HTML_HASH_ATTR)
157
+ continue;
65
158
  if (k === 'className')
66
159
  attrs += ` class="${escapeHtml(v)}"`;
67
160
  else
68
161
  attrs += ` ${k}="${escapeHtml(v)}"`;
69
162
  }
163
+ const style = serializeStyleAttr(node);
164
+ if (style)
165
+ attrs += ` style="${escapeHtml(style)}"`;
166
+ // `v-html` hosts carry the digest of the exact string SSR wrote, so the
167
+ // client can keep the SSR subtree without DOM-serializing its payload.
168
+ if (node.__rawHtml != null && !node.__rawOuter) {
169
+ attrs += ` ${HTML_HASH_ATTR}="${escapeHtml(htmlDigest(String(node.__rawHtml)))}"`;
170
+ }
70
171
  return { tag, open: `<${tag}${attrs}>` };
71
172
  }
72
173
  export function flattenSerializeNode(node) {
@@ -94,11 +195,22 @@ export function flattenSerializeNode(node) {
94
195
  if (node.__rawOuter && node.__rawHtml != null)
95
196
  return String(node.__rawHtml);
96
197
  const { open } = serializeOpenTag(node);
198
+ const lowerTag = String(tag).toLowerCase();
199
+ const control = controlValue(node, lowerTag);
97
200
  if (node.__rawHtml != null) {
98
- return isVoidTag(tag) ? `${open}${String(node.__rawHtml)}` : `${open}${String(node.__rawHtml)}</${tag}>`;
201
+ const lead = needsSacrificialLF(node, lowerTag, null) ? '\n' : '';
202
+ const raw = String(node.__rawHtml);
203
+ return isVoidTag(tag) ? `${open}${raw}` : `${open}${lead}${raw}</${tag}>`;
99
204
  }
205
+ if (lowerTag === 'textarea' && control != null) {
206
+ const lead = needsSacrificialLF(node, lowerTag, control) ? '\n' : '';
207
+ return `${open}${lead}${escapeHtml(control)}</${tag}>`;
208
+ }
209
+ if (lowerTag === 'select' && control != null)
210
+ markSelectSelection(node, control);
100
211
  const inner = (node.children || []).map(flattenSerializeNode).join('');
101
- return isVoidTag(tag) ? `${open}${inner}` : `${open}${inner}</${tag}>`;
212
+ const lead = needsSacrificialLF(node, lowerTag, control) ? '\n' : '';
213
+ return isVoidTag(tag) ? `${open}${inner}` : `${open}${lead}${inner}</${tag}>`;
102
214
  }
103
215
  return '';
104
216
  }
@@ -141,10 +253,20 @@ export function* streamSerializeChunks(node) {
141
253
  }
142
254
  const { open } = serializeOpenTag(node);
143
255
  yield open;
256
+ const lowerTag = String(tag).toLowerCase();
257
+ const control = controlValue(node, lowerTag);
258
+ const lead = needsSacrificialLF(node, lowerTag, control) ? '\n' : '';
144
259
  if (node.__rawHtml != null) {
145
- yield String(node.__rawHtml);
260
+ yield lead + String(node.__rawHtml);
261
+ }
262
+ else if (lowerTag === 'textarea' && control != null) {
263
+ yield lead + escapeHtml(control);
146
264
  }
147
265
  else {
266
+ if (lowerTag === 'select' && control != null)
267
+ markSelectSelection(node, control);
268
+ if (lead)
269
+ yield lead;
148
270
  for (const c of node.children || [])
149
271
  yield* streamSerializeChunks(c);
150
272
  }
@@ -274,6 +396,16 @@ function virtualRemove(node) {
274
396
  p.children.splice(idx, 1);
275
397
  node.parentNode = null;
276
398
  }
399
+ /**
400
+ * Replace an element's content with escaped text (`v-text` / `el.textContent`).
401
+ * Direct emit writes `textContent` for both static and reactive text, so SSR
402
+ * must model it or the first response ships an empty element.
403
+ */
404
+ function replaceSerializeText(node, value) {
405
+ const text = value == null ? '' : String(value);
406
+ node.__rawHtml = null;
407
+ node.children = text === '' ? [] : [{ __kind: 'text', value: text, parentNode: node }];
408
+ }
277
409
  function makeVirtualEl(tag) {
278
410
  const node = {
279
411
  __kind: 'el',
@@ -282,6 +414,7 @@ function makeVirtualEl(tag) {
282
414
  children: [],
283
415
  style: {},
284
416
  __rawHtml: null,
417
+ __vmzKey: null,
285
418
  parentNode: null,
286
419
  setAttribute(name, value) {
287
420
  applySerializeAttr(this, name, value);
@@ -305,6 +438,17 @@ function makeVirtualEl(tag) {
305
438
  this.children = [];
306
439
  },
307
440
  });
441
+ // Direct emit writes `el.textContent` for v-text (static `api.setText` and
442
+ // reactive `el.textContent = …`). Mirror it so the serializer sees the text.
443
+ Object.defineProperty(node, 'textContent', {
444
+ configurable: true,
445
+ get() {
446
+ return nodeText(this);
447
+ },
448
+ set(value) {
449
+ replaceSerializeText(this, value);
450
+ },
451
+ });
308
452
  return node;
309
453
  }
310
454
  /**
@@ -383,11 +527,19 @@ const serializeApi = {
383
527
  serializeApi._ssrCollected.push(child);
384
528
  return child;
385
529
  },
386
- el(tag) {
530
+ el(tag, key) {
387
531
  const node = makeVirtualEl(tag);
388
532
  if (String(tag || '').toLowerCase() === 'svg') {
389
533
  node.namespaceURI = 'http://www.w3.org/2000/svg';
390
534
  }
535
+ // Row roots need their key in the HTML so hydration reclaims by identity,
536
+ // not the order in which tags happen to appear.
537
+ if (key != null) {
538
+ node.__vmzKey = key;
539
+ if (!node.attrs)
540
+ node.attrs = Object.create(null);
541
+ node.attrs['data-vmz-key'] = String(key);
542
+ }
391
543
  return node;
392
544
  },
393
545
  elNS(ns, tag) {
@@ -529,6 +681,26 @@ const serializeApi = {
529
681
  bindComponentProp() {
530
682
  /* SSR: props already resolved into the child instance at create */
531
683
  },
684
+ bindComponentProps() {
685
+ /* SSR: `...api.spreadProps()` already delivered the object props at create */
686
+ },
687
+ bindComponentEvents() {
688
+ /* component events attach only on the client */
689
+ },
690
+ bindObjectListeners() {
691
+ /* object listeners attach only on the client */
692
+ },
693
+ spreadProps(source) {
694
+ const out = Object.create(null);
695
+ if (source && typeof source === 'object') {
696
+ for (const key of Object.keys(source)) {
697
+ if (!key || key.startsWith('#'))
698
+ continue;
699
+ out[key] = source[key];
700
+ }
701
+ }
702
+ return out;
703
+ },
532
704
  projectDefaultSlot(hostEl, node) {
533
705
  if (!hostEl || node == null)
534
706
  return;
@@ -552,8 +724,13 @@ const serializeApi = {
552
724
  target.__rawHtml = null;
553
725
  if (!Array.isArray(target.children))
554
726
  target.children = [];
555
- // Append — multiple projectDefaultSlot calls must accumulate (SSR).
556
- // Client path replaces the live <slot> then appends siblings; serialize must push.
727
+ // The client replaces the live `<slot>` with the first projected node,
728
+ // which discards its fallback children. Drop the fallback on the first
729
+ // projection too, then accumulate later projections as siblings.
730
+ if (!filledSerializeSlots.has(target)) {
731
+ filledSerializeSlots.add(target);
732
+ target.children.length = 0;
733
+ }
557
734
  target.children.push(node);
558
735
  return;
559
736
  }
@@ -612,6 +789,23 @@ const serializeApi = {
612
789
  el.__rawHtml = value == null ? '' : String(value);
613
790
  el.children = [];
614
791
  },
792
+ // Emitted `v-html` write. SSR always records the payload, and the open tag
793
+ // gains `data-vmz-html-hash` from that exact string at serialize time.
794
+ html(el, value) {
795
+ serializeApi.setHtml(el, value);
796
+ },
797
+ setText(el, value) {
798
+ if (!el || el.__kind !== 'el')
799
+ return;
800
+ replaceSerializeText(el, value);
801
+ },
802
+ setShow(el, condition) {
803
+ if (!el || el.__kind !== 'el')
804
+ return;
805
+ if (!el.style || typeof el.style !== 'object')
806
+ el.style = {};
807
+ el.style.display = condition ? '' : 'none';
808
+ },
615
809
  bindHtml(inst, bindingId, deps, get, el) {
616
810
  serializeApi.trackPatch(inst, deps || [], function bindHtmlPatch() {
617
811
  let raw;
@@ -624,14 +818,20 @@ const serializeApi = {
624
818
  serializeApi.setHtml(el, raw);
625
819
  }, bindingId);
626
820
  },
627
- component(hostInst, nameOrCtor, props, client) {
821
+ component(hostInst, nameOrCtor, props, client, key) {
628
822
  const name = typeof nameOrCtor === 'function' ? nameOrCtor.__vmzTag || nameOrCtor.name || 'Component' : nameOrCtor;
629
823
  const Ctor = typeof nameOrCtor === 'function' ? nameOrCtor : getRegisteredComponent(name);
630
824
  if (!Ctor) {
631
825
  return serializeUnknownComponentNode(name);
632
826
  }
827
+ const rawProps = (props || {});
828
+ // `id` / `data-*` / `aria-*` belong on the host element, matching the
829
+ // browser `api.component` fallthrough.
830
+ const hostAttrs = rawProps.__hostAttrs;
633
831
  const resolved = {};
634
- for (const [k, v] of Object.entries(props || {})) {
832
+ for (const [k, v] of Object.entries(rawProps)) {
833
+ if (k === '__hostAttrs')
834
+ continue;
635
835
  const onKey = typeof v === 'function' ? eventPropHandlerName(k) : null;
636
836
  if (onKey)
637
837
  continue;
@@ -640,6 +840,16 @@ const serializeApi = {
640
840
  else
641
841
  resolved[k] = v;
642
842
  }
843
+ const applyHostAttrs = (attrs) => {
844
+ if (!hostAttrs)
845
+ return attrs;
846
+ for (const [k, v] of Object.entries(hostAttrs)) {
847
+ if (v == null || v === false)
848
+ continue;
849
+ attrs[k] = v === true ? '' : String(v);
850
+ }
851
+ return attrs;
852
+ };
643
853
  if (client) {
644
854
  // resume: Island SSR includes body + ResumeEntry slice (same Direct schedule).
645
855
  const child = serializeApi._ssrChildInstance(Ctor, resolved);
@@ -675,6 +885,9 @@ const serializeApi = {
675
885
  const boxStyle = directHostBoxStyleAttr(name, Ctor);
676
886
  if (boxStyle)
677
887
  attrs.style = boxStyle;
888
+ applyHostAttrs(attrs);
889
+ if (key != null)
890
+ attrs['data-vmz-key'] = String(key);
678
891
  if (isEventEntryStrategy(String(client))) {
679
892
  attrs['data-vmz-entry'] = 'event';
680
893
  }
@@ -705,6 +918,9 @@ const serializeApi = {
705
918
  const boxStyle = directHostBoxStyleAttr(name, Ctor);
706
919
  if (boxStyle)
707
920
  attrs.style = boxStyle;
921
+ applyHostAttrs(attrs);
922
+ if (key != null)
923
+ attrs['data-vmz-key'] = String(key);
708
924
  return {
709
925
  __kind: 'el',
710
926
  tag: 'div',
@@ -723,6 +939,28 @@ const serializeApi = {
723
939
  }
724
940
  throw new Error(`vmz:dom serialize component <${name}> requires __vmzCreate (rebuild child with Direct)`);
725
941
  },
942
+ /**
943
+ * `<component :is>` on the server: `is` and props are evaluated once at
944
+ * create time, so the dynamic boundary serializes exactly like the resolved
945
+ * static component.
946
+ */
947
+ dynamicComponent(hostInst, bindingId, deps, getCtor, getProps, key) {
948
+ let Ctor = null;
949
+ try {
950
+ Ctor = getCtor.call(hostInst);
951
+ }
952
+ catch {
953
+ Ctor = null;
954
+ }
955
+ let props = {};
956
+ try {
957
+ props = getProps.call(hostInst) || {};
958
+ }
959
+ catch {
960
+ props = {};
961
+ }
962
+ return serializeApi.component(hostInst, Ctor, props, null, key);
963
+ },
726
964
  };
727
965
  function applySerializeAttr(el, name, value) {
728
966
  if (!el || el.__kind !== 'el')
@@ -751,11 +989,69 @@ function applySerializeAttr(el, name, value) {
751
989
  el.attrs[key] = value === true ? '' : String(value);
752
990
  return;
753
991
  }
992
+ // A `multiple` select model is a list. Stringifying it here would emit one
993
+ // `value="c,a"` attribute, so `markSelectSelection` could never match an
994
+ // option and SSR shipped a select with nothing selected.
995
+ if (key === 'value' && String(el.tag || '').toLowerCase() === 'select' && Array.isArray(value)) {
996
+ el.attrs[key] = value.map((item) => String(item));
997
+ return;
998
+ }
754
999
  if (value == null || value === false)
755
1000
  delete el.attrs[key];
756
1001
  else
757
1002
  el.attrs[key] = value === true ? '' : String(value);
758
1003
  }
759
1004
  function escapeHtml(s) {
760
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1005
+ return String(s)
1006
+ .replace(/&/g, '&amp;')
1007
+ .replace(/</g, '&lt;')
1008
+ .replace(/>/g, '&gt;')
1009
+ .replace(/"/g, '&quot;')
1010
+ // The HTML input stream normalizes a literal CR (and CRLF) to LF before
1011
+ // tokenization, so a raw `\r` would not survive the parse. Emit a character
1012
+ // reference instead: hydration then keeps the exact server value.
1013
+ .replace(/\r/g, '&#13;');
1014
+ }
1015
+ /** `<pre>` / `<textarea>` drop one leading LF after their start tag (HTML spec). */
1016
+ function dropsLeadingLF(tag) {
1017
+ return tag === 'pre' || tag === 'textarea';
1018
+ }
1019
+ /** Does the first token inside `node` start with LF? Comments and tags do not. */
1020
+ function firstTokenStartsWithLF(node) {
1021
+ if (node == null || node === false)
1022
+ return false;
1023
+ if (typeof node === 'string')
1024
+ return node.startsWith('\n');
1025
+ if (node.__kind === 'text')
1026
+ return String(node.value ?? '').startsWith('\n');
1027
+ if (node.__kind === 'comment')
1028
+ return false;
1029
+ if (node.__kind === 'frag') {
1030
+ if (node.__rawHtml != null)
1031
+ return String(node.__rawHtml).startsWith('\n');
1032
+ for (const c of node.children || []) {
1033
+ if (c == null || c === false)
1034
+ continue;
1035
+ return firstTokenStartsWithLF(c);
1036
+ }
1037
+ }
1038
+ return false;
1039
+ }
1040
+ /**
1041
+ * A value that itself starts with LF needs one sacrificial newline: the parser
1042
+ * discards the first LF token, leaving the real one in the DOM.
1043
+ */
1044
+ function needsSacrificialLF(node, lowerTag, control) {
1045
+ if (!dropsLeadingLF(lowerTag))
1046
+ return false;
1047
+ if (lowerTag === 'textarea' && control != null)
1048
+ return String(control).startsWith('\n');
1049
+ if (node.__rawHtml != null)
1050
+ return String(node.__rawHtml).startsWith('\n');
1051
+ for (const c of node.children || []) {
1052
+ if (c == null || c === false)
1053
+ continue;
1054
+ return firstTokenStartsWithLF(c);
1055
+ }
1056
+ return false;
761
1057
  }
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.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"
71
+ "@vmz/vmz-darwin-arm64": "0.3.14",
72
+ "@vmz/vmz-darwin-x64": "0.3.14",
73
+ "@vmz/vmz-linux-arm64": "0.3.14",
74
+ "@vmz/vmz-linux-x64": "0.3.14",
75
+ "@vmz/vmz-win32-arm64": "0.3.14",
76
+ "@vmz/vmz-win32-x64": "0.3.14"
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.13"
91
+ "version": "0.3.14"
92
92
  }