mithril-lynx 2.6.0 → 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 (55) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/LICENSE.txt +21 -0
  5. package/PLAN_REVIEW.md +134 -0
  6. package/README.md +47 -7
  7. package/REQUEST.md +5 -2
  8. package/ROUTE.md +111 -16
  9. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  10. package/UPDATE_ANIMATION_GAP.md +133 -0
  11. package/informe-contrato-mithril-lynx.md +206 -0
  12. package/package.json +1 -9
  13. package/plugin.js +33 -0
  14. package/prompts-analisis-contrato-mithril-lynx.md +141 -0
  15. package/src/apply-patch.js +408 -68
  16. package/src/backends/virtual-backend.js +120 -22
  17. package/src/background.d.ts +40 -1
  18. package/src/background.js +116 -7
  19. package/src/channel.js +32 -7
  20. package/src/commit.js +9 -0
  21. package/src/dev-reload-client.js +46 -0
  22. package/src/dev-transport-noop.js +3 -0
  23. package/src/fake-dom.js +559 -61
  24. package/src/list-attributes.js +69 -0
  25. package/src/list-runtime.js +425 -0
  26. package/src/main-thread.js +33 -3
  27. package/src/mount-redraw.d.ts +10 -0
  28. package/src/mount-redraw.js +69 -3
  29. package/src/patch-protocol.js +52 -13
  30. package/src/reload/version.js +10 -1
  31. package/src/request.d.ts +2 -0
  32. package/src/request.js +59 -3
  33. package/src/route.d.ts +52 -5
  34. package/src/route.js +236 -59
  35. package/src/testing.d.ts +14 -1
  36. package/src/testing.js +79 -11
  37. package/test/event-render-coalescing.test.ts +117 -0
  38. package/test/gesture.test.ts +75 -0
  39. package/test/input.test.ts +388 -0
  40. package/test/list.test.ts +422 -100
  41. package/test/patch-protocol.test.ts +41 -0
  42. package/test/remove-event.test.ts +26 -0
  43. package/test/remove-subtree-cleanup.test.ts +75 -0
  44. package/test/route-hot-reload.test.ts +4 -2
  45. package/test/route.test.ts +206 -6
  46. package/test/setup.ts +10 -0
  47. package/test/style-clear.test.ts +97 -0
  48. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  49. package/.omo/plans/m-route-en-memoria.md +0 -397
  50. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  51. package/.omo/run-continuation/ses_f48265d07ffenKG0GAz9ZRFvY6.json +0 -10
  52. package/src/list-cell.d.ts +0 -20
  53. package/src/list-cell.js +0 -73
  54. package/src/list-support.d.ts +0 -13
  55. package/src/list-support.js +0 -171
package/test/list.test.ts CHANGED
@@ -1,133 +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, JSON.stringify(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
+ });
85
205
 
86
- releaseCell(listHandle, signA);
87
- const signD = requestCell(listHandle, 3);
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
+ });
88
211
 
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");
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
+ });
293
+
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
+ });
301
+
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"]);
310
+ });
311
+
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"]);
92
320
  });
93
321
 
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();
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
+ });
97
327
 
98
- setCells(applier, buildCells(["a", "b"]));
99
- expect(() => requestCell(listHandle, 1)).not.toThrow();
100
- expect(() => requestCell(listHandle, 2)).toThrow(/cellIndex 2 out of range/);
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"]);
338
+ });
101
339
 
102
- setCells(applier, buildCells(["a", "b", "c"]));
103
- expect(() => requestCell(listHandle, 2)).not.toThrow();
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");
104
349
  });
105
350
 
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"]));
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
+ }
110
377
 
111
- requestCell(listHandle, 0);
112
- const wrapper = listHandle.children[0];
113
- expect(textOf(wrapper)).toBe("0:a");
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"]);
389
+ });
114
390
 
115
- setCells(applier, buildCells(["z", "b"])); // same count, index 0's content changed
116
- expect(textOf(wrapper)).toBe("0:z");
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"]);
117
395
  });
396
+ });
118
397
 
119
- it("a tap inside a cell dispatches through the background thread's own fake-dom node", () => {
120
- const { document, buildCells } = makeCellSource(() =>
121
- m("text", { ontap: () => { taps += 1; } }, "tap me"),
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
+ });
405
+
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
+ });
415
+
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();
425
+ });
426
+
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"),
122
442
  );
123
- let taps = 0;
124
- const { applier, listHandle } = setupList();
125
- const cells = buildCells([{}]);
126
- setCells(applier, cells);
127
- requestCell(listHandle, 0);
128
-
129
- const node = document.getNodeById((cells[0] as any).rootChildIds[0]);
130
- node!.dispatchEvent({ type: "tap", currentTarget: node, preventDefault() {}, stopPropagation() {} });
131
- 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);
132
454
  });
133
455
  });
@@ -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
+ });