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.
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
@@ -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, 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,60 @@ 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
-
96
- const { applier, listHandle } = setupList("grow");
95
+ const { buildCells } = makeCellSource((item: string, index: number) => m("text", {}, `${index}:${item}`));
96
+ const { applier, listHandle } = setupList();
97
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 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
+
137
+ it("a tap inside a cell dispatches through the background thread's own fake-dom node", () => {
138
+ const { document, buildCells } = makeCellSource(() =>
139
+ m("text", { ontap: () => { taps += 1; } }, "tap me"),
140
+ );
141
+ let taps = 0;
142
+ const { applier, listHandle } = setupList();
143
+ const cells = buildCells([{}]);
144
+ setCells(applier, cells);
145
+ requestCell(listHandle, 0);
146
+
147
+ const node = document.getNodeById((cells[0] as any).rootChildIds[0]);
148
+ node!.dispatchEvent({ type: "tap", currentTarget: node, preventDefault() {}, stopPropagation() {} });
149
+ expect(taps).toBe(1);
150
+ });
105
151
  });
@@ -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
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import { createPatchApplier } from "../src/apply-patch.js";
3
+ import { Op } from "../src/patch-protocol.js";
4
+
5
+ // R3: Op.RemoveEvent must actually remove the native listener, not silently
6
+ // no-op — otherwise a remove→re-add cycle on a kept element accumulates
7
+ // duplicate native listeners and the app's handler fires N times per event
8
+ // (see informe-contrato-mithril-lynx.md §R3).
9
+ describe("Op.RemoveEvent removes the native listener (no duplicate-fire leak)", () => {
10
+ it("AddEvent → RemoveEvent → AddEvent leaves exactly one listener", () => {
11
+ lynxTestingEnv.switchToMainThread();
12
+ const pageId = __GetElementUniqueID(__CreatePage());
13
+ const applier = createPatchApplier(pageId);
14
+ applier.registerPageRoot(__CreateView(pageId));
15
+
16
+ applier.applyPatch([Op.CreateElement, "view", 1, Op.AddEvent, 1, "tap"]);
17
+ const handle = applier.getHandle(1) as any;
18
+ expect(handle.__vanillaListeners.tap.size).toBe(1);
19
+
20
+ applier.applyPatch([Op.RemoveEvent, 1, "tap"]);
21
+ expect(handle.__vanillaListeners.tap.size).toBe(0);
22
+
23
+ applier.applyPatch([Op.AddEvent, 1, "tap"]);
24
+ expect(handle.__vanillaListeners.tap.size).toBe(1);
25
+ });
26
+ });
@@ -15,7 +15,8 @@ describe("route.js + stable-host: a same-path re-resolve patches in place, never
15
15
  lynxTestingEnv.switchToMainThread();
16
16
  const capturedOps: unknown[][] = [];
17
17
  lynx.getJSContext().addEventListener("MithrilLynx:Patch", (event: any) => {
18
- capturedOps.push(event.data);
18
+ // event.data is [PROTOCOL_VERSION, ...ops] — drop the version prefix.
19
+ capturedOps.push(event.data.slice(1));
19
20
  });
20
21
 
21
22
  lynxTestingEnv.switchToBackgroundThread();
@@ -22,9 +22,12 @@ function setupRealTree() {
22
22
  // tree inspected.
23
23
  lynxTestingEnv.switchToMainThread();
24
24
  lynx.getJSContext().addEventListener("MithrilLynx:Patch", (event: any) => {
25
- capturedOps.push(event.data);
25
+ // event.data is [PROTOCOL_VERSION, ...ops] — mirror main-thread.js's
26
+ // own onPatch by stripping the version prefix before applying.
27
+ const ops = event.data.slice(1);
28
+ capturedOps.push(ops);
26
29
  lynxTestingEnv.switchToMainThread();
27
- applier.applyPatch(event.data);
30
+ applier.applyPatch(ops);
28
31
  });
29
32
 
30
33
  return { applier, capturedOps };
package/test/setup.ts CHANGED
@@ -4,6 +4,16 @@
4
4
  // for everyone else (src/testing.js's installTestingPolyfills) — see that
5
5
  // file's header for what it covers and why.
6
6
 
7
+ import { afterEach } from "@rstest/core";
7
8
  import { installTestingPolyfills } from "../src/testing.js";
9
+ import { unregister } from "../src/mount-redraw.js";
8
10
 
9
11
  globalThis.onInjectMainThreadGlobals = installTestingPolyfills;
12
+
13
+ // mount-redraw.js is a single-slot singleton and register() now fails fast
14
+ // on a second registration (R2) — clear it between tests so each test's own
15
+ // renderApp() starts from a clean slate instead of colliding with the
16
+ // previous test's still-registered redraw.
17
+ afterEach(() => {
18
+ unregister();
19
+ });