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.
@@ -1,153 +1,175 @@
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
- }
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;
40
+ }
57
41
 
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
- }
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
+ }
68
48
 
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
- );
49
+ /** The wrapper of the lowest currently-attached cellIndex greater than
50
+ * `cellIndex`, or null if none — the DOM position a cell at `cellIndex`
51
+ * needs to be inserted before to land in the right place. Native lays
52
+ * cells out in document-tree order, not by `item-key`, so a recycled
53
+ * wrapper that keeps its OLD tree position (this file's own first
54
+ * version never repositioned it at all) shows up out of order the
55
+ * moment it's reused for a different index confirmed on real
56
+ * hardware via a scrambled child order after scrolling (item-keys
57
+ * 7,8,9,3,4,5,6). Same problem `@lynx-js/react`'s own
58
+ * `attachListItemAtIndex`/`findNextAttachedItem` solves for its
59
+ * Element Template list. */
60
+ function findNextAttachedWrapper(cellIndex) {
61
+ let best = null;
62
+ for (const entry of signMap.values()) {
63
+ if (entry.cellIndex > cellIndex && (best == null || entry.cellIndex < best.cellIndex)) best = entry;
64
+ }
65
+ return best ? best.wrapperHandle : null;
85
66
  }
86
67
 
87
- let items = [];
88
- let count = 0;
89
- const recycleMap = new Map(); // typeKey -> Map<sign, cell>
90
- const signMap = new Map(); // sign -> cell
68
+ function attachWrapper(listHandle, wrapperHandle, cellIndex) {
69
+ const refHandle = findNextAttachedWrapper(cellIndex);
70
+ if (refHandle != null) __InsertElementBefore(listHandle, wrapperHandle, refHandle);
71
+ else __AppendElement(listHandle, wrapperHandle);
72
+ }
91
73
 
