mithril-lynx 2.5.0 → 2.6.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.
@@ -0,0 +1,10 @@
1
+ {
2
+ "sessionID": "ses_f48265d07ffenKG0GAz9ZRFvY6",
3
+ "updatedAt": "2026-09-19T04:26:35.138Z",
4
+ "sources": {
5
+ "background-task": {
6
+ "state": "idle",
7
+ "updatedAt": "2026-09-19T04:26:35.138Z"
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mithril-lynx",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Mithril.js on Lynx: real mithril/render/render.js driven through a Lynx-backed fake DOM, with an explicit single commit hook (no conditional global flush) and three reload modes (data-light, structural-light, full). A complete rewrite of the previous mithril-lynx (0.0.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,6 +36,10 @@
36
36
  "./list-support": {
37
37
  "types": "./src/list-support.d.ts",
38
38
  "default": "./src/list-support.js"
39
+ },
40
+ "./list-cell": {
41
+ "types": "./src/list-cell.d.ts",
42
+ "default": "./src/list-cell.js"
39
43
  }
40
44
  },
41
45
  "scripts": {
@@ -6,11 +6,11 @@
6
6
  // low-level interpreter of the flat op array straight onto the real
7
7
  // Element PAPI, in the spirit of ReactLynx's own `snapshotPatchApply.js`
8
8
  // (see rspeedy-react-analysis/LYNX_PAPI_SPEC.md §4.3): a switch over op
9
- // codes, one real PAPI call per case, nothing else. One deliberate
10
- // exception: Op.CreateList's own cell content (see list-support.js) DOES
11
- // run a real Mithril render pass right here, on this thread — the only way
12
- // to satisfy native's synchronous componentAtIndex contract at all (see
13
- // docs/native-papi/papi-06-virtualized-lists.md in mithril-lynx-ui).
9
+ // codes, one real PAPI call per case, nothing else. Op.CreateList's own
10
+ // cell content (list-support.js) is no exception to that: componentAtIndex
11
+ // replays ops the background thread already computed (list-cell.js), the
12
+ // same way this function replays the app's own top-level tree see
13
+ // docs/native-papi/papi-06-virtualized-lists.md in mithril-lynx-ui.
14
14
  //
15
15
  // The exact `__Create*`/pageId contract below (one `pageId` shared by every
16
16
  // element on a page, `__CreateView`/`__CreateText`/generic `__CreateElement`
@@ -191,16 +191,41 @@ function registerGestureDetector(handle, id, gestureId, gestureType, arenaPolicy
191
191
  * @param {number} pageId - `__GetElementUniqueID(pageElement)` of the real
192
192
  * page this applier is attached to. Every element this applier creates
193
193
  * belongs to that one page — see CONTRACT.md / lynx-mithril-shim.js.
194
+ * @param {object} [options]
195
+ * @param {Function} [options.onEvent]
196
+ * @param {boolean} [options.flush] - Whether `applyPatch` calls the bare,
197
+ * whole-page `__FlushElementTree()` after applying its ops. Defaults to
198
+ * `true` — the right default for the ONE real top-level applier per page
199
+ * (main-thread.js's own use). `false` for a per-cell applier
200
+ * (list-support.js): a list cell's real commit point is the list-specific
201
+ * `__FlushElementTree(wrapperHandle, {triggerLayout, operationID,
202
+ * elementID, listID})` call list-support.js already makes right after —
203
+ * calling the bare, whole-page flush too, from inside native's own
204
+ * synchronous componentAtIndex callback, is a second, unrelated flush this
205
+ * applier was never meant to trigger on that call site's behalf.
194
206
  */
195
- export function createPatchApplier(pageId, { onEvent } = {}) {
207
+ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
196
208
  // id (as allocated by the background's virtual backend) -> real PAPI
197
209
  // element handle. id 0 is reserved for "the page itself" (see
198
210
  // fake-dom.js's LynxDocument) — pre-seeded here so the very first
199
211
  // InsertBefore/AppendChild targeting id 0 has somewhere real to land.
200
212
  const handles = new Map();
213
+ // list id -> its setCells(cells) function (list-support.js) — kept here,
214
+ // not as a property on the list's own handle: a real native list handle
215
+ // does not reliably hold a custom property across calls (confirmed on
216
+ // device), only `handles` (a plain Map) does.
217
+ const listSetters = new Map();
218
+
219
+ /** General form: seed the mapping for any id, not just the page root —
220
+ * list-support.js uses this to alias a list-cell.js `containerId` (an
221
+ * off-tree id from the background thread's OWN document) to the real
222
+ * native wrapper element it created for that cell. */
223
+ function registerRoot(id, handle) {
224
+ handles.set(id, handle);
225
+ }
201
226
 
202
227
  function registerPageRoot(pageElementHandle) {
203
- handles.set(0, pageElementHandle);
228
+ registerRoot(0, pageElementHandle);
204
229
  }
205
230
 
206
231
  function createElementHandle(tag) {
@@ -210,12 +235,15 @@ export function createPatchApplier(pageId, { onEvent } = {}) {
210
235
  }
211
236
 
212
237
  /**
213
- * Applies one commit's worth of ops, then flushes exactly once
214
- * `__FlushElementTree` is the real commit; nothing before it is visible.
215
- * This function itself is the ONLY caller of `__FlushElementTree` on
216
- * this applier's page never called conditionally, never looked up
217
- * through a global (mirrors the fix in commit.js on the background
218
- * side: one explicit call site, not an implicit one).
238
+ * Applies one commit's worth of ops, then unless this applier was
239
+ * created with `flush: false` (see this function's own constructor
240
+ * options above) flushes exactly once with the bare, whole-page
241
+ * `__FlushElementTree()`; that call is the real commit for the ONE
242
+ * top-level applier per page, never looked up through a global. A
243
+ * per-cell applier (list-support.js) passes `flush: false` and issues
244
+ * its own list-specific `__FlushElementTree(wrapperHandle, {...})` call
245
+ * afterward instead — that one, not this one, is that cell's real
246
+ * commit point.
219
247
  */
220
248
  function applyPatch(ops) {
221
249
  for (let i = 0; i < ops.length; ) {
@@ -363,29 +391,30 @@ export function createPatchApplier(pageId, { onEvent } = {}) {
363
391
  }
364
392
  case Op.CreateList: {
365
393
  const id = ops[i++];
366
- const rendererKey = ops[i++];
367
394
  const scrollOrientation = ops[i++];
368
395
  const listType = ops[i++];
369
396
  const spanCount = ops[i++];
370
- handles.set(id, createNativeList(pageId, rendererKey, scrollOrientation, listType, spanCount, createPatchApplier, onEvent));
397
+ const { handle, setCells } = createNativeList(pageId, scrollOrientation, listType, spanCount, createPatchApplier, onEvent);
398
+ handles.set(id, handle);
399
+ listSetters.set(id, setCells);
371
400
  break;
372
401
  }
373
402
  case Op.SetListItems: {
374
403
  const id = ops[i++];
375
- const itemsJSON = ops[i++];
376
- const listHandle = handles.get(id);
377
- listHandle.__setItems(JSON.parse(itemsJSON));
404
+ const cellsJSON = ops[i++];
405
+ listSetters.get(id)(JSON.parse(cellsJSON));
378
406
  break;
379
407
  }
380
408
  default:
381
409
  throw new Error(`[mithril-lynx] Unknown patch opcode: ${opcode}`);
382
410
  }
383
411
  }
384
- __FlushElementTree();
412
+ if (flush) __FlushElementTree();
385
413
  }
386
414
 
387
415
  return {
388
416
  registerPageRoot,
417
+ registerRoot,
389
418
  applyPatch,
390
419
  /** The real PAPI element handle for a given background-side id, or
391
420
  * `undefined` if nothing was ever created for it. Exists for tests
@@ -75,13 +75,13 @@ export function createVirtualBackend() {
75
75
  removeGestureDetector(id, gestureId) {
76
76
  pushOp(ops, Op.RemoveGestureDetector, id, gestureId);
77
77
  },
78
- createList(rendererKey, scrollOrientation, listType, spanCount) {
78
+ createList(scrollOrientation, listType, spanCount) {
79
79
  const id = nextId++;
80
- pushOp(ops, Op.CreateList, id, rendererKey, scrollOrientation, listType, spanCount);
80
+ pushOp(ops, Op.CreateList, id, scrollOrientation, listType, spanCount);
81
81
  return id;
82
82
  },
83
- setListItems(id, items) {
84
- pushOp(ops, Op.SetListItems, id, JSON.stringify(items));
83
+ setListItems(id, cells) {
84
+ pushOp(ops, Op.SetListItems, id, JSON.stringify(cells));
85
85
  },
86
86
  /** Drains and returns the accumulated ops. Called once per commit. */
87
87
  takeOps() {
@@ -90,5 +90,19 @@ export function createVirtualBackend() {
90
90
  ops = [];
91
91
  return out;
92
92
  },
93
+ /**
94
+ * Runs `fn` (a DOM mutation against a node from THIS backend's own
95
+ * document — same id space as everything else, so event dispatch
96
+ * keeps working normally) and returns just the ops it produced,
97
+ * removing them from the shared buffer so they never also go out
98
+ * with the next `takeOps()`. Used by list-cell.js to render one list
99
+ * item off-tree and ship its construction ops separately, instead of
100
+ * as part of the app's own visible-tree patch.
101
+ */
102
+ captureOps(fn) {
103
+ const start = ops.length;
104
+ fn();
105
+ return ops.splice(start, ops.length - start);
106
+ },
93
107
  };
94
108
  }
package/src/fake-dom.js CHANGED
@@ -178,7 +178,7 @@ export class LynxElement extends LynxContainerNode {
178
178
  this.tag = tag;
179
179
  this.namespaceURI = ns;
180
180
  this._id = listConfig
181
- ? backend.createList(listConfig.rendererKey, listConfig.scrollOrientation, listConfig.listType, listConfig.spanCount)
181
+ ? backend.createList(listConfig.scrollOrientation, listConfig.listType, listConfig.spanCount)
182
182
  : ns
183
183
  ? backend.createElementNS(ns, tag)
184
184
  : backend.createElement(tag);
@@ -440,22 +440,25 @@ export class LynxDocument extends LynxContainerNode {
440
440
 
441
441
  /**
442
442
  * A native virtualized list — see patch-protocol.js's Op.CreateList.
443
- * `rendererKey` must match a key registered on the MAIN thread via
444
- * mithril-lynx/list-support's registerListRenderer() (see
445
- * docs/native-papi/papi-06-virtualized-lists.md in mithril-lynx-ui):
446
- * the renderer function itself can't cross the thread boundary, only
447
- * this string key can. Call `.setListItems(items)` on the result to
448
- * populate it — items must be JSON-serializable, since they DO cross
449
- * the boundary, as data.
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.
450
446
  */
451
- createNativeList(rendererKey, options = {}) {
447
+ createNativeList(options = {}) {
452
448
  return new LynxElement(this, this._backend, "list", undefined, {
453
- rendererKey,
454
449
  scrollOrientation: options.scrollOrientation ?? "vertical",
455
450
  listType: options.listType ?? "single",
456
451
  spanCount: options.spanCount ?? 1,
457
452
  });
458
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);
461
+ }
459
462
  }
460
463
 
461
464
  export function createLynxDocument(backend) {
@@ -0,0 +1,20 @@
1
+ // Ambient declaration for the ESM src/list-cell.js — the background-thread
2
+ // half of native list support. mithril-lynx-ui's <List>/<FeedList> call
3
+ // this once per item, from the SAME thread/document as the rest of the
4
+ // app's own tree — see that file's own header for why.
5
+
6
+ export interface ListCell {
7
+ typeKey: string;
8
+ containerId: number;
9
+ ops: unknown[];
10
+ rootChildIds: number[];
11
+ }
12
+
13
+ export function renderListCell(
14
+ document: unknown,
15
+ render: (dom: unknown, vnodes: unknown[], redraw: () => void) => void,
16
+ redraw: () => void,
17
+ renderItem: (item: unknown, index: number) => unknown,
18
+ item: unknown,
19
+ index: number,
20
+ ): ListCell;
@@ -0,0 +1,73 @@
1
+ // src/list-cell.js
2
+ //
3
+ // Runs ONLY on the background thread — the counterpart to list-support.js's
4
+ // main-thread half. Turns one `renderItem(item, index)` call into a
5
+ // self-contained set of construction ops, using the app's OWN document/
6
+ // backend/render (the same ones background.js already created for the rest
7
+ // of the tree) — never a separate, isolated render pipeline. That's the
8
+ // whole point: the item's real fake-dom nodes end up registered in the same
9
+ // `document._nodesById` map as everything else in the app, so a forwarded
10
+ // native event for one of them dispatches through `onEventFromMainThread`
11
+ // exactly like any other element's event already does. No extra reporting
12
+ // convention needed.
13
+ //
14
+ // The main thread never calls renderItem() itself — see list-support.js.
15
+
16
+ import { Op, forEachOp } from "./patch-protocol.js";
17
+
18
+ function typeKeyOf(vnode) {
19
+ return typeof vnode.tag === "string" ? vnode.tag : (vnode.tag && vnode.tag.name) || "default";
20
+ }
21
+
22
+ /** The real handle ids inserted directly under `containerId` — what a
23
+ * recycled cell on the main thread needs to remove before it can attach a
24
+ * different item's content into the same native wrapper (see
25
+ * list-support.js's clearWrapperChildren). Scanning the ops after the fact,
26
+ * instead of tracking during render, keeps this file from needing any
27
+ * backend-internal access beyond `captureOps` itself. */
28
+ function findTopLevelChildIds(ops, containerId) {
29
+ const ids = [];
30
+ forEachOp(ops, (opcode, args) => {
31
+ if (opcode === Op.InsertBefore && args[0] === containerId) ids.push(args[1]);
32
+ });
33
+ return ids;
34
+ }
35
+
36
+ /**
37
+ * @param {import("./fake-dom.js").LynxDocument} document - the app's own
38
+ * document (e.g. `vnode.dom.ownerDocument` from inside a component) — NOT
39
+ * a separate one. Sharing it is what keeps a list cell's ids in the same
40
+ * `_nodesById` space as the rest of the app.
41
+ * @param {(dom: object, vnodes: unknown[], redraw: () => void) => void} render
42
+ * - a `mithril-runtime/render/render.js` instance. Callers typically keep
43
+ * one shared instance per `<List>` (see mithril-lynx-ui's list.js), not
44
+ * one per cell — render() is designed to manage multiple independent
45
+ * containers safely.
46
+ * @param {() => void} redraw - forwarded as render()'s own redraw callback,
47
+ * so a `ontap` (etc.) handler inside the rendered cell triggers a REAL
48
+ * app-wide redraw afterward, exactly like any other event in the app —
49
+ * see mithril-lynx/mount-redraw's `redraw()`.
50
+ * @param {(item: unknown, index: number) => unknown} renderItem
51
+ * @param {unknown} item
52
+ * @param {number} index
53
+ * @returns {{ typeKey: string, containerId: number, ops: unknown[], rootChildIds: number[] }}
54
+ */
55
+ export function renderListCell(document, render, redraw, renderItem, item, index) {
56
+ // An ordinary element in the SAME document, never inserted into the
57
+ // visible tree (no `appendChild`/`insertBefore` call targets it) — its
58
+ // own `Op.CreateElement` never gets captured below since it's created
59
+ // OUTSIDE the capture, before there's anything to record; only what
60
+ // render() does INSIDE it is captured. The main thread's list-support.js
61
+ // treats `containerId` as an alias for whatever real native wrapper it
62
+ // creates for this cell, the same way apply-patch.js's top-level applier
63
+ // treats id 0 as an alias for the real page element.
64
+ const container = document.createElement("list-cell-root");
65
+ const vnode = renderItem(item, index);
66
+ const typeKey = typeKeyOf(vnode);
67
+
68
+ const ops = document.captureOps(() => {
69
+ render(container, [vnode], redraw);
70
+ });
71
+
72
+ return { typeKey, containerId: container._id, ops, rootChildIds: findTopLevelChildIds(ops, container._id) };
73
+ }
@@ -1,10 +1,13 @@
1
- // Ambient declaration for the ESM src/list-support.js.
2
- //
3
- // Import this from your app's main-thread.ts (alongside setupRenderer()
4
- // from "mithril-lynx/main-thread") and call registerListRenderer() once
5
- // per list your app uses — see that file's own header, and
6
- // docs/native-papi/papi-06-virtualized-lists.md in mithril-lynx-ui, for
7
- // why the render function itself has to be registered here rather than
8
- // passed in from background.ts directly.
1
+ // Ambient declaration for the ESM src/list-support.js — the main-thread
2
+ // half of native list support. Not meant to be imported directly by app
3
+ // code; apply-patch.js's own Op.CreateList case is the only caller. See
4
+ // list-cell.d.ts for the background-thread half an app actually uses.
9
5
 
10
- export function registerListRenderer(key: string, renderItem: (item: unknown, index: number) => unknown): void;
6
+ export function createNativeList(
7
+ pageId: number,
8
+ scrollOrientation: string,
9
+ listType: string,
10
+ spanCount: number,
11
+ createPatchApplier: (pageId: number, options?: { onEvent?: Function; flush?: boolean }) => object,
12
+ onEvent?: Function,
13
+ ): { handle: unknown; setCells: (cells: unknown[]) => void };
@@ -1,153 +1,149 @@
1
1
  // src/list-support.js
2
2
  //
3
- // Runs ONLY on the main thread — imported from an app's own main-thread.ts,
4
- // alongside setupRenderer() (see docs/native-papi/papi-06-virtualized-lists.md
5
- // in mithril-lynx-ui for the full design). Registers the render function(s)
6
- // a native virtualized list needs: the function itself can never cross the
7
- // background-thread/main-thread boundary (real main and background threads
8
- // are separate JS engine instances — no shared closures, only serializable
9
- // messages), so an app registers it here by a string key instead, and
10
- // mithril-lynx-ui's List component references that same key from
11
- // background.ts.
3
+ // Runs ONLY on the main thread — the counterpart to list-cell.js's
4
+ // background-thread half. Wires up the native list contract
5
+ // (__CreateList/__UpdateListCallbacks, the synchronous componentAtIndex/
6
+ // enqueueComponent pair, recycling cells by a type key) unchanged from
7
+ // mithril-lynx v1's own device-verified list.js.
12
8
  //
13
- // The native list contract this wires up (__CreateList/__UpdateListCallbacks,
14
- // the synchronous componentAtIndex/enqueueComponent pair, recycling cells by
15
- // a type key) is unchanged from mithril-lynx v1's own list.js that part
16
- // was already device-verified. What's different: each cell is rendered by a
17
- // REAL, self-contained render pass entirely on THIS thread (the exact same
18
- // pieces background.js uses for the app's own tree mithril-runtime's real
19
- // render(), fake-dom.js, a virtual backend just applied to itself
20
- // immediately via a nested patch applier, instead of crossing a channel).
21
- // componentAtIndex's native contract is synchronous; a cross-thread round
22
- // trip could never satisfy that, so this is the only architecture that can.
9
+ // What's different from v1, and from this file's own first version: a
10
+ // cell's content is never rendered here. `componentAtIndex` only replays
11
+ // ops the background thread already computed (list-cell.js), the same way
12
+ // apply-patch.js's own top-level applyPatch replays the app's own tree —
13
+ // this file has no dependency on mithril-runtime, fake-dom.js, or a virtual
14
+ // backend at all anymore, because it never runs a render pass of its own.
15
+ // componentAtIndex(itemCount) cost is therefore the cost of creating/
16
+ // updating real elements from an already-known op list, same as any other
17
+ // patch not a render.
23
18
  //
24
- // KNOWN GAP, not solved by this file: an event handler (e.g. `ontap`)
25
- // inside a renderItem()-produced vnode fires entirely on this thread, with
26
- // no way back to the app's own state on the background thread — there is
27
- // no background-thread fake-dom node for list cell content to dispatch
28
- // through (unlike every other element in the app, which the background
29
- // thread DOES know about). Reaching back into app state from inside a list
30
- // cell needs a deliberate reporting convention, not built here yet.
31
-
32
- import renderFactory from "mithril-runtime/render/render.js";
33
- import { createLynxDocument } from "./fake-dom.js";
34
- import { createVirtualBackend } from "./backends/virtual-backend.js";
35
-
36
- const renderers = new Map();
37
-
38
- /** Call once per list your app uses, from main-thread.ts see this file's
39
- * own header for why the render function itself can't just be imported
40
- * from background.ts and passed across directly. */
41
- export function registerListRenderer(key, renderItem) {
42
- renderers.set(key, renderItem);
43
- }
44
-
45
- function typeKeyOf(vnode) {
46
- return typeof vnode.tag === "string" ? vnode.tag : (vnode.tag && vnode.tag.name) || "default";
47
- }
48
-
49
- function makeCell(pageId, wrapperHandle, createPatchApplier) {
50
- const backend = createVirtualBackend();
51
- const document = createLynxDocument(backend);
52
- const render = renderFactory();
53
- const applier = createPatchApplier(pageId);
54
- applier.registerPageRoot(wrapperHandle);
55
- return { wrapper: wrapperHandle, backend, document, render, applier, typeKey: null };
56
- }
57
-
58
- /** Re-renders `vnode` into `cell`'s own persistent fake-dom document —
59
- * Mithril's own diff (real, not simulated) computes the minimal update
60
- * against whatever this cell showed before, exactly like a normal redraw,
61
- * so recycling a cell for a different item never needs a manual "clear
62
- * old content first" step. */
63
- function renderCellVnode(cell, vnode) {
64
- cell.render(cell.document, [vnode], () => {});
65
- const ops = cell.backend.takeOps();
66
- if (ops) cell.applier.applyPatch(ops);
67
- }
68
-
69
- /**
70
- * @param {number} pageId
71
- * @param {string} rendererKey
72
- * @param {(pageId: number, options?: {onEvent?: Function}) => object} createPatchApplier
73
- * - passed in rather than imported, to avoid a circular import with
74
- * apply-patch.js (the only caller).
75
- * @param {Function} [onEvent] - forwarded into each cell's own patch
76
- * applier, same as the top-level one — see this file's own "known gap"
77
- * note above for what this does NOT yet solve.
78
- */
79
- export function createNativeList(pageId, rendererKey, scrollOrientation, listType, spanCount, createPatchApplier, onEvent) {
80
- const renderItem = renderers.get(rendererKey);
81
- if (renderItem == null) {
82
- throw new Error(
83
- `[mithril-lynx] no list renderer registered for "${rendererKey}" — call registerListRenderer("${rendererKey}", ...) from main-thread.ts.`,
84
- );
19
+ // Recycling a cell for a different item clears its real children (removed
20
+ // by the handle ids list-cell.js recorded see clearWrapperChildren below)
21
+ // and replays the new item's ops fresh; this is a real teardown-and-rebuild
22
+ // of that cell's content, not a diff against what was there before. A real
23
+ // Mithril diff on recycle would need a persistent per-slot fake-dom
24
+ // document kept in sync with which native cell native actually chose to
25
+ // reuse information only native has, on the main thread, which the
26
+ // background thread (where the diff would have to run) can't see without a
27
+ // round trip. Not implemented.
28
+
29
+ export function createNativeList(pageId, scrollOrientation, listType, spanCount, createPatchApplier, onEvent) {
30
+ let cells = []; // current cells, indexed by cellIndex — see patch-protocol.js's Op.SetListItems
31
+ let count = 0;
32
+ const signMap = new Map(); // sign -> { wrapperHandle, applier, rootChildIds, cellIndex, typeKey }
33
+ const recycleMap = new Map(); // typeKey -> Map<sign, entry> (entries currently off-screen, available to reuse)
34
+
35
+ function replayCell(wrapperHandle, cell) {
36
+ const applier = createPatchApplier(pageId, { onEvent, flush: false });
37
+ applier.registerRoot(cell.containerId, wrapperHandle);
38
+ applier.applyPatch(cell.ops);
39
+ return applier;
85
40
  }
86
41
 
87
- let items = [];
88
- let count = 0;
89
- const recycleMap = new Map(); // typeKey -> Map<sign, cell>
90
- const signMap = new Map(); // sign -> cell
42
+ function clearWrapperChildren(entry) {
43
+ for (const id of entry.rootChildIds) {
44
+ const handle = entry.applier.getHandle(id);
45
+ if (handle != null) __RemoveElement(entry.wrapperHandle, handle);
46
+ }
47
+ }
91
48
 
92
- function bindFreshItem(listHandle, listId, cellIndex, opId, vnode, typeKey) {
49
+ function bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush) {
93
50
  const wrapperHandle = __CreateElement("list-item", pageId, {});
94
51
  __SetAttribute(wrapperHandle, "item-key", String(cellIndex));
95
52
  __AppendElement(listHandle, wrapperHandle);
96
53
 
97
- const cell = makeCell(pageId, wrapperHandle, (pid) => createPatchApplier(pid, { onEvent }));
98
- cell.typeKey = typeKey;
99
- renderCellVnode(cell, vnode);
100
-
54
+ const applier = replayCell(wrapperHandle, cell);
101
55
  const sign = __GetElementUniqueID(wrapperHandle);
102
- signMap.set(sign, cell);
103
- __FlushElementTree(wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
56
+ signMap.set(sign, { wrapperHandle, applier, rootChildIds: cell.rootChildIds, cellIndex, typeKey: cell.typeKey });
57
+ if (flush) __FlushElementTree(wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
104
58
  return sign;
105
59
  }
106
60
 
107
- function bindRecycledItem(listId, cellIndex, opId, vnode, typeKey, pool) {
108
- const [sign, cell] = pool.entries().next().value;
61
+ function bindRecycledItem(listId, cellIndex, opId, cell, pool, flush) {
62
+ const [sign, entry] = pool.entries().next().value;
109
63
  pool.delete(sign);
110
- __SetAttribute(cell.wrapper, "item-key", String(cellIndex));
111
- cell.typeKey = typeKey;
112
- renderCellVnode(cell, vnode);
113
- signMap.set(sign, cell);
114
- __FlushElementTree(cell.wrapper, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
64
+ clearWrapperChildren(entry);
65
+ __SetAttribute(entry.wrapperHandle, "item-key", String(cellIndex));
66
+ const applier = replayCell(entry.wrapperHandle, cell);
67
+ entry.applier = applier;
68
+ entry.rootChildIds = cell.rootChildIds;
69
+ entry.cellIndex = cellIndex;
70
+ signMap.set(sign, entry);
71
+ if (flush) __FlushElementTree(entry.wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
115
72
  return sign;
116
73
  }
117
74
 
118
- function componentAtIndex(listHandle, listId, cellIndex, opId) {
75
+ function bindCell(listHandle, listId, cellIndex, opId, flush) {
119
76
  if (cellIndex < 0 || cellIndex >= count) {
120
77
  throw new Error(`[mithril-lynx] list: cellIndex ${cellIndex} out of range (itemCount=${count})`);
121
78
  }
122
- const vnode = renderItem(items[cellIndex], cellIndex);
123
- const typeKey = typeKeyOf(vnode);
124
- const pool = recycleMap.get(typeKey);
125
- if (pool && pool.size > 0) return bindRecycledItem(listId, cellIndex, opId, vnode, typeKey, pool);
126
- return bindFreshItem(listHandle, listId, cellIndex, opId, vnode, typeKey);
79
+ const cell = cells[cellIndex];
80
+ const pool = recycleMap.get(cell.typeKey);
81
+ if (pool && pool.size > 0) return bindRecycledItem(listId, cellIndex, opId, cell, pool, flush);
82
+ return bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush);
83
+ }
84
+
85
+ function componentAtIndex(listHandle, listId, cellIndex, opId) {
86
+ return bindCell(listHandle, listId, cellIndex, opId, true);
87
+ }
88
+
89
+ /**
90
+ * The batched form of componentAtIndex — real native calls this (not a
91
+ * fallback: confirmed against `@lynx-js/react`'s own
92
+ * componentAtIndexFactory/attachListItemAtIndex, which always registers
93
+ * BOTH forms) for several cells at once, expecting exactly ONE
94
+ * `__FlushElementTree` covering all of them (`operationIDs`/`elementIDs`
95
+ * arrays), not one per cell — the real reason a native list's initial,
96
+ * simultaneously-visible cells need this at all.
97
+ */
98
+ function componentAtIndexes(listHandle, listId, cellIndexes, operationIDs) {
99
+ const elementIDs = cellIndexes.map((cellIndex, i) => bindCell(listHandle, listId, cellIndex, operationIDs[i], false));
100
+ __FlushElementTree(listHandle, { triggerLayout: true, operationIDs, elementIDs, listID: listId });
127
101
  }
128
102
 
129
103
  function enqueueComponent(_listHandle, _listId, sign) {
130
- const cell = signMap.get(sign);
131
- if (cell == null) return;
104
+ const entry = signMap.get(sign);
105
+ if (entry == null) return;
132
106
  signMap.delete(sign);
133
- if (!recycleMap.has(cell.typeKey)) recycleMap.set(cell.typeKey, new Map());
134
- recycleMap.get(cell.typeKey).set(sign, cell);
107
+ if (!recycleMap.has(entry.typeKey)) recycleMap.set(entry.typeKey, new Map());
108
+ recycleMap.get(entry.typeKey).set(sign, entry);
135
109
  }
136
110
 
137
111
  function sendListInfo(listHandle, listId, insertAction, removeAction, updateAction) {
138
112
  __SetAttribute(listHandle, "update-list-info", { insertAction, removeAction, updateAction });
139
- __UpdateListCallbacks(listHandle, componentAtIndex, enqueueComponent);
113
+ __UpdateListCallbacks(listHandle, componentAtIndex, enqueueComponent, componentAtIndexes);
140
114
  }
141
115
 
142
- const listHandle = __CreateList(pageId, componentAtIndex, enqueueComponent, {});
116
+ const listHandle = __CreateList(pageId, componentAtIndex, enqueueComponent, {}, componentAtIndexes);
143
117
  const listId = __GetElementUniqueID(listHandle);
144
118
  __SetAttribute(listHandle, "scroll-orientation", scrollOrientation);
145
119
  __SetAttribute(listHandle, "list-type", listType);
146
120
  __SetAttribute(listHandle, "span-count", String(spanCount));
147
121
 
148
- listHandle.__setItems = (nextItems) => {
149
- const nextCount = nextItems.length;
150
- items = nextItems;
122
+ // Re-flushes every currently-attached (on-screen) cell with its newest
123
+ // ops e.g. after a redraw triggered by a tap inside a cell, or any
124
+ // other app state change that reaches this list's items. Unconditional,
125
+ // same as List's own onupdate already unconditionally resends the whole
126
+ // `items` array on every relevant redraw (see mithril-lynx-ui's
127
+ // list/list.js) — a real "did this cell's content actually change"
128
+ // check would need to compare `ops` arrays, not implemented yet. Cells
129
+ // that aren't attached right now just pick up their new content next
130
+ // time componentAtIndex asks for that index.
131
+ function refreshAttachedCells(nextCells) {
132
+ for (const [sign, entry] of signMap) {
133
+ const nextCell = nextCells[entry.cellIndex];
134
+ clearWrapperChildren(entry);
135
+ entry.applier = replayCell(entry.wrapperHandle, nextCell);
136
+ entry.rootChildIds = nextCell.rootChildIds;
137
+ entry.typeKey = nextCell.typeKey;
138
+ signMap.set(sign, entry);
139
+ __FlushElementTree(entry.wrapperHandle, { triggerLayout: false });
140
+ }
141
+ }
142
+
143
+ function setCells(nextCells) {
144
+ const nextCount = nextCells.length;
145
+ refreshAttachedCells(nextCells);
146
+ cells = nextCells;
151
147
  if (nextCount === count) return;
152
148
  if (nextCount > count) {
153
149
  sendListInfo(
@@ -161,7 +157,15 @@ export function createNativeList(pageId, rendererKey, scrollOrientation, listTyp
161
157
  sendListInfo(listHandle, listId, [], Array.from({ length: count - nextCount }, (_, i) => nextCount + i), []);
162
158
  }
163
159
  count = nextCount;
164
- };
160
+ }
165
161
 
166
- return listHandle;
162
+ // Returned separately, not as a property stuck onto `listHandle` itself:
163
+ // confirmed on real hardware (unlike @lynx-js/testing-environment's
164
+ // simulated PAPI) that a native list's own returned handle does not
165
+ // reliably hold a custom property across calls — assigning one there
166
+ // and reading it back later from apply-patch.js's Op.SetListItems case
167
+ // threw "TypeError: not a function" on the very first SetListItems, even
168
+ // with zero items. apply-patch.js keeps `setCells` in its own map
169
+ // instead, keyed the same way `handles` already is.
170
+ return { handle: listHandle, setCells };
167
171
  }
@@ -43,12 +43,13 @@ export const Op = Object.freeze({
43
43
  SetGestureDetector: 14, // id, gestureId, gestureType, arenaPolicy
44
44
  RemoveGestureDetector: 15, // id, gestureId
45
45
  // A native virtualized list — see docs/native-papi/papi-06-virtualized-lists.md
46
- // in mithril-lynx-ui for the full design. rendererKey looks up a
47
- // render function registered on the MAIN thread (mithril-lynx/
48
- // list-support's registerListRenderer()) — the function itself can't
49
- // cross the thread boundary, only this string key can.
50
- CreateList: 16, // id, rendererKey, scrollOrientation, listType, spanCount
51
- SetListItems: 17, // id, itemsJSON (items must be JSON-serializable — they DO cross the boundary, as data)
46
+ // in mithril-lynx-ui for the full design. Each list item is rendered on
47
+ // the BACKGROUND thread, through the app's own real render pass
48
+ // (list-cell.js), same as everything else in the tree — the main thread
49
+ // never runs renderItem() itself, only replays the ops that rendering
50
+ // already produced (apply-patch.js's Op.CreateList case + list-support.js).
51
+ CreateList: 16, // id, scrollOrientation, listType, spanCount
52
+ SetListItems: 17, // id, cellsJSON — cellsJSON = JSON.stringify(cells), cells: Array<{ typeKey, containerId, ops, rootChildIds }>, one entry per current item, computed by list-cell.js's renderListCell(). `ops` is a flat op array in this SAME encoding, scoped to `containerId` as its root parent id.
52
53
  });
53
54
 
54
55
  /**
@@ -59,3 +60,43 @@ export const Op = Object.freeze({
59
60
  export function pushOp(ops, opcode, ...args) {
60
61
  ops.push(opcode, ...args);
61
62
  }
63
+
64
+ // How many argument slots follow each opcode — the single source of truth
65
+ // for walking a flat ops array without re-interpreting it (apply-patch.js's
66
+ // own switch increments `i` inline instead of using this table, since it
67
+ // also needs to look at individual arg values as it goes; this exists for
68
+ // callers that only need to skip/scan, e.g. list-cell.js's
69
+ // findTopLevelChildIds(), which never applies the ops itself).
70
+ export const OP_ARITY = Object.freeze({
71
+ [Op.CreateElement]: 2, // tag, id
72
+ [Op.CreateElementNS]: 3, // ns, tag, id
73
+ [Op.CreateText]: 2, // value, id
74
+ [Op.CreateFragment]: 1, // id (never actually emitted — see fake-dom.js's LynxFragment)
75
+ [Op.InsertBefore]: 3, // parentId, childId, refId
76
+ [Op.RemoveChild]: 2, // parentId, childId
77
+ [Op.SetAttribute]: 3, // id, name, value
78
+ [Op.RemoveAttribute]: 2, // id, name
79
+ [Op.SetAttributeNS]: 4, // id, ns, name, value
80
+ [Op.SetStyleProperty]: 3, // id, name, value
81
+ [Op.RemoveStyleProperty]: 2, // id, name
82
+ [Op.SetText]: 2, // id, value
83
+ [Op.AddEvent]: 2, // id, type
84
+ [Op.RemoveEvent]: 2, // id, type
85
+ [Op.SetGestureDetector]: 4, // id, gestureId, gestureType, arenaPolicy
86
+ [Op.RemoveGestureDetector]: 2, // id, gestureId
87
+ [Op.CreateList]: 4, // id, scrollOrientation, listType, spanCount
88
+ [Op.SetListItems]: 2, // id, cellsJSON
89
+ });
90
+
91
+ /** Walks a flat ops array, calling `visit(opcode, args)` once per op — args
92
+ * is the plain slice of that op's own arguments (not including the opcode
93
+ * itself). Throws on an unknown opcode rather than silently desyncing. */
94
+ export function forEachOp(ops, visit) {
95
+ for (let i = 0; i < ops.length; ) {
96
+ const opcode = ops[i++];
97
+ const arity = OP_ARITY[opcode];
98
+ if (arity === undefined) throw new Error(`[mithril-lynx] Unknown patch opcode: ${opcode}`);
99
+ visit(opcode, ops.slice(i, i + arity));
100
+ i += arity;
101
+ }
102
+ }
package/test/list.test.ts CHANGED
@@ -1,19 +1,21 @@
1
1
  import { describe, expect, it } from "@rstest/core";
2
2
  import m from "mithril";
3
+ import renderFactory from "mithril-runtime/render/render.js";
3
4
  import { createPatchApplier } from "../src/apply-patch.js";
4
- import { registerListRenderer } from "../src/list-support.js";
5
+ import { createVirtualBackend } from "../src/backends/virtual-backend.js";
6
+ import { createLynxDocument } from "../src/fake-dom.js";
7
+ import { renderListCell } from "../src/list-cell.js";
5
8
  import { Op } from "../src/patch-protocol.js";
6
9
 
7
- // Op.CreateList end-to-end: registerListRenderer() (the main-thread.ts side
8
- // of the design see docs/native-papi/papi-06-virtualized-lists.md in
9
- // mithril-lynx-ui) plus a raw CreateList/SetListItems op sequence applied
10
- // directly (mithril-lynx-ui's own List component is what normally produces
11
- // these ops from the background thread; this test drives apply-patch.js
12
- // directly, the same level end-to-end.test.ts already operates at).
13
- //
14
- // componentAtIndex/enqueueComponent are native's own synchronous contract —
15
- // driven directly here, exactly like mithril-lynx-ui's own list.test.ts
16
- // already does against mithril-lynx-v1's list.js.
10
+ // Op.CreateList end-to-end. A cell's own content is computed on the
11
+ // BACKGROUND thread (list-cell.js's renderListCell(), through the app's own
12
+ // document/render no separate render pipeline) and only replayed on the
13
+ // main thread (list-support.js) see
14
+ // docs/native-papi/papi-06-virtualized-lists.md in mithril-lynx-ui. This
15
+ // test drives both halves directly: a background document renders each item
16
+ // into cells, then a main-thread applier replays Op.CreateList/
17
+ // Op.SetListItems with those cells, exactly like mithril-lynx-ui's own List
18
+ // component does end to end.
17
19
 
18
20
  function requestCell(listHandle: any, index: number, opId = 1) {
19
21
  const listId = __GetElementUniqueID(listHandle);
@@ -34,37 +36,37 @@ function textOf(node: any): string {
34
36
  return out;
35
37
  }
36
38
 
37
- function setupList(rendererKey: string) {
39
+ /** Stands in for mithril-lynx-ui's <List>: one persistent background
40
+ * document + render instance, reused across calls — see list-cell.js's own
41
+ * header for why callers keep one of these per list, not one per cell. */
42
+ function makeCellSource(renderItem: (item: any, index: number) => unknown) {
43
+ const document = createLynxDocument(createVirtualBackend());
44
+ const render = renderFactory();
45
+ return {
46
+ document,
47
+ buildCells: (items: unknown[]) => items.map((item, index) => renderListCell(document, render, () => {}, renderItem, item, index)),
48
+ };
49
+ }
50
+
51
+ function setupList() {
38
52
  lynxTestingEnv.switchToMainThread();
39
53
  const pageId = __GetElementUniqueID(__CreatePage());
40
54
  const applier = createPatchApplier(pageId);
41
55
  applier.registerPageRoot(__CreateView(pageId));
42
- applier.applyPatch([Op.CreateList, 1, rendererKey, "vertical", "single", 1]);
56
+ applier.applyPatch([Op.CreateList, 1, "vertical", "single", 1]);
43
57
  const listHandle = applier.getHandle(1) as any;
44
58
  return { applier, listHandle };
45
59
  }
46
60
 
47
- function setItems(applier: ReturnType<typeof createPatchApplier>, items: unknown[]) {
48
- applier.applyPatch([Op.SetListItems, 1, JSON.stringify(items)]);
61
+ function setCells(applier: ReturnType<typeof createPatchApplier>, cells: unknown[]) {
62
+ applier.applyPatch([Op.SetListItems, 1, JSON.stringify(cells)]);
49
63
  }
50
64
 
51
65
  describe("Op.CreateList (native virtualized list support)", () => {
52
- it("throws a clear error for an unregistered renderer key", () => {
53
- lynxTestingEnv.switchToMainThread();
54
- const pageId = __GetElementUniqueID(__CreatePage());
55
- const applier = createPatchApplier(pageId);
56
- applier.registerPageRoot(__CreateView(pageId));
57
-
58
- expect(() => applier.applyPatch([Op.CreateList, 1, "nonexistent-key", "vertical", "single", 1])).toThrow(
59
- /no list renderer registered for "nonexistent-key"/,
60
- );
61
- });
62
-
63
- it("renders real content per cell via the registered renderer, driven by componentAtIndex", () => {
64
- registerListRenderer("basic", (item: string, index: number) => m("text", {}, `${index}:${item}`));
65
-
66
- const { applier, listHandle } = setupList("basic");
67
- setItems(applier, ["a", "b", "c"]);
66
+ it("renders real content per cell from ops the background thread already computed", () => {
67
+ const { buildCells } = makeCellSource((item: string, index: number) => m("text", {}, `${index}:${item}`));
68
+ const { applier, listHandle } = setupList();
69
+ setCells(applier, buildCells(["a", "b", "c"]));
68
70
 
69
71
  requestCell(listHandle, 0);
70
72
  requestCell(listHandle, 1);
@@ -73,10 +75,9 @@ describe("Op.CreateList (native virtualized list support)", () => {
73
75
  });
74
76
 
75
77
  it("recycles a cell for a different index, and its content updates to match", () => {
76
- registerListRenderer("recycle-basic", (item: string, index: number) => m("text", {}, `${index}:${item}`));
77
-
78
- const { applier, listHandle } = setupList("recycle-basic");
79
- setItems(applier, ["a", "b", "c", "d"]);
78
+ const { buildCells } = makeCellSource((item: string, index: number) => m("text", {}, `${index}:${item}`));
79
+ const { applier, listHandle } = setupList();
80
+ setCells(applier, buildCells(["a", "b", "c", "d"]));
80
81
 
81
82
  const signA = requestCell(listHandle, 0);
82
83
  const wrapperA = listHandle.children[0];
@@ -91,15 +92,42 @@ describe("Op.CreateList (native virtualized list support)", () => {
91
92
  });
92
93
 
93
94
  it("SetListItems with a larger array requests the newly available indices without error", () => {
94
- registerListRenderer("grow", (item: string, index: number) => m("text", {}, `${index}:${item}`));
95
+ const { buildCells } = makeCellSource((item: string, index: number) => m("text", {}, `${index}:${item}`));
96
+ const { applier, listHandle } = setupList();
95
97
 
96
- const { applier, listHandle } = setupList("grow");
97
-
98
- setItems(applier, ["a", "b"]);
98
+ setCells(applier, buildCells(["a", "b"]));
99
99
  expect(() => requestCell(listHandle, 1)).not.toThrow();
100
100
  expect(() => requestCell(listHandle, 2)).toThrow(/cellIndex 2 out of range/);
101
101
 
102
- setItems(applier, ["a", "b", "c"]);
102
+ setCells(applier, buildCells(["a", "b", "c"]));
103
103
  expect(() => requestCell(listHandle, 2)).not.toThrow();
104
104
  });
105
+
106
+ it("SetListItems re-flushes an already-attached cell's content in place", () => {
107
+ const { buildCells } = makeCellSource((item: string, index: number) => m("text", {}, `${index}:${item}`));
108
+ const { applier, listHandle } = setupList();
109
+ setCells(applier, buildCells(["a", "b"]));
110
+
111
+ requestCell(listHandle, 0);
112
+ const wrapper = listHandle.children[0];
113
+ expect(textOf(wrapper)).toBe("0:a");
114
+
115
+ setCells(applier, buildCells(["z", "b"])); // same count, index 0's content changed
116
+ expect(textOf(wrapper)).toBe("0:z");
117
+ });
118
+
119
+ it("a tap inside a cell dispatches through the background thread's own fake-dom node", () => {
120
+ const { document, buildCells } = makeCellSource(() =>
121
+ m("text", { ontap: () => { taps += 1; } }, "tap me"),
122
+ );
123
+ let taps = 0;
124
+ const { applier, listHandle } = setupList();
125
+ const cells = buildCells([{}]);
126
+ setCells(applier, cells);
127
+ requestCell(listHandle, 0);
128
+
129
+ const node = document.getNodeById((cells[0] as any).rootChildIds[0]);
130
+ node!.dispatchEvent({ type: "tap", currentTarget: node, preventDefault() {}, stopPropagation() {} });
131
+ expect(taps).toBe(1);
132
+ });
105
133
  });