mithril-lynx 2.6.2 → 3.0.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/README.md +47 -8
  5. package/ROUTE.md +111 -16
  6. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  7. package/UPDATE_ANIMATION_GAP.md +133 -0
  8. package/package.json +1 -9
  9. package/plugin.js +33 -0
  10. package/src/apply-patch.js +369 -60
  11. package/src/backends/virtual-backend.js +120 -22
  12. package/src/background.d.ts +26 -0
  13. package/src/background.js +116 -7
  14. package/src/channel.js +22 -5
  15. package/src/commit.js +9 -0
  16. package/src/dev-reload-client.js +46 -0
  17. package/src/dev-transport-noop.js +3 -0
  18. package/src/fake-dom.js +559 -61
  19. package/src/list-attributes.js +69 -0
  20. package/src/list-runtime.js +425 -0
  21. package/src/main-thread.js +18 -1
  22. package/src/mount-redraw.js +14 -1
  23. package/src/patch-protocol.js +41 -16
  24. package/src/reload/version.js +10 -1
  25. package/src/request.js +38 -0
  26. package/src/route.d.ts +30 -2
  27. package/src/route.js +214 -56
  28. package/src/testing.d.ts +14 -1
  29. package/src/testing.js +56 -1
  30. package/test/event-render-coalescing.test.ts +117 -0
  31. package/test/gesture.test.ts +75 -0
  32. package/test/input.test.ts +388 -0
  33. package/test/list.test.ts +417 -113
  34. package/test/remove-subtree-cleanup.test.ts +75 -0
  35. package/test/route-hot-reload.test.ts +2 -1
  36. package/test/route.test.ts +201 -4
  37. package/test/style-clear.test.ts +97 -0
  38. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  39. package/.omo/plans/m-route-en-memoria.md +0 -397
  40. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  41. package/.omo/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
  42. package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +0 -10
  43. package/src/list-cell.d.ts +0 -20
  44. package/src/list-cell.js +0 -73
  45. package/src/list-support.d.ts +0 -13
  46. package/src/list-support.js +0 -197
package/src/fake-dom.js CHANGED
@@ -25,22 +25,46 @@
25
25
  // side needs to be "a DOM", the other side only needs to be "a PAPI patch
26
26
  // applier".
27
27
 
28
+ import { TYPED_ATTRIBUTES_BY_TAG } from "./list-attributes.js";
29
+ import { FORM_FIELD_TAGS } from "./patch-protocol.js";
30
+
28
31
  const DASH_CASE = /-/;
29
32
 
33
+ /**
34
+ * Converts a camelCase style property name to its dash-case CSS form.
35
+ * @param {string} name - The camelCase property name (e.g. `backgroundColor`).
36
+ * @returns {string} The dash-case name (e.g. `background-color`).
37
+ * @example
38
+ * camelToDash("backgroundColor"); // "background-color"
39
+ */
30
40
  function camelToDash(name) {
31
41
  return name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
32
42
  }
33
43
 
