mithril-lynx 2.6.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/README.md +47 -8
  5. package/ROUTE.md +111 -16
  6. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  7. package/UPDATE_ANIMATION_GAP.md +133 -0
  8. package/package.json +1 -9
  9. package/plugin.js +33 -0
  10. package/src/apply-patch.js +369 -60
  11. package/src/backends/virtual-backend.js +120 -22
  12. package/src/background.d.ts +26 -0
  13. package/src/background.js +116 -7
  14. package/src/channel.js +22 -5
  15. package/src/commit.js +9 -0
  16. package/src/dev-reload-client.js +46 -0
  17. package/src/dev-transport-noop.js +3 -0
  18. package/src/fake-dom.js +559 -61
  19. package/src/list-attributes.js +69 -0
  20. package/src/list-runtime.js +425 -0
  21. package/src/main-thread.js +18 -1
  22. package/src/mount-redraw.js +14 -1
  23. package/src/patch-protocol.js +41 -16
  24. package/src/reload/version.js +10 -1
  25. package/src/request.js +38 -0
  26. package/src/route.d.ts +30 -2
  27. package/src/route.js +214 -56
  28. package/src/testing.d.ts +14 -1
  29. package/src/testing.js +56 -1
  30. package/test/event-render-coalescing.test.ts +117 -0
  31. package/test/gesture.test.ts +75 -0
  32. package/test/input.test.ts +388 -0
  33. package/test/list.test.ts +417 -113
  34. package/test/remove-subtree-cleanup.test.ts +75 -0
  35. package/test/route-hot-reload.test.ts +2 -1
  36. package/test/route.test.ts +201 -4
  37. package/test/style-clear.test.ts +97 -0
  38. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  39. package/.omo/plans/m-route-en-memoria.md +0 -397
  40. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  41. package/.omo/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
  42. package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +0 -10
  43. package/src/list-cell.d.ts +0 -20
  44. package/src/list-cell.js +0 -73
  45. package/src/list-support.d.ts +0 -13
  46. package/src/list-support.js +0 -197
package/test/list.test.ts CHANGED
@@ -1,151 +1,455 @@
1
- import { describe, expect, it } from "@rstest/core";
2
- import m from "mithril";
3
- import renderFactory from "mithril-runtime/render/render.js";
1
+ import { afterEach, describe, expect, it, rstest } from "@rstest/core";
2
+ import m from "mithril-runtime";
4
3
  import { createPatchApplier } from "../src/apply-patch.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";
