@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
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
# The Jaren View Format
|
|
2
|
+
|
|
3
|
+
**Version 0.1 — Specification**
|
|
4
|
+
|
|
5
|
+
Module: `@jarenjs/view`. This document is the language contract for the
|
|
6
|
+
Jaren vnode format — the JSON vocabulary user interfaces are written in —
|
|
7
|
+
and the behavioral contract every renderer of the format MUST honor. It
|
|
8
|
+
plays the role for views that [QUERY-FORMAT](../../json/docs/QUERY-FORMAT.md)
|
|
9
|
+
plays for queries and [JSLT-FORMAT](../../json/docs/JSLT-FORMAT.md) for
|
|
10
|
+
stylesheets: vnodes are the intended *output* vocabulary of JSLT rule
|
|
11
|
+
bodies, closing the XSLT triangle — match with JSONPath, type with JSON
|
|
12
|
+
Schema, produce vnodes.
|
|
13
|
+
|
|
14
|
+
## 1. Introduction
|
|
15
|
+
|
|
16
|
+
### 1.1 What this format is
|
|
17
|
+
|
|
18
|
+
A **vnode** is a plain JSON value describing a fragment of user
|
|
19
|
+
interface. There are no functions anywhere in a vnode document: event
|
|
20
|
+
handlers are data (§4), so a complete interface is serializable,
|
|
21
|
+
schema-validatable, diffable, and generatable by a constrained decoder.
|
|
22
|
+
The grammar is published as JSON Schema in
|
|
23
|
+
[`schemas/jaren-vnode.schema.json`](../schemas/jaren-vnode.schema.json).
|
|
24
|
+
|
|
25
|
+
Schema validity is a **structural** property, not a safety one. A
|
|
26
|
+
grammar-valid vnode can still carry an `innerHTML` sink, an inline `on*`
|
|
27
|
+
handler or a `javascript:` URL, so validation alone does not make an
|
|
28
|
+
untrusted document safe to render — the default renderers trust their
|
|
29
|
+
input. Rendering an untrusted document requires the SAFE profile (§8).
|
|
30
|
+
|
|
31
|
+
### 1.2 Conformance and normative language
|
|
32
|
+
|
|
33
|
+
The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**,
|
|
34
|
+
**RECOMMENDED**, **MAY**, and **OPTIONAL** are to be interpreted as
|
|
35
|
+
described in RFC 2119.
|
|
36
|
+
|
|
37
|
+
- A **producer** (a JSLT stylesheet, a hand-written view, a model)
|
|
38
|
+
emits vnode documents and MUST emit documents valid per §2–§4.
|
|
39
|
+
- A **renderer** consumes vnode documents. Two renderer classes are
|
|
40
|
+
specified: **patching** renderers that maintain a live tree (§5) and
|
|
41
|
+
**serializing** renderers that emit markup (§6). The reference
|
|
42
|
+
implementations are `createDomRenderer` and `renderToString`.
|
|
43
|
+
|
|
44
|
+
### 1.3 Terminology
|
|
45
|
+
|
|
46
|
+
- **Vnode** — any value of the format (§2).
|
|
47
|
+
- **Element** — a vnode of the form `[tag, props?, ...children]`.
|
|
48
|
+
- **Text** — a string or number vnode.
|
|
49
|
+
- **List** — an array vnode spliced into its parent's children.
|
|
50
|
+
- **Binding** — the opaque JSON value of one `on` member (§4).
|
|
51
|
+
- **Reconciliation** — patching a live tree from one vnode to the next.
|
|
52
|
+
|
|
53
|
+
## 2. The vnode document
|
|
54
|
+
|
|
55
|
+
A vnode is classified by shape, in this order:
|
|
56
|
+
|
|
57
|
+
1. **Text** — a `string` or a `number`. Renders as
|
|
58
|
+
`String(value)`. Numbers are text so query results embed without
|
|
59
|
+
conversion; `NaN` and infinities are producer errors.
|
|
60
|
+
2. **Skipped** — `null` and the booleans `true`/`false` render nothing
|
|
61
|
+
and occupy no child position. This makes `{"$if": ...}` bodies and
|
|
62
|
+
boolean short-circuits compose without wrapper nodes, and it means
|
|
63
|
+
an *empty sequence* from a query (which `@jarenjs/json` maps to
|
|
64
|
+
`undefined`) also renders nothing.
|
|
65
|
+
3. **Element** — an array whose first item is a string: `[tag,
|
|
66
|
+
props?, ...children]`. The tag MUST be a non-empty string. If the
|
|
67
|
+
second item is a plain object (not an array, not `null`) it is the
|
|
68
|
+
**props**; otherwise it is the first child. The tag
|
|
69
|
+
`"jaren-widget"` is reserved: it marks a widget node (§7).
|
|
70
|
+
4. **List** — any other array. Its items are spliced into the parent's
|
|
71
|
+
children **in place**, recursively. A list at the root of a document
|
|
72
|
+
is a producer error in 0.1 (renderers MAY reject it): the root MUST
|
|
73
|
+
be a text or element vnode.
|
|
74
|
+
|
|
75
|
+
The props-detection rule (element item 2) means an element whose first
|
|
76
|
+
child is itself an object-shaped value cannot omit props: write
|
|
77
|
+
`["div", {}, child]`. Producers SHOULD always write the props object;
|
|
78
|
+
`h()` does.
|
|
79
|
+
|
|
80
|
+
**Rationale for the tagged-array form** (non-normative): rule bodies in
|
|
81
|
+
JSLT are query documents where object members with `$`-prefixed names
|
|
82
|
+
are operators; an object-shaped element vocabulary (`{"tag": ...}` or
|
|
83
|
+
`{"$": ...}`) would collide with or shadow that namespace. Arrays with a
|
|
84
|
+
literal string head pass through the query engine untouched, and the
|
|
85
|
+
list form is exactly what an `[{"$apply": ...}]` array-constructor body
|
|
86
|
+
produces. The same tagged-pair shape underlies the JTLT front-end.
|
|
87
|
+
|
|
88
|
+
## 3. Props
|
|
89
|
+
|
|
90
|
+
Prop values MUST be JSON values. Four names are renderer instructions:
|
|
91
|
+
|
|
92
|
+
- **`key`** — a string or number giving the element identity among its
|
|
93
|
+
siblings (§5.3). Never rendered.
|
|
94
|
+
- **`on`** — an object `{ [eventType]: binding }` (§4). Never rendered.
|
|
95
|
+
- **`memo`** — a producer-owned subtree-stability marker (§5.5). Never
|
|
96
|
+
rendered.
|
|
97
|
+
- **`style`** — a CSS declaration string, or an object of declarations.
|
|
98
|
+
Object keys in camelCase are converted to kebab-case; keys starting
|
|
99
|
+
with `--` pass through. `null`/`false` members are dropped.
|
|
100
|
+
|
|
101
|
+
Every other prop **writes through**:
|
|
102
|
+
|
|
103
|
+
- A patching renderer targeting the DOM SHOULD assign `node[name]` when
|
|
104
|
+
the node has that property (form controls: `value`, `checked`, ...)
|
|
105
|
+
and use attributes otherwise; elements in a foreign namespace (§5.4)
|
|
106
|
+
always use attributes.
|
|
107
|
+
- `true` renders as a bare attribute, `false` and `null` remove the
|
|
108
|
+
attribute / clear the property.
|
|
109
|
+
- Prop names are used as-is: producers write `class`, not `className`.
|
|
110
|
+
|
|
111
|
+
A **controlled form value** is authoritative. A patching renderer MUST
|
|
112
|
+
reassert `value`/`checked` on a form control against the control's
|
|
113
|
+
**live** DOM property, not against the previous vnode's value: a user
|
|
114
|
+
edit may have moved the live value since the last frame even when the
|
|
115
|
+
vnode value is unchanged, and the authoritative value MUST win. A
|
|
116
|
+
renderer SHOULD compare the live value first and write only on a genuine
|
|
117
|
+
difference, so an already-matching control is not rewritten (which
|
|
118
|
+
preserves the caret).
|
|
119
|
+
|
|
120
|
+
This reassertion MUST survive the sharing (§5.1) and `memo` (§5.5) fast
|
|
121
|
+
paths: a control inside a subtree those paths skip still holds
|
|
122
|
+
authoritative state, so a conforming renderer reconciles controls from
|
|
123
|
+
something other than the per-frame prop diff. The reference
|
|
124
|
+
implementation keeps a registry of controlled nodes and reconciles it
|
|
125
|
+
once per settled pass — which also lets a `select` resolve its value
|
|
126
|
+
after its options exist and a `multiple` select apply an array of
|
|
127
|
+
values. Composition/IME coordination is not yet specified (§8).
|
|
128
|
+
|
|
129
|
+
## 4. Events are data
|
|
130
|
+
|
|
131
|
+
The value of an `on` member is an opaque JSON **binding**. The view
|
|
132
|
+
layer MUST deliver the binding verbatim to the environment's event hook
|
|
133
|
+
(`onEvent(binding, nativeEvent)`) and MUST NOT interpret it. What a
|
|
134
|
+
binding means belongs to the layer above; in `@jarenjs/app` it is an
|
|
135
|
+
action name or `{"action": name, "with"?: payload, "event"?: [...],
|
|
136
|
+
"preventDefault"?: bool, "stopPropagation"?: bool}`
|
|
137
|
+
([APP-FORMAT](../../app/docs/APP-FORMAT.md) §4).
|
|
138
|
+
|
|
139
|
+
Because bindings are data, a producer builds event payloads at *render
|
|
140
|
+
time*: a JSLT rule that renders the node at `$path` can embed that
|
|
141
|
+
location — or any value in scope — inside the binding. This replaces
|
|
142
|
+
closure capture and payload-creator functions.
|
|
143
|
+
|
|
144
|
+
A patching renderer MUST rebind by data: attaching, changing or
|
|
145
|
+
removing a binding across renders MUST NOT accumulate native listeners.
|
|
146
|
+
The reference implementation stores the current `on` object on the DOM
|
|
147
|
+
node behind one shared proxy listener per event type.
|
|
148
|
+
|
|
149
|
+
## 5. Reconciliation
|
|
150
|
+
|
|
151
|
+
### 5.1 The sharing fast path
|
|
152
|
+
|
|
153
|
+
For any subtree where `oldVnode === newVnode`, a patching renderer
|
|
154
|
+
MUST skip reconciliation entirely — no descent, no prop diff. This is
|
|
155
|
+
the load-bearing clause of the format's performance story: the JSLT
|
|
156
|
+
engine guarantees that unchanged input flows to output by reference
|
|
157
|
+
(share disposition, rebuild-only-if-changed), and the JSON Patch
|
|
158
|
+
engine's copy-on-write guarantees the same for state. Reference
|
|
159
|
+
equality is therefore *evidence of no change*, end to end.
|
|
160
|
+
|
|
161
|
+
The corollary constraint: a renderer MUST NOT mutate or annotate
|
|
162
|
+
vnodes (no `.elm` backpointers, no normalization in place). Vnode
|
|
163
|
+
documents may be frozen, cached, or shared between subtrees.
|
|
164
|
+
|
|
165
|
+
### 5.2 Patch in place or replace
|
|
166
|
+
|
|
167
|
+
Two vnodes are **the same node** when both are text, or both are
|
|
168
|
+
elements with equal tag and equal `key`. Same nodes patch in place
|
|
169
|
+
(text: update the character data; element: diff props, reconcile
|
|
170
|
+
children). Different nodes replace: the old subtree is discarded and
|
|
171
|
+
the new one built fresh.
|
|
172
|
+
|
|
173
|
+
### 5.3 Children and keys
|
|
174
|
+
|
|
175
|
+
Child reconciliation MUST preserve the DOM node identity of keyed
|
|
176
|
+
elements that appear on both sides, moving them instead of recreating
|
|
177
|
+
them. The reference algorithm is a head/tail two-pointer sweep with a
|
|
178
|
+
lazily built key map for the middle; its guarantees, which every
|
|
179
|
+
implementation MUST match:
|
|
180
|
+
|
|
181
|
+
- keys are unique among siblings (duplicate keys are a producer error;
|
|
182
|
+
behavior is unspecified);
|
|
183
|
+
- an element vnode with a key never patches against one with a
|
|
184
|
+
different key or without one;
|
|
185
|
+
- unkeyed children reconcile positionally.
|
|
186
|
+
|
|
187
|
+
Mixed keyed/unkeyed sibling lists are legal but SHOULD be avoided;
|
|
188
|
+
reorder efficiency is only specified for fully keyed lists.
|
|
189
|
+
|
|
190
|
+
### 5.4 Namespaces
|
|
191
|
+
|
|
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.
|
|
195
|
+
|
|
196
|
+
### 5.5 The memo marker
|
|
197
|
+
|
|
198
|
+
§5.1's fast path needs the *same object* on both sides, which a
|
|
199
|
+
producer that rebuilds its tree cannot always give. The `memo` prop is
|
|
200
|
+
the declared alternative: when two **same-node** element vnodes (§5.2 —
|
|
201
|
+
equal tag, equal `key`) both carry a `memo` prop and the two values are
|
|
202
|
+
equal by `Object.is`, a patching renderer MUST skip the subtree —
|
|
203
|
+
no prop diff, no descent — exactly as if the vnodes were reference
|
|
204
|
+
equal.
|
|
205
|
+
|
|
206
|
+
- The equality is `Object.is` over the raw prop values — producers use
|
|
207
|
+
version counters, revision strings, or any stable token; an object
|
|
208
|
+
identity works exactly like §5.1.
|
|
209
|
+
- `memo` is a **producer-owned correctness assertion**, `key`'s
|
|
210
|
+
sibling: equal markers on same-identity nodes promise the subtrees
|
|
211
|
+
render identically. A violated promise means stale output — the same
|
|
212
|
+
class of producer error as a duplicate key. An absent `memo` on
|
|
213
|
+
either side means the ordinary diff runs; `undefined` never matches.
|
|
214
|
+
- The skip shares §5.1's soundness condition in the reference
|
|
215
|
+
renderer: while any widget is poisoned (§7.3) the marker is ignored,
|
|
216
|
+
so a memo-stable subtree can never leave a poisoned widget inert.
|
|
217
|
+
- `memo` is never rendered and never serialized (§3, §6).
|
|
218
|
+
|
|
219
|
+
## 6. Serialization
|
|
220
|
+
|
|
221
|
+
`renderToString(vnode)` MUST produce markup equivalent to what a
|
|
222
|
+
patching renderer would build: text and attribute values escaped (`&`,
|
|
223
|
+
`<`, `>` in text; `&`, `"` in double-quoted attributes), void elements
|
|
224
|
+
(`br`, `img`, `input`, ...) without end tags, `key` and `on` producing
|
|
225
|
+
no output, boolean and style props serialized per §3. Serialization is
|
|
226
|
+
pure: no state, no DOM, safe in any runtime.
|
|
227
|
+
|
|
228
|
+
Hydration in 0.1 is a client-side first render into the same container
|
|
229
|
+
(empty and rebuild). Adopting existing server-rendered DOM is a
|
|
230
|
+
roadmap item, not part of this contract.
|
|
231
|
+
|
|
232
|
+
## 7. Widgets
|
|
233
|
+
|
|
234
|
+
The escape hatch for irreducibly imperative islands — virtualized
|
|
235
|
+
grids, canvas, maps, third-party controls — mirroring the effect
|
|
236
|
+
registry of APP-FORMAT: JavaScript enters only at named, registered
|
|
237
|
+
boundaries. The format's side of the contract stays data; the
|
|
238
|
+
imperative side is a **registered widget**. The environment owns state
|
|
239
|
+
and orchestration; the widget owns its DOM.
|
|
240
|
+
|
|
241
|
+
### 7.1 The widget vnode
|
|
242
|
+
|
|
243
|
+
A widget node is an element vnode with the **reserved tag
|
|
244
|
+
`"jaren-widget"`**:
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
["jaren-widget", {
|
|
248
|
+
"name": "virtual-grid",
|
|
249
|
+
"props": { "rows": "$.visibleRows", "rowHeight": 28 },
|
|
250
|
+
"tag": "div",
|
|
251
|
+
"key": "grid",
|
|
252
|
+
"class": "grid-host"
|
|
253
|
+
}]
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
- **`name`** (REQUIRED) — the registered widget name, a non-empty
|
|
257
|
+
string. An unregistered name is a configuration/producer error; the
|
|
258
|
+
reference renderer throws.
|
|
259
|
+
- **`props`** (OPTIONAL) — the widget's JSON props, any JSON value;
|
|
260
|
+
`null` when absent. Compared **by reference** across renders — the
|
|
261
|
+
widget-level reading of §5.1's sharing contract: with the JSLT `memo`
|
|
262
|
+
option, unchanged state yields reference-equal props, so an untouched
|
|
263
|
+
widget is never poked.
|
|
264
|
+
- **`tag`** (OPTIONAL) — the host element's tag, default `"div"`
|
|
265
|
+
(producers inside an `svg` subtree pick an SVG container like `"g"`).
|
|
266
|
+
- Every other prop (`key`, `class`, `style`, `on`, `id`, ...) applies
|
|
267
|
+
to the **host element** exactly as on any element vnode (§3, §4).
|
|
268
|
+
- A widget node MUST NOT have vnode children — the widget owns the
|
|
269
|
+
host's subtree and a patching renderer never descends into it.
|
|
270
|
+
Children present is a producer error; the reference renderer throws.
|
|
271
|
+
|
|
272
|
+
**Why this tag** (non-normative, load-bearing): `$widget` is impossible
|
|
273
|
+
— in query/JSLT rule bodies a string leaf starting with `$` is a path
|
|
274
|
+
expression (QUERY-FORMAT §3.2, `JQ0004`), so every view stylesheet
|
|
275
|
+
would need `$$widget` escapes. `jaren-widget` is a valid custom-element
|
|
276
|
+
name, so a renderer that predates this section degrades to an inert,
|
|
277
|
+
harmless element; and the dash makes collision with real HTML tags
|
|
278
|
+
impossible.
|
|
279
|
+
|
|
280
|
+
### 7.2 The widget definition
|
|
281
|
+
|
|
282
|
+
A widget is registered JavaScript with this shape:
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
{
|
|
286
|
+
mount(host, props, emit), // REQUIRED → returns a handle
|
|
287
|
+
update(handle, props, prevProps),// OPTIONAL
|
|
288
|
+
unmount(handle), // OPTIONAL cleanup
|
|
289
|
+
ssr(props), // OPTIONAL → a vnode for serialization
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
- **`mount`** MUST be called with the host element **after** it is
|
|
294
|
+
connected to the rendered tree (grids measure layout at mount). It
|
|
295
|
+
returns an opaque handle threaded to `update`/`unmount`.
|
|
296
|
+
- **`update`** is called when the vnode's `props` **reference**
|
|
297
|
+
changed. When `update` is absent and props changed, the renderer
|
|
298
|
+
MUST fall back to `unmount` + a fresh `mount` into the same host.
|
|
299
|
+
- **`unmount`** MUST be called exactly once when the widget leaves the
|
|
300
|
+
tree — including when an *ancestor* subtree is removed or replaced.
|
|
301
|
+
This is where timers, listeners and observers die.
|
|
302
|
+
- **`emit(binding, nativeEvent?)`** — the function `mount` receives. It
|
|
303
|
+
delivers `(binding, event)` **verbatim** to the environment's event
|
|
304
|
+
hook, the identical contract to §4. A widget never invents its own
|
|
305
|
+
action vocabulary: bindings arrive in its `props` (authored by the
|
|
306
|
+
view stylesheet), and a widget that must attach runtime data (the
|
|
307
|
+
clicked row id, the visible range) composes the binding it was given
|
|
308
|
+
with that data — in `@jarenjs/app` terms, merges it into `with`. The
|
|
309
|
+
single point of binding interpretation stays the layer above.
|
|
310
|
+
|
|
311
|
+
### 7.3 Registration and reconciliation
|
|
312
|
+
|
|
313
|
+
Widgets are registered on the renderer:
|
|
314
|
+
`createDomRenderer(container, { onEvent, widgets, document })`, with
|
|
315
|
+
`widgets` a `Record<string, WidgetDef>`; `renderToString` takes an
|
|
316
|
+
OPTIONAL `{ widgets }` (§7.4). Reconciliation semantics a patching
|
|
317
|
+
renderer MUST honor:
|
|
318
|
+
|
|
319
|
+
- **Create** — the host element is created from `tag`, the host props
|
|
320
|
+
are applied (`name`/`props`/`tag` configure the widget and never
|
|
321
|
+
reach the DOM; `on` wires exactly as in §4), and the mount is
|
|
322
|
+
deferred: mounts run **after the patch completes**, in document
|
|
323
|
+
order, when every host is connected. A mount MAY dispatch through
|
|
324
|
+
`emit` synchronously; environments batch as usual, and the render
|
|
325
|
+
boundary itself is serialized (§7.3.2).
|
|
326
|
+
- **Patch in place** — two widget vnodes with equal `key`: a changed
|
|
327
|
+
`name` or host `tag` is a replace (destroy the old widget, create
|
|
328
|
+
and mount the new one in a fresh host). Otherwise the host props
|
|
329
|
+
diff normally, the renderer never touches the host's `childNodes`,
|
|
330
|
+
and a changed `props` reference reaches the widget through `update`
|
|
331
|
+
(or the unmount/remount fallback of §7.2). A reference-equal `props`
|
|
332
|
+
MUST NOT call into the widget at all.
|
|
333
|
+
- **Destroy** — when a subtree containing widgets is removed or
|
|
334
|
+
replaced, the renderer MUST call each widget's `unmount(handle)`
|
|
335
|
+
exactly once, without descending into any widget's host subtree (the
|
|
336
|
+
widget's own DOM may contain anything). The observable contract is
|
|
337
|
+
**ownership-exactness**: cleanup MUST be independent of speculative
|
|
338
|
+
or uncommitted desired-vnode state and exact for every resource the
|
|
339
|
+
renderer actually acquired — a vnode is a description that may be
|
|
340
|
+
old, new, or (after a mid-pass `destroy()`) only partially
|
|
341
|
+
committed, so it cannot be the authority. The reference
|
|
342
|
+
implementation walks the live DOM by its widget-host markers; a live
|
|
343
|
+
acquisition registry or a precise partial-commit model conforms
|
|
344
|
+
equally, provided the exactness holds. A throwing `unmount` MUST NOT
|
|
345
|
+
prevent sibling widgets from unmounting: the walk finishes, then the
|
|
346
|
+
first error surfaces. The walk snapshots each renderer-owned child
|
|
347
|
+
list before invoking hooks, so an `unmount` that detaches its own
|
|
348
|
+
host cannot shift a sibling out of the cleanup — though detaching,
|
|
349
|
+
replacing or reparenting the renderer-owned host is OUTSIDE the
|
|
350
|
+
widget's boundary (the widget owns the host's *subtree*; the host
|
|
351
|
+
element and its position belong to the renderer), and hosts SHOULD
|
|
352
|
+
NOT rely on it beyond this cleanup hardening.
|
|
353
|
+
- **Hook failure** — a throwing `mount` or `update` **poisons** the
|
|
354
|
+
widget: sibling widgets and the frame still complete, the first
|
|
355
|
+
error surfaces after the frame settles, and the NEXT render MUST
|
|
356
|
+
replace it with a fresh lifecycle rather than keep patching it.
|
|
357
|
+
Recovery MUST NOT depend on vnode reference identity: a producer
|
|
358
|
+
that reuses the failed vnode (or a reference-equal ancestor — the
|
|
359
|
+
JSLT memo does exactly this for unaffected subtrees) still recovers,
|
|
360
|
+
so a conforming renderer suspends its `===` subtree fast path while
|
|
361
|
+
any widget is poisoned. A poisoned widget never receives further
|
|
362
|
+
`update` calls; `unmount` runs on the old instance only when its
|
|
363
|
+
`mount` had succeeded (a mount that threw acquired nothing). A
|
|
364
|
+
half-mounted handle receiving updates, or a widget left permanently
|
|
365
|
+
inert by structural sharing, is non-conforming.
|
|
366
|
+
|
|
367
|
+
### 7.3.1 Renderer destroy
|
|
368
|
+
|
|
369
|
+
`createDomRenderer` returns a render function carrying a
|
|
370
|
+
**`destroy()`** member — the terminal teardown:
|
|
371
|
+
|
|
372
|
+
- every mounted widget in the rendered tree unmounts **exactly once**;
|
|
373
|
+
widgets still queued for mount never mount;
|
|
374
|
+
- the container is left **empty** (`textContent = ''`);
|
|
375
|
+
- later `render` calls are exact no-ops (a scheduled flush racing a
|
|
376
|
+
teardown cannot resurrect the DOM);
|
|
377
|
+
- `destroy()` is idempotent;
|
|
378
|
+
- a throwing widget `unmount` MUST NOT stop the teardown: the walk
|
|
379
|
+
completes, then the first error surfaces — the same isolation rule
|
|
380
|
+
as §7.3's destroy walk.
|
|
381
|
+
|
|
382
|
+
A host that merely wants a widget-free tree still renders a widget-free
|
|
383
|
+
frame; `destroy()` is for ending the renderer's life (`app.destroy()`
|
|
384
|
+
calls it). Thrown-value policy: the renderer never interprets what a
|
|
385
|
+
widget hook throws — the first thrown value of a frame or teardown
|
|
386
|
+
surfaces BY IDENTITY, whatever it is (`null` and `undefined`
|
|
387
|
+
included; parked failures are presence records, so no thrown value
|
|
388
|
+
can read as "nothing was thrown"). The `onFrame` option reports frame
|
|
389
|
+
settlement (`'live'` or `'destroyed'`) once per top-level render pass,
|
|
390
|
+
BEFORE a parked hook error is delivered — the channel a host uses to
|
|
391
|
+
run committed-frame work (the app loop's `afterRender`) without being
|
|
392
|
+
starved by error delivery. Dual-failure settlement: the parked hook
|
|
393
|
+
failure is copied and cleared BEFORE the frame callback runs, so a
|
|
394
|
+
throwing callback can neither hide it nor push it into a later frame;
|
|
395
|
+
the callback is isolated, and when both fail the parked hook failure
|
|
396
|
+
is primary — a host that must not lose its own callback failure
|
|
397
|
+
isolates that callback itself, exactly as `createApp` does. EVERY
|
|
398
|
+
failure of one frame stays observable regardless of how many walks or
|
|
399
|
+
hooks produced it: the first surfaces by identity, and from the second
|
|
400
|
+
on the frame delivers one framework-owned `AggregateError` over the
|
|
401
|
+
originals in occurrence order — separate sibling removals included; a
|
|
402
|
+
host-thrown value (an `AggregateError` of the host's own included) is
|
|
403
|
+
stored untouched as one element, never inspected. Capability
|
|
404
|
+
ACQUISITION is part of every widget boundary: reading `update`,
|
|
405
|
+
`unmount` or `ssr` off a definition executes host code when the
|
|
406
|
+
definition uses accessors or proxies, so the read shares the boundary
|
|
407
|
+
and policy of the call — a hostile `update` lookup poisons like a
|
|
408
|
+
throwing update, a hostile `unmount` lookup collects like a throwing
|
|
409
|
+
unmount (siblings unmount, the container empties), and a hostile `ssr`
|
|
410
|
+
lookup propagates from `renderToString` exactly like a throwing
|
|
411
|
+
`ssr()` (serialization is pure and offers no isolation). A `destroy()` entered from inside a widget
|
|
412
|
+
hook or nested render is still terminal: the active pass stops **immediately** — no
|
|
413
|
+
later sibling observes another `mount` or `update` in that frame, and
|
|
414
|
+
the no-`update` recycle fallback MUST NOT begin its fresh `mount` when
|
|
415
|
+
its `unmount` requested the destroy (the ended acquisition is recorded
|
|
416
|
+
so teardown does not unmount it a second time), and the fallback's
|
|
417
|
+
failure handling is PHASE-SENSITIVE: a failure before the old
|
|
418
|
+
instance's `unmount` had its exactly-once chance (a hostile `unmount`
|
|
419
|
+
lookup) leaves the old acquisition OWNED — the widget poisons with
|
|
420
|
+
update-failure semantics so replacement or terminal destroy still
|
|
421
|
+
releases the resource; only a failure after the teardown attempt takes
|
|
422
|
+
mount-failure semantics (nothing is left to release) — and the teardown runs
|
|
423
|
+
as the pass unwinds. Because teardown drains the renderer's live
|
|
424
|
+
resources rather than pairing a vnode against the DOM, it is exact
|
|
425
|
+
even when the aborted pass had structurally diverged from the
|
|
426
|
+
committed tree (insertions, removals, replacements or keyed moves
|
|
427
|
+
before or after the destroying widget): every successfully mounted
|
|
428
|
+
instance — a mount hook that itself requested the destroy included —
|
|
429
|
+
unmounts exactly once, pending mounts are canceled, the container ends
|
|
430
|
+
empty even when an unmount throws, and no internal traversal error is
|
|
431
|
+
ever produced. Terminal-cleanup errors carry provenance: a host may
|
|
432
|
+
register `onCleanupError` to receive the first unmount error after
|
|
433
|
+
every sibling cleaned up (the app loop uses this to assign its stable
|
|
434
|
+
cleanup code), instead of having it surface indistinguishably from a
|
|
435
|
+
render failure.
|
|
436
|
+
|
|
437
|
+
### 7.3.2 Render serialization
|
|
438
|
+
|
|
439
|
+
The public render boundary MUST NOT re-enter itself. A `render` call
|
|
440
|
+
made synchronously from inside a widget `mount`/`update` or an event
|
|
441
|
+
callback (an `emit` chain) queues behind the running patch instead of
|
|
442
|
+
nesting; multiple nested requests **coalesce to the latest vnode**
|
|
443
|
+
(every call carries the full desired tree, so intermediate trees are
|
|
444
|
+
redundant); the queued tree applies after the current frame, against
|
|
445
|
+
the committed baseline. Consequences a conforming renderer exhibits:
|
|
446
|
+
|
|
447
|
+
- no `update` is delivered before the widget's `mount` has returned
|
|
448
|
+
its handle;
|
|
449
|
+
- every `update` receives the last committed props as its previous
|
|
450
|
+
props, never a stale outer value;
|
|
451
|
+
- the app-integrated path (`createApp`'s FIFO transaction queue) and
|
|
452
|
+
the direct renderer path observe the same ordering discipline: the
|
|
453
|
+
app queue serializes *dispatches*, the renderer serializes *frames*.
|
|
454
|
+
|
|
455
|
+
### 7.4 Serialization
|
|
456
|
+
|
|
457
|
+
`renderToString(vnode, { widgets })`: a widget node serializes its
|
|
458
|
+
host element with the host props (widget-local members excluded),
|
|
459
|
+
containing the serialized `ssr(props)` vnode when the widget is
|
|
460
|
+
registered and has `ssr`, else empty content. Serialization stays pure
|
|
461
|
+
— no state, no DOM, no widget is mounted; the client-side first render
|
|
462
|
+
mounts widgets as usual (§6).
|
|
463
|
+
|
|
464
|
+
## 8. The safe profile
|
|
465
|
+
|
|
466
|
+
The default renderers **trust** their input (§1.1): every prop writes
|
|
467
|
+
through, so a producer that owns its stylesheet can reach `innerHTML`, an
|
|
468
|
+
`on*` handler or any DOM property, exactly as it could writing the DOM by
|
|
469
|
+
hand. A vnode from a source you do **not** control — a tenant, a remote
|
|
470
|
+
service, a model — is different, and schema validity does not close the
|
|
471
|
+
gap. A renderer therefore MUST offer a **safe mode** for untrusted
|
|
472
|
+
documents, selected by a `safe` option on both the patching and
|
|
473
|
+
serializing renderers.
|
|
474
|
+
|
|
475
|
+
Under the safe profile a renderer MUST:
|
|
476
|
+
|
|
477
|
+
1. **Restrict tags to an allow-list** of inert HTML and SVG elements, and
|
|
478
|
+
MUST drop any element whose tag is not on it — including any tag whose
|
|
479
|
+
name is not a bare identifier (`^[A-Za-z][A-Za-z0-9-]*$`), which closes
|
|
480
|
+
structural injection through the tag. `script`, `iframe`, `object`,
|
|
481
|
+
`embed`, `style`, `link`, `meta`, `base`, `foreignObject` and other
|
|
482
|
+
document-embedding or script/style-carrying elements MUST NOT be on the
|
|
483
|
+
allow-list.
|
|
484
|
+
2. **Reject a property whose name is not a bare identifier** (closing
|
|
485
|
+
attribute-name injection), **whose name begins with `on`** (no inline
|
|
486
|
+
handlers), **which is an HTML-parsing sink** (`innerHTML`, `outerHTML`,
|
|
487
|
+
`srcdoc`, `insertAdjacentHTML`, `dangerouslySetInnerHTML`), or **which is
|
|
488
|
+
`is`** (it upgrades an element to a registered customized built-in when the
|
|
489
|
+
markup is parsed, running host code).
|
|
490
|
+
3. **Sanitize URL attributes** (`href`, `src`, `action`, ...) through a
|
|
491
|
+
deny-list that rejects `javascript:`, `vbscript:`, `file:` and
|
|
492
|
+
document-carrying `data:`, and **drop inline styles** carrying
|
|
493
|
+
`expression(` or a script-scheme `url()`.
|
|
494
|
+
4. **Strip `on` bindings and `jaren-widget` nodes**: an untrusted document
|
|
495
|
+
MUST NOT bind host actions or mount imperative code. Safe-mode views are
|
|
496
|
+
display-oriented.
|
|
497
|
+
|
|
498
|
+
A dropped element serializes to nothing and patches to an empty node; the
|
|
499
|
+
patching and serializing renderers MUST make the **same** decision for a
|
|
500
|
+
given node, so a document renders to the same safe result on the client
|
|
501
|
+
and the server. The reference implementation is one shared policy
|
|
502
|
+
(`createSafePolicy`) that both renderers call.
|
|
503
|
+
|
|
504
|
+
The companion schema
|
|
505
|
+
[`schemas/jaren-vnode-safe.schema.json`](../schemas/jaren-vnode-safe.schema.json)
|
|
506
|
+
expresses the structural half — the tag and property-name constraints —
|
|
507
|
+
as a validation-time gate. It cannot inspect a URL or style **value**, so
|
|
508
|
+
the runtime policy is authoritative; a host validating untrusted input
|
|
509
|
+
SHOULD do both.
|
|
510
|
+
|
|
511
|
+
Safe mode reduces an untrusted view to a display; it is **not a complete
|
|
512
|
+
sandbox**. The tag allow-list still admits anchors, forms, controls and media,
|
|
513
|
+
so native navigation, form submission, focus and network loads remain
|
|
514
|
+
possible. It is also a **renderer** policy: a host embedding it in a larger
|
|
515
|
+
runtime MUST NOT assume that runtime inherits it (`@jarenjs/app`, for
|
|
516
|
+
instance, does not forward `safe`, and an app document names host actions and
|
|
517
|
+
effects — so a safe *view* does not make an untrusted *app document* safe).
|
|
518
|
+
|
|
519
|
+
Out of scope for this version, and NOT to be assumed: caret/IME fidelity under
|
|
520
|
+
safe rewrites across browser engines, MathML, `multiple`-select and
|
|
521
|
+
composition behavior proven in all three engines, and a Trusted Types
|
|
522
|
+
integration. Safe mode is one layer under a Content-Security-Policy, not a
|
|
523
|
+
substitute for one.
|
|
524
|
+
|
|
525
|
+
## 9. Open items (roadmap, non-normative)
|
|
526
|
+
|
|
527
|
+
- **Fragment / multi-root documents** — a list at the root.
|
|
528
|
+
- **DOM-adopting hydration** (§6).
|
|
529
|
+
- ~~Memoized rule outputs~~ — **shipped**: the JSLT engine's `memo`
|
|
530
|
+
option (on by default in `@jarenjs/app`) caches rule outputs by
|
|
531
|
+
(location, value reference) with compile-time eligibility analysis,
|
|
532
|
+
so unchanged *state* yields reference-equal *vnodes* across frames
|
|
533
|
+
and §5.1 fires for whole branches.
|
|
534
|
+
- ~~Component escape hatch~~ — **shipped**: the registered-widget
|
|
535
|
+
vocabulary of §7.
|
|
536
|
+
- ~~A renderer `destroy()`~~ — **shipped**: §7.3.1.
|
|
537
|
+
- **A `properties`-vs-`attributes` normative table** replacing the
|
|
538
|
+
`name in node` heuristic of §3.
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jarenjs/view",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.34.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./dist/types/index.d.ts",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/types/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./helpers": {
|
|
15
|
+
"types": "./dist/types/helpers/index.d.ts",
|
|
16
|
+
"default": "./src/helpers/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./helpers/*": {
|
|
19
|
+
"types": "./dist/types/helpers/*.d.ts",
|
|
20
|
+
"default": "./src/helpers/*.js"
|
|
21
|
+
},
|
|
22
|
+
"./schemas/*": "./schemas/*",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist/types/",
|
|
27
|
+
"src/",
|
|
28
|
+
"docs/",
|
|
29
|
+
"schemas/"
|
|
30
|
+
],
|
|
31
|
+
"description": "The Jaren vnode format: user interfaces as JSON documents, with a keyed DOM patcher and an SSR string renderer",
|
|
32
|
+
"author": "joham",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/jklarenbeek/jarenjs.git",
|
|
36
|
+
"directory": "packages/view"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=24"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"registry": "https://registry.npmjs.org/"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"jaren",
|
|
48
|
+
"json",
|
|
49
|
+
"vnode",
|
|
50
|
+
"vdom",
|
|
51
|
+
"view",
|
|
52
|
+
"renderer",
|
|
53
|
+
"ssr"
|
|
54
|
+
],
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@jarenjs/core": "^0.34.0"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "npm run build:types",
|
|
60
|
+
"build:types": "tsc -p tsconfig.json",
|
|
61
|
+
"prepack": "npm run build:types"
|
|
62
|
+
}
|
|
63
|
+
}
|