mithril-lynx 2.6.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.
@@ -216,6 +216,12 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
216
216
  // device), only `handles` (a plain Map) does.
217
217
  const listSetters = new Map();
218
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
+
219
225
  /** General form: seed the mapping for any id, not just the page root —
220
226
  * list-support.js uses this to alias a list-cell.js `containerId` (an
221
227
  * off-tree id from the background thread's OWN document) to the real
@@ -359,18 +365,43 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
359
365
  const id = ops[i++];
360
366
  const type = ops[i++];
361
367
  const handle = handles.get(id);
362
- __AddEventListener(handle, type, (nativeEvent) => {
368
+ const listener = (nativeEvent) => {
363
369
  onEvent?.(id, type, nativeEvent);
364
- }, {});
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, {});
365
375
  break;
366
376
  }
367
377
  case Op.RemoveEvent: {
368
- // PAPI has no documented `__RemoveEventListener` in the
369
- // validated v1 surface (CONTRACT.md never needed it,
370
- // since mithril-lynx v1 never tore down individual
371
- // listeners outside of removing the whole element).
372
- // Left as an explicit no-op + TODO rather than a guess.
373
- 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
+ }
374
405
  break;
375
406
  }
376
407
  case Op.SetGestureDetector: {
@@ -401,8 +432,8 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
401
432
  }
402
433
  case Op.SetListItems: {
403
434
  const id = ops[i++];
404
- const cellsJSON = ops[i++];
405
- listSetters.get(id)(JSON.parse(cellsJSON));
435
+ const cells = ops[i++];
436
+ listSetters.get(id)(cells);
406
437
  break;
407
438
  }
408
439
  default:
@@ -81,7 +81,7 @@ export function createVirtualBackend() {
81
81
  return id;
82
82
  },
83
83
  setListItems(id, cells) {
84
- pushOp(ops, Op.SetListItems, id, JSON.stringify(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() {
@@ -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 }`. */
@@ -46,10 +46,35 @@ export function createNativeList(pageId, scrollOrientation, listType, spanCount,
46
46
  }
47
47
  }
