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
@@ -0,0 +1,69 @@
1
+ // src/list-attributes.js
2
+ //
3
+ // The attribute catalog of Lynx's built-in `<list>` / `<list-item>`
4
+ // elements, shared by both threads. Source: the official element reference
5
+ // (lynxjs.org/api/elements/built-in/list), plus the list-item platform-info
6
+ // set ReactLynx itself sends to native (`platformInfoAttributes` in
7
+ // @lynx-js/react's runtime/lib/snapshot/snapshot/platformInfo.js).
8
+ //
9
+ // Why a catalog at all: these attributes are TYPED on the native side
10
+ // (booleans, numbers, the `item-snap` object). Mithril's plain attribute
11
+ // path can't carry that — it turns `true` into `setAttribute(key, "")` and
12
+ // `false` into `removeAttribute(key)` (render.js `setAttr`), and fake-dom's
13
+ // generic setAttribute stringifies. fake-dom exposes every name below as a
14
+ // real property on `list`/`list-item` elements, which makes Mithril take its
15
+ // property path instead (`key in vnode.dom` -> `vnode.dom[key] = value`) and
16
+ // hand over the raw value, `false` included.
17
+
18
+ /** Every documented `<list>` attribute. The CSS-only `list-main-axis-gap` /
19
+ * `list-cross-axis-gap` go through `style`, not here. */
20
+ export const LIST_ATTRIBUTES = Object.freeze([
21
+ "list-type",
22
+ "span-count",
23
+ "scroll-orientation",
24
+ "enable-scroll",
25
+ "enable-nested-scroll",
26
+ "sticky",
27
+ "sticky-offset",
28
+ "bounces",
29
+ "initial-scroll-index",
30
+ "need-visible-item-info",
31
+ "upper-threshold-item-count",
32
+ "lower-threshold-item-count",
33
+ "scroll-event-throttle",
34
+ "item-snap",
35
+ "update-animation",
36
+ "need-layout-complete-info",
37
+ "layout-id",
38
+ "preload-buffer-count",
39
+ "scroll-bar-enable",
40
+ "harmony-scroll-edge-effect",
41
+ "experimental-recycle-sticky-item",
42
+ ]);
43
+
44
+ /** `<list-item>` attributes native needs BEFORE it renders an item (they
45
+ * drive layout: spans, sticky slots, placeholder size, reuse pools), so they
46
+ * travel inside `update-list-info`'s insert/update actions, not only as
47
+ * element attributes. */
48
+ export const LIST_ITEM_PLATFORM_ATTRIBUTES = Object.freeze([
49
+ "item-key",
50
+ "full-span",
51
+ "sticky-top",
52
+ "sticky-bottom",
53
+ "estimated-height",
54
+ "estimated-height-px",
55
+ "estimated-main-axis-size-px",
56
+ "reuse-identifier",
57
+ "recyclable",
58
+ ]);
59
+
60
+ /** Platform-info attributes that exist only inside `update-list-info` and
61
+ * are never set on the element itself (ReactLynx's
62
+ * `platformInfoVirtualAttributes`). */
63
+ export const LIST_ITEM_VIRTUAL_ATTRIBUTES = Object.freeze(["reuse-identifier", "recyclable"]);
64
+
65
+ /** Typed attributes exposed as properties on the fake-dom element, per tag. */
66
+ export const TYPED_ATTRIBUTES_BY_TAG = Object.freeze({
67
+ list: LIST_ATTRIBUTES,
68
+ "list-item": LIST_ITEM_PLATFORM_ATTRIBUTES,
69
+ });
@@ -0,0 +1,425 @@
1
+ // src/list-runtime.js
2
+ //
3
+ // Main-thread runtime for Lynx's native `<list>`, driven by apply-patch.js.
4
+ // Runs ONLY on the main thread.
5
+ //
6
+ // The model is the one @lynx-js/react adopted for its element-template
7
+ // runtime (runtime/lib/element-template/runtime/list/list.js): `<list-item>`s
8
+ // are ORDINARY elements of the app's tree — created, diffed and updated by
9
+ // the normal patch stream — and each keeps its own element subtree for its
10
+ // whole life. The list never renders anything inside a native callback:
11
+ //
12
+ // - InsertBefore/RemoveChild ops whose parent is a list do NOT touch the
13
+ // native tree. They only update the list's logical item order and record
14
+ // what changed; at the end of the patch, `flushUpdates()` tells native
15
+ // through `update-list-info` (insert/remove/update actions, each carrying
16
+ // the item's platform info) and re-registers the callbacks.
17
+ // - `componentAtIndex` (native asking for index N) attaches item N's
18
+ // existing element under the list, before the next attached item, and
19
+ // flushes it. `enqueueComponent` (item scrolled away) detaches it again.
20
+ //
21
+ // Only attached items have native UI views; detached ones are plain element
22
+ // trees. Content updates inside any item are ordinary ops.
23
+ //
24
+ // PAPI contract, per ReactLynx:
25
+ // __CreateList(pageId, componentAtIndex, enqueueComponent, {}, componentAtIndexes)
26
+ // __SetAttribute(list, "update-list-info", { insertAction, removeAction, updateAction })
27
+ // __UpdateListCallbacks(list, componentAtIndex, enqueueComponent, componentAtIndexes)
28
+ // componentAtIndex(list, listID, cellIndex, operationID, enableReuseNotification) -> sign
29
+ // componentAtIndexes(list, listID, cellIndexes, operationIDs, enableReuseNotification, asyncFlush)
30
+ // enqueueComponent(list, listID, sign)
31
+
32
+ import { LIST_ITEM_PLATFORM_ATTRIBUTES } from "./list-attributes.js";
33
+
34
+ const PLATFORM_KEYS = new Set(LIST_ITEM_PLATFORM_ATTRIBUTES);
35
+
36
+ /**
37
+ * Whether an attribute name is part of a list item's platform info.
38
+ * @param {string} name - Attribute name.
39
+ * @returns {boolean}
40
+ */
41
+ export function isListItemPlatformAttribute(name) {
42
+ return PLATFORM_KEYS.has(name);
43
+ }
44
+
45
+ /**
46
+ * Shallow equality for two platform-info objects (their values are
47
+ * primitives, as documented for every catalogued list-item attribute).
48
+ * @param {Object<string, *>} a - First info.
49
+ * @param {Object<string, *>} b - Second info.
50
+ * @returns {boolean}
51
+ */
52
+ function sameInfo(a, b) {
53
+ const aKeys = Object.keys(a);
54
+ if (aKeys.length !== Object.keys(b).length) return false;
55
+ for (const key of aKeys) if (a[key] !== b[key]) return false;
56
+ return true;
57
+ }
58
+
59
+ /**
60
+ * The item's native `type`: its reuse pool. `reuse-identifier` when set,
61
+ * like ReactLynx; otherwise one shared pool.
62
+ * @param {{info: Object<string, *>}} item - The item record.
63
+ * @returns {string}
64
+ */
65
+ function typeOf(item) {
66
+ const reuse = item.info["reuse-identifier"];
67
+ return reuse != null ? String(reuse) : "list-item";
68
+ }
69
+
70
+ /**
71
+ * Creates one native list and the state behind it.
72
+ * @param {number} pageId - The page's unique id.
73
+ * @returns {{handle: *, insert: (id: number, handle: *, info: Object, refId: number) => void, remove: (id: number) => void, has: (id: number) => boolean, updateInfo: (id: number, info: Object) => void, setUpdateAnimation: (value: *) => void, flushUpdates: () => void, detachRemoved: () => boolean, destroy: () => void}}
74
+ */
75
+ export function createListRuntime(pageId) {
76
+ /** @type {Array<{id: number, handle: *, info: Object<string, *>, attached: boolean, needsAttachMove: boolean, skipNextEnqueue: boolean}>} */
77
+ let items = [];
78
+ const itemsById = new Map();
79
+ const itemsBySign = new Map();
80
+ let hasAttachedMoves = false;
81
+ let destroyed = false;
82
+ // On-screen items removed by the current patch, detached only after the
83
+ // patch's flush (see remove()).
84
+ let removedAttached = [];
85
+ // Mirrors the list's `update-animation` attribute (see remove()).
86
+ let animatesUpdates = false;
87
+
88
+ /**
89
+ * Records the list's `update-animation` attribute.
90
+ * @param {*} value - The attribute value.
91
+ * @returns {void}
92
+ */
93
+ function setUpdateAnimation(value) {
94
+ animatesUpdates = value === "default";
95
+ if (animatesUpdates && typeof console !== "undefined") {
96
+ console.warn(
97
+ "[mithril-lynx] list: update-animation=\"default\" is not fully supported yet — removed items stay " +
98
+ "attached to the list element (detaching them while native animates crashes Lynx). " +
99
+ "See UPDATE_ANIMATION_GAP.md in mithril-lynx.",
100
+ );
101
+ }
102
+ }
103
+ // Set while a patch is changing this list: the item order and infos as
104
+ // they were BEFORE the patch, plus what was inserted/removed since.
105
+ let pending = null;
106
+
107
+ /**
108
+ * Starts recording changes for the current patch, if not already.
109
+ * @returns {{beforeItems: Array<{id: number, info: Object}>, beforeIndexById: Map<number, number>, insertIds: Set<number>, removeIds: Set<number>}}
110
+ */
111
+ function markPending() {
112
+ if (pending == null) {
113
+ const beforeItems = items.map((item) => ({ id: item.id, info: Object.assign({}, item.info) }));
114
+ const beforeIndexById = new Map();
115
+ beforeItems.forEach((item, index) => beforeIndexById.set(item.id, index));
116
+ pending = { beforeItems, beforeIndexById, insertIds: new Set(), removeIds: new Set() };
117
+ }
118
+ return pending;
119
+ }
120
+
121
+ /**
122
+ * Index of an item in the current order.
123
+ * @param {number} id - Item id.
124
+ * @returns {number} The index, or -1.
125
+ */
126
+ function indexOf(id) {
127
+ for (let i = 0; i < items.length; i++) if (items[i].id === id) return i;
128
+ return -1;
129
+ }
130
+
131
+ /**
132
+ * Inserts (or moves) an item before `refId` (-1 = at the end).
133
+ * @param {number} id - Item id.
134
+ * @param {*} handle - The item's `list-item` element.
135
+ * @param {Object<string, *>} info - Its current platform info.
136
+ * @param {number} refId - Id of the item to insert before, or -1.
137
+ * @returns {void}
138
+ */
139
+ function insert(id, handle, info, refId) {
140
+ const p = markPending();
141
+ let item = itemsById.get(id);
142
+ if (item != null) {
143
+ // A move: Mithril re-inserted an item that is already in the list.
144
+ // Native sees it as remove(old index) + insert(new index), like
145
+ // ReactLynx; an attached one is re-placed on the next callback.
146
+ items.splice(indexOf(id), 1);
147
+ if (p.beforeIndexById.has(id)) p.removeIds.add(id);
148
+ if (item.attached) {
149
+ item.needsAttachMove = true;
150
+ hasAttachedMoves = true;
151
+ }
152
+ } else {
153
+ item = { id, handle, info: Object.assign({}, info), attached: false, needsAttachMove: false, skipNextEnqueue: false };
154
+ itemsById.set(id, item);
155
+ }
156
+ const refIndex = refId === -1 ? -1 : indexOf(refId);
157
+ if (refIndex === -1) items.push(item);
158
+ else items.splice(refIndex, 0, item);
159
+ p.insertIds.add(id);
160
+ }
161
+
162
+ /**
163
+ * Removes an item from the list's data. An item that is on screen is
164
+ * detached from the list element only AFTER native has processed this
165
+ * patch's `removeAction` (see detachRemoved()). Verified on a real device
166
+ * (Android, Lynx Go):
167
+ * - detaching it here, before `update-list-info` is flushed, makes
168
+ * native fail to find the item's holder and crash (SIGSEGV after
169
+ * "[List] Fail to erase item holder");
170
+ * - never detaching it renders fine (native drops its cell) but leaves
171
+ * its element as an orphan child of the list, growing with every
172
+ * removal — native never calls `enqueueComponent` for removed items;
173
+ * - detaching it after the flush is clean, EXCEPT while an
174
+ * `update-animation` is running on the list: native animates the
175
+ * removed cell and crashes when the animation ends if its element is
176
+ * gone. So with `update-animation="default"` removed items are left
177
+ * attached (see setUpdateAnimation()).
178
+ * @param {number} id - Item id.
179
+ * @returns {void}
180
+ */
181
+ function remove(id) {
182
+ const item = itemsById.get(id);
183
+ if (item == null) return;
184
+ const p = markPending();
185
+ items.splice(indexOf(id), 1);
186
+ itemsById.delete(id);
187
+ if (p.beforeIndexById.has(id)) p.removeIds.add(id);
188
+ p.insertIds.delete(id);
189
+ if (item.attached) removedAttached.push(item);
190
+ else forgetSigns(item);
191
+ }
192
+
193
+ /**
194
+ * Drops every sign that maps to `item`.
195
+ * @param {Object} item - The item record.
196
+ * @returns {void}
197
+ */
198
+ function forgetSigns(item) {
199
+ for (const [sign, bound] of itemsBySign) if (bound === item) itemsBySign.delete(sign);
200
+ }
201
+
202
+ /**
203
+ * Detaches the on-screen items removed by the last patch. Must run after
204
+ * that patch's `update-list-info` has been flushed to native.
205
+ * @returns {boolean} Whether anything was detached (and needs a flush).
206
+ */
207
+ function detachRemoved() {
208
+ if (removedAttached.length === 0) return false;
209
+ const detached = removedAttached;
210
+ removedAttached = [];
211
+ let any = false;
212
+ for (const item of detached) {
213
+ forgetSigns(item);
214
+ if (!item.attached || destroyed || animatesUpdates) continue;
215
+ __RemoveElement(handle, item.handle);
216
+ item.attached = false;
217
+ any = true;
218
+ }
219
+ return any;
220
+ }
221
+
222
+ /**
223
+ * Records a platform-info change on an item already in the list.
224
+ * @param {number} id - Item id.
225
+ * @param {Object<string, *>} info - The new info.
226
+ * @returns {void}
227
+ */
228
+ function updateInfo(id, info) {
229
+ const item = itemsById.get(id);
230
+ if (item == null) return;
231
+ markPending();
232
+ item.info = Object.assign({}, info);
233
+ }
234
+
235
+ /**
236
+ * Sends this patch's changes to native, if any. Must run before the
237
+ * patch's `__FlushElementTree()`.
238
+ * @returns {void}
239
+ */
240
+ function flushUpdates() {
241
+ if (pending == null || destroyed) {
242
+ pending = null;
243
+ return;
244
+ }
245
+ const { beforeItems, beforeIndexById, insertIds, removeIds } = pending;
246
+ pending = null;
247
+ const insertAction = [];
248
+ const removeAction = [];
249
+ const updateAction = [];
250
+ beforeItems.forEach((item, index) => {
251
+ if (removeIds.has(item.id)) removeAction.push(index);
252
+ });
253
+ items.forEach((item, index) => {
254
+ if (insertIds.has(item.id)) {
255
+ insertAction.push(Object.assign({ position: index, type: typeOf(item) }, item.info));
256
+ return;
257
+ }
258
+ const before = beforeItems[beforeIndexById.get(item.id)];
259
+ if (!sameInfo(before.info, item.info)) {
260
+ updateAction.push(Object.assign({}, item.info, { from: index, to: index, type: typeOf(item), flush: false }));
261
+ }
262
+ });
263
+ if (insertAction.length === 0 && removeAction.length === 0 && updateAction.length === 0) return;
264
+ __SetAttribute(handle, "update-list-info", { insertAction, removeAction, updateAction });
265
+ __UpdateListCallbacks(handle, componentAtIndex, enqueueComponent, componentAtIndexes);
266
+ }
267
+
268
+ /**
269
+ * Re-places attached items that moved, walking from the end so each one
270
+ * lands before its already-placed successor. Like ReactLynx, the move
271
+ * makes native enqueue the item once more, which `skipNextEnqueue` absorbs.
272
+ * @returns {void}
273
+ */
274
+ function moveAttachedItemsIntoFinalOrder() {
275
+ if (!hasAttachedMoves) return;
276
+ let reference = null;
277
+ for (let i = items.length - 1; i >= 0; i--) {
278
+ const item = items[i];
279
+ if (!item.attached) {
280
+ item.needsAttachMove = false;
281
+ continue;
282
+ }
283
+ if (item.needsAttachMove) {
284
+ if (reference != null) __InsertElementBefore(handle, item.handle, reference);
285
+ else __AppendElement(handle, item.handle);
286
+ item.needsAttachMove = false;
287
+ item.skipNextEnqueue = true;
288
+ }
289
+ reference = item.handle;
290
+ }
291
+ hasAttachedMoves = false;
292
+ }
293
+
294
+ /**
295
+ * The first attached item after `cellIndex`, whose element a newly
296
+ * attached item must be inserted before: native lays cells out in tree
297
+ * order.
298
+ * @param {number} cellIndex - The index being attached.
299
+ * @returns {*} The next attached element, or `null`.
300
+ */
301
+ function nextAttachedHandle(cellIndex) {
302
+ for (let i = cellIndex + 1; i < items.length; i++) if (items[i].attached) return items[i].handle;
303
+ return null;
304
+ }
305
+
306
+ /**
307
+ * Attaches item `cellIndex` and returns its sign.
308
+ * @param {number} listID - The list's unique id.
309
+ * @param {number} cellIndex - The requested index.
310
+ * @param {number} operationID - Native operation id.
311
+ * @param {boolean} batch - Whether this is part of `componentAtIndexes`.
312
+ * @param {boolean} [asyncFlush=false] - Batch only: flush this item asynchronously.
313
+ * @returns {number} The item element's unique id, or -1 for an unknown index.
314
+ */
315
+ function attach(listID, cellIndex, operationID, batch, asyncFlush = false) {
316
+ moveAttachedItemsIntoFinalOrder();
317
+ const item = items[cellIndex];
318
+ if (item == null) {
319
+ if (typeof console !== "undefined") {
320
+ console.error(`[mithril-lynx] list: componentAtIndex(${cellIndex}) is out of range (${items.length} items).`);
321
+ }
322
+ return -1;
323
+ }
324
+ if (!item.attached) {
325
+ const reference = nextAttachedHandle(cellIndex);
326
+ if (reference != null) __InsertElementBefore(handle, item.handle, reference);
327
+ else __AppendElement(handle, item.handle);
328
+ item.attached = true;
329
+ item.needsAttachMove = false;
330
+ }
331
+ const sign = __GetElementUniqueID(item.handle);
332
+ itemsBySign.set(sign, item);
333
+ if (!batch) __FlushElementTree(item.handle, { triggerLayout: true, operationID, elementID: sign, listID });
334
+ else if (asyncFlush) __FlushElementTree(item.handle, { asyncFlush: true });
335
+ return sign;
336
+ }
337
+
338
+ /**
339
+ * Native callback: provides the item at one index.
340
+ * @param {*} _list - The list element.
341
+ * @param {number} listID - The list's unique id.
342
+ * @param {number} cellIndex - The requested index.
343
+ * @param {number} operationID - Native operation id.
344
+ * @returns {number} The item's sign, or -1.
345
+ */
346
+ function componentAtIndex(_list, listID, cellIndex, operationID) {
347
+ if (destroyed) return -1;
348
+ return attach(listID, cellIndex, operationID, false);
349
+ }
350
+
351
+ /**
352
+ * Native callback: provides several items with one list flush.
353
+ * @param {*} _list - The list element.
354
+ * @param {number} listID - The list's unique id.
355
+ * @param {number[]} cellIndexes - The requested indexes.
356
+ * @param {number[]} operationIDs - Native operation ids, one per index.
357
+ * @param {boolean} [_enableReuseNotification] - Unused: items are never reused for other data.
358
+ * @param {boolean} [asyncFlush] - Flush each item asynchronously.
359
+ * @returns {void}
360
+ */
361
+ function componentAtIndexes(_list, listID, cellIndexes, operationIDs, _enableReuseNotification, asyncFlush) {
362
+ if (destroyed) return;
363
+ const elementIDs = cellIndexes.map((cellIndex, i) => attach(listID, cellIndex, operationIDs[i], true, asyncFlush === true));
364
+ __FlushElementTree(handle, { triggerLayout: true, operationIDs, elementIDs, listID });
365
+ }
366
+
367
+ /**
368
+ * Native callback: an item scrolled away; detach its element.
369
+ * @param {*} _list - The list element.
370
+ * @param {number} _listID - The list's unique id.
371
+ * @param {number} sign - The item's sign.
372
+ * @returns {void}
373
+ */
374
+ function enqueueComponent(_list, _listID, sign) {
375
+ if (destroyed) return;
376
+ const item = itemsBySign.get(sign);
377
+ if (item == null) return;
378
+ if (!item.attached) {
379
+ itemsBySign.delete(sign);
380
+ return;
381
+ }
382
+ if (item.skipNextEnqueue) {
383
+ item.skipNextEnqueue = false;
384
+ return;
385
+ }
386
+ __RemoveElement(handle, item.handle);
387
+ item.attached = false;
388
+ item.needsAttachMove = false;
389
+ itemsBySign.delete(sign);
390
+ }
391
+
392
+ /**
393
+ * Neutralizes the native callbacks and drops all state, like ReactLynx's
394
+ * `snapshotDestroyList`. Safe to call more than once.
395
+ * @returns {void}
396
+ */
397
+ function destroy() {
398
+ if (destroyed) return;
399
+ destroyed = true;
400
+ __UpdateListCallbacks(handle, () => -1, () => {}, () => {});
401
+ items = [];
402
+ itemsById.clear();
403
+ itemsBySign.clear();
404
+ removedAttached = [];
405
+ pending = null;
406
+ }
407
+
408
+ const handle = __CreateList(pageId, componentAtIndex, enqueueComponent, {}, componentAtIndexes);
409
+
410
+ return {
411
+ handle,
412
+ insert,
413
+ remove,
414
+ updateInfo,
415
+ setUpdateAnimation,
416
+ /**
417
+ * @param {number} id - Item id.
418
+ * @returns {boolean} Whether the item is in this list.
419
+ */
420
+ has: (id) => itemsById.has(id),
421
+ flushUpdates,
422
+ detachRemoved,
423
+ destroy,
424
+ };
425
+ }
@@ -32,6 +32,7 @@ Object.assign(globalThis, {
32
32
  * finish before or after that fires — patches arriving early are buffered
33
33
  * and replayed in order once the page exists), then wires the patch/event
34
34
  * channel for the lifetime of the page.
35
+ * @returns {void}
35
36
  */
36
37
  export function setupRenderer() {
37
38
  const engine = lynx.getEngine();
@@ -39,6 +40,12 @@ export function setupRenderer() {
39
40
  let pageReady = false;
40
41
  let pendingPatches = [];
41
42
 
43
+ /**
44
+ * Receives a patch, validating its protocol version and buffering it until the page exists.
45
+ * @param {{data: unknown}} event - The patch event.
46
+ * @returns {void}
47
+ * @throws {Error} On a protocol version mismatch.
48
+ */
42
49
  const onPatch = (event) => {
43
50
  const data = event.data;
44
51
  if (!Array.isArray(data) || data[0] !== PROTOCOL_VERSION) {
@@ -60,11 +67,15 @@ export function setupRenderer() {
60
67
  };
61
68
  onPatchFromBackground(onPatch);
62
69
 
70
+ /**
71
+ * Creates the real page and applier, then flushes any buffered patches.
72
+ * @returns {void}
73
+ */
63
74
  const onRenderPage = () => {
64
75
  const page = __CreatePage("0", 0);
65
76
  const pageId = __GetElementUniqueID(page);
66
77
  applier = createPatchApplier(pageId, {
67
- onEvent: (id, type, nativeEvent) => sendEventToBackground(id, type, nativeEvent),
78
+ onEvent: (id, type, nativeEvent, seq) => sendEventToBackground(id, type, nativeEvent, seq),
68
79
  });
69
80
  applier.registerPageRoot(page);
70
81
  pageReady = true;
@@ -73,7 +84,13 @@ export function setupRenderer() {
73
84
  };
74
85
  engine.addEventListener(renderPageEventName, onRenderPage);
75
86
 
87
+ /**
88
+ * Neutralizes native list callbacks and removes the page lifecycle listeners.
89
+ * @returns {void}
90
+ */
76
91
  const onDestroyLifetime = () => {
92
+ // Native lists must not call back into state that is going away.
93
+ if (applier != null) applier.dispose();
77
94
  engine.removeEventListener(renderPageEventName, onRenderPage);
78
95
  engine.removeEventListener(destroyLifetimeEventName, onDestroyLifetime);
79
96
  };
@@ -61,6 +61,8 @@ let redrawDelayMs = REDRAW_DELAY_MS;
61
61
  * which is a safety margin rather than a scheduling guarantee. A device whose
62
62
  * timer behaves differently (see FETCH_INVESTIGATION.md §4.6) can raise or
63
63
  * lower it here.
64
+ * @param {{redrawDelayMs?: number}} [options] - Settings; `redrawDelayMs` overrides the debounce delay.
65
+ * @returns {void}
64
66
  */
65
67
  export function configure(options) {
66
68
  if (options && options.redrawDelayMs != null) {
@@ -68,6 +70,11 @@ export function configure(options) {
68
70
  }
69
71
  }
70
72
 
73
+ /**
74
+ * Schedules a callback after the redraw delay, preferring `lynx.setTimeout`.
75
+ * @param {() => void} fn - The callback to run.
76
+ * @returns {*} The timer handle.
77
+ */
71
78
  function schedule(fn) {
72
79
  const timer = typeof lynx !== "undefined" && typeof lynx.setTimeout === "function" ? lynx.setTimeout.bind(lynx) : setTimeout;
73
80
  return timer(fn, redrawDelayMs);
@@ -80,6 +87,9 @@ function schedule(fn) {
80
87
  * documents corrupt the shared id space (see R2 in
81
88
  * informe-contrato-mithril-lynx.md). Call {@link unregister} on teardown
82
89
  * or full reload before registering a new one.
90
+ * @param {() => void} redraw - The app's redraw function.
91
+ * @returns {void}
92
+ * @throws {Error} If a redraw is already registered.
83
93
  */
84
94
  export function register(redraw) {
85
95
  if (currentRedraw != null) {
@@ -97,6 +107,7 @@ export function register(redraw) {
97
107
  * Clears the current redraw registration (and the pending debounce flag) —
98
108
  * used by a full reload and by the test suite between mounts. After this,
99
109
  * `redraw()` is a no-op again until the next `register()`.
110
+ * @returns {void}
100
111
  */
101
112
  export function unregister() {
102
113
  currentRedraw = null;
@@ -115,7 +126,9 @@ export function unregister() {
115
126
  * renderApp()/route() ran has nothing to redraw yet, which isn't
116
127
  * necessarily a bug the way calling commit() before mounting is. Multiple
117
128
  * calls within the delay window collapse into a single scheduled render,
118
- * same debounce real Mithril's `redraw()` does with its `pending` flag. */
129
+ * same debounce real Mithril's `redraw()` does with its `pending` flag.
130
+ * @returns {void}
131
+ */
119
132
  export function redraw() {
120
133
  if (pending) return;
121
134
  pending = true;