92
- function bindFreshItem(listHandle, listId, cellIndex, opId, vnode, typeKey) {
74
+ function bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush) {
93
75
  const wrapperHandle = __CreateElement("list-item", pageId, {});
94
76
  __SetAttribute(wrapperHandle, "item-key", String(cellIndex));
95
- __AppendElement(listHandle, wrapperHandle);
96
-
97
- const cell = makeCell(pageId, wrapperHandle, (pid) => createPatchApplier(pid, { onEvent }));
98
- cell.typeKey = typeKey;
99
- renderCellVnode(cell, vnode);
77
+ attachWrapper(listHandle, wrapperHandle, cellIndex);
100
78
 
79
+ const applier = replayCell(wrapperHandle, cell);
101
80
  const sign = __GetElementUniqueID(wrapperHandle);
102
- signMap.set(sign, cell);
103
- __FlushElementTree(wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
81
+ signMap.set(sign, { wrapperHandle, applier, rootChildIds: cell.rootChildIds, cellIndex, typeKey: cell.typeKey });
82
+ if (flush) __FlushElementTree(wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
104
83
  return sign;
105
84
  }
106
85
 
107
- function bindRecycledItem(listId, cellIndex, opId, vnode, typeKey, pool) {
108
- const [sign, cell] = pool.entries().next().value;
86
+ function bindRecycledItem(listHandle, listId, cellIndex, opId, cell, pool, flush) {
87
+ const [sign, entry] = pool.entries().next().value;
109
88
  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 });
89
+ clearWrapperChildren(entry);
90
+ __SetAttribute(entry.wrapperHandle, "item-key", String(cellIndex));
91
+ attachWrapper(listHandle, entry.wrapperHandle, cellIndex);
92
+ const applier = replayCell(entry.wrapperHandle, cell);
93
+ entry.applier = applier;
94
+ entry.rootChildIds = cell.rootChildIds;
95
+ entry.cellIndex = cellIndex;
96
+ signMap.set(sign, entry);
97
+ if (flush) __FlushElementTree(entry.wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
115
98
  return sign;
116
99
  }
117
100
 
118
- function componentAtIndex(listHandle, listId, cellIndex, opId) {
101
+ function bindCell(listHandle, listId, cellIndex, opId, flush) {
119
102
  if (cellIndex < 0 || cellIndex >= count) {
120
103
  throw new Error(`[mithril-lynx] list: cellIndex ${cellIndex} out of range (itemCount=${count})`);
121
104
  }
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);
105
+ const cell = cells[cellIndex];
106
+ const pool = recycleMap.get(cell.typeKey);
107
+ if (pool && pool.size > 0) return bindRecycledItem(listHandle, listId, cellIndex, opId, cell, pool, flush);
108
+ return bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush);
109
+ }
110
+
111
+ function componentAtIndex(listHandle, listId, cellIndex, opId) {
112
+ return bindCell(listHandle, listId, cellIndex, opId, true);
113
+ }
114
+
115
+ /**
116
+ * The batched form of componentAtIndex — real native calls this (not a
117
+ * fallback: confirmed against `@lynx-js/react`'s own
118
+ * componentAtIndexFactory/attachListItemAtIndex, which always registers
119
+ * BOTH forms) for several cells at once, expecting exactly ONE
120
+ * `__FlushElementTree` covering all of them (`operationIDs`/`elementIDs`
121
+ * arrays), not one per cell — the real reason a native list's initial,
122
+ * simultaneously-visible cells need this at all.
123
+ */
124
+ function componentAtIndexes(listHandle, listId, cellIndexes, operationIDs) {
125
+ const elementIDs = cellIndexes.map((cellIndex, i) => bindCell(listHandle, listId, cellIndex, operationIDs[i], false));
126
+ __FlushElementTree(listHandle, { triggerLayout: true, operationIDs, elementIDs, listID: listId });
127
127
  }
128
128
 
129
129
  function enqueueComponent(_listHandle, _listId, sign) {
130
- const cell = signMap.get(sign);
131
- if (cell == null) return;
130
+ const entry = signMap.get(sign);
131
+ if (entry == null) return;
132
132
  signMap.delete(sign);
133
- if (!recycleMap.has(cell.typeKey)) recycleMap.set(cell.typeKey, new Map());
134
- recycleMap.get(cell.typeKey).set(sign, cell);
133
+ if (!recycleMap.has(entry.typeKey)) recycleMap.set(entry.typeKey, new Map());
134
+ recycleMap.get(entry.typeKey).set(sign, entry);
135
135
  }
136
136
 
137
137
  function sendListInfo(listHandle, listId, insertAction, removeAction, updateAction) {
138
138
  __SetAttribute(listHandle, "update-list-info", { insertAction, removeAction, updateAction });
139
- __UpdateListCallbacks(listHandle, componentAtIndex, enqueueComponent);
139
+ __UpdateListCallbacks(listHandle, componentAtIndex, enqueueComponent, componentAtIndexes);
140
140
  }
141
141
 
142
- const listHandle = __CreateList(pageId, componentAtIndex, enqueueComponent, {});
142
+ const listHandle = __CreateList(pageId, componentAtIndex, enqueueComponent, {}, componentAtIndexes);
143
143
  const listId = __GetElementUniqueID(listHandle);
144
144
  __SetAttribute(listHandle, "scroll-orientation", scrollOrientation);
145
145
  __SetAttribute(listHandle, "list-type", listType);
146
146
  __SetAttribute(listHandle, "span-count", String(spanCount));
147
147
 
148
- listHandle.__setItems = (nextItems) => {
149
- const nextCount = nextItems.length;
150
- items = nextItems;
148
+ // Re-flushes every currently-attached (on-screen) cell with its newest
149
+ // ops e.g. after a redraw triggered by a tap inside a cell, or any
150
+ // other app state change that reaches this list's items. Unconditional,
151
+ // same as List's own onupdate already unconditionally resends the whole
152
+ // `items` array on every relevant redraw (see mithril-lynx-ui's
153
+ // list/list.js) — a real "did this cell's content actually change"
154
+ // check would need to compare `ops` arrays, not implemented yet. Cells
155
+ // that aren't attached right now just pick up their new content next
156
+ // time componentAtIndex asks for that index.
157
+ function refreshAttachedCells(nextCells) {
158
+ for (const [sign, entry] of signMap) {
159
+ const nextCell = nextCells[entry.cellIndex];
160
+ clearWrapperChildren(entry);
161
+ entry.applier = replayCell(entry.wrapperHandle, nextCell);
162
+ entry.rootChildIds = nextCell.rootChildIds;
163
+ entry.typeKey = nextCell.typeKey;
164
+ signMap.set(sign, entry);
165
+ __FlushElementTree(entry.wrapperHandle, { triggerLayout: false });
166
+ }
167
+ }
168
+
169
+ function setCells(nextCells) {
170
+ const nextCount = nextCells.length;
171
+ refreshAttachedCells(nextCells);
172
+ cells = nextCells;
151
173
  if (nextCount === count) return;
152
174
  if (nextCount > count) {
153
175
  sendListInfo(
@@ -161,7 +183,15 @@ export function createNativeList(pageId, rendererKey, scrollOrientation, listTyp
161
183
  sendListInfo(listHandle, listId, [], Array.from({ length: count - nextCount }, (_, i) => nextCount + i), []);
162
184
  }
163
185
  count = nextCount;
164
- };
186
+ }
165
187
 
166
- return listHandle;
188
+ // Returned separately, not as a property stuck onto `listHandle` itself:
189
+ // confirmed on real hardware (unlike @lynx-js/testing-environment's
190
+ // simulated PAPI) that a native list's own returned handle does not
191
+ // reliably hold a custom property across calls — assigning one there
192
+ // and reading it back later from apply-patch.js's Op.SetListItems case
193
+ // threw "TypeError: not a function" on the very first SetListItems, even
194
+ // with zero items. apply-patch.js keeps `setCells` in its own map
195
+ // instead, keyed the same way `handles` already is.
196
+ return { handle: listHandle, setCells };
167
197
  }
@@ -10,6 +10,7 @@
10
10
  // was never part of the bug this rewrite exists to fix.
11
11
 
12
12
  import { createPatchApplier } from "./apply-patch.js";
13
+ import { PROTOCOL_VERSION } from "./patch-protocol.js";
13
14
  import {
14
15
  destroyLifetimeEventName,
15
16
  onPatchFromBackground,
@@ -39,11 +40,23 @@ export function setupRenderer() {
39
40
  let pendingPatches = [];
40
41
 
41
42
  const onPatch = (event) => {
43
+ const data = event.data;
44
+ if (!Array.isArray(data) || data[0] !== PROTOCOL_VERSION) {
45
+ throw new Error(
46
+ `[mithril-lynx] patch protocol mismatch: expected version ${PROTOCOL_VERSION}, ` +
47
+ `got ${Array.isArray(data) ? String(data[0]) : typeof data}. ` +
48
+ "This is always a stale/desynced bundle (partial HMR or a cached main-thread chunk) — " +
49
+ "rebuild both bundles together.",
50
+ );
51
+ }
52
+ // Strip the version prefix so applyPatch() still receives the bare
53
+ // flat op array the rest of the protocol documents.
54
+ const ops = data.slice(1);
42
55
  if (!pageReady) {
43
- pendingPatches.push(event.data);
56
+ pendingPatches.push(ops);
44
57
  return;
45
58
  }
46
- applier.applyPatch(event.data);
59
+ applier.applyPatch(ops);
47
60
  };
48
61
  onPatchFromBackground(onPatch);
49
62
 
@@ -6,6 +6,16 @@
6
6
  * auto-redraw-after-event contract doesn't cover. */
7
7
  export function register(redraw: () => void): void;
8
8
 
9
+ /** Clears the current redraw registration (and the pending debounce flag).
10
+ * Fail-fast counterpart to {@link register}: a second `register()` throws,
11
+ * so call this on teardown or full reload before mounting a new app. */
12
+ export function unregister(): void;
13
+
14
+ /** Overrides the debounce delay `redraw()` uses (default 50ms — see the
15
+ * `mount-redraw.js` header for why it is an empirical margin, not a
16
+ * scheduling guarantee). */
17
+ export function configure(options?: { redrawDelayMs?: number }): void;
18
+
9
19
  /** No-op before any app has mounted. Scheduled, not synchronous — see
10
20
  * mount-redraw.js's own header for why a synchronous call here would race
11
21
  * a caller's own pending `.then()`/callback that hasn't stored its result
@@ -48,16 +48,68 @@ const REDRAW_DELAY_MS = 50;
48
48
 
49
49
  let currentRedraw = null;
50
50
  let pending = false;
51
+ /** Handle of the currently-scheduled redraw timer, so `unregister()` can
52
+ * cancel it instead of leaving a stray callback that fires into a later
53
+ * mount. */
54
+ let pendingTimer = null;
55
+ /** Overridable via {@link configure}; defaults to REDRAW_DELAY_MS. */
56
+ let redrawDelayMs = REDRAW_DELAY_MS;
57
+
58
+ /**
59
+ * Overrides the debounce delay `redraw()` uses. Defaults to `REDRAW_DELAY_MS`
60
+ * (50ms) — the empirically-chosen margin documented at the top of this file,
61
+ * which is a safety margin rather than a scheduling guarantee. A device whose
62
+ * timer behaves differently (see FETCH_INVESTIGATION.md §4.6) can raise or
63
+ * lower it here.
64
+ */
65
+ export function configure(options) {
66
+ if (options && options.redrawDelayMs != null) {
67
+ redrawDelayMs = options.redrawDelayMs;
68
+ }
69
+ }
51
70
 
52
71
  function schedule(fn) {
53
72
  const timer = typeof lynx !== "undefined" && typeof lynx.setTimeout === "function" ? lynx.setTimeout.bind(lynx) : setTimeout;
54
- timer(fn, REDRAW_DELAY_MS);
73
+ return timer(fn, redrawDelayMs);
55
74
  }
56
75
 
76
+ /**
77
+ * Registers the current app's redraw. Fail-fast: throws if a redraw is
78
+ * already registered, because a second live `renderApp()` in the same
79
+ * background context would silently overwrite the slot and let two
80
+ * documents corrupt the shared id space (see R2 in
81
+ * informe-contrato-mithril-lynx.md). Call {@link unregister} on teardown
82
+ * or full reload before registering a new one.
83
+ */
57
84
  export function register(redraw) {
85
+ if (currentRedraw != null) {
86
+ throw new Error(
87
+ "[mithril-lynx] redraw already registered — a shim instance is single-use: " +
88
+ "one renderApp() per background context, one redraw slot, for the " +
89
+ "lifetime of that context. Call unregister() (or do a full reload) " +
90
+ "before mounting a new app.",
91
+ );
92
+ }
58
93
  currentRedraw = redraw;
59
94
  }
60
95
 
96
+ /**
97
+ * Clears the current redraw registration (and the pending debounce flag) —
98
+ * used by a full reload and by the test suite between mounts. After this,
99
+ * `redraw()` is a no-op again until the next `register()`.
100
+ */
101
+ export function unregister() {
102
+ currentRedraw = null;
103
+ pending = false;
104
+ if (pendingTimer != null) {
105
+ const clear = typeof lynx !== "undefined" && typeof lynx.clearTimeout === "function"
106
+ ? lynx.clearTimeout.bind(lynx)
107
+ : clearTimeout;
108
+ clear(pendingTimer);
109
+ pendingTimer = null;
110
+ }
111
+ }
112
+
61
113
  /** What `request.js` calls after a non-background request resolves. A
62
114
  * no-op before any app has mounted — a request kicked off before
63
115
  * renderApp()/route() ran has nothing to redraw yet, which isn't
@@ -67,7 +119,8 @@ export function register(redraw) {
67
119
  export function redraw() {
68
120
  if (pending) return;
69
121
  pending = true;
70
- schedule(() => {
122
+ pendingTimer = schedule(() => {
123
+ pendingTimer = null;
71
124
  pending = false;
72
125
  if (currentRedraw != null) currentRedraw();
73
126
  });
@@ -13,6 +13,20 @@
13
13
  // main-thread side by `applyPatch()` (see backends/papi-backend.js), so
14
14
  // nodes never need to be looked up by anything other than that integer.
15
15
 
16
+ /**
17
+ * The wire protocol version, prepended to every patch by
18
+ * `sendPatchToMainThread()` and validated (then stripped) by the main
19
+ * thread before any op is interpreted. The two bundles are normally built
20
+ * from the same source, but a partial HMR or a cached main-thread bundle
21
+ * could otherwise re-interpret a reordered opcode silently — a version
22
+ * mismatch throws instead of corrupting the mirrored id space.
23
+ *
24
+ * The value is deliberately outside the 0..17 opcode range (0x4d4c = "ML"
25
+ * in ASCII) so a versioned array can never be misread as an op sequence if
26
+ * it is ever fed to `applyPatch()` without the strip.
27
+ */
28
+ export const PROTOCOL_VERSION = 0x4d4c;
29
+
16
30
  export const Op = Object.freeze({
17
31
  CreateElement: 0,
18
32
  CreateElementNS: 1,
@@ -43,12 +57,13 @@ export const Op = Object.freeze({
43
57
  SetGestureDetector: 14, // id, gestureId, gestureType, arenaPolicy
44
58
  RemoveGestureDetector: 15, // id, gestureId
45
59
  // 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)
60
+ // in mithril-lynx-ui for the full design. Each list item is rendered on
61
+ // the BACKGROUND thread, through the app's own real render pass
62
+ // (list-cell.js), same as everything else in the tree — the main thread
63
+ // never runs renderItem() itself, only replays the ops that rendering
64
+ // already produced (apply-patch.js's Op.CreateList case + list-support.js).
65
+ CreateList: 16, // id, scrollOrientation, listType, spanCount
66
+ SetListItems: 17, // id, 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
67
  });
53
68
 
54
69
  /**
@@ -59,3 +74,43 @@ export const Op = Object.freeze({
59
74
  export function pushOp(ops, opcode, ...args) {
60
75
  ops.push(opcode, ...args);
61
76
  }
77
+
78
+ // How many argument slots follow each opcode — the single source of truth
79
+ // for walking a flat ops array without re-interpreting it (apply-patch.js's
80
+ // own switch increments `i` inline instead of using this table, since it
81
+ // also needs to look at individual arg values as it goes; this exists for
82
+ // callers that only need to skip/scan, e.g. list-cell.js's
83
+ // findTopLevelChildIds(), which never applies the ops itself).
84
+ export const OP_ARITY = Object.freeze({
85
+ [Op.CreateElement]: 2, // tag, id
86
+ [Op.CreateElementNS]: 3, // ns, tag, id
87
+ [Op.CreateText]: 2, // value, id
88
+ [Op.CreateFragment]: 1, // id (never actually emitted — see fake-dom.js's LynxFragment)
89
+ [Op.InsertBefore]: 3, // parentId, childId, refId
90
+ [Op.RemoveChild]: 2, // parentId, childId
91
+ [Op.SetAttribute]: 3, // id, name, value
92
+ [Op.RemoveAttribute]: 2, // id, name
93
+ [Op.SetAttributeNS]: 4, // id, ns, name, value
94
+ [Op.SetStyleProperty]: 3, // id, name, value
95
+ [Op.RemoveStyleProperty]: 2, // id, name
96
+ [Op.SetText]: 2, // id, value
97
+ [Op.AddEvent]: 2, // id, type
98
+ [Op.RemoveEvent]: 2, // id, type
99
+ [Op.SetGestureDetector]: 4, // id, gestureId, gestureType, arenaPolicy
100
+ [Op.RemoveGestureDetector]: 2, // id, gestureId
101
+ [Op.CreateList]: 4, // id, scrollOrientation, listType, spanCount
102
+ [Op.SetListItems]: 2, // id, cells
103
+ });
104
+
105
+ /** Walks a flat ops array, calling `visit(opcode, args)` once per op — args
106
+ * is the plain slice of that op's own arguments (not including the opcode
107
+ * itself). Throws on an unknown opcode rather than silently desyncing. */
108
+ export function forEachOp(ops, visit) {
109
+ for (let i = 0; i < ops.length; ) {
110
+ const opcode = ops[i++];
111
+ const arity = OP_ARITY[opcode];
112
+ if (arity === undefined) throw new Error(`[mithril-lynx] Unknown patch opcode: ${opcode}`);
113
+ visit(opcode, ops.slice(i, i + arity));
114
+ i += arity;
115
+ }
116
+ }
package/src/request.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface RequestOptions<T = any> {
10
10
  serialize?: (data: unknown) => string;
11
11
  deserialize?: (data: unknown) => unknown;
12
12
  extract?: (response: unknown, options: RequestOptions<T>) => unknown;
13
+ /** A constructor applied to the response: per element when the response
14
+ * is an array (matching real `m.request`), otherwise to the whole result. */
13
15
  type?: new (data: any) => T;
14
16
  background?: boolean;
15
17
  // Present on the real m.request signature but confirmed unsupported —
package/src/request.js CHANGED
@@ -112,9 +112,13 @@ export function createRequestor(fetchImpl) {
112
112
  // controller so a caller-provided signal and our timeout can both
113
113
  // trigger the same abort.
114
114
  const ctrl = new AbortController();
115
+ // Stored so the listener can be detached on settle (see detachSignal
116
+ // below) — a long-lived shared AbortSignal would otherwise accumulate
117
+ // one dead closure per request.
118
+ const abortHandler = () => ctrl.abort();
115
119
  if (options.signal) {
116
120
  if (options.signal.aborted) ctrl.abort();
117
- else options.signal.addEventListener("abort", () => ctrl.abort());
121
+ else options.signal.addEventListener("abort", abortHandler);
118
122
  }
119
123
  let timeoutId;
120
124
  if (options.timeout) {
@@ -138,8 +142,10 @@ export function createRequestor(fetchImpl) {
138
142
 
139
143
  if (typeof options.extract === "function") {
140
144
  // Matches real m.request: extract() bypasses the status check
141
- // entirely — it decides success/failure itself.
142
- return options.extract(response, options);
145
+ // entirely — it decides success/failure itself. `type` is still
146
+ // applied afterwards, exactly as upstream does (extract does NOT
147
+ // skip the type constructor).
148
+ return applyType(options.extract(response, options), options.type);
143
149
  }
144
150
 
145
151
  const ok = response.ok || response.status === 304;
@@ -156,12 +162,24 @@ export function createRequestor(fetchImpl) {
156
162
  });
157
163
  });
158
164
 
165
+ // Detach the caller's signal listener once this request settles, so a
166
+ // long-lived shared AbortSignal doesn't accumulate a dead closure per
167
+ // request. Done inside the settled handlers (rather than a separate
168
+ // `.then`) so an ignored rejection still surfaces as unhandled.
169
+ const detachSignal = () => {
170
+ if (options.signal && !options.signal.aborted) {
171
+ options.signal.removeEventListener("abort", abortHandler);
172
+ }
173
+ };
174
+
159
175
  const result = promise.then(
160
176
  (value) => {
177
+ detachSignal();
161
178
  if (options.background !== true) sharedRedraw();
162
179
  return value;
163
180
  },
164
181
  (error) => {
182
+ detachSignal();
165
183
  clearRequestTimeout();
166
184
  if (options.background !== true) sharedRedraw();
167
185
  throw error;
package/src/route.d.ts CHANGED
@@ -1,4 +1,14 @@
1
- import type { Component } from "mithril";
1
+ /**
2
+ * Minimal Mithril-style component type. Declared locally because this
3
+ * package's runtime peer is `mithril-runtime`, which ships no TypeScript
4
+ * types of its own; the previous `import type { Component } from "mithril"`
5
+ * depended on an undeclared module. `view`'s return is typed `any` so
6
+ * `route.Link` stays assignable to a caller's own `m()` typed against
7
+ * `@types/mithril`.
8
+ */
9
+ export interface Component<Attrs = unknown> {
10
+ view(vnode: { attrs: Attrs; children?: unknown }): any;
11
+ }
2
12
 
3
13
  export interface RouteResolver {
4
14
  onmatch?(args: Record<string, string>, requestedPath: string, route: string): unknown;
@@ -19,9 +29,18 @@ export interface Route {
19
29
  (defaultRoute: string, routes: Record<string, unknown | RouteResolver>): void;
20
30
  set(path: string, data?: unknown, options?: { replace?: boolean }): void;
21
31
  get(): string | undefined;
32
+ /** Returns the named route parameter (with `key`), or the whole params
33
+ * object (without). Typed `unknown` because the value can be a string
34
+ * (path params), a string|boolean (query params — `"true"`/`"false"` are
35
+ * coerced), or anything passed as `data` to `set(path, data)`; the common
36
+ * path-param case is always a string. */
22
37
  param(key?: string): unknown;
23
- back(): void;
24
- forward(): void;
38
+ /** Walks back one entry; returns `false` (without navigating) at the top
39
+ * of the history stack so a back affordance can disable itself. */
40
+ back(): boolean;
41
+ /** Walks forward one entry; returns `false` (without navigating) at the
42
+ * end of the history stack so a forward affordance can disable itself. */
43
+ forward(): boolean;
25
44
  prefix: string;
26
45
  SKIP: unknown;
27
46
  Link: Component<RouteLinkAttrs>;