48
48
 
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;
66
+ }
67
+
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
+ }
73
+
49
74
  function bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush) {
50
75
  const wrapperHandle = __CreateElement("list-item", pageId, {});
51
76
  __SetAttribute(wrapperHandle, "item-key", String(cellIndex));
52
- __AppendElement(listHandle, wrapperHandle);
77
+ attachWrapper(listHandle, wrapperHandle, cellIndex);
53
78
 
54
79
  const applier = replayCell(wrapperHandle, cell);
55
80
  const sign = __GetElementUniqueID(wrapperHandle);
@@ -58,11 +83,12 @@ export function createNativeList(pageId, scrollOrientation, listType, spanCount,
58
83
  return sign;
59
84
  }
60
85
 
61
- function bindRecycledItem(listId, cellIndex, opId, cell, pool, flush) {
86
+ function bindRecycledItem(listHandle, listId, cellIndex, opId, cell, pool, flush) {
62
87
  const [sign, entry] = pool.entries().next().value;
63
88
  pool.delete(sign);
64
89
  clearWrapperChildren(entry);
65
90
  __SetAttribute(entry.wrapperHandle, "item-key", String(cellIndex));
91
+ attachWrapper(listHandle, entry.wrapperHandle, cellIndex);
66
92
  const applier = replayCell(entry.wrapperHandle, cell);
67
93
  entry.applier = applier;
68
94
  entry.rootChildIds = cell.rootChildIds;
@@ -78,7 +104,7 @@ export function createNativeList(pageId, scrollOrientation, listType, spanCount,
78
104
  }
79
105
  const cell = cells[cellIndex];
80
106
  const pool = recycleMap.get(cell.typeKey);
81
- if (pool && pool.size > 0) return bindRecycledItem(listId, cellIndex, opId, cell, pool, flush);
107
+ if (pool && pool.size > 0) return bindRecycledItem(listHandle, listId, cellIndex, opId, cell, pool, flush);
82
108
  return bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush);
83
109
  }
84
110
 
@@ -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,
@@ -49,7 +63,7 @@ export const Op = Object.freeze({
49
63
  // never runs renderItem() itself, only replays the ops that rendering
50
64
  // already produced (apply-patch.js's Op.CreateList case + list-support.js).
51
65
  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.
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.
53
67
  });
54
68
 
55
69
  /**
@@ -85,7 +99,7 @@ export const OP_ARITY = Object.freeze({
85
99
  [Op.SetGestureDetector]: 4, // id, gestureId, gestureType, arenaPolicy
86
100
  [Op.RemoveGestureDetector]: 2, // id, gestureId
87
101
  [Op.CreateList]: 4, // id, scrollOrientation, listType, spanCount
88
- [Op.SetListItems]: 2, // id, cellsJSON
102
+ [Op.SetListItems]: 2, // id, cells
89
103
  });
90
104
 
91
105
  /** Walks a flat ops array, calling `visit(opcode, args)` once per op — args
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>;
package/src/route.js CHANGED
@@ -116,15 +116,32 @@ export function createRoute() {
116
116
  if (!compiled.some((entry) => entry.check(defaultData))) {
117
117
  throw new ReferenceError("Default route doesn't match any known routes.");
118
118
  }
119
- history = [defaultRoute];
120
- historyIndex = 0;
121
- ready = true;
122
- resolveRoute(defaultRoute, null);
119
+ if (!ready) {
120
+ history = [defaultRoute];
121
+ historyIndex = 0;
122
+ ready = true;
123
+ resolveRoute(defaultRoute, null);
124
+ } else {
125
+ // Re-registration (e.g. HMR of the route module): keep the history
126
+ // stack and re-resolve the CURRENT path against the new table —
127
+ // the same thing the documented HMR pattern does by hand with
128
+ // route.set(route.get(), null, {replace: true}). Resolving
129
+ // defaultRoute here instead would silently jump the screen back to
130
+ // the initial route and discard the user's back/forward stack.
131
+ resolveRoute(currentPath != null ? currentPath : defaultRoute, null);
132
+ }
123
133
  }
124
134
 
125
135
  route.SKIP = {};
126
136
 
127
137
  route.set = function (path, data, options) {
138
+ if (!ready) {
139
+ throw new Error(
140
+ "[mithril-lynx] route.set() called before route(defaultRoute, routes) — " +
141
+ "the route table does not exist yet, so this navigation would be " +
142
+ "silently dropped. Call route() first.",
143
+ );
144
+ }
128
145
  if (lastUpdate != null) {
129
146
  options = options || {};
130
147
  options.replace = true;
@@ -161,14 +178,16 @@ export function createRoute() {
161
178
  * is F4/F5 of the plan, not done yet.
162
179
  */
163
180
  route.back = function () {
164
- if (historyIndex <= 0) return;
181
+ if (historyIndex <= 0) return false;
165
182
  historyIndex--;
166
183
  resolveRoute(history[historyIndex], null);
184
+ return true;
167
185
  };
168
186
  route.forward = function () {
169
- if (historyIndex >= history.length - 1) return;
187
+ if (historyIndex >= history.length - 1) return false;
170
188
  historyIndex++;
171
189
  resolveRoute(history[historyIndex], null);
190
+ return true;
172
191
  };
173
192
 
174
193
  // Lynx has no `<a>`/`onclick` — this renders a tap-driven element
package/src/testing.js CHANGED
@@ -24,25 +24,38 @@ export { createPatchApplier } from "./apply-patch.js";
24
24
  /**
25
25
  * Installs the polyfill on the main-thread globals object
26
26
  * @lynx-js/testing-environment hands to onInjectMainThreadGlobals. Scoped
27
- * to exactly what apply-patch.js calls (no gesture/list support — those
28
- * don't exist in mithril-lynx yet, see the main README's known gaps):
29
- * @lynx-js/testing-environment already implements __CreateView/__CreateText/
30
- * __CreateElement/__CreateRawText/__AppendElement/__InsertElementBefore/
31
- * __RemoveElement/__SetAttribute/__SetClasses/__AddInlineStyle/
32
- * __FlushElementTree/__GetElementUniqueID — the one real gap is
33
- * __AddEventListener (the testing environment only implements the
34
- * string/worklet-event __AddEvent family that ReactLynx uses; mithril-lynx
35
- * binds real JS function listeners directly).
27
+ * to the one real gap: @lynx-js/testing-environment already implements
28
+ * __CreateView/__CreateText/__CreateElement/__CreateRawText/__AppendElement/
29
+ * __InsertElementBefore/__RemoveElement/__SetAttribute/__SetClasses/
30
+ * __AddInlineStyle/__FlushElementTree/__GetElementUniqueID — the missing
31
+ * piece is __AddEventListener/__RemoveEventListener (the testing environment
32
+ * only implements the string/worklet-event __AddEvent family that ReactLynx
33
+ * uses; mithril-lynx binds real JS function listeners directly). Gesture and
34
+ * list ops (Op.SetGestureDetector/Op.CreateList) are covered by the testing
35
+ * environment itself, not by this polyfill — see test/gesture.test.ts and
36
+ * test/list.test.ts.
36
37
  */
37
38
  export function installTestingPolyfills(target) {
38
39
  target.lynx.getEngine = target.lynx.getNative;
39
40
 
40
- target.__AddEventListener = (node, name, handler) => {
41
+ // Faithful stand-ins for the native `__AddEventListener` /
42
+ // `__RemoveEventListener` PAPIs (element, name, callback, options). The
43
+ // options object is accepted and ignored here — the real native side
44
+ // requires it (FiberRemoveEventListener throws "param size should >= 4"
45
+ // without it), so __RemoveEventListener mirrors that arity requirement to
46
+ // keep the test environment honest about the wire contract.
47
+ target.__AddEventListener = (node, name, handler, _options) => {
41
48
  node.__vanillaListeners ??= {};
42
49
  (node.__vanillaListeners[name] ??= new Set()).add(handler);
43
50
  };
44
51
 
45
- target.__RemoveEventListener = (node, name, handler) => {
52
+ target.__RemoveEventListener = function (node, name, handler, _options) {
53
+ if (arguments.length < 4) {
54
+ throw new Error(
55
+ "[mithril-lynx/testing] __RemoveEventListener requires 4 params " +
56
+ "(element, name, callback, options) — matching FiberRemoveEventListener.",
57
+ );
58
+ }
46
59
  node.__vanillaListeners?.[name]?.delete(handler);
47
60
  };
48
61
  }
package/test/list.test.ts CHANGED
@@ -59,7 +59,7 @@ function setupList() {
59
59
  }
60
60
 
61
61
  function setCells(applier: ReturnType<typeof createPatchApplier>, cells: unknown[]) {
62
- applier.applyPatch([Op.SetListItems, 1, JSON.stringify(cells)]);
62
+ applier.applyPatch([Op.SetListItems, 1, cells]);
63
63
  }
64
64
 
65
65
  describe("Op.CreateList (native virtualized list support)", () => {
@@ -116,6 +116,24 @@ describe("Op.CreateList (native virtualized list support)", () => {
116
116
  expect(textOf(wrapper)).toBe("0:z");
117
117
  });
118
118
 
119
+ it("a recycled cell is repositioned in the real tree to match its new index, not left where it was created", () => {
120
+ const { buildCells } = makeCellSource((item: string, index: number) => m("text", {}, `${index}:${item}`));
121
+ const { applier, listHandle } = setupList();
122
+ setCells(applier, buildCells(["a", "b", "c", "d"]));
123
+
124
+ requestCell(listHandle, 0);
125
+ const signB = requestCell(listHandle, 1);
126
+ requestCell(listHandle, 2);
127
+ // Real tree order matches request order so far: a, b, c.
128
+ expect([listHandle.children[0], listHandle.children[1], listHandle.children[2]].map(textOf)).toEqual(["0:a", "1:b", "2:c"]);
129
+
130
+ releaseCell(listHandle, signB); // b's wrapper goes to the recycle pool, still sitting in the middle of the tree
131
+ requestCell(listHandle, 3); // recycled for "d" — index 3 is after every other attached cell, so it belongs at the END
132
+
133
+ const children = [listHandle.children[0], listHandle.children[1], listHandle.children[2]];
134
+ expect(children.map(textOf)).toEqual(["0:a", "2:c", "3:d"]); // not ["0:a", "3:d", "2:c"] — the bug this test guards against
135
+ });
136
+
119
137
  it("a tap inside a cell dispatches through the background thread's own fake-dom node", () => {
120
138
  const { document, buildCells } = makeCellSource(() =>
121
139
  m("text", { ontap: () => { taps += 1; } }, "tap me"),
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import { Op, OP_ARITY, PROTOCOL_VERSION, forEachOp, pushOp } from "../src/patch-protocol.js";
3
+
4
+ // M8: OP_ARITY is the "single source of truth" for walking a flat op array,
5
+ // but apply-patch.js re-implements the arity inline in its own switch. These
6
+ // tests lock the two to each other: every opcode must have an arity entry
7
+ // (no gap → no silent desync in forEachOp/list-cell), and the version prefix
8
+ // must never collide with a real opcode.
9
+ describe("patch-protocol OP_ARITY completeness", () => {
10
+ it("has an arity entry for every opcode in Op", () => {
11
+ const opcodes = Object.values(Op) as number[];
12
+ expect(opcodes.length).toBeGreaterThan(0);
13
+ for (const opcode of opcodes) {
14
+ expect(OP_ARITY[opcode], `OP_ARITY is missing opcode ${opcode}`).toBeDefined();
15
+ }
16
+ });
17
+
18
+ it("PROTOCOL_VERSION does not collide with any opcode value", () => {
19
+ for (const opcode of Object.values(Op) as number[]) {
20
+ expect(opcode).not.toBe(PROTOCOL_VERSION);
21
+ }
22
+ });
23
+
24
+ it("forEachOp walks a flat array built by pushOp without desyncing", () => {
25
+ const ops: unknown[] = [];
26
+ pushOp(ops, Op.CreateElement, "view", 1);
27
+ pushOp(ops, Op.SetText, 1, "hi");
28
+ pushOp(ops, Op.InsertBefore, 0, 1, -1);
29
+
30
+ const seen: number[] = [];
31
+ forEachOp(ops, (opcode, args) => {
32
+ seen.push(opcode);
33
+ if (opcode === Op.InsertBefore) expect(args).toEqual([0, 1, -1]);
34
+ });
35
+ expect(seen).toEqual([Op.CreateElement, Op.SetText, Op.InsertBefore]);
36
+ });
37
+
38
+ it("forEachOp throws on an unknown opcode instead of silently desyncing", () => {
39
+ expect(() => forEachOp([999], () => {})).toThrow(/Unknown patch opcode/);
40
+ });
41
+ });