mithril-lynx 2.5.0 → 2.6.2

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.
@@ -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,47 @@ 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
+ // id -> Map<event type, listener callback>. `__AddEventListener` needs the
220
+ // exact callback reference back when `__RemoveEventListener` runs, so the
221
+ // listener cannot be an inline closure re-created per Op.AddEvent — it is
222
+ // stored here and reused by the Op.RemoveEvent case.
223
+ const eventListeners = new Map();
224
+
225
+ /** General form: seed the mapping for any id, not just the page root —
226
+ * list-support.js uses this to alias a list-cell.js `containerId` (an
227
+ * off-tree id from the background thread's OWN document) to the real
228
+ * native wrapper element it created for that cell. */
229
+ function registerRoot(id, handle) {
230
+ handles.set(id, handle);
231
+ }
201
232
 
202
233
  function registerPageRoot(pageElementHandle) {
203
- handles.set(0, pageElementHandle);
234
+ registerRoot(0, pageElementHandle);
204
235
  }
205
236
 
206
237
  function createElementHandle(tag) {
@@ -210,12 +241,15 @@ export function createPatchApplier(pageId, { onEvent } = {}) {
210
241
  }
211
242
 
212
243
  /**
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).
244
+ * Applies one commit's worth of ops, then — unless this applier was
245
+ * created with `flush: false` (see this function's own constructor
246
+ * options above) — flushes exactly once with the bare, whole-page
247
+ * `__FlushElementTree()`; that call is the real commit for the ONE
248
+ * top-level applier per page, never looked up through a global. A
249
+ * per-cell applier (list-support.js) passes `flush: false` and issues
250
+ * its own list-specific `__FlushElementTree(wrapperHandle, {...})` call
251
+ * afterward instead — that one, not this one, is that cell's real
252
+ * commit point.
219
253
  */
220
254
  function applyPatch(ops) {
221
255
  for (let i = 0; i < ops.length; ) {
@@ -331,18 +365,43 @@ export function createPatchApplier(pageId, { onEvent } = {}) {
331
365
  const id = ops[i++];
332
366
  const type = ops[i++];
333
367
  const handle = handles.get(id);
334
- __AddEventListener(handle, type, (nativeEvent) => {
368
+ const listener = (nativeEvent) => {
335
369
  onEvent?.(id, type, nativeEvent);
336
- }, {});
370
+ };
371
+ let byType = eventListeners.get(id);
372
+ if (byType == null) eventListeners.set(id, (byType = new Map()));
373
+ byType.set(type, listener);
374
+ __AddEventListener(handle, type, listener, {});
337
375
  break;
338
376
  }
339
377
  case Op.RemoveEvent: {
340
- // PAPI has no documented `__RemoveEventListener` in the
341
- // validated v1 surface (CONTRACT.md never needed it,
342
- // since mithril-lynx v1 never tore down individual
343
- // listeners outside of removing the whole element).
344
- // Left as an explicit no-op + TODO rather than a guess.
345
- i += 2;
378
+ const id = ops[i++];
379
+ const type = ops[i++];
380
+ const handle = handles.get(id);
381
+ const byType = eventListeners.get(id);
382
+ const listener = byType ? byType.get(type) : undefined;
383
+ if (typeof __RemoveEventListener === "function") {
384
+ // Native Fiber requires the options argument (>= 4 params) and
385
+ // derives the binding slot from it — pass the same `{}` the
386
+ // Op.AddEvent case uses so add/remove target the same slot.
387
+ if (listener != null) __RemoveEventListener(handle, type, listener, {});
388
+ } else if (listener != null) {
389
+ // Failing loudly is deliberate: without __RemoveEventListener a
390
+ // remove→re-add cycle on a kept element would accumulate
391
+ // duplicate native listeners and fire the handler N times per
392
+ // event. A silent no-op here is exactly the class of bug the
393
+ // whole project refuses to ship.
394
+ throw new Error(
395
+ "[mithril-lynx] __RemoveEventListener is not available on this runtime, " +
396
+ "so Op.RemoveEvent cannot be applied — a conditional event handler " +
397
+ "would leak duplicate listeners. Keep the handler constant or " +
398
+ "remove the whole element instead.",
399
+ );
400
+ }
401
+ if (byType != null) {
402
+ byType.delete(type);
403
+ if (byType.size === 0) eventListeners.delete(id);
404
+ }
346
405
  break;
347
406
  }
348
407
  case Op.SetGestureDetector: {
@@ -363,29 +422,30 @@ export function createPatchApplier(pageId, { onEvent } = {}) {
363
422
  }
364
423
  case Op.CreateList: {
365
424
  const id = ops[i++];
366
- const rendererKey = ops[i++];
367
425
  const scrollOrientation = ops[i++];
368
426
  const listType = ops[i++];
369
427
  const spanCount = ops[i++];
370
- handles.set(id, createNativeList(pageId, rendererKey, scrollOrientation, listType, spanCount, createPatchApplier, onEvent));
428
+ const { handle, setCells } = createNativeList(pageId, scrollOrientation, listType, spanCount, createPatchApplier, onEvent);
429
+ handles.set(id, handle);
430
+ listSetters.set(id, setCells);
371
431
  break;
372
432
  }
373
433
  case Op.SetListItems: {
374
434
  const id = ops[i++];
375
- const itemsJSON = ops[i++];
376
- const listHandle = handles.get(id);
377
- listHandle.__setItems(JSON.parse(itemsJSON));
435
+ const cells = ops[i++];
436
+ listSetters.get(id)(cells);
378
437
  break;
379
438
  }
380
439
  default:
381
440
  throw new Error(`[mithril-lynx] Unknown patch opcode: ${opcode}`);
382
441
  }
383
442
  }
384
- __FlushElementTree();
443
+ if (flush) __FlushElementTree();
385
444
  }
386
445
 
387
446
  return {
388
447
  registerPageRoot,
448
+ registerRoot,
389
449
  applyPatch,
390
450
  /** The real PAPI element handle for a given background-side id, or
391
451
  * `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, 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
  }
@@ -3,9 +3,22 @@ export interface RenderAppOptions {
3
3
  sendPatch?: (ops: unknown[]) => void;
4
4
  }
5
5
 
6
+ /** Minimal shape of a fake-DOM node exposed for tests and HMR glue — enough
7
+ * to dispatch a synthetic event (e.g. `node.dispatchEvent({ type: "tap" })`)
8
+ * and correlate with a background-side id. */
9
+ export interface LynxNode {
10
+ dispatchEvent(event: unknown): unknown;
11
+ }
12
+
13
+ /** The subset of the fake-DOM document exposed on the render handle — used
14
+ * by tests and by an app's own HMR glue, never by the channel wiring. */
15
+ export interface LynxDocument {
16
+ getNodeById(id: number): LynxNode | null;
17
+ }
18
+
6
19
  export interface RenderAppHandle {
7
20
  redraw: () => void;
8
- document: unknown;
21
+ document: LynxDocument;
9
22
  }
10
23
 
11
24
  export function renderApp(options: RenderAppOptions): RenderAppHandle;
package/src/channel.js CHANGED
@@ -20,9 +20,17 @@ export const eventFromMainThreadEventName = "MithrilLynx:Event";
20
20
  export const renderPageEventName = "__RenderPage";
21
21
  export const destroyLifetimeEventName = "__DestroyLifetime";
22
22
 
23
- /** Background thread: ship one commit's ops to the main thread. */
23
+ import { PROTOCOL_VERSION } from "./patch-protocol.js";
24
+
25
+ /**
26
+ * Background thread: ship one commit's ops to the main thread.
27
+ *
28
+ * The patch is prefixed with `PROTOCOL_VERSION` so the main thread can
29
+ * detect a stale/desynced bundle (partial HMR, cache) and fail loudly
30
+ * instead of re-interpreting reordered opcodes against the shared id space.
31
+ */
24
32
  export function sendPatchToMainThread(ops) {
25
- lynx.getCoreContext().dispatchEvent({ type: patchEventName, data: ops });
33
+ lynx.getCoreContext().dispatchEvent({ type: patchEventName, data: [PROTOCOL_VERSION, ...ops] });
26
34
  }
27
35
 
28
36
  /** Background thread: receive a forwarded native event `{ id, type, payload }`. */
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 };