@jarenjs/view 0.34.0
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 +181 -0
- package/dist/types/dom.d.ts +226 -0
- package/dist/types/helpers/index.d.ts +15 -0
- package/dist/types/helpers/memo.d.ts +49 -0
- package/dist/types/helpers/metrics.d.ts +31 -0
- package/dist/types/helpers/svg.d.ts +164 -0
- package/dist/types/helpers/theme.d.ts +47 -0
- package/dist/types/helpers/url.d.ts +71 -0
- package/dist/types/html.d.ts +80 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/safe.d.ts +121 -0
- package/dist/types/vnode.d.ts +113 -0
- package/docs/VIEW-FORMAT.md +538 -0
- package/package.json +63 -0
- package/schemas/jaren-vnode-safe.schema.json +92 -0
- package/schemas/jaren-vnode.schema.json +127 -0
- package/src/dom.js +1173 -0
- package/src/helpers/index.js +35 -0
- package/src/helpers/memo.js +61 -0
- package/src/helpers/metrics.js +116 -0
- package/src/helpers/svg.js +233 -0
- package/src/helpers/theme.js +75 -0
- package/src/helpers/url.js +154 -0
- package/src/html.js +197 -0
- package/src/index.js +34 -0
- package/src/safe.js +278 -0
- package/src/vnode.js +170 -0
package/src/dom.js
ADDED
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The DOM renderer — a keyed vnode-JSON patcher.
|
|
4
|
+
*
|
|
5
|
+
* This is the only module in the Jaren suite that touches the DOM. It
|
|
6
|
+
* follows the repository philosophy at the render level: decide once,
|
|
7
|
+
* then run tight loops.
|
|
8
|
+
*
|
|
9
|
+
* Reconciliation contract (see docs/VIEW-FORMAT.md §5):
|
|
10
|
+
*
|
|
11
|
+
* - `oldVnode === newVnode` skips the whole subtree in O(1). The JSLT
|
|
12
|
+
* engine's structural sharing (unchanged input → shared output) is what
|
|
13
|
+
* makes this fast path fire in practice.
|
|
14
|
+
* - Children reconcile with a head/tail two-pointer sweep plus a key map
|
|
15
|
+
* for the middle — keyed moves reuse DOM nodes, unkeyed children patch
|
|
16
|
+
* positionally.
|
|
17
|
+
* - Vnodes are never mutated or annotated: the DOM-node ↔ vnode
|
|
18
|
+
* correspondence lives in parallel arrays local to each patch, so
|
|
19
|
+
* frozen or shared vnode JSON (a JSLT output, a cached document) is
|
|
20
|
+
* always safe.
|
|
21
|
+
* - The render boundary is serialized: a `render` entered from inside
|
|
22
|
+
* a widget hook or event callback (a synchronous `emit` chain) never
|
|
23
|
+
* nests — it queues behind the running patch, nested requests
|
|
24
|
+
* coalesce to the latest vnode, and it applies against the committed
|
|
25
|
+
* baseline. No widget sees `update` before its `mount` returned or
|
|
26
|
+
* receives stale previous props.
|
|
27
|
+
*
|
|
28
|
+
* Event handling stores the binding JSON on the DOM node and attaches one
|
|
29
|
+
* shared proxy listener per event type; rebinding a handler on re-render
|
|
30
|
+
* never touches `addEventListener`.
|
|
31
|
+
*
|
|
32
|
+
* Widgets (VIEW-FORMAT §7): a vnode with the reserved tag `jaren-widget`
|
|
33
|
+
* mounts a registered JavaScript widget into a host element the patcher
|
|
34
|
+
* owns but never descends into. Widget bookkeeping lives on the DOM node
|
|
35
|
+
* (`__jarenWidget`), never on the vnode — §5.1 forbids annotating vnodes
|
|
36
|
+
* — and the destroy walk that guarantees `unmount` only runs when a
|
|
37
|
+
* widget has actually been created, so widget-free documents keep O(1)
|
|
38
|
+
* subtree removal.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { kebabCase } from '@jarenjs/core/string';
|
|
42
|
+
import {
|
|
43
|
+
isTextNode,
|
|
44
|
+
isElementNode,
|
|
45
|
+
isSameNode,
|
|
46
|
+
propsOf,
|
|
47
|
+
keyOf,
|
|
48
|
+
childrenOf,
|
|
49
|
+
EMPTY_PROPS,
|
|
50
|
+
WIDGET_TAG,
|
|
51
|
+
} from './vnode.js';
|
|
52
|
+
import { createSafePolicy } from './safe.js';
|
|
53
|
+
|
|
54
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
55
|
+
|
|
56
|
+
/** Props that are renderer instructions, never written to the DOM. */
|
|
57
|
+
const SKIP_PROPS = { key: true, on: true, memo: true };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Widget-vnode props that configure the widget instead of the host
|
|
61
|
+
* element. Every other prop (`key`/`on` included) flows through
|
|
62
|
+
* `setProp` exactly as on any element vnode.
|
|
63
|
+
*/
|
|
64
|
+
const WIDGET_SKIP_PROPS = { name: true, props: true, tag: true };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @callback EventBindingHandler
|
|
68
|
+
* @param {any} binding - The opaque `on` binding JSON from the vnode.
|
|
69
|
+
* @param {any} event - The native DOM event.
|
|
70
|
+
* @returns {void}
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @callback WidgetEmit
|
|
75
|
+
* @param {any} binding - An opaque binding, delivered verbatim to the
|
|
76
|
+
* renderer's `onEvent` hook — the identical contract to a vnode `on`
|
|
77
|
+
* member (VIEW-FORMAT §4).
|
|
78
|
+
* @param {any} [nativeEvent] - The native event, when the emission was
|
|
79
|
+
* caused by one.
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A registered widget definition (VIEW-FORMAT §7). The renderer owns the
|
|
85
|
+
* host element and the widget owns the host's subtree.
|
|
86
|
+
*
|
|
87
|
+
* Failure policy: a throwing `mount` or `update` poisons the widget —
|
|
88
|
+
* siblings and the frame still complete, the first error surfaces after
|
|
89
|
+
* the frame settles, and the NEXT render replaces it with a fresh
|
|
90
|
+
* lifecycle (`unmount` runs on the old instance only when its `mount`
|
|
91
|
+
* had succeeded). Recovery never depends on the producer allocating a
|
|
92
|
+
* fresh vnode: while any widget is poisoned the `===` subtree fast
|
|
93
|
+
* path is suspended, so a memoized reference-equal tree still reaches
|
|
94
|
+
* and replaces it. A poisoned widget never receives further `update`
|
|
95
|
+
* calls.
|
|
96
|
+
* @typedef {Object} WidgetDef
|
|
97
|
+
* @property {(host: any, props: any, emit: WidgetEmit) => any} mount -
|
|
98
|
+
* Called with the host element after it is connected to the rendered
|
|
99
|
+
* tree; returns an opaque handle threaded to `update`/`unmount`.
|
|
100
|
+
* @property {(handle: any, props: any, prevProps: any) => void} [update]
|
|
101
|
+
* Called when the vnode's `props` reference changed. Absent: the
|
|
102
|
+
* renderer falls back to `unmount` + fresh `mount` into the same host.
|
|
103
|
+
* @property {(handle: any) => void} [unmount] - Called exactly once when
|
|
104
|
+
* the widget leaves the tree; timers, listeners and observers die here.
|
|
105
|
+
* @property {(props: any) => any} [ssr] - A vnode for `renderToString`.
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @typedef {Object} DomRendererOptions
|
|
110
|
+
* @property {EventBindingHandler} [onEvent] - Receives every fired `on`
|
|
111
|
+
* binding; without it, `on` props are stored but never fired.
|
|
112
|
+
* @property {Record<string, WidgetDef>} [widgets] - Registered widget
|
|
113
|
+
* definitions by name (VIEW-FORMAT §7).
|
|
114
|
+
* @property {any} [document] - The document to create nodes with
|
|
115
|
+
* (defaults to `container.ownerDocument`).
|
|
116
|
+
* @property {boolean} [safe=false] - Render under the SAFE policy
|
|
117
|
+
* ({@link createSafePolicy}): treat the vnode as untrusted. Tags are
|
|
118
|
+
* restricted to an inert HTML/SVG allow-list, scripting-sink and inline
|
|
119
|
+
* `on*` properties are dropped, injection-shaped names are rejected, URL
|
|
120
|
+
* attributes are sanitized, and `on` event bindings are stripped. The
|
|
121
|
+
* default is trusted rendering — the equivalent of writing the DOM by
|
|
122
|
+
* hand — so a source-authored view is unaffected. Client and server share
|
|
123
|
+
* the one policy, so they neutralize an attack identically.
|
|
124
|
+
* @property {(info: { kind: 'tag' | 'prop' | 'event' | 'widget', name: string }) => void}
|
|
125
|
+
* [onUnsafe] - In safe mode, called for everything stripped: a disallowed
|
|
126
|
+
* `tag`, a rejected or sanitized-away `prop`, a stripped `on` binding
|
|
127
|
+
* (`event`) or a `widget`.
|
|
128
|
+
* @property {(thrown: unknown) => void} [onCleanupError] - Receives
|
|
129
|
+
* the first VALUE a widget `unmount` threw during TERMINAL teardown
|
|
130
|
+
* (`destroy()`, direct or deferred) — by identity, whatever host
|
|
131
|
+
* code threw — after every sibling cleaned up: the provenance
|
|
132
|
+
* channel that lets a host assign cleanup failures their own error
|
|
133
|
+
* policy, distinct from mount/update/render failures. Absent: the
|
|
134
|
+
* value surfaces after the teardown (thrown from `destroy()` or
|
|
135
|
+
* from the render pass that finished a deferred teardown).
|
|
136
|
+
* @property {(state: 'live' | 'destroyed') => void} [onFrame] - Called
|
|
137
|
+
* once at the end of every top-level render pass: `'live'` = a
|
|
138
|
+
* committed live frame settled (DOM patch and widget mounts done; a
|
|
139
|
+
* parked widget hook error, if any, is delivered AFTER this call),
|
|
140
|
+
* `'destroyed'` = the pass ended in terminal teardown. Not called
|
|
141
|
+
* for a post-destroy no-op render or by `destroy()` itself.
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The renderer returned by {@link createDomRenderer}: the patch
|
|
146
|
+
* function, carrying the terminal `destroy()` (VIEW-FORMAT §7.3.1).
|
|
147
|
+
* @typedef {((vnode: any) => void) & { destroy: () => void }} DomRenderer
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Create a renderer bound to a container element. The returned function
|
|
152
|
+
* patches the container's single root node to match the given vnode;
|
|
153
|
+
* `render.destroy()` is the terminal teardown.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* const render = createDomRenderer(document.getElementById('app'), {
|
|
157
|
+
* onEvent: (binding, event) => dispatch(binding, event),
|
|
158
|
+
* });
|
|
159
|
+
* render(['main', {}, ['h1', {}, 'Hello']]);
|
|
160
|
+
* render.destroy();
|
|
161
|
+
*
|
|
162
|
+
* @param {any} container - The DOM element to render into (emptied on
|
|
163
|
+
* first render).
|
|
164
|
+
* @param {DomRendererOptions} [options]
|
|
165
|
+
* @returns {DomRenderer}
|
|
166
|
+
*/
|
|
167
|
+
export function createDomRenderer(container, options = {}) {
|
|
168
|
+
const ctx = {
|
|
169
|
+
doc: options.document ?? container.ownerDocument,
|
|
170
|
+
onEvent: options.onEvent ?? null,
|
|
171
|
+
widgets: options.widgets ?? EMPTY_PROPS,
|
|
172
|
+
/** The safe render policy, or null for trusted (default) rendering.
|
|
173
|
+
* Consulted for every tag and prop; the same object the SSR serializer
|
|
174
|
+
* uses, so client and server strip an attack identically. */
|
|
175
|
+
policy: options.safe ? createSafePolicy() : null,
|
|
176
|
+
/** Reports everything safe mode strips — a tag, a property, an `on`
|
|
177
|
+
* binding or a widget — so a host can observe a hostile document rather
|
|
178
|
+
* than have it silently vanish. The policy stays a pure decision; the
|
|
179
|
+
* renderer reports at the point it acts. */
|
|
180
|
+
onUnsafe: typeof options.onUnsafe === 'function' ? options.onUnsafe : null,
|
|
181
|
+
/** Controlled form-control nodes (`value`/`checked`), reconciled against
|
|
182
|
+
* the live DOM at the END of every render pass — so an authoritative
|
|
183
|
+
* value is reasserted even when the `===`/`memo` fast paths skip the
|
|
184
|
+
* subtree the control lives in. Trusted mode only: safe mode strips
|
|
185
|
+
* events, so a safe-mode view has no controlled inputs to fight the user
|
|
186
|
+
* over. @type {Set<any>} */
|
|
187
|
+
controlled: new Set(),
|
|
188
|
+
/** Widget host nodes created this patch, awaiting `mount` (§7). */
|
|
189
|
+
mountQueue: [],
|
|
190
|
+
/** True once any widget node exists — gates the destroy walk. */
|
|
191
|
+
hasWidgets: false,
|
|
192
|
+
/** First value a widget hook (`mount`/`update`/`unmount`) threw
|
|
193
|
+
* this frame (§7), as a PRESENCE record — host code may legally
|
|
194
|
+
* `throw null`/`throw undefined`, so the thrown value can never
|
|
195
|
+
* double as the absence sentinel. The walk, the patch and the
|
|
196
|
+
* mount flush always finish; the value surfaces BY IDENTITY after
|
|
197
|
+
* the frame settles.
|
|
198
|
+
* @type {{ value: unknown } | null} */
|
|
199
|
+
frameError: null,
|
|
200
|
+
/** Live poisoned widgets (§7.3). While non-zero, the `===` subtree
|
|
201
|
+
* fast path is disabled so structural sharing (the JSLT memo
|
|
202
|
+
* reusing a reference-equal vnode) can never leave a poisoned
|
|
203
|
+
* widget permanently inert — every render revisits it until it is
|
|
204
|
+
* replaced or removed. */
|
|
205
|
+
poisonedCount: 0,
|
|
206
|
+
/** True after `destroy()`: every later render is an exact no-op. */
|
|
207
|
+
destroyed: false,
|
|
208
|
+
/** Terminal-cleanup sink (see `teardown`): receives the first
|
|
209
|
+
* VALUE a widget `unmount` threw during terminal teardown — by
|
|
210
|
+
* identity, whatever it is — after every sibling cleaned up. */
|
|
211
|
+
onCleanupError: options.onCleanupError ?? null,
|
|
212
|
+
/** Frame-settlement sink: called once at the end of every
|
|
213
|
+
* top-level render pass with `'live'` (a committed live frame —
|
|
214
|
+
* possibly with a parked hook error, delivered afterwards) or
|
|
215
|
+
* `'destroyed'` (the pass ended in terminal teardown). Called
|
|
216
|
+
* BEFORE a parked hook error is thrown, so a committed frame's
|
|
217
|
+
* host callback is never starved by error delivery. */
|
|
218
|
+
onFrame: options.onFrame ?? null,
|
|
219
|
+
/** The one `emit` every widget of this renderer receives. */
|
|
220
|
+
emit: /** @type {WidgetEmit | null} */ (null),
|
|
221
|
+
};
|
|
222
|
+
ctx.emit = function emit(binding, nativeEvent) {
|
|
223
|
+
if (ctx.onEvent !== null) ctx.onEvent(binding, nativeEvent);
|
|
224
|
+
};
|
|
225
|
+
/** @type {any} */
|
|
226
|
+
let oldVnode = null;
|
|
227
|
+
/** @type {any} */
|
|
228
|
+
let rootNode = null;
|
|
229
|
+
/** True while a patch/mount pass runs: the renderer boundary is
|
|
230
|
+
* serialized — a render entered from inside a widget hook or event
|
|
231
|
+
* callback never nests. */
|
|
232
|
+
let rendering = false;
|
|
233
|
+
/** The latest vnode a nested render asked for. Nested renders
|
|
234
|
+
* COALESCE: intermediate trees are redundant because every call
|
|
235
|
+
* carries the full desired tree; only the last one is applied. */
|
|
236
|
+
let pendingVnode;
|
|
237
|
+
let destroyPending = false;
|
|
238
|
+
|
|
239
|
+
function render(vnode) {
|
|
240
|
+
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');
|
|
243
|
+
}
|
|
244
|
+
if (rendering) {
|
|
245
|
+
// re-entrant call (a widget mount/update emitted synchronously):
|
|
246
|
+
// queue behind the current patch — it applies after this frame,
|
|
247
|
+
// against the committed baseline, never against stale props
|
|
248
|
+
pendingVnode = vnode;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
rendering = true;
|
|
252
|
+
try {
|
|
253
|
+
let next = vnode;
|
|
254
|
+
do {
|
|
255
|
+
pendingVnode = undefined;
|
|
256
|
+
if (rootNode === null) {
|
|
257
|
+
container.textContent = '';
|
|
258
|
+
rootNode = createNode(ctx, next, null);
|
|
259
|
+
container.appendChild(rootNode);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
rootNode = patchNode(ctx, container, rootNode, oldVnode, next, null);
|
|
263
|
+
}
|
|
264
|
+
oldVnode = next;
|
|
265
|
+
// mount flush: after the patch completes every queued host is
|
|
266
|
+
// connected; an emit during mount defers into pendingVnode
|
|
267
|
+
flushMounts(ctx);
|
|
268
|
+
next = pendingVnode;
|
|
269
|
+
} while (next !== undefined && !ctx.destroyed);
|
|
270
|
+
// Controlled-input reconciliation, once per settled pass. It runs here,
|
|
271
|
+
// not inside the prop diff, so it survives every skip: a same-object
|
|
272
|
+
// re-render, a shared subtree and an equal `memo` marker all return
|
|
273
|
+
// before `patchProps`, but the control is still in the registry with
|
|
274
|
+
// its intended value. Also the moment a select's options all exist.
|
|
275
|
+
if (!ctx.destroyed && ctx.controlled.size > 0) {
|
|
276
|
+
reconcileControlledSet(ctx, container);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
rendering = false;
|
|
281
|
+
pendingVnode = undefined;
|
|
282
|
+
}
|
|
283
|
+
if (destroyPending) {
|
|
284
|
+
destroyPending = false;
|
|
285
|
+
teardown();
|
|
286
|
+
}
|
|
287
|
+
// dual-failure settlement: the parked hook failure is copied and
|
|
288
|
+
// CLEARED before the frame callback runs, so a throwing callback
|
|
289
|
+
// can neither hide it nor push it into a later frame. The callback
|
|
290
|
+
// is isolated; frame settlement still precedes parked-error
|
|
291
|
+
// delivery (a committed live frame is real even when a widget hook
|
|
292
|
+
// failed during it). When both fail, the parked hook failure is
|
|
293
|
+
// primary; a host that must not lose its own callback failure
|
|
294
|
+
// isolates that callback itself (`createApp` does exactly that).
|
|
295
|
+
const parked = ctx.frameError;
|
|
296
|
+
ctx.frameError = null;
|
|
297
|
+
/** @type {{ value: unknown } | null} */
|
|
298
|
+
let callbackFailure = null;
|
|
299
|
+
if (ctx.onFrame !== null) {
|
|
300
|
+
try {
|
|
301
|
+
ctx.onFrame(ctx.destroyed ? 'destroyed' : 'live');
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
callbackFailure = { value: err };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (parked !== null) throw parked.value;
|
|
308
|
+
if (callbackFailure !== null) throw callbackFailure.value;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The destroy walk shared by `destroy()` and a deferred destroy.
|
|
313
|
+
* Terminal-cleanup errors carry PROVENANCE: with an `onCleanupError`
|
|
314
|
+
* sink registered (the app loop registers one to assign its stable
|
|
315
|
+
* cleanup code) the first error routes there — after every sibling
|
|
316
|
+
* cleaned up — instead of surfacing indistinguishably from a
|
|
317
|
+
* mount/update/render failure; without a sink it surfaces after the
|
|
318
|
+
* teardown, as before.
|
|
319
|
+
*/
|
|
320
|
+
function teardown() {
|
|
321
|
+
ctx.mountQueue.length = 0;
|
|
322
|
+
// Release the controlled-node registry: a destroyed renderer must not
|
|
323
|
+
// retain detached form controls (nor their stored values) past teardown.
|
|
324
|
+
ctx.controlled.clear();
|
|
325
|
+
if (rootNode !== null) {
|
|
326
|
+
/** @type {unknown[]} */
|
|
327
|
+
const failures = [];
|
|
328
|
+
if (ctx.hasWidgets) {
|
|
329
|
+
destroyDomWalk(ctx, rootNode, failures);
|
|
330
|
+
}
|
|
331
|
+
container.textContent = '';
|
|
332
|
+
rootNode = null;
|
|
333
|
+
oldVnode = null;
|
|
334
|
+
if (failures.length > 0) {
|
|
335
|
+
// one delivery: a single failure surfaces by identity; several
|
|
336
|
+
// aggregate — the first is primary (errors[0]) and every later
|
|
337
|
+
// one stays observable instead of silently vanishing
|
|
338
|
+
const value = failures.length === 1
|
|
339
|
+
? failures[0]
|
|
340
|
+
: new AggregateError(failures, 'multiple cleanup failures in one teardown');
|
|
341
|
+
if (ctx.onCleanupError !== null) ctx.onCleanupError(value);
|
|
342
|
+
else if (ctx.frameError === null) ctx.frameError = { value };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Terminal teardown (VIEW-FORMAT §7.3.1): every mounted widget in the
|
|
349
|
+
* rendered tree unmounts exactly once (pending mounts are canceled),
|
|
350
|
+
* the container is left empty, and later `render` calls are exact
|
|
351
|
+
* no-ops. Idempotent. Called from inside a widget hook or a nested
|
|
352
|
+
* render it is still terminal: the active pass stops and the
|
|
353
|
+
* teardown runs when that pass unwinds. A throwing widget `unmount`
|
|
354
|
+
* never stops the walk; the first such error is thrown after the
|
|
355
|
+
* teardown completes.
|
|
356
|
+
*/
|
|
357
|
+
render.destroy = function destroy() {
|
|
358
|
+
if (ctx.destroyed) return;
|
|
359
|
+
ctx.destroyed = true;
|
|
360
|
+
if (rendering) {
|
|
361
|
+
// called from inside the active pass: the pass sees `destroyed`,
|
|
362
|
+
// stops, and finishes the teardown as it unwinds
|
|
363
|
+
destroyPending = true;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
teardown();
|
|
367
|
+
if (ctx.frameError !== null) {
|
|
368
|
+
const { value } = ctx.frameError;
|
|
369
|
+
ctx.frameError = null;
|
|
370
|
+
throw value;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
return render;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Mount every queued widget, in queue (document) order. A throwing
|
|
379
|
+
* `mount` poisons that widget (§7 failure policy): the host stays
|
|
380
|
+
* inert, siblings still mount, the first error parks on the frame, and
|
|
381
|
+
* the next render that revisits the widget replaces it with a fresh
|
|
382
|
+
* lifecycle. A poisoned-at-mount widget never receives `update` or
|
|
383
|
+
* `unmount` — it holds no successfully acquired resources.
|
|
384
|
+
* @param {any} ctx
|
|
385
|
+
*/
|
|
386
|
+
function flushMounts(ctx) {
|
|
387
|
+
const queue = ctx.mountQueue;
|
|
388
|
+
while (queue.length > 0) {
|
|
389
|
+
if (ctx.destroyed) { queue.length = 0; return; }
|
|
390
|
+
const node = queue.shift();
|
|
391
|
+
const w = node.__jarenWidget;
|
|
392
|
+
if (w.mounted || w.destroyed) continue;
|
|
393
|
+
w.mounted = true;
|
|
394
|
+
try {
|
|
395
|
+
w.handle = w.def.mount(node, w.props, ctx.emit);
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
w.failed = 'mount';
|
|
399
|
+
ctx.poisonedCount++;
|
|
400
|
+
appendFrameFailure(ctx, err);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Create a fresh DOM node for a vnode.
|
|
407
|
+
* @param {any} ctx
|
|
408
|
+
* @param {any} vnode
|
|
409
|
+
* @param {string | null} ns
|
|
410
|
+
* @returns {any}
|
|
411
|
+
*/
|
|
412
|
+
function createNode(ctx, vnode, ns) {
|
|
413
|
+
vnode = resolveForPolicy(ctx, vnode, true);
|
|
414
|
+
if (isTextNode(vnode)) {
|
|
415
|
+
return ctx.doc.createTextNode(String(vnode));
|
|
416
|
+
}
|
|
417
|
+
// Fail closed: a value that is neither text nor a valid element vnode (a
|
|
418
|
+
// stray object, say `{foo:'bar'}`, that slipped in as a child) renders as
|
|
419
|
+
// nothing — matching the serializer, and never reaching `createElement`
|
|
420
|
+
// with an undefined tag, which would build an `<undefined>` element without
|
|
421
|
+
// consulting the safe allow-list. Lists and skipped values were already
|
|
422
|
+
// resolved by `childrenOf`, so only a malformed node reaches here.
|
|
423
|
+
if (!isElementNode(vnode)) {
|
|
424
|
+
return ctx.doc.createTextNode('');
|
|
425
|
+
}
|
|
426
|
+
const tag = vnode[0];
|
|
427
|
+
if (tag === WIDGET_TAG) {
|
|
428
|
+
// Only reachable in trusted mode: `resolveForPolicy` already turned a
|
|
429
|
+
// safe-mode widget into an empty text node above.
|
|
430
|
+
return createWidgetNode(ctx, vnode, ns);
|
|
431
|
+
}
|
|
432
|
+
if (tag === 'svg') ns = SVG_NS;
|
|
433
|
+
const node = ns !== null
|
|
434
|
+
? ctx.doc.createElementNS(ns, tag)
|
|
435
|
+
: ctx.doc.createElement(tag);
|
|
436
|
+
const props = propsOf(vnode);
|
|
437
|
+
for (const name in props) {
|
|
438
|
+
setProp(ctx, node, name, undefined, props[name], ns);
|
|
439
|
+
}
|
|
440
|
+
const children = childrenOf(vnode);
|
|
441
|
+
for (let i = 0; i < children.length; i++) {
|
|
442
|
+
node.appendChild(createNode(ctx, children[i], ns));
|
|
443
|
+
}
|
|
444
|
+
registerControlled(ctx, node, props);
|
|
445
|
+
return node;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Map a vnode the safe policy rejects — a widget (imperative JS) or an
|
|
450
|
+
* element whose tag is off the allow-list or is injection-shaped — to a
|
|
451
|
+
* stable empty-text sentinel. Trusted mode returns the vnode untouched.
|
|
452
|
+
*
|
|
453
|
+
* This runs at the top of BOTH `createNode` and `patchNode`, which is what
|
|
454
|
+
* closes the update-path escape: a rejected node has the identity of an empty
|
|
455
|
+
* text node on every frame, so it can never reach `patchWidgetNode` or the
|
|
456
|
+
* element patch — a stripped widget cannot mount on a later frame, and a
|
|
457
|
+
* blocked element cannot be patched as if its placeholder were real.
|
|
458
|
+
* @param {any} ctx
|
|
459
|
+
* @param {any} vnode
|
|
460
|
+
* @param {boolean} report - Whether to notify `onUnsafe` (the new side of a
|
|
461
|
+
* patch reports; the old side does not, so a persistently-rejected node is
|
|
462
|
+
* not reported twice per frame)
|
|
463
|
+
* @returns {any} the vnode, or `''` when the policy rejects it
|
|
464
|
+
*/
|
|
465
|
+
function resolveForPolicy(ctx, vnode, report) {
|
|
466
|
+
if (ctx.policy === null || !isElementNode(vnode)) return vnode;
|
|
467
|
+
const tag = vnode[0];
|
|
468
|
+
if (tag === WIDGET_TAG) {
|
|
469
|
+
if (report && ctx.onUnsafe !== null) {
|
|
470
|
+
ctx.onUnsafe({ kind: 'widget', name: String(propsOf(vnode).name ?? '') });
|
|
471
|
+
}
|
|
472
|
+
return '';
|
|
473
|
+
}
|
|
474
|
+
if (ctx.policy.tag(tag) === null) {
|
|
475
|
+
if (report && ctx.onUnsafe !== null) ctx.onUnsafe({ kind: 'tag', name: String(tag) });
|
|
476
|
+
return '';
|
|
477
|
+
}
|
|
478
|
+
return vnode;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Patch `node` (rendered from `oldV`) to match `newV`; returns the node
|
|
483
|
+
* now in the DOM (a replacement when patching in place was impossible).
|
|
484
|
+
* @param {any} ctx
|
|
485
|
+
* @param {any} parent
|
|
486
|
+
* @param {any} node
|
|
487
|
+
* @param {any} oldV
|
|
488
|
+
* @param {any} newV
|
|
489
|
+
* @param {string | null} ns
|
|
490
|
+
* @returns {any}
|
|
491
|
+
*/
|
|
492
|
+
function patchNode(ctx, parent, node, oldV, newV, ns) {
|
|
493
|
+
// a destroy() requested inside a widget hook stops the active pass:
|
|
494
|
+
// no later sibling may observe another update in this frame
|
|
495
|
+
if (ctx.destroyed) return node;
|
|
496
|
+
// Normalize BEFORE the identity diff: a safe-rejected node has the identity
|
|
497
|
+
// of an empty text node on both sides, so it can never enter the widget or
|
|
498
|
+
// element patch path (the update-path escape). The old side does not report
|
|
499
|
+
// — its strip was reported when it was first rendered.
|
|
500
|
+
if (ctx.policy !== null) {
|
|
501
|
+
oldV = resolveForPolicy(ctx, oldV, false);
|
|
502
|
+
newV = resolveForPolicy(ctx, newV, true);
|
|
503
|
+
}
|
|
504
|
+
// the === fast path is sound only while no widget is poisoned: a
|
|
505
|
+
// reference-equal subtree may hide a poisoned widget awaiting its
|
|
506
|
+
// replacement (§7.3), so recovery must not depend on the producer
|
|
507
|
+
// allocating a fresh vnode
|
|
508
|
+
if (oldV === newV && ctx.poisonedCount === 0) return node;
|
|
509
|
+
if (isTextNode(oldV) && isTextNode(newV)) {
|
|
510
|
+
const text = String(newV);
|
|
511
|
+
if (node.nodeValue !== text) node.nodeValue = text;
|
|
512
|
+
return node;
|
|
513
|
+
}
|
|
514
|
+
if (isSameNode(oldV, newV)) {
|
|
515
|
+
if (newV[0] === WIDGET_TAG) {
|
|
516
|
+
return patchWidgetNode(ctx, parent, node, oldV, newV, ns);
|
|
517
|
+
}
|
|
518
|
+
if (newV[0] === 'svg') ns = SVG_NS;
|
|
519
|
+
// the memo marker (§5.5): a producer-owned stability assertion —
|
|
520
|
+
// equal `memo` values on same-identity vnodes promise an identical
|
|
521
|
+
// subtree, so the diff skips it without touching props or
|
|
522
|
+
// children. `key`'s sibling: an instruction, never markup. It
|
|
523
|
+
// extends the `===` fast path across allocation boundaries (a
|
|
524
|
+
// rebuilt tree can still skip its unchanged regions) and shares
|
|
525
|
+
// its soundness condition — suspended while any widget is
|
|
526
|
+
// poisoned, so a memo-stable subtree can never hide one
|
|
527
|
+
const memo = propsOf(newV).memo;
|
|
528
|
+
if (memo !== undefined && Object.is(memo, propsOf(oldV).memo)
|
|
529
|
+
&& ctx.poisonedCount === 0) {
|
|
530
|
+
return node;
|
|
531
|
+
}
|
|
532
|
+
patchProps(ctx, node, propsOf(oldV), propsOf(newV), ns);
|
|
533
|
+
patchChildren(ctx, node, childrenOf(oldV), childrenOf(newV), ns);
|
|
534
|
+
return node;
|
|
535
|
+
}
|
|
536
|
+
destroyNode(ctx, node);
|
|
537
|
+
const next = createNode(ctx, newV, ns);
|
|
538
|
+
parent.replaceChild(next, node);
|
|
539
|
+
return next;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Diff two props objects onto a DOM node.
|
|
544
|
+
* @param {any} ctx
|
|
545
|
+
* @param {any} node
|
|
546
|
+
* @param {Record<string, any>} oldProps
|
|
547
|
+
* @param {Record<string, any>} newProps
|
|
548
|
+
* @param {string | null} ns
|
|
549
|
+
*/
|
|
550
|
+
function patchProps(ctx, node, oldProps, newProps, ns) {
|
|
551
|
+
if (oldProps !== newProps) {
|
|
552
|
+
for (const name in oldProps) {
|
|
553
|
+
if (!(name in newProps)) {
|
|
554
|
+
setProp(ctx, node, name, oldProps[name], undefined, ns);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
for (const name in newProps) {
|
|
558
|
+
if (oldProps[name] !== newProps[name]) {
|
|
559
|
+
setProp(ctx, node, name, oldProps[name], newProps[name], ns);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// Record (or refresh) this control's authoritative value; the actual write
|
|
564
|
+
// happens in the end-of-pass reconciliation so it survives the skip paths.
|
|
565
|
+
registerControlled(ctx, node, newProps);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Record a form control's authoritative `value`/`checked` so the end-of-pass
|
|
570
|
+
* reconciliation can reassert it against the live DOM — even for a control
|
|
571
|
+
* inside a subtree a later frame skips. Trusted mode only: a safe-mode view
|
|
572
|
+
* strips events and is display-oriented, so there is no controlled state to
|
|
573
|
+
* fight the user over. Stores the intended value on the DOM node (never the
|
|
574
|
+
* vnode — §5.1) and keeps the node in `ctx.controlled`.
|
|
575
|
+
* @param {any} ctx
|
|
576
|
+
* @param {any} node
|
|
577
|
+
* @param {Record<string, any>} props
|
|
578
|
+
*/
|
|
579
|
+
function registerControlled(ctx, node, props) {
|
|
580
|
+
if (ctx.policy !== null) return;
|
|
581
|
+
const kind = node.nodeName;
|
|
582
|
+
if (kind !== 'INPUT' && kind !== 'TEXTAREA' && kind !== 'SELECT') return;
|
|
583
|
+
const hasValue = 'value' in props;
|
|
584
|
+
const hasChecked = kind === 'INPUT' && 'checked' in props;
|
|
585
|
+
if (!hasValue && !hasChecked) {
|
|
586
|
+
if (node.__jarenControlled !== undefined) {
|
|
587
|
+
node.__jarenControlled = undefined;
|
|
588
|
+
ctx.controlled.delete(node);
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
node.__jarenControlled = {
|
|
593
|
+
hasValue,
|
|
594
|
+
hasChecked,
|
|
595
|
+
value: hasValue ? props.value : undefined,
|
|
596
|
+
checked: hasChecked ? props.checked === true : undefined,
|
|
597
|
+
};
|
|
598
|
+
ctx.controlled.add(node);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Reconcile every registered controlled node against the live DOM, once per
|
|
602
|
+
* settled render pass. A node no longer connected to the container is dropped
|
|
603
|
+
* from the registry here, which is why registration needs no destroy hook. */
|
|
604
|
+
function reconcileControlledSet(ctx, container) {
|
|
605
|
+
for (const node of ctx.controlled) {
|
|
606
|
+
if (!isConnectedTo(node, container)) {
|
|
607
|
+
node.__jarenControlled = undefined;
|
|
608
|
+
ctx.controlled.delete(node);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
reconcileControlled(node);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Reassert one control's authoritative value/checked. React's controlled
|
|
617
|
+
* contract: the passed value wins over a user edit. Writes only on a genuine
|
|
618
|
+
* divergence, which preserves the caret on an unchanged control. Runs after
|
|
619
|
+
* the whole tree is built, so a `select` sees its options, and a `multiple`
|
|
620
|
+
* select applies an array by marking each option `selected`.
|
|
621
|
+
* @param {any} node
|
|
622
|
+
*/
|
|
623
|
+
function reconcileControlled(node) {
|
|
624
|
+
const c = node.__jarenControlled;
|
|
625
|
+
if (c === undefined) return;
|
|
626
|
+
if (c.hasChecked) {
|
|
627
|
+
const want = c.checked === true;
|
|
628
|
+
if (node.checked !== want) node.checked = want;
|
|
629
|
+
}
|
|
630
|
+
if (!c.hasValue) return;
|
|
631
|
+
const isMultiple = node.multiple === true
|
|
632
|
+
|| (typeof node.getAttribute === 'function' && node.getAttribute('multiple') != null);
|
|
633
|
+
if (node.nodeName === 'SELECT' && isMultiple && Array.isArray(c.value)) {
|
|
634
|
+
const want = new Set(c.value.map((v) => String(v)));
|
|
635
|
+
const options = node.options ?? node.childNodes ?? [];
|
|
636
|
+
for (let i = 0; i < options.length; i++) {
|
|
637
|
+
const opt = options[i];
|
|
638
|
+
const ov = opt.value != null ? opt.value
|
|
639
|
+
: (typeof opt.getAttribute === 'function' ? opt.getAttribute('value') : null);
|
|
640
|
+
const sel = ov != null && want.has(String(ov));
|
|
641
|
+
if (opt.selected !== sel) opt.selected = sel;
|
|
642
|
+
}
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const want = c.value == null ? '' : String(c.value);
|
|
646
|
+
if (node.value !== want) node.value = want;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Whether `node` is still attached beneath `root` (the render container).
|
|
650
|
+
* A detached node's ancestor chain stops before the container. */
|
|
651
|
+
function isConnectedTo(node, root) {
|
|
652
|
+
let n = node;
|
|
653
|
+
while (n != null) {
|
|
654
|
+
if (n === root) return true;
|
|
655
|
+
n = n.parentNode;
|
|
656
|
+
}
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Write one prop change to the DOM.
|
|
662
|
+
* @param {any} ctx
|
|
663
|
+
* @param {any} node
|
|
664
|
+
* @param {string} name
|
|
665
|
+
* @param {any} oldValue
|
|
666
|
+
* @param {any} newValue
|
|
667
|
+
* @param {string | null} ns
|
|
668
|
+
*/
|
|
669
|
+
function setProp(ctx, node, name, oldValue, newValue, ns) {
|
|
670
|
+
if (name === 'on') {
|
|
671
|
+
// Safe mode strips event bindings: an untrusted document must not bind
|
|
672
|
+
// the host's application actions. Never wires the listener.
|
|
673
|
+
if (ctx.policy !== null && ctx.policy.dropsEvents) {
|
|
674
|
+
if (ctx.onUnsafe !== null) ctx.onUnsafe({ kind: 'event', name: 'on' });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
setEvents(ctx, node, oldValue, newValue);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (name in SKIP_PROPS) return;
|
|
681
|
+
if (ctx.policy !== null) {
|
|
682
|
+
const decided = ctx.policy.prop(name, newValue);
|
|
683
|
+
// A rejected NAME (scripting sink, inline `on*`, injection-shaped) is
|
|
684
|
+
// dropped; a sanitized-away URL or dangerous style keeps its (safe) name
|
|
685
|
+
// and clears the value.
|
|
686
|
+
if (decided === null) {
|
|
687
|
+
if (ctx.onUnsafe !== null) ctx.onUnsafe({ kind: 'prop', name });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
name = decided.name;
|
|
691
|
+
newValue = decided.value;
|
|
692
|
+
if (newValue === null && ctx.onUnsafe !== null) ctx.onUnsafe({ kind: 'prop', name });
|
|
693
|
+
// SAFE MODE IS ATTRIBUTE-ONLY. Never `node[name] = …`: a live DOM property
|
|
694
|
+
// write can THROW (`input.files` is read-only) and it diverges from SSR
|
|
695
|
+
// for a cleared value (an empty reflected attribute vs an omitted one).
|
|
696
|
+
// Writing/removing the attribute matches exactly what the serializer does.
|
|
697
|
+
if (name === 'style' && typeof newValue === 'object' && newValue !== null) {
|
|
698
|
+
newValue = styleToString(newValue);
|
|
699
|
+
}
|
|
700
|
+
if (newValue == null || newValue === false) node.removeAttribute(name);
|
|
701
|
+
else node.setAttribute(name, newValue === true ? '' : String(newValue));
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
// Trusted path (unchanged): a property where the node has one, else an
|
|
705
|
+
// attribute — the equivalent of writing the DOM by hand.
|
|
706
|
+
if (name === 'style' && typeof newValue === 'object' && newValue !== null) {
|
|
707
|
+
newValue = styleToString(newValue);
|
|
708
|
+
}
|
|
709
|
+
if (ns === null && name in node && name !== 'list' && name !== 'form') {
|
|
710
|
+
node[name] = newValue == null ? '' : newValue;
|
|
711
|
+
}
|
|
712
|
+
else if (newValue == null || newValue === false) {
|
|
713
|
+
node.removeAttribute(name);
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
node.setAttribute(name, newValue === true ? '' : String(newValue));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* The shared proxy listener: reads the current binding off the node, so
|
|
722
|
+
* re-renders rebind by data alone.
|
|
723
|
+
* @param {any} event
|
|
724
|
+
*/
|
|
725
|
+
function eventProxy(event) {
|
|
726
|
+
const node = event.currentTarget;
|
|
727
|
+
const binding = node.__jarenOn?.[event.type];
|
|
728
|
+
if (binding !== undefined && node.__jarenEmit !== null) {
|
|
729
|
+
node.__jarenEmit(binding, event);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Reconcile the `on` prop: `{ [eventType]: binding }`.
|
|
735
|
+
* @param {any} ctx
|
|
736
|
+
* @param {any} node
|
|
737
|
+
* @param {Record<string, any> | undefined} oldOn
|
|
738
|
+
* @param {Record<string, any> | undefined} newOn
|
|
739
|
+
*/
|
|
740
|
+
function setEvents(ctx, node, oldOn, newOn) {
|
|
741
|
+
const prev = node.__jarenOn ?? EMPTY_PROPS;
|
|
742
|
+
const next = newOn ?? EMPTY_PROPS;
|
|
743
|
+
node.__jarenOn = next;
|
|
744
|
+
node.__jarenEmit = ctx.onEvent;
|
|
745
|
+
for (const type in prev) {
|
|
746
|
+
if (!(type in next)) node.removeEventListener(type, eventProxy);
|
|
747
|
+
}
|
|
748
|
+
for (const type in next) {
|
|
749
|
+
if (!(type in prev)) node.addEventListener(type, eventProxy);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Resolve and validate a widget vnode against the registry: a non-empty
|
|
755
|
+
* registered `name`, and no vnode children — the widget owns the host's
|
|
756
|
+
* subtree. Violations are configuration/producer errors, the same class
|
|
757
|
+
* as the root-vnode check.
|
|
758
|
+
* @param {any} ctx
|
|
759
|
+
* @param {any} vnode
|
|
760
|
+
* @param {Record<string, any>} props
|
|
761
|
+
* @returns {WidgetDef}
|
|
762
|
+
*/
|
|
763
|
+
function widgetDef(ctx, vnode, props) {
|
|
764
|
+
const name = props.name;
|
|
765
|
+
if (typeof name !== 'string' || name === '') {
|
|
766
|
+
throw new TypeError('view: a jaren-widget vnode must have a non-empty "name" prop');
|
|
767
|
+
}
|
|
768
|
+
if (!Object.hasOwn(ctx.widgets, name)) {
|
|
769
|
+
throw new TypeError(`view: unregistered widget '${name}'`);
|
|
770
|
+
}
|
|
771
|
+
if (childrenOf(vnode).length !== 0) {
|
|
772
|
+
throw new TypeError(
|
|
773
|
+
`view: widget '${name}' must not have vnode children — the widget owns the host's subtree`);
|
|
774
|
+
}
|
|
775
|
+
return ctx.widgets[name];
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Create a widget host element (VIEW-FORMAT §7): apply the host props,
|
|
780
|
+
* store the widget bookkeeping on the DOM node (never on the vnode) and
|
|
781
|
+
* queue the node for the post-patch mount flush.
|
|
782
|
+
* @param {any} ctx
|
|
783
|
+
* @param {any} vnode
|
|
784
|
+
* @param {string | null} ns
|
|
785
|
+
* @returns {any}
|
|
786
|
+
*/
|
|
787
|
+
function createWidgetNode(ctx, vnode, ns) {
|
|
788
|
+
const props = propsOf(vnode);
|
|
789
|
+
const def = widgetDef(ctx, vnode, props);
|
|
790
|
+
const tag = props.tag ?? 'div';
|
|
791
|
+
const node = ns !== null
|
|
792
|
+
? ctx.doc.createElementNS(ns, tag)
|
|
793
|
+
: ctx.doc.createElement(tag);
|
|
794
|
+
for (const name in props) {
|
|
795
|
+
if (name in WIDGET_SKIP_PROPS) continue;
|
|
796
|
+
setProp(ctx, node, name, undefined, props[name], ns);
|
|
797
|
+
}
|
|
798
|
+
node.__jarenWidget = {
|
|
799
|
+
name: props.name,
|
|
800
|
+
def,
|
|
801
|
+
props: props.props ?? null,
|
|
802
|
+
handle: undefined,
|
|
803
|
+
mounted: false,
|
|
804
|
+
destroyed: false,
|
|
805
|
+
/** `false`, or the poisoning hook: `'mount'` (skip unmount — the
|
|
806
|
+
* widget acquired nothing) or `'update'` (unmount still runs). A
|
|
807
|
+
* poisoned widget is replaced on the next render that revisits it. */
|
|
808
|
+
failed: /** @type {false | 'mount' | 'update'} */ (false),
|
|
809
|
+
};
|
|
810
|
+
ctx.hasWidgets = true;
|
|
811
|
+
ctx.mountQueue.push(node);
|
|
812
|
+
return node;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Patch two widget vnodes under the same key: a `name` or host-`tag`
|
|
817
|
+
* change replaces the widget (destroy old, create new, mount queued);
|
|
818
|
+
* otherwise the host props diff normally, `childNodes` are never
|
|
819
|
+
* touched, and a changed `props` *reference* reaches the widget through
|
|
820
|
+
* `update` (or the unmount + fresh-mount fallback). Reference-equal
|
|
821
|
+
* `props` never poke the widget — the JSLT memo option turns unchanged
|
|
822
|
+
* state into exactly that.
|
|
823
|
+
* @param {any} ctx
|
|
824
|
+
* @param {any} parent
|
|
825
|
+
* @param {any} node
|
|
826
|
+
* @param {any} oldV
|
|
827
|
+
* @param {any} newV
|
|
828
|
+
* @param {string | null} ns
|
|
829
|
+
* @returns {any}
|
|
830
|
+
*/
|
|
831
|
+
function patchWidgetNode(ctx, parent, node, oldV, newV, ns) {
|
|
832
|
+
const oldProps = propsOf(oldV);
|
|
833
|
+
const newProps = propsOf(newV);
|
|
834
|
+
const w = node.__jarenWidget;
|
|
835
|
+
if (newProps.name !== oldProps.name || (newProps.tag ?? 'div') !== (oldProps.tag ?? 'div')
|
|
836
|
+
|| w.failed !== false) {
|
|
837
|
+
// a poisoned widget (a hook threw) is replaced, not patched: the
|
|
838
|
+
// old lifecycle ends (unmount only if mount succeeded) and a fresh
|
|
839
|
+
// one begins — half-mounted handles never receive updates
|
|
840
|
+
destroyNode(ctx, node);
|
|
841
|
+
const next = createWidgetNode(ctx, newV, ns);
|
|
842
|
+
parent.replaceChild(next, node);
|
|
843
|
+
return next;
|
|
844
|
+
}
|
|
845
|
+
widgetDef(ctx, newV, newProps);
|
|
846
|
+
patchWidgetProps(ctx, node, oldProps, newProps, ns);
|
|
847
|
+
const props = newProps.props ?? null;
|
|
848
|
+
const prevProps = oldProps.props ?? null;
|
|
849
|
+
if (props !== prevProps) {
|
|
850
|
+
w.props = props;
|
|
851
|
+
if (w.mounted) {
|
|
852
|
+
// capability ACQUISITION is host-observable (an accessor or
|
|
853
|
+
// proxy trap can throw): the `update` read shares the poison
|
|
854
|
+
// boundary with its invocation — a lookup failure poisons the
|
|
855
|
+
// widget exactly like an invocation failure, the frame settles,
|
|
856
|
+
// and the next render replaces it
|
|
857
|
+
let update;
|
|
858
|
+
let acquired = true;
|
|
859
|
+
try {
|
|
860
|
+
update = w.def.update;
|
|
861
|
+
}
|
|
862
|
+
catch (err) {
|
|
863
|
+
acquired = false;
|
|
864
|
+
w.failed = 'update';
|
|
865
|
+
ctx.poisonedCount++;
|
|
866
|
+
appendFrameFailure(ctx, err);
|
|
867
|
+
}
|
|
868
|
+
if (acquired && update !== undefined) {
|
|
869
|
+
try {
|
|
870
|
+
update.call(w.def, w.handle, props, prevProps);
|
|
871
|
+
}
|
|
872
|
+
catch (err) {
|
|
873
|
+
w.failed = 'update';
|
|
874
|
+
ctx.poisonedCount++;
|
|
875
|
+
appendFrameFailure(ctx, err);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
else if (acquired) {
|
|
879
|
+
// no update hook: recycle the host with a fresh lifecycle.
|
|
880
|
+
// The catch is PHASE-SENSITIVE: a failure before the old
|
|
881
|
+
// instance's `unmount` had its exactly-once chance (a hostile
|
|
882
|
+
// `unmount` lookup) leaves the OLD acquisition owned — poison
|
|
883
|
+
// as 'update' so replacement and terminal destroy still
|
|
884
|
+
// release it; only after the teardown attempt does a failure
|
|
885
|
+
// take mount-failure semantics (nothing left to release).
|
|
886
|
+
let unmountAttempted = false;
|
|
887
|
+
try {
|
|
888
|
+
const unmount = w.def.unmount;
|
|
889
|
+
if (unmount !== undefined) {
|
|
890
|
+
unmountAttempted = true;
|
|
891
|
+
unmount.call(w.def, w.handle);
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
unmountAttempted = true; // nothing to run: teardown is complete
|
|
895
|
+
}
|
|
896
|
+
// the unmount may have requested terminal destroy: the old
|
|
897
|
+
// acquisition has ENDED (record it, or deferred teardown
|
|
898
|
+
// would unmount it a second time) and no fresh acquisition
|
|
899
|
+
// may begin — a mount after a terminal request would run
|
|
900
|
+
// brand-new host side effects on a destroyed renderer
|
|
901
|
+
if (ctx.destroyed) {
|
|
902
|
+
w.mounted = false;
|
|
903
|
+
w.handle = undefined;
|
|
904
|
+
w.destroyed = true;
|
|
905
|
+
return node;
|
|
906
|
+
}
|
|
907
|
+
w.handle = w.def.mount(node, props, ctx.emit);
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
w.failed = unmountAttempted ? 'mount' : 'update';
|
|
911
|
+
ctx.poisonedCount++;
|
|
912
|
+
appendFrameFailure(ctx, err);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// not yet mounted (still queued): the pending mount reads w.props
|
|
917
|
+
}
|
|
918
|
+
return node;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Diff a widget host's props: `patchProps` minus the widget-local
|
|
923
|
+
* members (`name`/`props`/`tag`), which configure the widget, not the
|
|
924
|
+
* DOM.
|
|
925
|
+
* @param {any} ctx
|
|
926
|
+
* @param {any} node
|
|
927
|
+
* @param {Record<string, any>} oldProps
|
|
928
|
+
* @param {Record<string, any>} newProps
|
|
929
|
+
* @param {string | null} ns
|
|
930
|
+
*/
|
|
931
|
+
function patchWidgetProps(ctx, node, oldProps, newProps, ns) {
|
|
932
|
+
if (oldProps === newProps) return;
|
|
933
|
+
for (const name in oldProps) {
|
|
934
|
+
if (name in WIDGET_SKIP_PROPS) continue;
|
|
935
|
+
if (!(name in newProps)) {
|
|
936
|
+
setProp(ctx, node, name, oldProps[name], undefined, ns);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
for (const name in newProps) {
|
|
940
|
+
if (name in WIDGET_SKIP_PROPS) continue;
|
|
941
|
+
if (oldProps[name] !== newProps[name]) {
|
|
942
|
+
setProp(ctx, node, name, oldProps[name], newProps[name], ns);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Notify every widget in a discarded subtree that it is leaving the
|
|
949
|
+
* tree. A no-op until a widget has actually been created, so widget-free
|
|
950
|
+
* documents keep O(1) removal. A throwing `unmount` must not stop the
|
|
951
|
+
* walk or the patch — one broken widget must not leak its siblings — so
|
|
952
|
+
* the first captured error parks on `ctx` and `render` rethrows it after
|
|
953
|
+
* the frame settles.
|
|
954
|
+
*
|
|
955
|
+
* The walk is OWNERSHIP-based: it follows the live DOM and this
|
|
956
|
+
* renderer's own widget-host marker, never a vnode. A vnode is a
|
|
957
|
+
* *description* — old, new, or (after a mid-pass `destroy()`) only
|
|
958
|
+
* partially committed — and pairing it with the DOM is exactly how a
|
|
959
|
+
* teardown skips a mounted widget or indexes a missing node. What the
|
|
960
|
+
* renderer actually acquired is recorded on the DOM nodes it owns, so
|
|
961
|
+
* that is what teardown drains.
|
|
962
|
+
* @param {any} ctx
|
|
963
|
+
* @param {any} node - The root DOM node of the discarded subtree.
|
|
964
|
+
*/
|
|
965
|
+
function destroyNode(ctx, node) {
|
|
966
|
+
if (!ctx.hasWidgets) return;
|
|
967
|
+
/** @type {unknown[]} */
|
|
968
|
+
const failures = [];
|
|
969
|
+
destroyDomWalk(ctx, node, failures);
|
|
970
|
+
for (let i = 0; i < failures.length; i++) appendFrameFailure(ctx, failures[i]);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Park one more failure on the frame. EVERY failure of a frame stays
|
|
975
|
+
* observable regardless of how many internal walks or hooks produced
|
|
976
|
+
* them: the first surfaces by identity; from the second on, the frame
|
|
977
|
+
* delivers one framework-owned AggregateError over the originals in
|
|
978
|
+
* occurrence order (only the framework's OWN envelope is ever
|
|
979
|
+
* extended — a host-thrown value, an AggregateError included, is
|
|
980
|
+
* stored untouched as one element; nothing is inspected).
|
|
981
|
+
* @param {any} ctx
|
|
982
|
+
* @param {unknown} value
|
|
983
|
+
*/
|
|
984
|
+
function appendFrameFailure(ctx, value) {
|
|
985
|
+
if (ctx.frameError === null) {
|
|
986
|
+
ctx.frameError = { value, envelope: false };
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const prior = ctx.frameError;
|
|
990
|
+
const items = prior.envelope
|
|
991
|
+
? [.../** @type {AggregateError} */ (prior.value).errors, value]
|
|
992
|
+
: [prior.value, value];
|
|
993
|
+
ctx.frameError = {
|
|
994
|
+
value: new AggregateError(items, 'multiple failures in one frame'),
|
|
995
|
+
envelope: true,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* The recursive half of `destroyNode`: unmount marked widget hosts,
|
|
1001
|
+
* recurse through ordinary DOM children, never descend into a widget's
|
|
1002
|
+
* host subtree (the widget's own DOM may contain anything — including a
|
|
1003
|
+
* nested renderer whose widgets it, not this renderer, owns). A
|
|
1004
|
+
* destroyed poisoned widget leaves the live-poison count — replacement
|
|
1005
|
+
* and subtree removal are the two ways a poisoned widget recovers,
|
|
1006
|
+
* after which the `===` fast path is sound again.
|
|
1007
|
+
* @param {any} ctx
|
|
1008
|
+
* @param {any} node
|
|
1009
|
+
* @param {unknown[]} failures - Collects EVERY cleanup failure, in
|
|
1010
|
+
* document order — an array, never a thrown-value sentinel, so a
|
|
1011
|
+
* hook that legally throws `null` still counts and a second failure
|
|
1012
|
+
* is never hidden behind the first.
|
|
1013
|
+
*/
|
|
1014
|
+
function destroyDomWalk(ctx, node, failures) {
|
|
1015
|
+
const w = node.__jarenWidget;
|
|
1016
|
+
if (w !== undefined) {
|
|
1017
|
+
if (!w.destroyed) {
|
|
1018
|
+
w.destroyed = true;
|
|
1019
|
+
if (w.failed !== false) ctx.poisonedCount--;
|
|
1020
|
+
if (w.mounted && w.failed !== 'mount') {
|
|
1021
|
+
// the `unmount` READ shares the collection boundary with its
|
|
1022
|
+
// call: a hostile accessor is a cleanup failure like any
|
|
1023
|
+
// other — every sibling still unmounts, the container still
|
|
1024
|
+
// empties
|
|
1025
|
+
try {
|
|
1026
|
+
const unmount = w.def.unmount;
|
|
1027
|
+
if (unmount !== undefined) unmount.call(w.def, w.handle);
|
|
1028
|
+
}
|
|
1029
|
+
catch (err) {
|
|
1030
|
+
failures.push(err);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return; // the widget owns everything below its host
|
|
1035
|
+
}
|
|
1036
|
+
const children = node.childNodes;
|
|
1037
|
+
if (children !== undefined) {
|
|
1038
|
+
// snapshot before invoking hooks: `childNodes` is live, and an
|
|
1039
|
+
// `unmount` that detaches its own host would shift the indices and
|
|
1040
|
+
// silently skip a sibling's cleanup
|
|
1041
|
+
const snapshot = [];
|
|
1042
|
+
for (let i = 0; i < children.length; i++) snapshot.push(children[i]);
|
|
1043
|
+
for (let i = 0; i < snapshot.length; i++) {
|
|
1044
|
+
destroyDomWalk(ctx, snapshot[i], failures);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Serialize a style object to a CSS declaration string. CamelCase keys
|
|
1051
|
+
* become kebab-case; `--custom-properties` pass through.
|
|
1052
|
+
* @param {Record<string, any>} style
|
|
1053
|
+
* @returns {string}
|
|
1054
|
+
*/
|
|
1055
|
+
export function styleToString(style) {
|
|
1056
|
+
let out = '';
|
|
1057
|
+
for (const name in style) {
|
|
1058
|
+
const value = style[name];
|
|
1059
|
+
if (value == null || value === false) continue;
|
|
1060
|
+
const cssName = name.startsWith('--') ? name : kebabCase(name);
|
|
1061
|
+
out += (out === '' ? '' : ';') + cssName + ':' + String(value);
|
|
1062
|
+
}
|
|
1063
|
+
return out;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Reconcile an element's children with a head/tail sweep plus a key map
|
|
1068
|
+
* for the middle. `oldCh[i]` corresponds to the i-th DOM child at entry;
|
|
1069
|
+
* the parallel `oldDom` snapshot keeps that correspondence stable across
|
|
1070
|
+
* moves (arrays are positional only at snapshot time — afterwards they
|
|
1071
|
+
* are identity maps, index → node).
|
|
1072
|
+
* @param {any} ctx
|
|
1073
|
+
* @param {any} parent
|
|
1074
|
+
* @param {any[]} oldCh
|
|
1075
|
+
* @param {any[]} newCh
|
|
1076
|
+
* @param {string | null} ns
|
|
1077
|
+
*/
|
|
1078
|
+
function patchChildren(ctx, parent, oldCh, newCh, ns) {
|
|
1079
|
+
const oldDom = [];
|
|
1080
|
+
{
|
|
1081
|
+
const live = parent.childNodes;
|
|
1082
|
+
for (let i = 0; i < oldCh.length; i++) oldDom.push(live[i]);
|
|
1083
|
+
}
|
|
1084
|
+
let oldStart = 0;
|
|
1085
|
+
let oldEnd = oldCh.length - 1;
|
|
1086
|
+
let newStart = 0;
|
|
1087
|
+
let newEnd = newCh.length - 1;
|
|
1088
|
+
/** @type {Map<any, number> | null} */
|
|
1089
|
+
let keyMap = null;
|
|
1090
|
+
/** The DOM node currently sitting just after the unprocessed tail. */
|
|
1091
|
+
let tailRef = null;
|
|
1092
|
+
|
|
1093
|
+
while (oldStart <= oldEnd && newStart <= newEnd) {
|
|
1094
|
+
if (ctx.destroyed) return; // a mid-pass destroy stops the traversal
|
|
1095
|
+
const oS = oldCh[oldStart];
|
|
1096
|
+
if (oS === undefined) { oldStart++; continue; } // consumed by a keyed move
|
|
1097
|
+
const oE = oldCh[oldEnd];
|
|
1098
|
+
if (oE === undefined) { oldEnd--; continue; }
|
|
1099
|
+
const nS = newCh[newStart];
|
|
1100
|
+
const nE = newCh[newEnd];
|
|
1101
|
+
|
|
1102
|
+
if (isSameNode(oS, nS)) {
|
|
1103
|
+
patchNode(ctx, parent, oldDom[oldStart], oS, nS, ns);
|
|
1104
|
+
oldStart++; newStart++;
|
|
1105
|
+
}
|
|
1106
|
+
else if (isSameNode(oE, nE)) {
|
|
1107
|
+
patchNode(ctx, parent, oldDom[oldEnd], oE, nE, ns);
|
|
1108
|
+
tailRef = oldDom[oldEnd];
|
|
1109
|
+
oldEnd--; newEnd--;
|
|
1110
|
+
}
|
|
1111
|
+
else if (isSameNode(oS, nE)) {
|
|
1112
|
+
// old head moved to the tail
|
|
1113
|
+
const node = patchNode(ctx, parent, oldDom[oldStart], oS, nE, ns);
|
|
1114
|
+
parent.insertBefore(node, tailRef);
|
|
1115
|
+
tailRef = node;
|
|
1116
|
+
oldStart++; newEnd--;
|
|
1117
|
+
}
|
|
1118
|
+
else if (isSameNode(oE, nS)) {
|
|
1119
|
+
// old tail moved to the head
|
|
1120
|
+
const node = patchNode(ctx, parent, oldDom[oldEnd], oE, nS, ns);
|
|
1121
|
+
parent.insertBefore(node, oldDom[oldStart]);
|
|
1122
|
+
oldEnd--; newStart++;
|
|
1123
|
+
}
|
|
1124
|
+
else {
|
|
1125
|
+
if (keyMap === null) keyMap = buildKeyMap(oldCh, oldStart, oldEnd);
|
|
1126
|
+
const key = keyOf(nS);
|
|
1127
|
+
const idx = key !== undefined ? keyMap.get(key) : undefined;
|
|
1128
|
+
if (idx === undefined || oldCh[idx] === undefined || !isSameNode(oldCh[idx], nS)) {
|
|
1129
|
+
parent.insertBefore(createNode(ctx, nS, ns), oldDom[oldStart]);
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
const node = patchNode(ctx, parent, oldDom[idx], oldCh[idx], nS, ns);
|
|
1133
|
+
parent.insertBefore(node, oldDom[oldStart]);
|
|
1134
|
+
oldCh[idx] = undefined;
|
|
1135
|
+
}
|
|
1136
|
+
newStart++;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (oldStart > oldEnd) {
|
|
1141
|
+
// old range exhausted: mount the remaining new children before the tail
|
|
1142
|
+
for (let i = newStart; i <= newEnd; i++) {
|
|
1143
|
+
if (ctx.destroyed) return;
|
|
1144
|
+
parent.insertBefore(createNode(ctx, newCh[i], ns), tailRef);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
// new range exhausted: unmount the remaining old children
|
|
1149
|
+
for (let i = oldStart; i <= oldEnd; i++) {
|
|
1150
|
+
if (ctx.destroyed) return;
|
|
1151
|
+
if (oldCh[i] !== undefined) {
|
|
1152
|
+
destroyNode(ctx, oldDom[i]);
|
|
1153
|
+
parent.removeChild(oldDom[i]);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Key → index map over the unprocessed old range.
|
|
1161
|
+
* @param {any[]} oldCh
|
|
1162
|
+
* @param {number} start
|
|
1163
|
+
* @param {number} end
|
|
1164
|
+
* @returns {Map<any, number>}
|
|
1165
|
+
*/
|
|
1166
|
+
function buildKeyMap(oldCh, start, end) {
|
|
1167
|
+
const map = new Map();
|
|
1168
|
+
for (let i = start; i <= end; i++) {
|
|
1169
|
+
const key = keyOf(oldCh[i]);
|
|
1170
|
+
if (key !== undefined && !map.has(key)) map.set(key, i);
|
|
1171
|
+
}
|
|
1172
|
+
return map;
|
|
1173
|
+
}
|