8
- import { Op } from "../src/patch-protocol.js";
9
-
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.
19
-
20
- function requestCell(listHandle: any, index: number, opId = 1) {
21
- const listId = __GetElementUniqueID(listHandle);
22
- return listHandle.componentAtIndex(listHandle, listId, index, opId);
4
+ import { renderApp } from "../src/background.js";
5
+ import { LIST_ATTRIBUTES } from "../src/list-attributes.js";
6
+ import { unregister } from "../src/mount-redraw.js";
7
+ import { forEachOp, Op } from "../src/patch-protocol.js";
8
+
9
+ // `<list>` / `<list-item>` as ordinary Mithril elements, end to end: the
10
+ // background thread renders and diffs them like any other element, the main
11
+ // thread (list-runtime.js) turns list-child ops into `update-list-info` and
12
+ // attaches items only when native asks (componentAtIndex). The testing
13
+ // environment records every `update-list-info` value as a JSON array on the
14
+ // list element, and keeps the callbacks passed to __CreateList /
15
+ // __UpdateListCallbacks on the element itself.
16
+
17
+ type Info = { insertAction: any[]; removeAction: number[]; updateAction: any[] };
18
+
19
+ const realCreateList = () => (globalThis as any).__CreateList;
20
+ let restoreCreateList: (() => void) | null = null;
21
+
22
+ afterEach(() => {
23
+ restoreCreateList?.();
24
+ restoreCreateList = null;
25
+ });
26
+
27
+ /** Mounts `view` through the real renderApp + a main-thread applier. */
28
+ function mount(view: () => unknown) {
29
+ // Capture the 5th __CreateList argument (componentAtIndexes), which the
30
+ // testing environment does not keep on the element. Re-installed after
31
+ // every switch to the main thread, which re-injects its globals.
32
+ const batchCallbacks: Function[] = [];
33
+ let original: any = null;
34
+ const toMainThread = () => {
35
+ lynxTestingEnv.switchToMainThread();
36
+ const current = realCreateList();
37
+ if (current.__captures) return;
38
+ original = current;
39
+ const wrapper: any = (...args: any[]) => {
40
+ batchCallbacks.push(args[4]);
41
+ return current(...args);
42
+ };
43
+ wrapper.__captures = true;
44
+ (globalThis as any).__CreateList = wrapper;
45
+ };
46
+ restoreCreateList = () => {
47
+ lynxTestingEnv.switchToMainThread();
48
+ if (original != null) (globalThis as any).__CreateList = original;
49
+ };
50
+ toMainThread();
51
+ const pageId = __GetElementUniqueID(__CreatePage());
52
+ let router: ((event: any) => void) | null = null;
53
+ const applier = createPatchApplier(pageId, {
54
+ onEvent: (id, type, payload) => {
55
+ lynxTestingEnv.switchToBackgroundThread();
56
+ router?.({ data: { id, type, payload } });
57
+ lynxTestingEnv.switchToMainThread();
58
+ },
59
+ });
60
+ applier.registerPageRoot(__CreateView(pageId));
61
+
62
+ lynxTestingEnv.switchToBackgroundThread();
63
+ unregister();
64
+ const patches: unknown[][] = [];
65
+ const app = renderApp({
66
+ root: view,
67
+ sendPatch: (ops) => patches.push(ops),
68
+ subscribeEvents: (handler) => {
69
+ router = handler;
70
+ },
71
+ });
72
+
73
+ let applied = 0;
74
+ /** Applies pending patches on the main thread and returns their ops.
75
+ * `beforeApply` runs on the main thread right before (to install spies:
76
+ * switching threads re-injects the main-thread globals). */
77
+ const flush = (beforeApply?: () => void) => {
78
+ const fresh = patches.slice(applied);
79
+ applied = patches.length;
80
+ toMainThread();
81
+ beforeApply?.();
82
+ for (const ops of fresh) applier.applyPatch(ops);
83
+ return fresh;
84
+ };
85
+ const redraw = (beforeApply?: () => void) => {
86
+ lynxTestingEnv.switchToBackgroundThread();
87
+ app.redraw();
88
+ return flush(beforeApply);
89
+ };
90
+ flush();
91
+ const listNode = () => findByTag(app.document, "list");
92
+ const listHandle = () => applier.getHandle(listNode()._id) as any;
93
+ const listID = () => __GetElementUniqueID(listHandle());
94
+ return {
95
+ app,
96
+ applier,
97
+ redraw,
98
+ flush,
99
+ listNode,
100
+ listHandle,
101
+ /** Every update-list-info the list received, in order. */
102
+ infos: (): Info[] => JSON.parse(listHandle().getAttribute("update-list-info") ?? "[]"),
103
+ lastInfo: (): Info => {
104
+ const all = JSON.parse(listHandle().getAttribute("update-list-info") ?? "[]");
105
+ return all[all.length - 1];
106
+ },
107
+ requestCell: (index: number) => listHandle().componentAtIndex(listHandle(), listID(), index, 1, false),
108
+ requestCells: (indexes: number[]) =>
109
+ batchCallbacks[batchCallbacks.length - 1](listHandle(), listID(), indexes, indexes.map((_, i) => i + 1), false, false),
110
+ releaseCell: (sign: number) => listHandle().enqueueComponent(listHandle(), listID(), sign),
111
+ /** item-key of each list-item currently attached natively, in tree order. */
112
+ attachedKeys: () => Array.from(listHandle().children as any[]).map((child: any) => child.getAttribute("item-key")),
113
+ itemHandle: (key: string) => applier.getHandle(findItem(app.document, key)._id) as any,
114
+ };
23
115
  }
24
116
 
25
- function releaseCell(listHandle: any, sign: unknown) {
26
- const listId = __GetElementUniqueID(listHandle);
27
- listHandle.enqueueComponent(listHandle, listId, sign);
117
+ function findByTag(node: any, tag: string): any {
118
+ if (node.tag === tag) return node;
119
+ for (const child of node._children ?? []) {
120
+ const found = findByTag(child, tag);
121
+ if (found) return found;
122
+ }
123
+ return null;
28
124
  }
29
125
 
30
- function textOf(node: any): string {
31
- if (node == null) return "";
32
- let out = "";
33
- for (let child = node.firstChild; child != null; child = child.nextSibling) {
34
- out += child.nodeType === 3 ? child.nodeValue : textOf(child);
126
+ function findItem(node: any, key: string): any {
127
+ if (node.tag === "list-item" && node._typedValues?.["item-key"] === key) return node;
128
+ for (const child of node._children ?? []) {
129
+ const found = findItem(child, key);
130
+ if (found) return found;
35
131
  }
36
- return out;
132
+ return null;
37
133
  }
38
134
 
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
- };
135
+ function listView(keys: () => string[], itemAttrs: (key: string) => object = () => ({}), listAttrs: () => object = () => ({})) {
136
+ return () =>
137
+ m(
138
+ "list",
139
+ Object.assign({ "list-type": "single", "span-count": 1, "scroll-orientation": "vertical" }, listAttrs()),
140
+ keys().map((key) => m("list-item", Object.assign({ key, "item-key": key }, itemAttrs(key)), m("text", key))),
141
+ );
49
142
  }
50
143
 
51
- function setupList() {
52
- lynxTestingEnv.switchToMainThread();
53
- const pageId = __GetElementUniqueID(__CreatePage());
54
- const applier = createPatchApplier(pageId);
55
- applier.registerPageRoot(__CreateView(pageId));
56
- applier.applyPatch([Op.CreateList, 1, "vertical", "single", 1]);
57
- const listHandle = applier.getHandle(1) as any;
58
- return { applier, listHandle };
144
+ /** Replays one update-list-info onto a key array the way native does. */
145
+ function applyInfo(keys: string[], info: Info): string[] {
146
+ const out = keys.slice();
147
+ for (const index of info.removeAction.slice().sort((a, b) => b - a)) out.splice(index, 1);
148
+ for (const action of info.insertAction.slice().sort((a, b) => a.position - b.position)) out.splice(action.position, 0, action["item-key"]);
149
+ return out;
59
150
  }
60
151
 
61
- function setCells(applier: ReturnType<typeof createPatchApplier>, cells: unknown[]) {
62
- applier.applyPatch([Op.SetListItems, 1, cells]);
152
+ function setAttributeOps(patches: unknown[][]) {
153
+ const out: Record<string, unknown> = {};
154
+ for (const ops of patches) {
155
+ forEachOp(ops, (opcode, args) => {
156
+ if (opcode === Op.SetAttribute) out[args[1] as string] = args[2];
157
+ });
158
+ }
159
+ return out;
63
160
  }
64
161
 
65
- describe("Op.CreateList (native virtualized list support)", () => {
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"]));
162
+ describe("<list> attributes", () => {
163
+ it("every documented <list> attribute reaches native with its raw type", () => {
164
+ const values: Record<string, unknown> = {
165
+ "list-type": "flow",
166
+ "span-count": 2,
167
+ "scroll-orientation": "horizontal",
168
+ "enable-scroll": false,
169
+ "enable-nested-scroll": false,
170
+ sticky: true,
171
+ "sticky-offset": 50,
172
+ bounces: false,
173
+ "initial-scroll-index": 3,
174
+ "need-visible-item-info": true,
175
+ "upper-threshold-item-count": 2,
176
+ "lower-threshold-item-count": 4,
177
+ "scroll-event-throttle": 100,
178
+ "item-snap": { factor: 0.5, offset: -20 },
179
+ "update-animation": "default",
180
+ "need-layout-complete-info": true,
181
+ "layout-id": 7,
182
+ "preload-buffer-count": 5,
183
+ "scroll-bar-enable": false,
184
+ "harmony-scroll-edge-effect": false,
185
+ "experimental-recycle-sticky-item": false,
186
+ };
187
+ expect(Object.keys(values).sort()).toEqual([...LIST_ATTRIBUTES].sort());
70
188
 
71
- requestCell(listHandle, 0);
72
- requestCell(listHandle, 1);
73
- const cellWrapper = listHandle.children[1]; // second appended cell -> index 1
74
- expect(textOf(cellWrapper)).toBe("1:b");
189
+ const { listHandle } = mount(() => m("list", values));
190
+ for (const [name, value] of Object.entries(values)) {
191
+ const stored = listHandle().getAttribute(name);
192
+ expect([name, stored]).toEqual([name, typeof value === "string" ? value : JSON.stringify(value)]);
193
+ }
75
194
  });
76
195
 
77
- it("recycles a cell for a different index, and its content updates to match", () => {
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"]));
196
+ it("false is sent as false (not dropped), and clearing a prop removes it", () => {
197
+ let attrs: Record<string, unknown> = { bounces: false };
198
+ const { listHandle, redraw } = mount(() => m("list", attrs));
199
+ expect(listHandle().getAttribute("bounces")).toBe("false");
81
200
 
82
- const signA = requestCell(listHandle, 0);
83
- const wrapperA = listHandle.children[0];
84
- expect(textOf(wrapperA)).toBe("0:a");
201
+ attrs = {};
202
+ redraw();
203
+ expect(listHandle().getAttribute("bounces")).toBeNull();
204
+ });
205
+
206
+ it("an unchanged object attribute is not re-sent on redraw", () => {
207
+ const { redraw } = mount(() => m("list", { "item-snap": { factor: 0, offset: 0 } }));
208
+ expect(setAttributeOps(redraw())).toEqual({});
209
+ });
210
+ });
211
+
212
+ describe("<list-item> platform info and update-list-info", () => {
213
+ it("the initial render inserts every item with its platform info", () => {
214
+ const { lastInfo, itemHandle } = mount(
215
+ listView(
216
+ () => ["a", "b", "c"],
217
+ (key) => (key === "b" ? { "full-span": true, "sticky-top": true, "estimated-main-axis-size-px": 80, "reuse-identifier": "header", recyclable: false } : {}),
218
+ ),
219
+ );
220
+ expect(lastInfo()).toEqual({
221
+ insertAction: [
222
+ { position: 0, type: "list-item", "item-key": "a" },
223
+ { position: 1, type: "header", "item-key": "b", "full-span": true, "sticky-top": true, "estimated-main-axis-size-px": 80, "reuse-identifier": "header", recyclable: false },
224
+ { position: 2, type: "list-item", "item-key": "c" },
225
+ ],
226
+ removeAction: [],
227
+ updateAction: [],
228
+ });
229
+ // Real attributes on the element too — except the list-only virtual ones.
230
+ expect(itemHandle("b").getAttribute("full-span")).toBe("true");
231
+ expect(itemHandle("b").getAttribute("reuse-identifier")).toBeNull();
232
+ expect(itemHandle("b").getAttribute("recyclable")).toBeNull();
233
+ });
234
+
235
+ it("covers every documented <list-item> attribute", () => {
236
+ const all = {
237
+ "full-span": true,
238
+ "sticky-top": true,
239
+ "sticky-bottom": false,
240
+ "estimated-height": 40,
241
+ "estimated-height-px": 41,
242
+ "estimated-main-axis-size-px": 42,
243
+ "reuse-identifier": "r",
244
+ recyclable: false,
245
+ };
246
+ const { lastInfo } = mount(listView(() => ["a"], () => all));
247
+ expect(lastInfo().insertAction[0]).toEqual(Object.assign({ position: 0, type: "r", "item-key": "a" }, all));
248
+ });
249
+
250
+ it("a keyed insert and remove in the middle produce exactly that", () => {
251
+ let keys = ["a", "b", "c"];
252
+ const { redraw, lastInfo } = mount(listView(() => keys));
253
+ keys = ["a", "x", "c"];
254
+ redraw();
255
+ expect(lastInfo()).toEqual({
256
+ insertAction: [{ position: 1, type: "list-item", "item-key": "x" }],
257
+ removeAction: [1],
258
+ updateAction: [],
259
+ });
260
+ });
261
+
262
+ it("reorders, inserts and removes replay natively into the new key order", () => {
263
+ let keys = ["a", "b", "c", "d", "e"];
264
+ const { redraw, lastInfo } = mount(listView(() => keys));
265
+ const scenarios = [
266
+ ["e", "a", "b", "c", "d"],
267
+ ["b", "e", "a", "d"],
268
+ ["z", "d", "a", "y", "b", "e"],
269
+ [],
270
+ ["q", "r"],
271
+ ];
272
+ for (const next of scenarios) {
273
+ const before = keys;
274
+ keys = next;
275
+ redraw();
276
+ expect(applyInfo(before, lastInfo())).toEqual(next);
277
+ }
278
+ });
279
+
280
+ it("a platform-info change on an existing item is an updateAction", () => {
281
+ let wide = false;
282
+ const { redraw, lastInfo, infos } = mount(listView(() => ["a", "b"], (key) => (key === "b" && wide ? { "full-span": true } : {})));
283
+ const count = infos().length;
284
+ wide = true;
285
+ redraw();
286
+ expect(infos().length).toBe(count + 1);
287
+ expect(lastInfo()).toEqual({
288
+ insertAction: [],
289
+ removeAction: [],
290
+ updateAction: [{ "item-key": "b", "full-span": true, from: 1, to: 1, type: "list-item", flush: false }],
291
+ });
292
+ });
85
293
 
86
- releaseCell(listHandle, signA);
87
- const signD = requestCell(listHandle, 3);
294
+ it("a redraw that changes nothing about the items sends no update-list-info", () => {
295
+ const { redraw, infos } = mount(listView(() => ["a", "b"]));
296
+ const count = infos().length;
297
+ redraw();
298
+ expect(infos().length).toBe(count);
299
+ });
300
+ });
88
301
 
89
- // Recycled: the SAME sign/wrapper comes back, now showing the new index's content.
90
- expect(signD).toBe(signA);
91
- expect(textOf(wrapperA)).toBe("3:d");
302
+ describe("native callbacks", () => {
303
+ it("componentAtIndex attaches items in list order and returns their sign", () => {
304
+ const { requestCell, attachedKeys, itemHandle } = mount(listView(() => ["a", "b", "c"]));
305
+ expect(attachedKeys()).toEqual([]);
306
+ const sign = requestCell(2);
307
+ expect(sign).toBe(__GetElementUniqueID(itemHandle("c")));
308
+ requestCell(0);
309
+ expect(attachedKeys()).toEqual(["a", "c"]);
92
310
  });
93
311
 
94
- it("SetListItems with a larger array requests the newly available indices without error", () => {
95
- const { buildCells } = makeCellSource((item: string, index: number) => m("text", {}, `${index}:${item}`));
96
- const { applier, listHandle } = setupList();
312
+ it("enqueueComponent detaches an item and it can be attached again", () => {
313
+ const { requestCell, releaseCell, attachedKeys } = mount(listView(() => ["a", "b"]));
314
+ const sign = requestCell(0);
315
+ requestCell(1);
316
+ releaseCell(sign);
317
+ expect(attachedKeys()).toEqual(["b"]);
318
+ requestCell(0);
319
+ expect(attachedKeys()).toEqual(["a", "b"]);
320
+ });
97
321
 
98
- setCells(applier, buildCells(["a", "b"]));
99
- expect(() => requestCell(listHandle, 1)).not.toThrow();
100
- expect(() => requestCell(listHandle, 2)).toThrow(/cellIndex 2 out of range/);
322
+ it("componentAtIndexes attaches a batch", () => {
323
+ const { requestCells, attachedKeys } = mount(listView(() => ["a", "b", "c"]));
324
+ requestCells([1, 0]);
325
+ expect(attachedKeys()).toEqual(["a", "b"]);
326
+ });
101
327
 
102
- setCells(applier, buildCells(["a", "b", "c"]));
103
- expect(() => requestCell(listHandle, 2)).not.toThrow();
328
+ it("attached items that move are re-placed on the next callback", () => {
329
+ let keys = ["a", "b", "c"];
330
+ const { redraw, requestCell, attachedKeys } = mount(listView(() => keys));
331
+ requestCell(0);
332
+ requestCell(1);
333
+ requestCell(2);
334
+ keys = ["c", "a", "b"];
335
+ redraw();
336
+ requestCell(0);
337
+ expect(attachedKeys()).toEqual(["c", "a", "b"]);
104
338
  });
105
339
 
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"]));
340
+ it("content updates reach an attached item", () => {
341
+ let label = "one";
342
+ const { redraw, requestCell, itemHandle } = mount(() =>
343
+ m("list", { "list-type": "single", "span-count": 1 }, [m("list-item", { key: "a", "item-key": "a" }, m("text", label))]),
344
+ );
345
+ requestCell(0);
346
+ label = "two";
347
+ redraw();
348
+ expect(itemHandle("a").textContent).toBe("two");
349
+ });
110
350
 
111
- requestCell(listHandle, 0);
112
- const wrapper = listHandle.children[0];
113
- expect(textOf(wrapper)).toBe("0:a");
351
+ /** Removes "a" (on screen) and returns the list-related PAPI calls in order. */
352
+ function removeOnScreenItem(listAttrs: object) {
353
+ let keys = ["a", "b"];
354
+ const warn = rstest.spyOn(console, "warn").mockImplementation(() => {});
355
+ const app = mount(listView(() => keys, () => ({}), () => listAttrs));
356
+ warn.mockRestore();
357
+ const signA = app.requestCell(0);
358
+ app.requestCell(1);
359
+ const calls: string[] = [];
360
+ keys = ["b"];
361
+ app.redraw(() => {
362
+ const flush = (globalThis as any).__FlushElementTree;
363
+ const remove = (globalThis as any).__RemoveElement;
364
+ (globalThis as any).__FlushElementTree = (...args: any[]) => {
365
+ calls.push("flush");
366
+ return flush(...args);
367
+ };
368
+ (globalThis as any).__RemoveElement = (parent: any, child: any) => {
369
+ if (parent === app.listHandle()) calls.push("detach " + child.getAttribute("item-key"));
370
+ return remove(parent, child);
371
+ };
372
+ });
373
+ lynxTestingEnv.switchToBackgroundThread();
374
+ lynxTestingEnv.switchToMainThread();
375
+ return { ...app, calls, signA };
376
+ }
114
377
 
115
- setCells(applier, buildCells(["z", "b"])); // same count, index 0's content changed
116
- expect(textOf(wrapper)).toBe("0:z");
378
+ it("a removed on-screen item is detached only after the patch's update-list-info flush", () => {
379
+ // Verified on a real device (Android, Lynx Go): detaching before native
380
+ // processed the removeAction crashed (SIGSEGV after "[List] Fail to
381
+ // erase item holder"), and native never enqueues removed items, so not
382
+ // detaching left orphan children accumulating under the list.
383
+ const { calls, attachedKeys, releaseCell, signA } = removeOnScreenItem({});
384
+ expect(calls).toEqual(["flush", "detach a", "flush"]);
385
+ expect(attachedKeys()).toEqual(["b"]);
386
+ // A late enqueue for it (other native versions) is a harmless no-op.
387
+ releaseCell(signA);
388
+ expect(attachedKeys()).toEqual(["b"]);
117
389
  });
118
390
 
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"]));
391
+ it("with update-animation on, a removed on-screen item is left attached (detaching it crashes native)", () => {
392
+ const { calls, attachedKeys } = removeOnScreenItem({ "update-animation": "default" });
393
+ expect(calls).toEqual(["flush"]);
394
+ expect(attachedKeys()).toEqual(["a", "b"]);
395
+ });
396
+ });
123
397
 
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"]);
398
+ describe("lifecycle", () => {
399
+ it("list events reach the background handler", () => {
400
+ const received: unknown[] = [];
401
+ const { listHandle } = mount(() => m("list", { onscrolltolower: (e: any) => received.push(e.detail) }));
402
+ for (const listener of listHandle().__vanillaListeners.scrolltolower) listener({ detail: { scrollTop: 10 } });
403
+ expect(received).toEqual([{ scrollTop: 10 }]);
404
+ });
129
405
 
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
406
+ it("removed items run onremove", () => {
407
+ const removed: string[] = [];
408
+ let keys = ["a", "b"];
409
+ const Row = { view: (v: any) => m("text", v.attrs.k), onremove: (v: any) => removed.push(v.attrs.k) };
410
+ const { redraw } = mount(() => m("list", keys.map((k) => m("list-item", { key: k, "item-key": k }, m(Row, { k })))));
411
+ keys = ["b"];
412
+ redraw();
413
+ expect(removed).toEqual(["a"]);
414
+ });
132
415
 
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
416
+ it("removing the list neutralizes its native callbacks and releases its items", () => {
417
+ let show = true;
418
+ const { redraw, listHandle, applier } = mount(() => (show ? m("list", [m("list-item", { key: "a", "item-key": "a" }, m("text", "a"))]) : m("view")));
419
+ const handle = listHandle();
420
+ const listID = __GetElementUniqueID(handle);
421
+ show = false;
422
+ redraw();
423
+ expect(handle.componentAtIndex(handle, listID, 0, 1, false)).toBe(-1);
424
+ expect(applier.getHandle(3)).toBeUndefined();
135
425
  });
136
426
 
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"),
427
+ it("gesture worklets inside items are released with the list", () => {
428
+ const registrySize = () => Object.keys((globalThis as any).lynxWorkletImpl?._workletMap ?? {}).length;
429
+ let show = true;
430
+ lynxTestingEnv.switchToMainThread();
431
+ const before = registrySize();
432
+ const { redraw } = mount(() =>
433
+ show
434
+ ? m("list", [
435
+ m(
436
+ "list-item",
437
+ { key: "a", "item-key": "a", oncreate: (v: any) => v.dom.setGestureDetector("native", { mode: "claim" }) },
438
+ m("text", "a"),
439
+ ),
440
+ ])
441
+ : m("view"),
140
442
  );
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);
443
+ expect(registrySize()).toBe(before + 3);
444
+ show = false;
445
+ redraw();
446
+ expect(registrySize()).toBe(before);
447
+ });
448
+
449
+ it("dispose() neutralizes every list", () => {
450
+ const { applier, listHandle } = mount(listView(() => ["a"]));
451
+ const handle = listHandle();
452
+ applier.dispose();
453
+ expect(handle.componentAtIndex(handle, __GetElementUniqueID(handle), 0, 1, false)).toBe(-1);
150
454
  });
151
455
  });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import { createPatchApplier } from "../src/apply-patch.js";
3
+ import { createVirtualBackend } from "../src/backends/virtual-backend.js";
4
+ import { createLynxDocument } from "../src/fake-dom.js";
5
+ import { Op } from "../src/patch-protocol.js";
6
+
7
+ // Mithril's removeDOM only ever calls removeChild on the ROOT of a removed
8
+ // subtree, so both threads must release every descendant's bookkeeping
9
+ // themselves — otherwise a long-running app leaks every element (and its
10
+ // listeners) it ever removed.
11
+ describe("removing a subtree releases every descendant", () => {
12
+ it("main thread: RemoveChild drops the handles of the whole subtree", () => {
13
+ lynxTestingEnv.switchToMainThread();
14
+ const pageId = __GetElementUniqueID(__CreatePage());
15
+ const applier = createPatchApplier(pageId);
16
+ applier.registerPageRoot(__CreateView(pageId));
17
+
18
+ applier.applyPatch([
19
+ Op.CreateElement, "view", 1,
20
+ Op.CreateElement, "view", 2,
21
+ Op.CreateElement, "view", 3,
22
+ Op.InsertBefore, 0, 1, -1,
23
+ Op.InsertBefore, 1, 2, -1,
24
+ Op.InsertBefore, 2, 3, -1,
25
+ Op.AddEvent, 2, "tap",
26
+ Op.AddEvent, 3, "tap",
27
+ ]);
28
+ expect(applier.getHandle(3)).toBeDefined();
29
+
30
+ applier.applyPatch([Op.RemoveChild, 0, 1]);
31
+ expect(applier.getHandle(1)).toBeUndefined();
32
+ expect(applier.getHandle(2)).toBeUndefined();
33
+ expect(applier.getHandle(3)).toBeUndefined();
34
+ expect(applier.getHandle(0)).toBeDefined();
35
+ });
36
+
37
+ it("main thread: a node moved out of the subtree before removal survives", () => {
38
+ lynxTestingEnv.switchToMainThread();
39
+ const pageId = __GetElementUniqueID(__CreatePage());
40
+ const applier = createPatchApplier(pageId);
41
+ applier.registerPageRoot(__CreateView(pageId));
42
+
43
+ applier.applyPatch([
44
+ Op.CreateElement, "view", 1,
45
+ Op.CreateElement, "view", 2,
46
+ Op.CreateElement, "view", 4,
47
+ Op.InsertBefore, 0, 1, -1,
48
+ Op.InsertBefore, 0, 4, -1,
49
+ Op.InsertBefore, 1, 2, -1,
50
+ // Move 2 from under 1 to under 4.
51
+ Op.InsertBefore, 4, 2, -1,
52
+ ]);
53
+
54
+ applier.applyPatch([Op.RemoveChild, 0, 1]);
55
+ expect(applier.getHandle(1)).toBeUndefined();
56
+ expect(applier.getHandle(2)).toBeDefined();
57
+ expect(applier.getHandle(4)).toBeDefined();
58
+ });
59
+
60
+ it("background thread: removeChild drops the whole subtree from the id map", () => {
61
+ const document = createLynxDocument(createVirtualBackend());
62
+ const a = document.createElement("view");
63
+ const b = document.createElement("view");
64
+ const c = document.createElement("text");
65
+ document.appendChild(a);
66
+ a.appendChild(b);
67
+ b.appendChild(c);
68
+ expect(document.getNodeById(c._id)).toBe(c);
69
+
70
+ document.removeChild(a);
71
+ expect(document.getNodeById(a._id)).toBeNull();
72
+ expect(document.getNodeById(b._id)).toBeNull();
73
+ expect(document.getNodeById(c._id)).toBeNull();
74
+ });
75
+ });