@jarenjs/view 0.75.0 → 0.83.3

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
@@ -50,6 +50,13 @@ render.destroy(); // terminal teardown, idempotent
50
50
 
51
51
  `render.destroy()` unmounts every mounted widget exactly once (pending mounts are canceled), empties the container, and turns every later `render` call into an exact no-op — including a scheduled flush that fires after destruction. The render boundary is **serialized**: a `render` entered synchronously from inside a widget hook or event callback (an `emit` chain) never nests — it queues behind the running patch, multiple nested requests coalesce to the latest vnode, and the queued tree is applied against the committed baseline, so no widget sees `update` before its `mount` returned or receives stale previous props.
52
52
 
53
+ Both renderers accept fragment roots without a wrapper. Pass `hydrate: true`
54
+ to adopt matching server DOM on the first trusted render; local mismatches
55
+ are repaired and widgets get fresh hosts. Safe mode rebuilds existing DOM.
56
+ Controlled text writes defer through composition and the final input event,
57
+ then settle with the caret preserved. The browser tests cover these paths in
58
+ Chromium, Firefox and WebKit; native OS IME audits remain separate.
59
+
53
60
  ### On the server
54
61
 
55
62
  ```javascript
@@ -76,6 +76,12 @@ export type DomRendererOptions = {
76
76
  * (defaults to `container.ownerDocument`).
77
77
  */
78
78
  document?: any;
79
+ /**
80
+ * - Adopt matching server DOM on the
81
+ * first render. Mismatches are replaced locally. Safe mode rebuilds
82
+ * existing markup because the renderer cannot trust its provenance.
83
+ */
84
+ hydrate?: boolean;
79
85
  /**
80
86
  * - Render under the SAFE policy
81
87
  * ({@link createSafePolicy}): treat the vnode as untrusted. Tags are
@@ -167,6 +173,9 @@ export type DomRenderer = ((vnode: any) => void) & {
167
173
  * definitions by name (VIEW-FORMAT §7).
168
174
  * @property {any} [document] - The document to create nodes with
169
175
  * (defaults to `container.ownerDocument`).
176
+ * @property {boolean} [hydrate=false] - Adopt matching server DOM on the
177
+ * first render. Mismatches are replaced locally. Safe mode rebuilds
178
+ * existing markup because the renderer cannot trust its provenance.
170
179
  * @property {boolean} [safe=false] - Render under the SAFE policy
171
180
  * ({@link createSafePolicy}): treat the vnode as untrusted. Tags are
172
181
  * restricted to an inert HTML/SVG allow-list, scripting-sink and inline
@@ -6,3 +6,5 @@ export { EMPTY_PROPS, WIDGET_TAG, h, isTextNode, isElementNode, isSkippedNode, i
6
6
  export { createDomRenderer, styleToString, } from './dom.js';
7
7
  export { renderToString, escapeText, escapeAttribute, } from './html.js';
8
8
  export { createSafePolicy, } from './safe.js';
9
+ export type DomRendererOptions = import('./dom.js').DomRendererOptions;
10
+ /** @typedef {import('./dom.js').DomRendererOptions} DomRendererOptions */
@@ -190,8 +190,9 @@ reorder efficiency is only specified for fully keyed lists.
190
190
  ### 5.4 Namespaces
191
191
 
192
192
  An element with tag `svg` and its descendants are created in the SVG
193
- namespace. Re-entering HTML through `foreignObject` is not supported in
194
- 0.1.
193
+ namespace. Children of SVG `foreignObject` re-enter the HTML namespace;
194
+ a nested `svg` enters SVG again. `xlink:*` and `xml:*` attributes use their
195
+ namespace URIs for setting and removal. Safe mode excludes `foreignObject`.
195
196
 
196
197
  ### 5.5 The memo marker
197
198
 
@@ -238,9 +239,18 @@ and works through `optgroup`. HTML cannot encode a single select's
238
239
  option until the DOM renderer reconciles the control.
239
240
  Safe mode keeps its attribute-only policy (§8).
240
241
 
241
- Hydration in 0.1 is a client-side first render into the same container
242
- (empty and rebuild). Adopting existing server-rendered DOM is a
243
- roadmap item, not part of this contract.
242
+ Both renderers accept a single vnode, a root list, or an empty/skipped root.
243
+ Lists flatten with the ordinary child rules and need no wrapper element.
244
+
245
+ `createDomRenderer(container, { hydrate: true })` adopts matching text and
246
+ element nodes on its first trusted render, patches props and handlers,
247
+ reconciles controlled values, repairs mismatches locally and removes surplus
248
+ nodes and attributes. Initial matching is positional by tag and namespace;
249
+ subsequent keyed patches preserve adopted identities. Widgets mount once on
250
+ fresh hosts because their server-side markup has no transferable handle.
251
+ Without `hydrate`, the first render rebuilds. Safe mode also rebuilds: existing
252
+ DOM does not inherit trust from a sanitized vnode. Hydration does not remove
253
+ listeners installed by another owner, so the host must own the adopted DOM.
244
254
 
245
255
  ## 7. Widgets
246
256
 
@@ -524,21 +534,25 @@ SHOULD do both.
524
534
  Safe mode reduces an untrusted view to a display; it is **not a complete
525
535
  sandbox**. The tag allow-list still admits anchors, forms, controls and media,
526
536
  so native navigation, form submission, focus and network loads remain
527
- possible. It is also a **renderer** policy: a host embedding it in a larger
528
- runtime MUST NOT assume that runtime inherits it (`@jarenjs/app`, for
529
- instance, does not forward `safe`, and an app document names host actions and
530
- effects so a safe *view* does not make an untrusted *app document* safe).
531
-
532
- Out of scope for this version, and NOT to be assumed: caret/IME fidelity under
533
- safe rewrites across browser engines, MathML, `multiple`-select and
534
- composition behavior proven in all three engines, and a Trusted Types
535
- integration. Safe mode is one layer under a Content-Security-Policy, not a
536
- substitute for one.
537
+ possible. It is also a **renderer** policy. `@jarenjs/app` forwards `safe`
538
+ and `onUnsafe` and provides explicit effect/subscription/widget/event-field
539
+ grants (APP-FORMAT §8.2.1). Those grants bound named host access; they do not
540
+ isolate computation, navigation or network activity.
541
+
542
+ The browser suite exercises safe create→update→remove→reinsert, URL/style
543
+ reflection, controlled inputs, multiple selects and composition in Chromium,
544
+ Firefox and WebKit. Trusted controlled text writes defer through composition
545
+ and the final input event, then settle while retaining a clamped caret range.
546
+ Composition cleanup cancels pending settlement on removal or destruction.
547
+ Safe controls retain the attribute-only display policy rather than the trusted
548
+ controlled-input policy.
549
+
550
+ Native OS IME fidelity and assistive-technology behavior still require manual
551
+ audits. MathML and Trusted Types integration remain outside this version.
552
+ Safe mode is one layer under a Content-Security-Policy, not a substitute for one.
537
553
 
538
554
  ## 9. Open items (roadmap, non-normative)
539
555
 
540
- - **Fragment / multi-root documents** — a list at the root.
541
- - **DOM-adopting hydration** (§6).
542
556
  - ~~Memoized rule outputs~~ — **shipped**: the JSLT engine's `memo`
543
557
  option (on by default in `@jarenjs/app`) caches rule outputs by
544
558
  (location, value reference) with compile-time eligibility analysis,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/view",
3
3
  "private": false,
4
- "version": "0.75.0",
4
+ "version": "0.83.3",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -53,7 +53,7 @@
53
53
  "ssr"
54
54
  ],
55
55
  "dependencies": {
56
- "@jarenjs/core": "^0.75.0"
56
+ "@jarenjs/core": "^0.83.3"
57
57
  },
58
58
  "scripts": {
59
59
  "build": "npm run build:types",
package/src/dom.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  propsOf,
47
47
  keyOf,
48
48
  childrenOf,
49
+ isSkippedNode,
49
50
  EMPTY_PROPS,
50
51
  WIDGET_TAG,
51
52
  } from './vnode.js';
@@ -113,6 +114,9 @@ const WIDGET_SKIP_PROPS = { name: true, props: true, tag: true };
113
114
  * definitions by name (VIEW-FORMAT §7).
114
115
  * @property {any} [document] - The document to create nodes with
115
116
  * (defaults to `container.ownerDocument`).
117
+ * @property {boolean} [hydrate=false] - Adopt matching server DOM on the
118
+ * first render. Mismatches are replaced locally. Safe mode rebuilds
119
+ * existing markup because the renderer cannot trust its provenance.
116
120
  * @property {boolean} [safe=false] - Render under the SAFE policy
117
121
  * ({@link createSafePolicy}): treat the vnode as untrusted. Tags are
118
122
  * restricted to an inert HTML/SVG allow-list, scripting-sink and inline
@@ -238,9 +242,10 @@ export function createDomRenderer(container, options = {}) {
238
242
 
239
243
  function render(vnode) {
240
244
  if (ctx.destroyed) return; // a scheduled flush after destroy is a no-op
241
- if (!isTextNode(vnode) && !isElementNode(vnode)) {
242
- throw new TypeError('view: the root vnode must be a text or element vnode');
245
+ if (!isTextNode(vnode) && !Array.isArray(vnode) && !isSkippedNode(vnode)) {
246
+ throw new TypeError('view: the root must be a vnode or a list of vnodes');
243
247
  }
248
+ if (isSkippedNode(vnode)) vnode = [];
244
249
  if (rendering) {
245
250
  // re-entrant call (a widget mount/update emitted synchronously):
246
251
  // queue behind the current patch — it applies after this frame,
@@ -254,12 +259,17 @@ export function createDomRenderer(container, options = {}) {
254
259
  do {
255
260
  pendingVnode = undefined;
256
261
  if (rootNode === null) {
257
- container.textContent = '';
258
- rootNode = createNode(ctx, next, null);
259
- container.appendChild(rootNode);
262
+ if (options.hydrate && ctx.policy === null) {
263
+ adoptChildren(ctx, container, childrenOf(['root', {}, next]), null);
264
+ }
265
+ else {
266
+ container.textContent = '';
267
+ for (const child of childrenOf(['root', {}, next])) container.appendChild(createNode(ctx, child, null));
268
+ }
269
+ rootNode = container;
260
270
  }
261
271
  else {
262
- rootNode = patchNode(ctx, container, rootNode, oldVnode, next, null);
272
+ patchChildren(ctx, container, childrenOf(['root', {}, oldVnode]), childrenOf(['root', {}, next]), null);
263
273
  }
264
274
  oldVnode = next;
265
275
  // mount flush: after the patch completes every queued host is
@@ -321,7 +331,7 @@ export function createDomRenderer(container, options = {}) {
321
331
  ctx.mountQueue.length = 0;
322
332
  // Release the controlled-node registry: a destroyed renderer must not
323
333
  // retain detached form controls (nor their stored values) past teardown.
324
- ctx.controlled.clear();
334
+ for (const node of ctx.controlled) releaseControlled(ctx, node);
325
335
  if (rootNode !== null) {
326
336
  /** @type {unknown[]} */
327
337
  const failures = [];
@@ -439,12 +449,49 @@ function createNode(ctx, vnode, ns) {
439
449
  }
440
450
  const children = childrenOf(vnode);
441
451
  for (let i = 0; i < children.length; i++) {
442
- node.appendChild(createNode(ctx, children[i], ns));
452
+ node.appendChild(createNode(ctx, children[i], tag === 'foreignObject' ? null : ns));
443
453
  }
444
454
  registerControlled(ctx, node, props);
445
455
  return node;
446
456
  }
447
457
 
458
+ /** Adopt matching nodes positionally once; subsequent keyed reconciliation
459
+ * has the same vnode baseline as a fresh mount. Browser parser repairs and
460
+ * missing/extra nodes are local mismatches, never a second tree of state. */
461
+ function adoptChildren(ctx, parent, children, ns) {
462
+ for (let i = 0; i < children.length; i++) {
463
+ const vnode = children[i];
464
+ const node = parent.childNodes[i];
465
+ if (node && isTextNode(vnode) && node.nodeType === 3) {
466
+ if (node.nodeValue !== String(vnode)) node.nodeValue = String(vnode);
467
+ continue;
468
+ }
469
+ const tag = isElementNode(vnode) ? vnode[0] : null;
470
+ const namespace = tag === 'svg' ? SVG_NS : ns;
471
+ if (node && tag && tag !== WIDGET_TAG && node.nodeType === 1
472
+ && (node.localName ?? node.tagName?.toLowerCase()) === tag
473
+ && (namespace === null ? node.namespaceURI == null || node.namespaceURI === 'http://www.w3.org/1999/xhtml' : node.namespaceURI === namespace)) {
474
+ const props = propsOf(vnode);
475
+ const names = new Set(Object.keys(props).map((name) => name === 'className' ? 'class' : name === 'htmlFor' ? 'for' : name.toLowerCase()));
476
+ for (const name of node.getAttributeNames()) {
477
+ if (!names.has(name.toLowerCase())) node.removeAttribute(name);
478
+ }
479
+ for (const name in props) setProp(ctx, node, name, undefined, props[name], namespace);
480
+ // A textarea's server text is its default value, not a vnode child
481
+ // when `value` supplies the authoritative content.
482
+ if (!(tag === 'textarea' && Object.hasOwn(props, 'value')))
483
+ adoptChildren(ctx, node, childrenOf(vnode), tag === 'foreignObject' ? null : namespace);
484
+ registerControlled(ctx, node, props);
485
+ }
486
+ else {
487
+ const fresh = createNode(ctx, vnode, ns);
488
+ if (node) parent.replaceChild(fresh, node);
489
+ else parent.appendChild(fresh);
490
+ }
491
+ }
492
+ while (parent.childNodes.length > children.length) parent.removeChild(parent.childNodes[parent.childNodes.length - 1]);
493
+ }
494
+
448
495
  /**
449
496
  * Map a vnode the safe policy rejects — a widget (imperative JS) or an
450
497
  * element whose tag is off the allow-list or is injection-shaped — to a
@@ -530,7 +577,7 @@ function patchNode(ctx, parent, node, oldV, newV, ns) {
530
577
  return node;
531
578
  }
532
579
  patchProps(ctx, node, propsOf(oldV), propsOf(newV), ns);
533
- patchChildren(ctx, node, childrenOf(oldV), childrenOf(newV), ns);
580
+ patchChildren(ctx, node, childrenOf(oldV), childrenOf(newV), newV[0] === 'foreignObject' ? null : ns);
534
581
  return node;
535
582
  }
536
583
  destroyNode(ctx, node);
@@ -584,11 +631,32 @@ function registerControlled(ctx, node, props) {
584
631
  const hasChecked = kind === 'INPUT' && 'checked' in props;
585
632
  if (!hasValue && !hasChecked) {
586
633
  if (node.__jarenControlled !== undefined) {
587
- node.__jarenControlled = undefined;
588
- ctx.controlled.delete(node);
634
+ releaseControlled(ctx, node);
589
635
  }
590
636
  return;
591
637
  }
638
+ if (node.__jarenComposition === undefined && kind !== 'SELECT') {
639
+ let settlement;
640
+ const start = () => {
641
+ clearTimeout(settlement);
642
+ node.__jarenComposing = true;
643
+ node.__jarenCompositionDirty = false;
644
+ };
645
+ const end = () => {
646
+ clearTimeout(settlement);
647
+ // Engines can checkpoint microtasks between compositionend and the
648
+ // final input. Keep writes deferred through that event sequence, or
649
+ // the stale value clears Firefox's dirty flag and loses change-on-blur.
650
+ settlement = setTimeout(() => {
651
+ node.__jarenComposing = false;
652
+ if (node.__jarenCompositionDirty && !ctx.destroyed && ctx.controlled.has(node) && node.parentNode !== null)
653
+ reconcileControlled(node);
654
+ }, 0);
655
+ };
656
+ node.__jarenComposition = { start, end, cancel: () => clearTimeout(settlement) };
657
+ node.addEventListener('compositionstart', start, true);
658
+ node.addEventListener('compositionend', end, true);
659
+ }
592
660
  node.__jarenControlled = {
593
661
  hasValue,
594
662
  hasChecked,
@@ -598,14 +666,27 @@ function registerControlled(ctx, node, props) {
598
666
  ctx.controlled.add(node);
599
667
  }
600
668
 
669
+ /** Release listeners as well as registry ownership, including detached nodes. */
670
+ function releaseControlled(ctx, node) {
671
+ const listeners = node.__jarenComposition;
672
+ if (listeners !== undefined) {
673
+ listeners.cancel();
674
+ node.removeEventListener('compositionstart', listeners.start, true);
675
+ node.removeEventListener('compositionend', listeners.end, true);
676
+ }
677
+ node.__jarenComposition = undefined;
678
+ node.__jarenComposing = false;
679
+ node.__jarenControlled = undefined;
680
+ ctx.controlled.delete(node);
681
+ }
682
+
601
683
  /** Reconcile every registered controlled node against the live DOM, once per
602
684
  * settled render pass. A node no longer connected to the container is dropped
603
685
  * from the registry here, which is why registration needs no destroy hook. */
604
686
  function reconcileControlledSet(ctx, container) {
605
687
  for (const node of ctx.controlled) {
606
688
  if (!isConnectedTo(node, container)) {
607
- node.__jarenControlled = undefined;
608
- ctx.controlled.delete(node);
689
+ releaseControlled(ctx, node);
609
690
  continue;
610
691
  }
611
692
  reconcileControlled(node);
@@ -628,6 +709,7 @@ function reconcileControlled(node) {
628
709
  if (node.checked !== want) node.checked = want;
629
710
  }
630
711
  if (!c.hasValue) return;
712
+ if (node.__jarenComposing) { node.__jarenCompositionDirty = true; return; }
631
713
  const isMultiple = node.multiple === true
632
714
  || (typeof node.getAttribute === 'function' && node.getAttribute('multiple') != null);
633
715
  if (node.nodeName === 'SELECT' && isMultiple && Array.isArray(c.value)) {
@@ -643,7 +725,17 @@ function reconcileControlled(node) {
643
725
  return;
644
726
  }
645
727
  const want = c.value == null ? '' : String(c.value);
646
- if (node.value !== want) node.value = want;
728
+ if (node.value !== want) {
729
+ // Preserve a live edit's caret. A newly created or unfocused control
730
+ // keeps the browser's normal assignment behavior (caret at the end).
731
+ const focused = node.ownerDocument?.activeElement === node;
732
+ const start = focused ? node.selectionStart : null;
733
+ const end = focused ? node.selectionEnd : null;
734
+ const direction = node.selectionDirection;
735
+ node.value = want;
736
+ if (typeof start === 'number' && typeof end === 'number' && typeof node.setSelectionRange === 'function')
737
+ node.setSelectionRange(Math.min(start, want.length), Math.min(end, want.length), direction ?? 'none');
738
+ }
647
739
  }
648
740
 
649
741
  /** Whether `node` is still attached beneath `root` (the render container).
@@ -703,6 +795,16 @@ function setProp(ctx, node, name, oldValue, newValue, ns) {
703
795
  }
704
796
  // Trusted path (unchanged): a property where the node has one, else an
705
797
  // attribute — the equivalent of writing the DOM by hand.
798
+ // Controlled values settle after children and never interrupt composition.
799
+ if ((name === 'value' || name === 'checked')
800
+ && (node.nodeName === 'INPUT' || node.nodeName === 'TEXTAREA' || node.nodeName === 'SELECT')) return;
801
+ const attrNs = name.startsWith('xlink:') ? 'http://www.w3.org/1999/xlink'
802
+ : name.startsWith('xml:') ? 'http://www.w3.org/XML/1998/namespace' : null;
803
+ if (attrNs !== null && typeof node.setAttributeNS === 'function') {
804
+ if (newValue == null || newValue === false) node.removeAttributeNS(attrNs, name.slice(name.indexOf(':') + 1));
805
+ else node.setAttributeNS(attrNs, name, String(newValue));
806
+ return;
807
+ }
706
808
  if (name === 'style' && typeof newValue === 'object' && newValue !== null) {
707
809
  newValue = styleToString(newValue);
708
810
  }
package/src/html.js CHANGED
@@ -158,6 +158,8 @@ function renderNode(vnode, widgets, policy, onUnsafe, selection = null) {
158
158
  return escapeText(String(vnode));
159
159
  }
160
160
  if (!isElementNode(vnode)) {
161
+ if (Array.isArray(vnode)) return childrenOf(['root', {}, vnode])
162
+ .map((child) => renderNode(child, widgets, policy, onUnsafe, selection)).join('');
161
163
  return '';
162
164
  }
163
165
  const tag = vnode[0];
package/src/index.js CHANGED
@@ -32,3 +32,5 @@ export {
32
32
  export {
33
33
  createSafePolicy,
34
34
  } from './safe.js';
35
+
36
+ /** @typedef {import('./dom.js').DomRendererOptions} DomRendererOptions */