@jarenjs/app 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 +197 -0
- package/dist/types/actions.d.ts +89 -0
- package/dist/types/app.d.ts +293 -0
- package/dist/types/diagnostics.d.ts +67 -0
- package/dist/types/docstore.d.ts +52 -0
- package/dist/types/errors.d.ts +178 -0
- package/dist/types/focus.d.ts +89 -0
- package/dist/types/forms.d.ts +122 -0
- package/dist/types/index.d.ts +14 -0
- package/dist/types/splitter.d.ts +37 -0
- package/dist/types/tasks.d.ts +129 -0
- package/docs/APP-FORMAT.md +804 -0
- package/docs/TASKS.md +254 -0
- package/package.json +57 -0
- package/schemas/jaren-app.draft-07.schema.json +86 -0
- package/schemas/jaren-app.schema.json +86 -0
- package/src/actions.js +167 -0
- package/src/app.js +1622 -0
- package/src/diagnostics.js +77 -0
- package/src/docstore.js +71 -0
- package/src/errors.js +245 -0
- package/src/focus.js +188 -0
- package/src/forms.js +325 -0
- package/src/index.js +16 -0
- package/src/splitter.js +113 -0
- package/src/tasks.js +299 -0
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
# The Jaren App Format
|
|
2
|
+
|
|
3
|
+
**Version 0.1 — Specification**
|
|
4
|
+
|
|
5
|
+
Module: `@jarenjs/app`. This document is the contract for the Jaren
|
|
6
|
+
**app document** — a complete interactive application as one JSON value
|
|
7
|
+
— and for the loop that runs it. It builds directly on three published
|
|
8
|
+
contracts: [QUERY-FORMAT](../../json/docs/QUERY-FORMAT.md) (action and
|
|
9
|
+
`when` documents), [JSLT-FORMAT](../../json/docs/JSLT-FORMAT.md) (the
|
|
10
|
+
view stylesheet) and [VIEW-FORMAT](../../view/docs/VIEW-FORMAT.md) (the
|
|
11
|
+
vnode output vocabulary and renderer behavior).
|
|
12
|
+
|
|
13
|
+
## 1. Introduction
|
|
14
|
+
|
|
15
|
+
### 1.1 The loop
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
event → binding (§4) → action document (§3) → transition (§3.2)
|
|
19
|
+
→ invariant check (§6) → next state → subscriptions refresh (§5.3)
|
|
20
|
+
→ batched re-render (view stylesheet → vnodes → keyed DOM patch)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Everything the loop executes is compiled once, when the app document is
|
|
24
|
+
loaded. JavaScript participates only at **named, registered
|
|
25
|
+
boundaries**: effect handlers, subscription handlers, event-field
|
|
26
|
+
extractors (§5.4), registered widgets (§5.5), the `compileTypeTest`
|
|
27
|
+
hook and the `validateState` hook. Between the boundaries, everything
|
|
28
|
+
is data.
|
|
29
|
+
|
|
30
|
+
### 1.2 Conformance and normative language
|
|
31
|
+
|
|
32
|
+
The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**,
|
|
33
|
+
**MAY**, and **OPTIONAL** are to be interpreted as described in
|
|
34
|
+
RFC 2119. A **producer** emits app documents and MUST emit documents
|
|
35
|
+
valid per §2–§5. A **runtime** (the reference implementation is
|
|
36
|
+
`createApp`) MUST reject invalid documents with the compile errors of
|
|
37
|
+
§10, MUST raise the runtime errors of §10 under the conditions
|
|
38
|
+
specified there, and MUST serialize dispatches per the transaction
|
|
39
|
+
model of §8.
|
|
40
|
+
|
|
41
|
+
## 2. The app document
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"$app": "0.1",
|
|
46
|
+
"state": { },
|
|
47
|
+
"view": [ ],
|
|
48
|
+
"actions": { },
|
|
49
|
+
"subs": [ ]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **`$app`** (OPTIONAL) — the format version. Absent implies `"0.1"`.
|
|
54
|
+
- **`state`** (OPTIONAL) — the initial state, any JSON value. Absent
|
|
55
|
+
means the state starts `undefined`; a document SHOULD provide it.
|
|
56
|
+
The state MUST be treated as immutable by every boundary function.
|
|
57
|
+
- **`view`** (REQUIRED) — a JSLT stylesheet document (bare rule array
|
|
58
|
+
or envelope form), compiled per JSLT-FORMAT. Each render evaluates it
|
|
59
|
+
against the current state; its output MUST be a single text or
|
|
60
|
+
element vnode.
|
|
61
|
+
- **`actions`** (OPTIONAL) — an object mapping action names to action
|
|
62
|
+
documents (§3).
|
|
63
|
+
- **`subs`** (OPTIONAL) — an array of subscription entries (§5.2).
|
|
64
|
+
|
|
65
|
+
Unknown top-level members MUST be ignored (forward compatibility).
|
|
66
|
+
|
|
67
|
+
## 3. Actions
|
|
68
|
+
|
|
69
|
+
### 3.1 The action document
|
|
70
|
+
|
|
71
|
+
An action is a Jaren JSON Query document evaluated with:
|
|
72
|
+
|
|
73
|
+
- **`$`** — the current state (the whole document; cross-cutting
|
|
74
|
+
transitions are the point, exactly as in `x-form` rules);
|
|
75
|
+
- **`$event`** — when the dispatch originated from a DOM event, a
|
|
76
|
+
serializable slice of it (below); otherwise `null`;
|
|
77
|
+
- **`$payload`** — the dispatch payload (§4); `null` when absent.
|
|
78
|
+
|
|
79
|
+
These three names are the whole ambient vocabulary. The runtime takes
|
|
80
|
+
the query's **first** result (`query.first` semantics).
|
|
81
|
+
|
|
82
|
+
For a DOM dispatch, `$event` is the default slice
|
|
83
|
+
`{ "type", "value", "checked", "key" }` **plus** one member per field
|
|
84
|
+
name the binding requested (§4's `event` member). Each requested name
|
|
85
|
+
is resolved in precedence order:
|
|
86
|
+
|
|
87
|
+
1. a host extractor registered under that name
|
|
88
|
+
(`options.eventFields`, §5.4) — the host wins, so a host can also
|
|
89
|
+
*override* a built-in;
|
|
90
|
+
2. the built-in allow-list below;
|
|
91
|
+
3. neither — the member is bound `null` and `JA2009` is reported,
|
|
92
|
+
**without dropping the dispatch**: a typo in one field name MUST NOT
|
|
93
|
+
swallow the user's event (contrast §4's `JA2001`, where the whole
|
|
94
|
+
binding is unusable).
|
|
95
|
+
|
|
96
|
+
The built-in allow-list — every entry is a JSON primitive by
|
|
97
|
+
construction, `undefined` coerced to `null`:
|
|
98
|
+
|
|
99
|
+
- read from the event: `shiftKey`, `ctrlKey`, `altKey`, `metaKey`,
|
|
100
|
+
`button`, `buttons`, `clientX`, `clientY`, `offsetX`, `offsetY`,
|
|
101
|
+
`pageX`, `pageY`, `screenX`, `screenY`, `movementX`, `movementY`,
|
|
102
|
+
`deltaX`, `deltaY`, `deltaMode`, `code`, `repeat`, `location`,
|
|
103
|
+
`isComposing`, `detail`, `pointerId`, `pointerType`, `pressure`,
|
|
104
|
+
`isPrimary`;
|
|
105
|
+
- read from `event.target`: `selectionStart`, `selectionEnd`.
|
|
106
|
+
|
|
107
|
+
`target` itself, `files`, touch lists, and every other
|
|
108
|
+
host-object-valued field are deliberately excluded: host objects never
|
|
109
|
+
enter `$event` — it MUST survive `JSON.stringify`, the same invariant
|
|
110
|
+
as state. When a host needs data only such an object can provide (file
|
|
111
|
+
selections, say), it registers an extractor that maps the object to a
|
|
112
|
+
JSON value at the boundary (§5.4).
|
|
113
|
+
|
|
114
|
+
### 3.2 The transition object
|
|
115
|
+
|
|
116
|
+
An action's result MUST be nothing (empty sequence, `null` — a no-op)
|
|
117
|
+
or a **transition object** with any of:
|
|
118
|
+
|
|
119
|
+
- **`state`** — the next state, whole. Replaces the state.
|
|
120
|
+
- **`patch`** — an RFC 6902 JSON Patch, applied copy-on-write to the
|
|
121
|
+
state (after `state`, when both are present). Because the action is a
|
|
122
|
+
query document, op members like `value` and `path` are themselves
|
|
123
|
+
query expressions — computed at dispatch time from `$`, `$event` and
|
|
124
|
+
`$payload`.
|
|
125
|
+
- **`effects`** — an array of effect invocations (§5.1), run after the
|
|
126
|
+
state settles, in order, regardless of whether the state changed.
|
|
127
|
+
|
|
128
|
+
Order of application: `state`, then `patch`, then the invariant check
|
|
129
|
+
(§6), then `effects`, then (if the state reference changed)
|
|
130
|
+
subscription refresh and render scheduling. A failing `patch` aborts
|
|
131
|
+
the whole transition (`JA2004`): the copy-on-write engine guarantees
|
|
132
|
+
the state was never touched.
|
|
133
|
+
|
|
134
|
+
Change detection is **reference identity**: a transition whose result
|
|
135
|
+
is `===` the current state changes nothing, schedules nothing. The
|
|
136
|
+
copy-on-write patch engine and JSLT's structural sharing make reference
|
|
137
|
+
inequality mean real change, end to end — this is the same contract
|
|
138
|
+
VIEW-FORMAT §5.1 builds its fast path on.
|
|
139
|
+
|
|
140
|
+
One exception keeps controlled inputs correct: a transaction whose
|
|
141
|
+
**source is a DOM event** always schedules a **settlement** render, even
|
|
142
|
+
when it changes nothing (a no-op, a rejected or failed action, an
|
|
143
|
+
effects-only outcome). A user keystroke can move a controlled input off
|
|
144
|
+
authoritative state, and without a render the control would keep the
|
|
145
|
+
user's value; the settlement render lets VIEW-FORMAT §3 reassert it. It
|
|
146
|
+
is cheap — unchanged state re-projects to a reference-equal vnode the
|
|
147
|
+
patcher skips whole, leaving only the controlled reconciliation. A
|
|
148
|
+
programmatic `dispatch` is not a DOM event and does not force it.
|
|
149
|
+
|
|
150
|
+
A patch-only transition additionally yields its **changed paths**: the
|
|
151
|
+
runtime applies the patch with the engine's `changes` option and hands
|
|
152
|
+
the resulting JSON Pointers (invalidation-sound semantics, see the
|
|
153
|
+
`@jarenjs/json/patch` documentation) to state subscribers —
|
|
154
|
+
`listener(state, changes)` — with `changes = null` for whole-state
|
|
155
|
+
transitions, meaning "treat everything as changed". Dirty-path-pruned
|
|
156
|
+
re-rendering builds on this feed (roadmap); today the runtime itself
|
|
157
|
+
still re-runs the full view stylesheet per frame.
|
|
158
|
+
|
|
159
|
+
## 4. Event bindings
|
|
160
|
+
|
|
161
|
+
A vnode `on` binding (opaque to the view layer) is interpreted by this
|
|
162
|
+
format as either:
|
|
163
|
+
|
|
164
|
+
- a **string** — an action name; `$payload` is `null`; or
|
|
165
|
+
- an object **`{ "action": name, "with"?: payload, "event"?:
|
|
166
|
+
[fieldName, ...], "preventDefault"?: bool, "stopPropagation"?:
|
|
167
|
+
bool }`** — `$payload` is the `with` value, verbatim, and `event`,
|
|
168
|
+
when present, MUST be an array of strings naming the extra `$event`
|
|
169
|
+
fields to resolve (§3.1). The string binding form carries none of
|
|
170
|
+
the optional members.
|
|
171
|
+
|
|
172
|
+
The two **native controls** default to `false` and are allowed only on
|
|
173
|
+
the object form. When declared `true` **and the named action is
|
|
174
|
+
registered**, `event.preventDefault()` / `event.stopPropagation()` run
|
|
175
|
+
**synchronously in the native event callback**, before the action is
|
|
176
|
+
queued (§8) and before the browser can perform its default or bubble
|
|
177
|
+
behavior — a checkbox, link or button nested inside a clickable row is
|
|
178
|
+
expressible declaratively. Only a registered action owns the native
|
|
179
|
+
behavior: an unknown action name suppresses nothing (the dispatch
|
|
180
|
+
still queues and reports `JA2001`). A registered action that later
|
|
181
|
+
fails keeps its already-applied controls — whether the action succeeds
|
|
182
|
+
or fails cannot retroactively change an already-performed native
|
|
183
|
+
control. A widget's `emit(binding,
|
|
184
|
+
nativeEvent)` follows the identical path. Headless dispatch with an
|
|
185
|
+
event object lacking the methods is a documented no-op.
|
|
186
|
+
|
|
187
|
+
`$event` extraction runs synchronously at dispatch time too: the
|
|
188
|
+
native event is reduced to plain JSON before the transaction enters
|
|
189
|
+
the queue, so a recycled event object can never corrupt a queued
|
|
190
|
+
dispatch. Requesting a **default member** (`type`/`value`/`checked`/
|
|
191
|
+
`key`) never overwrites it — it is already bound; a registered
|
|
192
|
+
extractor still wins, the host chose to redefine it. A requested
|
|
193
|
+
`__proto__`/`constructor` member binds as an ordinary own data
|
|
194
|
+
property and never mutates a prototype. Registered extractors are
|
|
195
|
+
trusted host code and MUST return JSON.
|
|
196
|
+
|
|
197
|
+
Payloads are built at *render* time by the view stylesheet: a rule
|
|
198
|
+
body may embed `"$path"`, the matched value, or anything in scope
|
|
199
|
+
inside the binding. Combined with §3's `$event`, this replaces both of
|
|
200
|
+
hyperapp's payload-creator forms with data. Extraction requests are
|
|
201
|
+
render-time data the same way: a JSLT rule builds them, so they are
|
|
202
|
+
serializable, schema-checkable and replayable.
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
["tr", { "on": { "click": {
|
|
206
|
+
"action": "selectRow",
|
|
207
|
+
"with": { "id": "$.id" },
|
|
208
|
+
"event": ["shiftKey", "ctrlKey", "metaKey"]
|
|
209
|
+
} } }, "…"]
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The action then branches on `$event.shiftKey` to extend a selection
|
|
213
|
+
range instead of replacing it.
|
|
214
|
+
|
|
215
|
+
Anything else dispatched as a binding — including an object whose
|
|
216
|
+
`event` member is present but not an array of strings — is a producer
|
|
217
|
+
error (`JA2001` at runtime): the binding is unusable and the dispatch
|
|
218
|
+
is dropped.
|
|
219
|
+
|
|
220
|
+
## 5. The boundaries
|
|
221
|
+
|
|
222
|
+
### 5.1 Effects
|
|
223
|
+
|
|
224
|
+
An effect invocation is `{ "run": name, "with"?: props }`. The runtime
|
|
225
|
+
resolves `name` in the registered effect handlers
|
|
226
|
+
(`options.effects[name]`) and calls `handler(props ?? null, dispatch)`.
|
|
227
|
+
Effects are fire-and-forget from the loop's perspective; asynchronous
|
|
228
|
+
completion re-enters through `dispatch`. An unregistered name is
|
|
229
|
+
`JA2006`; a throwing handler is `JA2007`; neither aborts the loop or
|
|
230
|
+
the remaining effects. For asynchronous work, the shipped task
|
|
231
|
+
convention — request identity, stale-response rejection, cancellation,
|
|
232
|
+
and the `createTaskEffect` helper — is documented in
|
|
233
|
+
[TASKS.md](TASKS.md).
|
|
234
|
+
|
|
235
|
+
### 5.2 The derivation boundary
|
|
236
|
+
|
|
237
|
+
`options.viewModel` (OPTIONAL) maps the state to the view stylesheet's
|
|
238
|
+
input document before every render; default identity. It MUST be pure
|
|
239
|
+
(state in, document out, no dispatching) and is where JS-computed
|
|
240
|
+
derivations — `buildFormViewModel` from `@jarenjs/forms`, aggregations
|
|
241
|
+
the query language cannot express, memoized joins — enter the render
|
|
242
|
+
path without entering the state. This is the app-level generalization
|
|
243
|
+
of forms' layer-2 rule evaluation, kept at a boundary for the same
|
|
244
|
+
reason: derivations are recomputed, never dispatched.
|
|
245
|
+
|
|
246
|
+
### 5.3 Subscriptions
|
|
247
|
+
|
|
248
|
+
A subscription entry is `{ "run": name, "with"?: props, "when"?:
|
|
249
|
+
query, "withQuery"?: query, "key"?: query, "for"?: query }`. After
|
|
250
|
+
boot and after every state change, the runtime evaluates each entry's
|
|
251
|
+
`when` by **effective boolean value** against the current state (no
|
|
252
|
+
externals) and reconciles:
|
|
253
|
+
|
|
254
|
+
- newly live → `cleanup = handler(props, dispatch)`;
|
|
255
|
+
- newly dead → `cleanup()` if the handler returned one.
|
|
256
|
+
|
|
257
|
+
An absent `when` means always live while the app runs; `stop()` kills
|
|
258
|
+
all. Error policy, the mirror image of forms' `visible` rule: a
|
|
259
|
+
broken `when` fails **closed** — the subscription stops and the error
|
|
260
|
+
is reported — because a broken rule must never keep side effects
|
|
261
|
+
alive. An unregistered `run` name is `JA2008`, reported each time the
|
|
262
|
+
entry would start.
|
|
263
|
+
|
|
264
|
+
**Static versus dynamic props.** `with` is verbatim data — never
|
|
265
|
+
evaluated, and a static entry never restarts (the original contract,
|
|
266
|
+
preserved exactly). `withQuery` makes the entry DYNAMIC: a query
|
|
267
|
+
compiled like `when` and evaluated against the state per
|
|
268
|
+
reconciliation; its result (empty → `null`) is the handler's props. The
|
|
269
|
+
two are mutually exclusive (`JA0008`), because a member that is
|
|
270
|
+
sometimes data and sometimes executable is how a document becomes
|
|
271
|
+
accidentally executable.
|
|
272
|
+
|
|
273
|
+
**The restart rule.** A live dynamic subscription restarts — stop,
|
|
274
|
+
then start with the new props, within one reconciliation — exactly
|
|
275
|
+
when its **key** changes. The key derives from the resolved props BY
|
|
276
|
+
VALUE (`stableStringify`; structurally equal props share a key
|
|
277
|
+
regardless of member order), or from the explicit `key` query when
|
|
278
|
+
recomputing a deep key per transaction is worth opting out of
|
|
279
|
+
(`key` without `withQuery` or `for` is `JA0008`). A cyclic resolved
|
|
280
|
+
value cannot key and fails closed (`JA2016`) rather than hanging. An
|
|
281
|
+
unchanged key performs zero handler calls. A throwing cleanup is
|
|
282
|
+
isolated (`JA2012`) and never prevents the restart's start half; a
|
|
283
|
+
throwing start leaves the slot stopped (`JA2013`) until the next key
|
|
284
|
+
change retries it. This is the same "key plus supersede policy" shape
|
|
285
|
+
`createTaskEffect` (§9) models for effects — a reader who knows one
|
|
286
|
+
knows the other.
|
|
287
|
+
|
|
288
|
+
**Fan-out.** `for` names a query whose result is the entry's ITEM set
|
|
289
|
+
(one array value fans out over its elements; a single item over
|
|
290
|
+
itself; empty over none), and the runtime maintains **one instance per
|
|
291
|
+
item key** — `stableStringify(item)`, or the `key` query with `$item`
|
|
292
|
+
bound. `withQuery` (also with `$item`) shapes per-instance props;
|
|
293
|
+
without it the props ARE the item; `with` beside `for` is `JA0008`.
|
|
294
|
+
Reconciliation is deterministic: removed instances stop in their
|
|
295
|
+
previous order, then added — and changed, by resolved props value —
|
|
296
|
+
instances start in the document order of the item sequence; duplicate
|
|
297
|
+
keys collapse to the first occurrence. A resolution failure fails the
|
|
298
|
+
whole declaration closed (`JA2016`, naming the member). Exceeding
|
|
299
|
+
`maxSubInstances` (a `createApp` option, default 256) is `JA2017`, and
|
|
300
|
+
the previous instance set is KEPT — the bound is printed, never
|
|
301
|
+
silent.
|
|
302
|
+
|
|
303
|
+
**Scope.** Dynamic members are compiled closed-world: the only
|
|
304
|
+
external a document may reference is `$item`, and only under `for` —
|
|
305
|
+
any other free variable is `JA0008` at compile time. Reconciliation
|
|
306
|
+
runs when the state changed, which is precisely correct for
|
|
307
|
+
state-derived keys and props; a key derived from anything outside the
|
|
308
|
+
state is NOT supported, and no member sees the DOM, the clock or the
|
|
309
|
+
host.
|
|
310
|
+
|
|
311
|
+
### 5.4 Event-field extractors
|
|
312
|
+
|
|
313
|
+
`options.eventFields` (OPTIONAL) is a `Record<string, (nativeEvent:
|
|
314
|
+
any) => any>` of named JavaScript extractors — the same philosophy as
|
|
315
|
+
effects and subscriptions: JavaScript enters only at named, registered
|
|
316
|
+
boundaries. When a binding requests a field (§4), an extractor
|
|
317
|
+
registered under that name takes precedence over the built-in
|
|
318
|
+
allow-list (§3.1). The extractor receives the native event and MUST
|
|
319
|
+
return a JSON value; `undefined` is coerced to `null`. An extractor
|
|
320
|
+
that throws surfaces as the dispatching action's `JA2002`.
|
|
321
|
+
|
|
322
|
+
This is the escape hatch for everything the allow-list deliberately
|
|
323
|
+
cannot serialize. The worked example: a `"fileTokens"` extractor that
|
|
324
|
+
stows `event.target.files` in a host-side registry and returns opaque
|
|
325
|
+
string tokens, so a form can dispatch a file selection without a
|
|
326
|
+
`File` object ever entering `$event` or the state:
|
|
327
|
+
|
|
328
|
+
```javascript
|
|
329
|
+
// Token identity is MONOTONIC (or a UUID): a registry key is never
|
|
330
|
+
// reused, so a stale token can never rebind to a different File.
|
|
331
|
+
let nextFileToken = 1;
|
|
332
|
+
const fileRegistry = new Map();
|
|
333
|
+
createApp(doc, {
|
|
334
|
+
node,
|
|
335
|
+
eventFields: {
|
|
336
|
+
fileTokens: (event) =>
|
|
337
|
+
Array.from(event.target?.files ?? [], (file) => {
|
|
338
|
+
const token = `file:${nextFileToken++}`;
|
|
339
|
+
fileRegistry.set(token, file);
|
|
340
|
+
return token;
|
|
341
|
+
}),
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
A binding `{ "action": "pickFiles", "event": ["fileTokens"] }` then
|
|
347
|
+
binds `$event.fileTokens` to `["file:1", ...]` — JSON all the way —
|
|
348
|
+
and an upload effect later redeems the tokens at the boundary.
|
|
349
|
+
|
|
350
|
+
The registry the host builds around those tokens needs an explicit
|
|
351
|
+
lifecycle, because the tokens in state outlive the objects they name:
|
|
352
|
+
|
|
353
|
+
- **identity** — monotonic or UUID, never derived from the registry's
|
|
354
|
+
current size; a token is never rebound to a different `File`;
|
|
355
|
+
- **per-attempt identity** — each upload attempt gets its own request
|
|
356
|
+
id; a failed or canceled attempt may retain the same session-owned
|
|
357
|
+
`File` for an explicit retry;
|
|
358
|
+
- **consume** — a successfully committed file is removed from the
|
|
359
|
+
registry (and its object URLs revoked);
|
|
360
|
+
- **discard/revoke** — canceling the selection, closing the owning
|
|
361
|
+
route/session, or `app.destroy()` revokes every remaining token;
|
|
362
|
+
- **expiry** — a bounded retention policy, so an abandoned selection
|
|
363
|
+
cannot hold file handles forever; a redeemed-but-expired token MUST
|
|
364
|
+
fail safely (a structured error), never select another file.
|
|
365
|
+
|
|
366
|
+
### 5.5 Widgets
|
|
367
|
+
|
|
368
|
+
`options.widgets` (OPTIONAL) is a `Record<string, WidgetDef>` of
|
|
369
|
+
registered widget definitions for `jaren-widget` vnodes — JavaScript
|
|
370
|
+
enters only at named, registered boundaries, the same sentence shape
|
|
371
|
+
as effects and subs. The mechanism lives entirely in `@jarenjs/view`
|
|
372
|
+
(the vocabulary, lifecycle and reconciliation semantics are
|
|
373
|
+
VIEW-FORMAT §7); `createApp` only forwards the registry to the
|
|
374
|
+
renderer it creates — the dependency arrow stays one-way. A widget's
|
|
375
|
+
`emit` delivers ordinary §4 bindings, so widget events dispatch
|
|
376
|
+
through the same `handleBinding` path as any DOM event — including
|
|
377
|
+
§4's `event` extraction member.
|
|
378
|
+
|
|
379
|
+
## 6. Invariants
|
|
380
|
+
|
|
381
|
+
When `options.validateState` is present, every candidate next state
|
|
382
|
+
that differs (by reference) from the current state is passed to it. A
|
|
383
|
+
rejection — `false`, or an object with `valid: false` — blocks the
|
|
384
|
+
transition entirely (**fail closed**, matching `x-form.assert`): the
|
|
385
|
+
state does not change, no effects run, and `JA2005` is reported with
|
|
386
|
+
the hook's `errors` in `detail`.
|
|
387
|
+
|
|
388
|
+
The intended hook is a compiled `@jarenjs/validate` schema — including
|
|
389
|
+
`$query` cross-field assertions, so generated or replayed transitions
|
|
390
|
+
are checked for internal consistency, not just shape. The app package
|
|
391
|
+
never imports the validator; like forms, the application holds the key
|
|
392
|
+
(`compileTypeTest` for schema operators inside documents,
|
|
393
|
+
`validateState` for the authoritative check).
|
|
394
|
+
|
|
395
|
+
## 7. Serialization, replay, SSR (non-normative)
|
|
396
|
+
|
|
397
|
+
Because state, view, actions and subs are one JSON value and every
|
|
398
|
+
dispatch is `(name, payload)` — also JSON — the following are
|
|
399
|
+
compositions, not features: state snapshots, action logs, time-travel
|
|
400
|
+
(replay the log through a fresh `createApp` — with the LIVE effect and
|
|
401
|
+
subscription handlers stubbed or replaced, since replaying a log
|
|
402
|
+
re-runs its transitions but must not re-fire real side effects;
|
|
403
|
+
replaying recorded effect *outcomes* is an open follow-up, not a
|
|
404
|
+
shipped capability), server rendering
|
|
405
|
+
(`renderToString(app.getVnode())`), and remote mutation (ship a
|
|
406
|
+
transition's `patch` over the wire). Runtimes SHOULD keep it that way:
|
|
407
|
+
any extension that puts a function in the document breaks the format's
|
|
408
|
+
core property.
|
|
409
|
+
|
|
410
|
+
## 8. The transaction model
|
|
411
|
+
|
|
412
|
+
### 8.1 One FIFO queue
|
|
413
|
+
|
|
414
|
+
Every dispatch is one **transaction** on one FIFO queue; only the
|
|
415
|
+
queue drain evaluates and applies transitions. A dispatch made from an
|
|
416
|
+
effect handler, a state listener, a transaction observer, a
|
|
417
|
+
subscription handler or callback, a widget `emit`, or any lifecycle
|
|
418
|
+
hook **queues behind the current transaction** — dispatches never
|
|
419
|
+
nest. `setState` (§8.5) is a transaction on the same queue, so nothing
|
|
420
|
+
that changes state runs outside it. Within a transaction the order is
|
|
421
|
+
fixed:
|
|
422
|
+
|
|
423
|
+
1. action evaluation and state commit;
|
|
424
|
+
2. effect invocation;
|
|
425
|
+
3. listener notification (registration order — every listener observes
|
|
426
|
+
every transaction in the same order, with the state produced by
|
|
427
|
+
exactly that transaction and its changed paths);
|
|
428
|
+
4. subscription reconciliation;
|
|
429
|
+
5. render scheduling;
|
|
430
|
+
6. observer notification (§8.3).
|
|
431
|
+
|
|
432
|
+
All six complete before the next transaction begins. Errors from
|
|
433
|
+
isolated sites — listeners (`JA2011`), observers (`JA2011`), cleanups
|
|
434
|
+
(`JA2012`) — are reported through `onError` and never corrupt the
|
|
435
|
+
queue: whatever the sink throws surfaces to the outermost dispatch
|
|
436
|
+
caller only after the drain fully completed. A runaway
|
|
437
|
+
action→effect→action loop is diagnosed as `JA2010` after
|
|
438
|
+
`options.maxTurns` transactions (default 1000) instead of hanging;
|
|
439
|
+
`maxTurns` MUST be a positive finite integer — `createApp` rejects
|
|
440
|
+
anything else (a guard that silently coerces is no guard).
|
|
441
|
+
|
|
442
|
+
`validateState` is host code and may itself fail: a throwing validator
|
|
443
|
+
is isolated as `JA2015` — the transaction fails (its observer record
|
|
444
|
+
carries the code), the original cause is preserved, and the queue
|
|
445
|
+
keeps draining; parked errors from the default rethrowing sink surface
|
|
446
|
+
only after the drain.
|
|
447
|
+
|
|
448
|
+
`validateState` receives a second argument, the transaction context
|
|
449
|
+
`{ previous, action, payload, changes }` — `changes` is the patch
|
|
450
|
+
engine's changed-pointer list, or `null` meaning *unknown: validate
|
|
451
|
+
fully*. Selective validation keyed on `changes` is sound only when the
|
|
452
|
+
hook falls back to a full check for `null`.
|
|
453
|
+
|
|
454
|
+
### 8.2 Boot, stop, destroy
|
|
455
|
+
|
|
456
|
+
**Boot is a transaction.** Compiling the documents, creating the
|
|
457
|
+
renderer, validating the initial state, starting the initial
|
|
458
|
+
subscriptions, painting the first frame and draining the dispatches
|
|
459
|
+
those handlers queued either all succeed, or every already-acquired
|
|
460
|
+
resource — subscriptions, effect handlers, the renderer (the container
|
|
461
|
+
ends empty, scheduled work becomes a no-op) — is disposed and
|
|
462
|
+
`createApp` throws `JA0007` with the original failure as `cause`.
|
|
463
|
+
`onError` observes individual boot failures first: a sink that
|
|
464
|
+
swallows a subscription-start failure (`JA2013`), the initial-state
|
|
465
|
+
check (`JA2005`/`JA2015` with the boot context `{ previous: null,
|
|
466
|
+
action: null, payload: null, changes: null }`) or an error inside
|
|
467
|
+
queued boot work recovers it and boots the rest; renderer-construction
|
|
468
|
+
and first-frame failures are always fatal; the default rethrowing sink
|
|
469
|
+
aborts boot on any of them. No failure path leaves a live
|
|
470
|
+
subscription, a populated container or an unreturned app handle
|
|
471
|
+
behind. Boot is SCHEDULER-INDEPENDENT: every frame the boot
|
|
472
|
+
transaction produces commits inside the boot window (never on a later
|
|
473
|
+
microtask), so the deferred first `afterRender` always acts on the
|
|
474
|
+
final boot DOM — a boot subscription that changes state and queues a
|
|
475
|
+
focus intent settles identically under the synchronous and the
|
|
476
|
+
default scheduler. Effect-handler identities are snapshotted inside
|
|
477
|
+
the boot rollback BEFORE any resource is acquired: disposal never
|
|
478
|
+
re-enumerates a host registry at destroy time, and an unenumerable
|
|
479
|
+
registry rejects the boot (`JA0007`) while nothing is owned yet.
|
|
480
|
+
|
|
481
|
+
**Subscription startup is resource acquisition.** A slot commits live
|
|
482
|
+
only after its handler returned; a throwing handler leaves the slot
|
|
483
|
+
stopped (`JA2013`). Cleanups run exactly once, a throwing cleanup is
|
|
484
|
+
isolated (`JA2012`) and never skips its siblings. Because dispatches
|
|
485
|
+
queue, a condition flipped by a starting handler is observed by the
|
|
486
|
+
next transaction's reconciliation, which disposes the just-started
|
|
487
|
+
resource through the ordinary stop path — rapid `false`/`true`
|
|
488
|
+
condition changes coalesce per transaction.
|
|
489
|
+
|
|
490
|
+
**`stop()` halts; `destroy()` ends.** `stop()` is one-way and
|
|
491
|
+
nonterminal — there is no resume: it clears the queue, disposes live
|
|
492
|
+
subscriptions and listeners, and permanently ignores further
|
|
493
|
+
dispatches; the renderer and effect handlers stay untouched.
|
|
494
|
+
`destroy()` is terminal and idempotent: `stop()` plus observer
|
|
495
|
+
removal, effect-handler `dispose()` (each handler identity exactly
|
|
496
|
+
once), and renderer destruction — widgets unmount exactly once, the
|
|
497
|
+
container is left empty, and scheduled render flushes become exact
|
|
498
|
+
no-ops.
|
|
499
|
+
|
|
500
|
+
### 8.3 Transaction observers and diagnostics
|
|
501
|
+
|
|
502
|
+
`app.observe(fn)` delivers one bounded JSON record per settled
|
|
503
|
+
transaction: `{ seq, action, source, status, changedPaths,
|
|
504
|
+
scheduledEffects, durationMs, errorCode }` — `source` is
|
|
505
|
+
`'dispatch'`/`'binding'`/`'effect'`/`'subscription'`, `status` is
|
|
506
|
+
`'applied'`/`'noop'`/`'rejected'`/`'failed'`. Payload/event values are
|
|
507
|
+
included **only** when the app was created with `capturePayloads:
|
|
508
|
+
true` — diagnostics must not leak data by default. A throwing observer
|
|
509
|
+
is isolated (`JA2011`). `createTransactionLog({ limit, redact })`
|
|
510
|
+
packages the bounded ring buffer with a redaction hook and a versioned
|
|
511
|
+
`export()` envelope; the log lives in host memory, never in state.
|
|
512
|
+
|
|
513
|
+
### 8.4 The post-render focus/measurement queue
|
|
514
|
+
|
|
515
|
+
DOM nodes never enter state; focus, selection and measurement bridge
|
|
516
|
+
through JSON intents naming a `data-ref` attribute token.
|
|
517
|
+
`createFocusEffect({ container })` returns an effect handler whose
|
|
518
|
+
intents `{ ref, op?: "focus"|"select"|"measure", done?, id? }` queue
|
|
519
|
+
during the transaction and flush after the **next committed frame** —
|
|
520
|
+
wire its `flush` as `options.afterRender`. `afterRender` is a
|
|
521
|
+
**committed-live-frame boundary**: it runs exactly once per settled,
|
|
522
|
+
nonterminal committed frame — after the DOM patch *and* after widget
|
|
523
|
+
mounts, so a target born in the same transition is already connected;
|
|
524
|
+
when a widget hook error was parked during the frame, `afterRender`
|
|
525
|
+
runs BEFORE that error is delivered (the frame committed; error
|
|
526
|
+
delivery cannot starve its callback); and it never runs for a pass
|
|
527
|
+
that ended in terminal teardown (there is no live frame to act on).
|
|
528
|
+
Boot is ATOMIC for the callback: frames committed during the boot
|
|
529
|
+
transaction defer, and one `afterRender` fires only after the entire
|
|
530
|
+
boot — queued boot drain included — succeeded. A failed boot never
|
|
531
|
+
fires it, so no post-render side effect can escape a boot that rolled
|
|
532
|
+
back; a deferred callback's own failure follows boot policy (a
|
|
533
|
+
swallowing sink recovers it; an escaping failure rolls back as
|
|
534
|
+
`JA0007`). A missing target is a diagnosable
|
|
535
|
+
`JA2014`, never a silent no-op; sibling intents still resolve.
|
|
536
|
+
`measure` dispatches `done` with `{ id, ref, rect }`, the JSON-reduced
|
|
537
|
+
bounding rect. `app.destroy()` cancels pending intents through the
|
|
538
|
+
handler's `dispose()`. A headless app never flushes — the queue is a
|
|
539
|
+
documented no-op there.
|
|
540
|
+
|
|
541
|
+
### 8.5 `setState` is a transaction
|
|
542
|
+
|
|
543
|
+
`app.setState(next)` replaces the whole state from OUTSIDE the action
|
|
544
|
+
loop — SSR hydration, or a studio hot-swapping an edited `state` block
|
|
545
|
+
into a running app without a reboot. It runs no reducer and no effects,
|
|
546
|
+
notifies listeners with `null` changed-paths, refreshes `when`-gated
|
|
547
|
+
subscriptions and schedules one render.
|
|
548
|
+
|
|
549
|
+
It is public, so it is a contract, and it holds every guarantee §8.1
|
|
550
|
+
gives a dispatch:
|
|
551
|
+
|
|
552
|
+
- it **takes its turn** in the FIFO queue, so a replacement requested
|
|
553
|
+
from inside a listener, effect or subscription handler queues behind
|
|
554
|
+
the transaction that is running rather than interleaving with it;
|
|
555
|
+
- `validateState` **decides before the commit**, with the context
|
|
556
|
+
`{ previous, action: null, payload: null, changes: null }` — `action`
|
|
557
|
+
is `null` because no reducer ran, and `changes` is `null` because a
|
|
558
|
+
replacement changes everything. A rejected replacement is `JA2005` and
|
|
559
|
+
the state stands;
|
|
560
|
+
- every listener observes the SAME state, in registration order;
|
|
561
|
+
- the turn guard counts it, so a `setState` loop is `JA2010` rather than
|
|
562
|
+
a hang;
|
|
563
|
+
- observers see one record, `source: 'setState'`, `changedPaths: null`;
|
|
564
|
+
- a parked sink failure settles **at the `setState` caller**, not out of
|
|
565
|
+
the next unrelated dispatch.
|
|
566
|
+
|
|
567
|
+
A reference-identical replacement is a `noop`; a call after `stop()` or
|
|
568
|
+
`destroy()` does nothing.
|
|
569
|
+
|
|
570
|
+
### 8.6 Subscription ownership
|
|
571
|
+
|
|
572
|
+
A subscription slot is OWNED from before its handler runs, not from
|
|
573
|
+
after it returns. A handler is host code and can reach the app surface,
|
|
574
|
+
so it can re-enter reconciliation; a slot that only became live on the
|
|
575
|
+
way out looked startable to that re-entry, started again, and each
|
|
576
|
+
return overwrote the single cleanup slot — leaking every acquisition but
|
|
577
|
+
the last, past `destroy()`. The claim-first rule means one live slot is
|
|
578
|
+
one start and one release, whatever the handler does.
|
|
579
|
+
|
|
580
|
+
The `for` fan-out bound (`maxSubInstances`) stops the WORK, not just the
|
|
581
|
+
retention: enumeration halts the moment the bound is crossed and reports
|
|
582
|
+
`JA2017` with how many items were left. Bounding only what is kept still
|
|
583
|
+
ran every key and props expression of a runaway query first, so the
|
|
584
|
+
limit cost memory and CPU proportional to the mistake it existed to
|
|
585
|
+
contain.
|
|
586
|
+
|
|
587
|
+
### 8.7 Accessible component contracts (non-normative)
|
|
588
|
+
|
|
589
|
+
The format's accessibility position: **the widget escape hatch is not
|
|
590
|
+
an accessibility escape hatch**, and the primitives above exist so the
|
|
591
|
+
accessible patterns are expressible as data.
|
|
592
|
+
|
|
593
|
+
- **Dialogs**: opening moves focus into the dialog through a §8.4
|
|
594
|
+
intent (`data-ref` on its first control); closing restores it to the
|
|
595
|
+
opener the same way; the dialog element carries `role="dialog"`,
|
|
596
|
+
`aria-modal` and a label. The executable skeleton lives in the
|
|
597
|
+
focus-queue test suite and is the pattern to copy.
|
|
598
|
+
- **Tabs**: a tablist/tab/tabpanel triple is ordinary vnode data —
|
|
599
|
+
`role`/`aria-selected`/`aria-controls` are props like any other, and
|
|
600
|
+
arrow-key movement is a `keydown` binding requesting `key` (§3.1).
|
|
601
|
+
- **Rows with nested controls**: `stopPropagation` on the nested
|
|
602
|
+
binding (§4) keeps a checkbox or link inside a clickable row from
|
|
603
|
+
triggering the row action; `preventDefault` expresses suppressed
|
|
604
|
+
native behavior declaratively.
|
|
605
|
+
- **Widgets** own the complete keyboard, focus and announcement
|
|
606
|
+
behavior of their subtree — the renderer guarantees only mount/
|
|
607
|
+
update/unmount-exactly-once (VIEW-FORMAT §7); everything inside
|
|
608
|
+
is the widget contract's responsibility.
|
|
609
|
+
- These contracts are tested headlessly in this repository, and the
|
|
610
|
+
**lifecycle** half is additionally exercised against a real
|
|
611
|
+
Chromium/Firefox/WebKit matrix on every push (see
|
|
612
|
+
[the website's browser suite](../../website/README.md#browser-tests)):
|
|
613
|
+
boot with landmark semantics, client-side navigation mount/unmount,
|
|
614
|
+
keyboard activation of the router, and rapid route churn, each
|
|
615
|
+
asserting zero page errors under real engine scheduling. What that
|
|
616
|
+
matrix does **not** yet cover — and this document does not claim — is
|
|
617
|
+
the **accessibility** half: dialog focus traps and focus restoration
|
|
618
|
+
under an actual screen reader, AT semantics, and
|
|
619
|
+
`prefers-reduced-motion`. Those remain open and are tracked in the
|
|
620
|
+
roadmap.
|
|
621
|
+
|
|
622
|
+
## 9. Tasks and host concurrency
|
|
623
|
+
|
|
624
|
+
### 9.1 The convention
|
|
625
|
+
|
|
626
|
+
The async-task convention (state-side ids + guard-first completion
|
|
627
|
+
actions) is specified in [TASKS.md](./TASKS.md); its host half is
|
|
628
|
+
`createTaskEffect(run, options)`.
|
|
629
|
+
|
|
630
|
+
### 9.2 Concurrency modes and controls
|
|
631
|
+
|
|
632
|
+
`createTaskEffect` takes a per-slot concurrency `mode`:
|
|
633
|
+
|
|
634
|
+
| Mode | A new start while the slot is busy… |
|
|
635
|
+
|---|---|
|
|
636
|
+
| `"switch"` (default) | aborts the in-flight predecessor; newest wins |
|
|
637
|
+
| `"exhaust"` | is ignored entirely — the double-click-safe commit mode |
|
|
638
|
+
| `"concat"` | queues and runs strictly after — deliberately ordered commands |
|
|
639
|
+
| `"parallel"` | runs concurrently; the consumer owns the merge rule |
|
|
640
|
+
|
|
641
|
+
Whatever the mode, correctness stays visible in JSON state — the task
|
|
642
|
+
slot's monotonic `id` and the completion action's guard remain the
|
|
643
|
+
authority on which response may land. The handler exposes
|
|
644
|
+
`cancel(slot)` (abort in-flight, discard queued), `cancelAll()`, and
|
|
645
|
+
`dispose()` (terminal: nothing dispatches afterwards; called
|
|
646
|
+
automatically by `app.destroy()`). `run` is invoked through a uniform
|
|
647
|
+
promise boundary: a synchronous throw and a non-promise return settle
|
|
648
|
+
through the same path as a rejection/resolution.
|
|
649
|
+
|
|
650
|
+
## 10. Errors
|
|
651
|
+
|
|
652
|
+
### 10.1 The host-failure normalization policy
|
|
653
|
+
|
|
654
|
+
JavaScript's `throw` accepts any value — `null`, `undefined`, strings,
|
|
655
|
+
numbers, arbitrary objects — and every host extension point (effects,
|
|
656
|
+
subscriptions, validators, event extractors, listeners, observers,
|
|
657
|
+
widget hooks, error sinks) may produce any of them. One shared policy
|
|
658
|
+
covers them all:
|
|
659
|
+
|
|
660
|
+
- every caught value is treated as `unknown`; no boundary ever reads
|
|
661
|
+
`.message` off a raw caught value;
|
|
662
|
+
- an `Error` instance passes through BY IDENTITY wherever a contract
|
|
663
|
+
promises the original as `cause`;
|
|
664
|
+
- a non-Error value is wrapped in a `HostValueError` whose message
|
|
665
|
+
describes the value **without invoking user coercion** (a hostile
|
|
666
|
+
`toString` is never called) and which retains the original value as
|
|
667
|
+
an OWN `cause` property — set even for `undefined`, so
|
|
668
|
+
`Object.hasOwn(err, 'cause')` distinguishes "threw undefined" from
|
|
669
|
+
"no cause";
|
|
670
|
+
- parked failures use presence records, never the thrown value itself
|
|
671
|
+
as the absence sentinel — a thrown `null` still counts, still
|
|
672
|
+
surfaces, and still surfaces BY IDENTITY to the outermost caller;
|
|
673
|
+
- event extraction reports tagged outcomes: a thrown `undefined` is a
|
|
674
|
+
failure (`JA2002`), structurally distinct from an unknown field
|
|
675
|
+
(`JA2009`);
|
|
676
|
+
- direct and deferred teardown behave identically, all sibling
|
|
677
|
+
listeners, cleanups, queued transactions and renderer teardown
|
|
678
|
+
complete before the first sink failure surfaces, and terminal
|
|
679
|
+
idempotence survives any cleanup failure;
|
|
680
|
+
- the policy is TOTAL: no operation performed while handling a caught
|
|
681
|
+
value may itself escape. Classification (`instanceof Error`) and
|
|
682
|
+
every diagnostic property read (`message`, `name`) run inside
|
|
683
|
+
nonthrowing accessors, and value description uses only untrappable
|
|
684
|
+
conversions — a revoked `Proxy`, a throwing trap, a hostile
|
|
685
|
+
`message` accessor or a booby-trapped `toString`/`Symbol.toPrimitive`
|
|
686
|
+
degrades the *description*, never the guarantee. An `Error` whose
|
|
687
|
+
diagnostics are hostile still passes as `cause` by identity, with a
|
|
688
|
+
safe projected message beside it; a value whose classification
|
|
689
|
+
throws wraps like any non-Error;
|
|
690
|
+
- dual failures stay observable in their own frame: when both
|
|
691
|
+
`afterRender` and a widget hook fail in one frame, a collecting sink
|
|
692
|
+
receives the `afterRender` failure first and the parked hook failure
|
|
693
|
+
second — in that frame, never a later one. Under the default
|
|
694
|
+
rethrowing sink, ALL values the sink threw during one drain cross
|
|
695
|
+
the caller boundary together: one value by identity, several as one
|
|
696
|
+
`AggregateError` over the originals in report order. The collection
|
|
697
|
+
is FRAMEWORK-OWNED — appending performs no reflection or coercion on
|
|
698
|
+
a sink-thrown value, and a host-created `AggregateError` (the
|
|
699
|
+
framework's own envelope message included) stays ONE element by
|
|
700
|
+
identity, never flattened;
|
|
701
|
+
- capability ACQUISITION is part of the boundary: reading an optional
|
|
702
|
+
host method — an effect handler's `dispose`, a widget definition's
|
|
703
|
+
`update`/`unmount`, a registry member — executes host code when the
|
|
704
|
+
host used an accessor or proxy, so the read shares the isolation
|
|
705
|
+
boundary and the failure policy of the invocation. A hostile
|
|
706
|
+
`dispose` lookup is a cleanup failure (`JA2012`) after which later
|
|
707
|
+
disposers and renderer teardown still run; a hostile `unmount`
|
|
708
|
+
lookup is collected like a throwing unmount, every sibling still
|
|
709
|
+
unmounts and the container empties; a hostile `update` lookup
|
|
710
|
+
poisons the widget exactly like a throwing update, the frame
|
|
711
|
+
settles, and the next render replaces it; hostile effect/
|
|
712
|
+
subscription registry reads report `JA2007`/`JA2013` and the loop
|
|
713
|
+
drains on;
|
|
714
|
+
- one `app.destroy()` delivers ONE `JA2012`: a single cleanup failure
|
|
715
|
+
is its cause by identity; several aggregate in occurrence order
|
|
716
|
+
(subscription cleanups, then effect disposal, then the renderer
|
|
717
|
+
walk — whose own frame envelope arrives as one element). This holds
|
|
718
|
+
for an IN-HOOK destroy too: when a widget hook calls
|
|
719
|
+
`app.destroy()` and the renderer teardown defers to the end of the
|
|
720
|
+
active pass, the collector stays open and delivers once, after that
|
|
721
|
+
teardown completes. Outside `destroy()` — `stop()`, per-transaction
|
|
722
|
+
reconciliation — each cleanup failure reports its own `JA2012`, as
|
|
723
|
+
before;
|
|
724
|
+
- task settlement is total: abort classification reads the rejection
|
|
725
|
+
value's `name` through a safe accessor (the signal never suppresses
|
|
726
|
+
— a superseded task's non-abort failure still dispatches, the
|
|
727
|
+
state-side id guard is the authority), every non-abort rejection
|
|
728
|
+
produces exactly one `{ id, error }` settlement dispatch, and a
|
|
729
|
+
settlement dispatch that itself throws is re-raised on its own
|
|
730
|
+
microtask so the host's global error handling observes it — never a
|
|
731
|
+
framework-originated unhandled promise rejection.
|
|
732
|
+
|
|
733
|
+
Compile (`AppCompileError`, thrown by `createApp`; `docPath` is a JSON
|
|
734
|
+
Pointer into the app document):
|
|
735
|
+
|
|
736
|
+
| Code | Condition |
|
|
737
|
+
|---|---|
|
|
738
|
+
| `JA0001` | the app document is not an object |
|
|
739
|
+
| `JA0002` | `view` missing or failed to compile |
|
|
740
|
+
| `JA0003` | `actions` is not an object |
|
|
741
|
+
| `JA0004` | an action document failed to compile |
|
|
742
|
+
| `JA0005` | `subs` is not an array |
|
|
743
|
+
| `JA0006` | a subscription entry is malformed / its `when` failed to compile |
|
|
744
|
+
| `JA0007` | boot failed after compilation (renderer construction, initial-state check, initial subscriptions, first frame or queued boot work); everything acquired was rolled back (§8.2) |
|
|
745
|
+
| `JA0008` | a subscription dynamic member (`withQuery`/`key`/`for`) failed to compile, or the members combine invalidly (§5.3) |
|
|
746
|
+
|
|
747
|
+
Runtime (`AppRuntimeError`, routed through `options.onError`, which
|
|
748
|
+
defaults to rethrowing):
|
|
749
|
+
|
|
750
|
+
| Code | Condition |
|
|
751
|
+
|---|---|
|
|
752
|
+
| `JA2001` | unknown action name, or unusable binding |
|
|
753
|
+
| `JA2002` | an action or `when` document threw while evaluating, or a registered event-field extractor threw (the member is bound `null`; the dispatch is NOT dropped) |
|
|
754
|
+
| `JA2003` | a transition is not an object / `effects` not an array |
|
|
755
|
+
| `JA2004` | a `patch` failed to apply (transition aborted) |
|
|
756
|
+
| `JA2005` | `validateState` rejected the next state (transition blocked) |
|
|
757
|
+
| `JA2006` | unregistered effect name |
|
|
758
|
+
| `JA2007` | an effect handler threw |
|
|
759
|
+
| `JA2008` | unregistered subscription name |
|
|
760
|
+
| `JA2009` | a binding requested an unknown event field (the member is bound `null`; the dispatch is NOT dropped) |
|
|
761
|
+
| `JA2010` | the dispatch loop exceeded `maxTurns` in one drain; the queue was abandoned (§8.1) |
|
|
762
|
+
| `JA2011` | a state listener or transaction observer threw (isolated) |
|
|
763
|
+
| `JA2012` | a cleanup threw while stopping/reconciling/destroying (isolated; a widget `unmount` failing during renderer teardown — deferred teardown after an in-hook `destroy()` included — reports here with the first host cause preserved, after every sibling cleaned up) |
|
|
764
|
+
| `JA2013` | a subscription handler threw while starting; the slot stays stopped |
|
|
765
|
+
| `JA2014` | a post-render intent named a `data-ref` with no rendered target (§8.4) |
|
|
766
|
+
| `JA2015` | the `validateState` hook itself threw (the transaction failed; the queue keeps draining) |
|
|
767
|
+
| `JA2016` | a subscription dynamic query (`withQuery`/`key`/`for`) threw while evaluating — cyclic resolved values included; the subscription failed closed (§5.3) |
|
|
768
|
+
| `JA2017` | a subscription fan-out resolved more instances than `maxSubInstances`; the previous instance set was kept (§5.3) |
|
|
769
|
+
|
|
770
|
+
Wrapped causes are preserved on `error.cause`; compile errors from
|
|
771
|
+
embedded documents keep their own codes (`JQ...`, `JT...`) there —
|
|
772
|
+
with their `docPath`s pointing inside the embedded document, the
|
|
773
|
+
feedback shape a repair loop needs.
|
|
774
|
+
|
|
775
|
+
## 11. Open items (roadmap, non-normative)
|
|
776
|
+
|
|
777
|
+
- ~~The app-document meta-schema~~ — **shipped**:
|
|
778
|
+
[`schemas/jaren-app.schema.json`](../schemas/jaren-app.schema.json)
|
|
779
|
+
(draft 2020-12, with a mechanically derived draft-07 twin) composes
|
|
780
|
+
the published query and JSLT grammars by `$ref`; register those
|
|
781
|
+
artifacts alongside it. A constrained decoder held to it cannot emit
|
|
782
|
+
a structurally invalid application — and the test suite validates
|
|
783
|
+
the production website's own app document against it.
|
|
784
|
+
- ~~The standard forms stylesheet~~ — **shipped**: `createFormView` /
|
|
785
|
+
`createFormActions` render any `@jarenjs/forms` model through one
|
|
786
|
+
rule set. What remains of the idea is the other half — generalizing
|
|
787
|
+
`x-form`'s derivation vocabulary (`computed`/`visible`) to app-level
|
|
788
|
+
derived state, and letting a stylesheet replace the view-model
|
|
789
|
+
composition itself, which needs a language primitive this format does
|
|
790
|
+
not have (see ROADMAP, `@jarenjs/forms`).
|
|
791
|
+
- ~~Dirty-path-pruned re-rendering~~ — largely **shipped** through the
|
|
792
|
+
other side: the JSLT `memo` option makes unchanged subtrees return
|
|
793
|
+
reference-equal vnodes the renderer skips in O(1); prepass-level
|
|
794
|
+
pruning of match evaluation remains open (see ROADMAP).
|
|
795
|
+
- ~~Unifying the write path~~ — **shipped**: `@jarenjs/json/write`
|
|
796
|
+
`parents: 'create'` + undefined-deletes is now exactly forms'
|
|
797
|
+
`setValueAtPointer`, on the shared copy-on-write kernel.
|
|
798
|
+
- **Async action documents** — the effect→dispatch convention for
|
|
799
|
+
asynchronous work is now documented and helper-backed
|
|
800
|
+
([TASKS.md](TASKS.md): task-slot identity, stale-response rejection,
|
|
801
|
+
`createTaskEffect`); what remains open is a first-class *awaiting
|
|
802
|
+
disposition* — an action that suspends on an effect's settlement and
|
|
803
|
+
transitions with its result, instead of completing through a second
|
|
804
|
+
dispatched action.
|