44
+ /**
45
+ * Base class for every fake-DOM node: tracks the owning document and the parent link.
46
+ */
34
47
  class LynxNode {
48
+ /**
49
+ * @param {LynxDocument|null} ownerDocument - The document that owns this node (`null` only for the document itself, which sets it afterwards).
50
+ */
35
51
  constructor(ownerDocument) {
36
52
  this.ownerDocument = ownerDocument;
37
53
  this._parent = null;
38
54
  }
39
55
 
56
+ /**
57
+ * The parent container, or `null` when detached.
58
+ * @returns {LynxContainerNode|null}
59
+ */
40
60
  get parentNode() {
41
61
  return this._parent;
42
62
  }
43
63
 
64
+ /**
65
+ * The sibling immediately after this node in its parent's child list.
66
+ * @returns {LynxNode|null} The next sibling, or `null` when detached or last.
67
+ */
44
68
  get nextSibling() {
45
69
  if (!this._parent) return null;
46
70
  const siblings = this._parent._children;
@@ -55,12 +79,22 @@ class LynxNode {
55
79
  // for fragments (CONTRACT.md §c, `createDocumentFragment`): inserting a
56
80
  // fragment moves ITS children into the target and leaves the fragment
57
81
  // empty, rather than inserting the fragment node itself.
82
+ /**
83
+ * Base class for nodes that can hold children (elements, fragments, the document).
84
+ */
58
85
  class LynxContainerNode extends LynxNode {
86
+ /**
87
+ * @param {LynxDocument|null} ownerDocument - The document that owns this node.
88
+ */
59
89
  constructor(ownerDocument) {
60
90
  super(ownerDocument);
61
91
  this._children = [];
62
92
  }
63
93
 
94
+ /**
95
+ * The first child node.
96
+ * @returns {LynxNode|null} The first child, or `null` when empty.
97
+ */
64
98
  get firstChild() {
65
99
  return this._children[0] ?? null;
66
100
  }
@@ -70,10 +104,19 @@ class LynxContainerNode extends LynxNode {
70
104
  // `createDocumentFragment()` (createNodes' fragment-batching path) — a
71
105
  // real DOM's `childNodes` is a live NodeList, but render.js only ever
72
106
  // reads `.length` off it here, so the plain backing array is enough.
107
+ /**
108
+ * The live backing array of child nodes (only `.length` is ever read by render.js).
109
+ * @returns {LynxNode[]}
110
+ */
73
111
  get childNodes() {
74
112
  return this._children;
75
113
  }
76
114
 
115
+ /**
116
+ * Tests whether `other` is this node or one of its descendants.
117
+ * @param {LynxNode|null} other - The node to look for.
118
+ * @returns {boolean} `true` when `other` is this node or a descendant of it.
119
+ */
77
120
  contains(other) {
78
121
  let node = other;
79
122
  while (node) {
@@ -83,11 +126,23 @@ class LynxContainerNode extends LynxNode {
83
126
  return false;
84
127
  }
85
128
 
129
+ /**
130
+ * Appends a child at the end of this container.
131
+ * @param {LynxNode} child - The node to append.
132
+ * @returns {LynxNode} The appended node.
133
+ */
86
134
  appendChild(child) {
87
135
  this.insertBefore(child, null);
88
136
  return child;
89
137
  }
90
138
 
139
+ /**
140
+ * Inserts a child before a reference node, moving it from any previous parent.
141
+ * A fragment is never attached itself: its children are moved in and it is left empty.
142
+ * @param {LynxNode} child - The node to insert.
143
+ * @param {LynxNode|null} refChild - The node to insert before, or `null` to append.
144
+ * @returns {LynxNode} The inserted node.
145
+ */
91
146
  insertBefore(child, refChild) {
92
147
  if (child instanceof LynxFragment) {
93
148
  // Real DOM semantics: the fragment itself is never attached —
@@ -112,14 +167,35 @@ class LynxContainerNode extends LynxNode {
112
167
  return child;
113
168
  }
114
169
 
170
+ /**
171
+ * Removes a child from this container.
172
+ * @param {LynxNode} child - The node to remove.
173
+ * @returns {LynxNode} The removed node.
174
+ */
115
175
  removeChild(child) {
116
176
  this._removeChildBookkeeping(child);
117
177
  if (this._id != null && child._id != null) {
118
178
  this._backend.removeChild(this._id, child._id);
179
+ // render.js only removes the ROOT of a removed subtree, so every
180
+ // descendant has to leave `_nodesById` here too — otherwise the
181
+ // whole detached subtree stays reachable forever. Moves never
182
+ // come through here (insertBefore uses _removeChildBookkeeping).
183
+ const nodesById = this.ownerDocument._nodesById;
184
+ const stack = [child];
185
+ while (stack.length > 0) {
186
+ const node = stack.pop();
187
+ nodesById.delete(node._id);
188
+ if (node._children) for (const c of node._children) stack.push(c);
189
+ }
119
190
  }
120
191
  return child;
121
192
  }
122
193
 
194
+ /**
195
+ * Detaches `child` from the local child list and clears its parent link, without touching the backend.
196
+ * @param {LynxNode} child - The child to detach.
197
+ * @returns {void}
198
+ */
123
199
  _removeChildBookkeeping(child) {
124
200
  const index = this._children.indexOf(child);
125
201
  if (index !== -1) this._children.splice(index, 1);
@@ -127,20 +203,49 @@ class LynxContainerNode extends LynxNode {
127
203
  }
128
204
  }
129
205
 
206
+ /**
207
+ * Creates the `element.style` object: a proxy that forwards `setProperty`/`removeProperty`
208
+ * and camelCase or dash-case property assignments to the backend as dash-case ops.
209
+ * @param {LynxElement} element - The element the style object belongs to.
210
+ * @returns {CSSStyleDeclaration} A proxy that emulates the subset of `CSSStyleDeclaration` render.js uses.
211
+ */
130
212
  function createStyleProxy(element) {
131
213
  const methods = {
214
+ /**
215
+ * @param {string} name - Dash-case style property name.
216
+ * @param {*} value - Value, coerced with `String()`.
217
+ * @returns {void}
218
+ */
132
219
  setProperty(name, value) {
133
- element._styleEverSet = true;
220
+ element._activeStylesSet.add(name);
134
221
  element._backend.setStyleProperty(element._id, name, String(value));
135
222
  },
223
+ /**
224
+ * @param {string} name - Dash-case style property name.
225
+ * @returns {void}
226
+ */
136
227
  removeProperty(name) {
228
+ element._activeStylesSet.delete(name);
137
229
  element._backend.removeStyleProperty(element._id, name);
138
230
  },
139
231
  };
140
232
  return new Proxy(methods, {
233
+ /**
234
+ * Proxy trap: reads pass through to the `setProperty`/`removeProperty` methods.
235
+ * @param {Object} target - The methods object.
236
+ * @param {string|symbol} prop - The property being read.
237
+ * @returns {*} The property value.
238
+ */
141
239
  get(target, prop) {
142
240
  return target[prop];
143
241
  },
242
+ /**
243
+ * Proxy trap: assigning a property writes (or, for an empty value, removes) that style property.
244
+ * @param {Object} _target - Unused.
245
+ * @param {string|symbol} prop - The style property name (camelCase or dash-case); symbols are ignored.
246
+ * @param {*} value - The new value; `""`, `null` or `undefined` removes the property.
247
+ * @returns {boolean} Always `true`.
248
+ */
144
249
  set(_target, prop, value) {
145
250
  if (typeof prop !== "string") return true;
146
251
  // Direct camelCase assignment path (CONTRACT.md §f, line 764/781).
@@ -148,9 +253,10 @@ function createStyleProxy(element) {
148
253
  // key shape regardless of which of Mithril's two style paths ran.
149
254
  const name = DASH_CASE.test(prop) ? prop : camelToDash(prop);
150
255
  if (value === "" || value == null) {
256
+ element._activeStylesSet.delete(name);
151
257
  element._backend.removeStyleProperty(element._id, name);
152
258
  } else {
153
- element._styleEverSet = true;
259
+ element._activeStylesSet.add(name);
154
260
  element._backend.setStyleProperty(element._id, name, String(value));
155
261
  }
156
262
  return true;
@@ -164,41 +270,61 @@ function createStyleProxy(element) {
164
270
  // on the main thread — a single incrementing counter is simplest).
165
271
  let nextGestureId = 1;
166
272
 
273
+ /**
274
+ * A fake-DOM element that mirrors every mutation to the backend as a patch op.
275
+ */
167
276
  export class LynxElement extends LynxContainerNode {
168
277
  /**
169
- * `listConfig`, when given, makes this a native virtualized list
170
- * element instead of a plain one — see patch-protocol.js's
171
- * Op.CreateList and docs/native-papi/papi-06-virtualized-lists.md
172
- * (mithril-lynx-ui) for the full design. Not constructed directly;
173
- * use LynxDocument#createNativeList().
278
+ * @param {LynxDocument} ownerDocument - The owning document.
279
+ * @param {Object} backend - The patch backend that records ops.
280
+ * @param {string} tag - The tag name.
281
+ * @param {string} [ns] - The namespace URI, if any.
174
282
  */
175
- constructor(ownerDocument, backend, tag, ns, listConfig) {
283
+ constructor(ownerDocument, backend, tag, ns) {
176
284
  super(ownerDocument);
177
285
  this._backend = backend;
178
286
  this.tag = tag;
179
287
  this.namespaceURI = ns;
180
- this._id = listConfig
181
- ? backend.createList(listConfig.scrollOrientation, listConfig.listType, listConfig.spanCount)
182
- : ns
183
- ? backend.createElementNS(ns, tag)
184
- : backend.createElement(tag);
288
+ this._id = ns ? backend.createElementNS(ns, tag) : backend.createElement(tag);
185
289
  ownerDocument._nodesById.set(this._id, this);
186
290
  this._style = null;
187
- this._styleEverSet = false;
188
291
  this._listeners = Object.create(null);
189
292
  // `hasPropertyKey` (CONTRACT.md §e) requires `"value" in vnode.dom` etc.
190
293
  // to be true for the property-write fast path to apply to form
191
- // elements — plain own properties satisfy the `in` check.
192
- this.value = undefined;
294
+ // elements — plain own properties satisfy the `in` check. An
295
+ // <input>/<textarea> has a real `value` accessor on its prototype
296
+ // (LynxFormFieldElement), which an own property would shadow.
297
+ if (!("value" in this)) this.value = undefined;
193
298
  this.checked = undefined;
194
299
  this.selectedIndex = undefined;
195
300
  }
196
301
 
302
+ /**
303
+ * Lazily created style object for this element.
304
+ * @returns {CSSStyleDeclaration}
305
+ */
197
306
  get style() {
198
307
  if (!this._style) this._style = createStyleProxy(this);
199
308
  return this._style;
200
309
  }
201
310
 
311
+ /**
312
+ * Dash-case names of the style properties currently applied to this
313
+ * element, created lazily like `style` itself. Lets `style = ""` expand
314
+ * into one RemoveStyleProperty op per property actually set, since there
315
+ * is no device-validated bulk-clear PAPI call.
316
+ * @returns {Set<string>}
317
+ */
318
+ get _activeStylesSet() {
319
+ if (!this._activeStyles) this._activeStyles = new Set();
320
+ return this._activeStyles;
321
+ }
322
+
323
+ /**
324
+ * Replaces the element's style. `""`/`null` clears it, an object assigns each key,
325
+ * and a CSS text string is unsupported (a warning is logged).
326
+ * @param {Object|string|null} value - The new style.
327
+ */
202
328
  set style(value) {
203
329
  if (value == null || value === "") {
204
330
  // `element.style = ""` (CONTRACT.md §f, lines 750-752): clear.
@@ -207,15 +333,17 @@ export class LynxElement extends LynxContainerNode {
207
333
  // had any style at all (its "old is missing or a string, style is
208
334
  // an object" branch — see mithril-runtime/render/render.js) — so
209
335
  // without this guard, EVERY component with a plain object style
210
- // prop (an extremely common pattern, not an edge case) would hit
211
- // apply-patch.js's "bulk-clear not implemented" throw on its very
212
- // first render. `_styleEverSet` (set by createStyleProxy whenever
213
- // a real property is written) is exactly "was there anything to
214
- // clear" — skip emitting the op at all when there wasn't; the
215
- // throw still fires for the genuine case (an update actually
216
- // replacing a previously-set style), where a real bulk-clear PAPI
217
- // call would actually be needed and hasn't been validated yet.
218
- if (this._styleEverSet) this._backend.removeStyleProperty(this._id, "*");
336
+ // prop (an extremely common pattern, not an edge case) would
337
+ // emit a clear op on its very first render. There is no
338
+ // device-validated bulk-clear PAPI call, so the clear is expanded
339
+ // here into one RemoveStyleProperty per property actually applied
340
+ // (tracked by createStyleProxy). The set is read through its
341
+ // backing field so a clear on an element that never had a style
342
+ // neither creates it nor emits anything.
343
+ const active = this._activeStyles;
344
+ if (active == null || active.size === 0) return;
345
+ for (const name of active) this._backend.removeStyleProperty(this._id, name);
346
+ active.clear();
219
347
  return;
220
348
  }
221
349
  if (typeof value !== "object") {
@@ -238,10 +366,18 @@ export class LynxElement extends LynxContainerNode {
238
366
  }
239
367
  }
240
368
 
369
+ /**
370
+ * The element's class string.
371
+ * @returns {string} The class string, or `""` when unset.
372
+ */
241
373
  get className() {
242
374
  return this._className ?? "";
243
375
  }
244
376
 
377
+ /**
378
+ * Sets the element's classes, emitting a `class` attribute op.
379
+ * @param {string|null|undefined} value - The class string; `null`/`undefined` clears it.
380
+ */
245
381
  set className(value) {
246
382
  // Mithril's `setAttr`/`removeAttr` map `className` -> the `"class"`
247
383
  // attribute (CONTRACT.md §e); routed here directly since `className`
@@ -251,6 +387,12 @@ export class LynxElement extends LynxContainerNode {
251
387
  this._backend.setClasses(this._id, value == null ? "" : String(value));
252
388
  }
253
389
 
390
+ /**
391
+ * Sets an attribute; the `class` name is routed to `className`.
392
+ * @param {string} name - Attribute name.
393
+ * @param {*} value - Attribute value, coerced with `String()`; `null`/`undefined` is sent as `null`.
394
+ * @returns {void}
395
+ */
254
396
  setAttribute(name, value) {
255
397
  if (name === "class") {
256
398
  this.className = value;
@@ -259,6 +401,11 @@ export class LynxElement extends LynxContainerNode {
259
401
  this._backend.setAttribute(this._id, name, value == null ? null : String(value));
260
402
  }
261
403
 
404
+ /**
405
+ * Removes an attribute; removing `class` clears the class string.
406
+ * @param {string} name - Attribute name.
407
+ * @returns {void}
408
+ */
262
409
  removeAttribute(name) {
263
410
  if (name === "class") {
264
411
  this.className = "";
@@ -267,6 +414,13 @@ export class LynxElement extends LynxContainerNode {
267
414
  this._backend.removeAttribute(this._id, name);
268
415
  }
269
416
 
417
+ /**
418
+ * Sets a namespaced attribute.
419
+ * @param {string|null} ns - Attribute namespace.
420
+ * @param {string} name - Attribute name.
421
+ * @param {*} value - Attribute value, coerced with `String()`; `null`/`undefined` is sent as `null`.
422
+ * @returns {void}
423
+ */
270
424
  setAttributeNS(ns, name, value) {
271
425
  this._backend.setAttributeNS(this._id, ns, name, value == null ? null : String(value));
272
426
  }
@@ -287,21 +441,33 @@ export class LynxElement extends LynxContainerNode {
287
441
  return gestureId;
288
442
  }
289
443
 
444
+ /**
445
+ * Removes a gesture detector previously registered with `setGestureDetector()`.
446
+ * @param {number} gestureId - The id returned by `setGestureDetector()`.
447
+ * @returns {void}
448
+ */
290
449
  removeGestureDetector(gestureId) {
291
450
  this._backend.removeGestureDetector(this._id, gestureId);
292
451
  }
293
452
 
294
- /** Only meaningful on an element created via LynxDocument#createNativeList(). */
295
- setListItems(items) {
296
- this._backend.setListItems(this._id, items);
297
- }
298
-
453
+ /**
454
+ * Registers the single listener for an event type; the backend is told only the first time.
455
+ * Replaces any previous listener for the same type.
456
+ * @param {string} type - Event type (e.g. `tap`).
457
+ * @param {Function|{handleEvent: Function}} listener - A function or an EventListener object.
458
+ * @returns {void}
459
+ */
299
460
  addEventListener(type, listener) {
300
461
  const isNew = !(type in this._listeners);
301
462
  this._listeners[type] = listener;
302
463
  if (isNew) this._backend.addEvent(this._id, type);
303
464
  }
304
465
 
466
+ /**
467
+ * Removes the listener for an event type, if any.
468
+ * @param {string} type - Event type.
469
+ * @returns {void}
470
+ */
305
471
  removeEventListener(type) {
306
472
  if (!(type in this._listeners)) return;
307
473
  delete this._listeners[type];
@@ -321,6 +487,10 @@ export class LynxElement extends LynxContainerNode {
321
487
  else if (typeof listener.handleEvent === "function") listener.handleEvent(event);
322
488
  }
323
489
 
490
+ /**
491
+ * Only `""` is supported: removes every child. Any other value logs a warning and is ignored.
492
+ * @param {string} value - Must be the empty string.
493
+ */
324
494
  set textContent(value) {
325
495
  // render.js only ever does `dom.textContent = ""` (first-render
326
496
  // clear, CONTRACT.md §b line 898) — implemented as "remove every
@@ -335,6 +505,10 @@ export class LynxElement extends LynxContainerNode {
335
505
  for (const child of this._children.slice()) this.removeChild(child);
336
506
  }
337
507
 
508
+ /**
509
+ * Unsupported on Lynx elements; logs a warning.
510
+ * @param {string} _value - Ignored.
511
+ */
338
512
  set innerHTML(_value) {
339
513
  // `m.trust()`/contenteditable sync (CONTRACT.md §c) — Lynx elements
340
514
  // have no HTML-string target to parse into. Documented as
@@ -347,17 +521,268 @@ export class LynxElement extends LynxContainerNode {
347
521
  }
348
522
  }
349
523
 
524
+ /**
525
+ * Calls one of the native element's UI methods (`focus`, `getValue`,
526
+ * `scrollTo`, `autoScroll`, …) — the promise form of
527
+ * `lynx.createSelectorQuery().select(…).invoke({ method, params })`,
528
+ * without an `id` or a timer. The call travels with the next patch and
529
+ * runs on the main thread right after that patch is flushed, so it is
530
+ * safe from `oncreate`, when the element does not exist natively yet.
531
+ * Called outside a render (a timer, a promise), it is sent on its own
532
+ * within the same tick.
533
+ * @param {string} method - The UI method name.
534
+ * @param {Object} [params] - The method's parameters.
535
+ * @returns {Promise<unknown>} Resolves with the method's result; rejects
536
+ * with an `Error` carrying the native `code` and `data`, or when the
537
+ * element is removed before the call could run.
538
+ */
539
+ invoke(method, params) {
540
+ return new Promise((resolve, reject) => {
541
+ const callbackId = this.ownerDocument._registerInvoke(method, resolve, reject);
542
+ this._backend.invokeUIMethod(this._id, method, params ?? {}, callbackId);
543
+ });
544
+ }
545
+
546
+ /**
547
+ * Fire-and-forget UI method call, for methods whose result nobody reads.
548
+ * @param {string} method - The UI method name.
549
+ * @returns {void}
550
+ */
551
+ _invokeAndForget(method) {
552
+ this._backend.invokeUIMethod(this._id, method, {}, 0);
553
+ }
554
+
555
+ /**
556
+ * Focuses the element (the native `focus` UI method). Like `invoke()`,
557
+ * it runs once the current patch is applied, so it works from `oncreate`.
558
+ * Mithril's own post-render focus restoration never calls this: the
559
+ * fake document has no `activeElement` (see render.js's `render()`).
560
+ * @returns {void}
561
+ */
350
562
  focus() {
351
- // Native `<input>` focus on Lynx is managed by the platform, not by
352
- // a JS `.focus()` call reaching into the render pipeline — calling
353
- // into the backend here would mean patch application could disturb
354
- // focus mid-keystroke, which is the exact failure mode
355
- // mithril-lynx v1 was designed around (its `<input>` deliberately
356
- // has no bound `value` for the same reason). No-op by design.
563
+ this._invokeAndForget("focus");
564
+ }
565
+
566
+ /**
567
+ * Removes focus from the element (the native `blur` UI method).
568
+ * @returns {void}
569
+ */
570
+ blur() {
571
+ this._invokeAndForget("blur");
357
572
  }
358
573
  }
359
574
 
575
+ /**
576
+ * An `<input>` or `<textarea>`. Native has no `value` attribute, only UI
577
+ * methods, so `value` here is a real property: assigning a different value
578
+ * sends a `setValue` call (Op.SetInputValue), and every forwarded event of
579
+ * the field syncs it back from `detail.value` — exactly what a browser's
580
+ * input does. That makes Mithril's own form handling work unchanged:
581
+ * render.js skips writing a `value` equal to `dom.value`, so the text the
582
+ * user just typed is never sent back, while a value the app changed (a
583
+ * clear, an uppercase transform) is sent once.
584
+ */
585
+ class LynxFormFieldElement extends LynxElement {
586
+ constructor(ownerDocument, backend, tag, ns) {
587
+ super(ownerDocument, backend, tag, ns);
588
+ this._value = "";
589
+ // How many native `input` events the main thread had counted when the
590
+ // last synced event was sent — echoed in Op.SetInputValue so a value
591
+ // computed before a newer keystroke is dropped (see apply-patch.js).
592
+ this._inputSeq = 0;
593
+ this._selectionStart = -1;
594
+ this._selectionEnd = -1;
595
+ this._autofocused = false;
596
+ }
597
+
598
+ /**
599
+ * The field's current text, as last seen from native or set here.
600
+ * @returns {string}
601
+ */
602
+ get value() {
603
+ return this._value;
604
+ }
605
+
606
+ /**
607
+ * Sets the field's text; `null`/`undefined` clears it. Nothing is sent
608
+ * when it already holds that text.
609
+ * @param {*} value - The new text, coerced with `String()`.
610
+ */
611
+ set value(value) {
612
+ const text = value == null ? "" : String(value);
613
+ if (text === this._value) return;
614
+ this._value = text;
615
+ this._backend.setInputValue(this._id, text, this._inputSeq);
616
+ }
617
+
618
+ /**
619
+ * Selection start from the last forwarded event, `-1` when unknown.
620
+ * @returns {number}
621
+ */
622
+ get selectionStart() {
623
+ return this._selectionStart;
624
+ }
625
+
626
+ /** Read-only: ignored (a `selectionStart` attribute has no meaning here). */
627
+ set selectionStart(_value) {}
628
+
629
+ /**
630
+ * Selection end from the last forwarded event, `-1` when unknown.
631
+ * @returns {number}
632
+ */
633
+ get selectionEnd() {
634
+ return this._selectionEnd;
635
+ }
636
+
637
+ /** Read-only: ignored (a `selectionEnd` attribute has no meaning here). */
638
+ set selectionEnd(_value) {}
639
+
640
+ /**
641
+ * Whether the field was focused on creation by `autofocus`.
642
+ * @returns {boolean}
643
+ */
644
+ get autofocus() {
645
+ return this._autofocused;
646
+ }
647
+
648
+ /**
649
+ * `autofocus: true` focuses the field once, when it is created — the
650
+ * HTML attribute's semantics (Lynx has no such attribute). Later
651
+ * redraws, or toggling it, never focus it again.
652
+ * @param {*} value - Truthy to focus.
653
+ */
654
+ set autofocus(value) {
655
+ if (!value || this._autofocused) return;
656
+ this._autofocused = true;
657
+ this.focus();
658
+ }
659
+
660
+ /**
661
+ * Selects a range of the field's text (the native `setSelectionRange` UI method).
662
+ * @param {number} selectionStart - Start index.
663
+ * @param {number} selectionEnd - End index.
664
+ * @returns {Promise<unknown>}
665
+ */
666
+ setSelectionRange(selectionStart, selectionEnd) {
667
+ return this.invoke("setSelectionRange", { selectionStart, selectionEnd });
668
+ }
669
+
670
+ /**
671
+ * Syncs `value`, the selection and the input sequence from a forwarded
672
+ * native event, before its handlers run. Silent: nothing is sent.
673
+ * @param {{detail?: {value?: unknown, selectionStart?: unknown, selectionEnd?: unknown}}} payload - The native event.
674
+ * @param {number} [seq] - The field's native `input` event count.
675
+ * @returns {void}
676
+ */
677
+ _syncFromEvent(payload, seq) {
678
+ if (typeof seq === "number") this._inputSeq = seq;
679
+ const detail = payload && payload.detail;
680
+ if (detail == null) return;
681
+ if (typeof detail.value === "string") this._value = detail.value;
682
+ if (typeof detail.selectionStart === "number") this._selectionStart = detail.selectionStart;
683
+ if (typeof detail.selectionEnd === "number") this._selectionEnd = detail.selectionEnd;
684
+ }
685
+ }
686
+
687
+ /**
688
+ * A fake-DOM text node backed by a native text element.
689
+ */
690
+ /**
691
+ * An element whose native attributes are typed (`<list>`, `<list-item>`).
692
+ * Every catalogued attribute is a real property here (defined on the
693
+ * per-tag prototype by {@link typedElementClassFor}), so Mithril's property
694
+ * path assigns raw values — booleans, numbers, the `item-snap` object —
695
+ * instead of stringifying them or turning `false` into a removal. See
696
+ * list-attributes.js for why that matters.
697
+ */
698
+ class LynxTypedElement extends LynxElement {
699
+ constructor(ownerDocument, backend, tag, ns) {
700
+ super(ownerDocument, backend, tag, ns);
701
+ this._typedValues = Object.create(null);
702
+ }
703
+
704
+ /**
705
+ * Sets or clears one typed attribute, keeping its raw value. Objects are
706
+ * compared by content: Mithril re-assigns object attributes on every
707
+ * redraw (render.js `setAttr` never skips `typeof value === "object"`),
708
+ * and re-sending an unchanged `item-snap` would be a wasted native write.
709
+ * @param {string} name - The attribute name.
710
+ * @param {*} value - The raw value; `null`/`undefined` removes the attribute.
711
+ * @returns {void}
712
+ */
713
+ _setTypedAttribute(name, value) {
714
+ const had = name in this._typedValues;
715
+ if (value == null) {
716
+ if (!had) return;
717
+ delete this._typedValues[name];
718
+ this._backend.removeAttribute(this._id, name);
719
+ return;
720
+ }
721
+ if (had) {
722
+ const previous = this._typedValues[name];
723
+ if (previous === value) return;
724
+ if (typeof value === "object" && typeof previous === "object" && JSON.stringify(previous) === JSON.stringify(value)) return;
725
+ }
726
+ this._typedValues[name] = value;
727
+ this._backend.setAttribute(this._id, name, value);
728
+ }
729
+
730
+ /**
731
+ * Catalogued names keep their raw type; everything else behaves like
732
+ * {@link LynxElement#setAttribute}.
733
+ * @param {string} name - Attribute name.
734
+ * @param {*} value - Attribute value.
735
+ * @returns {void}
736
+ */
737
+ setAttribute(name, value) {
738
+ if (TYPED_ATTRIBUTES_BY_TAG[this.tag].includes(name)) this._setTypedAttribute(name, value);
739
+ else super.setAttribute(name, value);
740
+ }
741
+
742
+ /**
743
+ * @param {string} name - Attribute name.
744
+ * @returns {void}
745
+ */
746
+ removeAttribute(name) {
747
+ if (TYPED_ATTRIBUTES_BY_TAG[this.tag].includes(name)) this._setTypedAttribute(name, null);
748
+ else super.removeAttribute(name);
749
+ }
750
+ }
751
+
752
+ const typedElementClasses = Object.create(null);
753
+
754
+ /**
755
+ * Returns (and caches) the element class for a typed tag: a subclass of
756
+ * LynxTypedElement with one accessor per catalogued attribute on its
757
+ * prototype, so `name in element` holds for Mithril's `hasPropertyKey`.
758
+ * @param {string} tag - A key of TYPED_ATTRIBUTES_BY_TAG.
759
+ * @returns {typeof LynxTypedElement}
760
+ */
761
+ function typedElementClassFor(tag) {
762
+ let cls = typedElementClasses[tag];
763
+ if (cls) return cls;
764
+ cls = class extends LynxTypedElement {};
765
+ for (const name of TYPED_ATTRIBUTES_BY_TAG[tag]) {
766
+ Object.defineProperty(cls.prototype, name, {
767
+ get() {
768
+ return this._typedValues[name];
769
+ },
770
+ set(value) {
771
+ this._setTypedAttribute(name, value);
772
+ },
773
+ configurable: true,
774
+ });
775
+ }
776
+ typedElementClasses[tag] = cls;
777
+ return cls;
778
+ }
779
+
360
780
  export class LynxText extends LynxNode {
781
+ /**
782
+ * @param {LynxDocument} ownerDocument - The owning document.
783
+ * @param {Object} backend - The patch backend used to create and update the text.
784
+ * @param {string} text - The initial text.
785
+ */
361
786
  constructor(ownerDocument, backend, text) {
362
787
  super(ownerDocument);
363
788
  this._backend = backend;
@@ -371,10 +796,18 @@ export class LynxText extends LynxNode {
371
796
  this._id = backend.createText(text);
372
797
  }
373
798
 
799
+ /**
800
+ * The node's text.
801
+ * @returns {string}
802
+ */
374
803
  get nodeValue() {
375
804
  return this._text;
376
805
  }
377
806
 
807
+ /**
808
+ * Updates the text and emits a set-text op.
809
+ * @param {string} value - The new text.
810
+ */
378
811
  set nodeValue(value) {
379
812
  this._text = value;
380
813
  this._backend.setText(this._id, value);
@@ -387,9 +820,18 @@ export class LynxText extends LynxNode {
387
820
  // (this._id != null && child._id != null)` guards in insertBefore/
388
821
  // removeChild are what keep a fragment-as-parent from ever trying to call
389
822
  // the backend for itself.
823
+ /**
824
+ * A document fragment: never gets a backend id; its children are moved into the target on insertion.
825
+ */
390
826
  export class LynxFragment extends LynxContainerNode {}
391
827
 
828
+ /**
829
+ * The fake document handed to Mithril's render.js; id `0` is the real page container.
830
+ */
392
831
  export class LynxDocument extends LynxContainerNode {
832
+ /**
833
+ * @param {Object} backend - The patch backend that records ops (see `createVirtualBackend()`).
834
+ */
393
835
  constructor(backend) {
394
836
  super(null);
395
837
  this._backend = backend;
@@ -401,66 +843,122 @@ export class LynxDocument extends LynxContainerNode {
401
843
  this._id = 0;
402
844
  this.namespaceURI = undefined;
403
845
  /** id -> node, for dispatching a forwarded native event (which only
404
- * carries an id + type) to the right fake-dom element. Populated by
405
- * every LynxElement/LynxText constructor; never by fragments, which
406
- * have no id and are never event targets. */
846
+ * carries an id + type) to the right fake-dom element. Populated only
847
+ * by the LynxElement constructor: text nodes have an id but are never
848
+ * event targets, fragments have no id at all, and the document itself
849
+ * (id 0) is not registered either. */
407
850
  this._nodesById = new Map();
851
+ // callbackId -> pending `invoke()` promise, settled by resolveInvoke().
852
+ this._invokeCallbacks = new Map();
853
+ this._nextCallbackId = 1;
854
+ }
855
+
856
+ /**
857
+ * Registers a pending `invoke()` promise.
858
+ * @param {string} method - The UI method name, for the error message.
859
+ * @param {(data: unknown) => void} resolve - Settles it successfully.
860
+ * @param {(error: Error) => void} reject - Settles it with an error.
861
+ * @returns {number} The callback id to send with Op.InvokeUIMethod.
862
+ */
863
+ _registerInvoke(method, resolve, reject) {
864
+ const callbackId = this._nextCallbackId++;
865
+ this._invokeCallbacks.set(callbackId, { method, resolve, reject });
866
+ return callbackId;
867
+ }
868
+
869
+ /**
870
+ * Settles a pending `invoke()` with the result forwarded by the main
871
+ * thread. Unknown ids are ignored.
872
+ * @param {{callbackId: number, code: number, data: unknown}} result - The result.
873
+ * @returns {void}
874
+ */
875
+ resolveInvoke(result) {
876
+ const pending = result && this._invokeCallbacks.get(result.callbackId);
877
+ if (pending == null) return;
878
+ this._invokeCallbacks.delete(result.callbackId);
879
+ if (result.code === 0) {
880
+ pending.resolve(result.data);
881
+ return;
882
+ }
883
+ const detail = typeof result.data === "string" ? result.data : JSON.stringify(result.data);
884
+ const error = new Error(`[mithril-lynx] UI method "${pending.method}" failed (code ${result.code}): ${detail}`);
885
+ error.code = result.code;
886
+ error.data = result.data;
887
+ pending.reject(error);
408
888
  }
409
889
 
890
+ /**
891
+ * Looks up a node by backend id, used to route forwarded native events.
892
+ * @param {number} id - The backend id.
893
+ * @returns {LynxElement|null} The element, or `null` when unknown (text nodes and the document are never registered).
894
+ */
410
895
  getNodeById(id) {
411
896
  return this._nodesById.get(id) ?? null;
412
897
  }
413
898
 
899
+ /**
900
+ * No-op; present so render.js's focus-restoration check never throws.
901
+ * @returns {void}
902
+ */
414
903
  focus() {
415
904
  // Never meaningfully called on the document root itself; present so
416
905
  // render.js's post-render focus-restoration check (CONTRACT.md §b)
417
906
  // never throws if `activeElement` happens to resolve to the root.
418
907
  }
419
908
 
909
+ /**
910
+ * Only `""` is honored: removes every child of the document root.
911
+ * @param {string} value - Must be the empty string for any effect.
912
+ */
420
913
  set textContent(value) {
421
914
  if (value !== "") return;
422
915
  for (const child of this._children.slice()) this.removeChild(child);
423
916
  }
424
917
 
918
+ /**
919
+ * Creates an element.
920
+ * @param {string} tag - The tag name.
921
+ * @returns {LynxElement}
922
+ */
425
923
  createElement(tag) {
924
+ if (tag in TYPED_ATTRIBUTES_BY_TAG) return new (typedElementClassFor(tag))(this, this._backend, tag, undefined);
925
+ if (FORM_FIELD_TAGS.includes(tag)) return new LynxFormFieldElement(this, this._backend, tag, undefined);
426
926
  return new LynxElement(this, this._backend, tag, undefined);
427
927
  }
428
928
 
929
+ /**
930
+ * Creates a namespaced element.
931
+ * @param {string} ns - The namespace URI.
932
+ * @param {string} tag - The tag name.
933
+ * @returns {LynxElement}
934
+ */
429
935
  createElementNS(ns, tag) {
430
936
  return new LynxElement(this, this._backend, tag, ns);
431
937
  }
432
938
 
939
+ /**
940
+ * Creates a text node.
941
+ * @param {string} text - The initial text.
942
+ * @returns {LynxText}
943
+ */
433
944
  createTextNode(text) {
434
945
  return new LynxText(this, this._backend, text);
435
946
  }
436
947
 
437
- createDocumentFragment() {
438
- return new LynxFragment(this);
439
- }
440
-
441
948
  /**
442
- * A native virtualized list — see patch-protocol.js's Op.CreateList.
443
- * Populate it via `.setListItems(cells)` (list-cell.js builds `cells`
444
- * from an app's own `items`/`renderItem`) — see
445
- * docs/native-papi/papi-06-virtualized-lists.md in mithril-lynx-ui.
949
+ * Creates an empty document fragment.
950
+ * @returns {LynxFragment}
446
951
  */
447
- createNativeList(options = {}) {
448
- return new LynxElement(this, this._backend, "list", undefined, {
449
- scrollOrientation: options.scrollOrientation ?? "vertical",
450
- listType: options.listType ?? "single",
451
- spanCount: options.spanCount ?? 1,
452
- });
453
- }
454
-
455
- /** Delegates to the backend — see virtual-backend.js's own captureOps
456
- * for what this is for. Kept behind LynxDocument's public surface like
457
- * every other backend interaction in this file, rather than exposing
458
- * `_backend` itself to callers (list-cell.js). */
459
- captureOps(fn) {
460
- return this._backend.captureOps(fn);
952
+ createDocumentFragment() {
953
+ return new LynxFragment(this);
461
954
  }
462
955
  }
463
956
 
957
+ /**
958
+ * Creates a fake document bound to a patch backend.
959
+ * @param {Object} backend - The patch backend (see `createVirtualBackend()`).
960
+ * @returns {LynxDocument}
961
+ */
464
962
  export function createLynxDocument(backend) {
465
963
  return new LynxDocument(backend);
466
964
  }