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.
- package/CHANGELOG.md +50 -0
- package/FETCH_INVESTIGATION.md +3 -2
- package/INPUT.md +115 -0
- package/LICENSE.txt +21 -0
- package/PLAN_REVIEW.md +134 -0
- package/README.md +47 -7
- package/REQUEST.md +5 -2
- package/ROUTE.md +111 -16
- package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
- package/UPDATE_ANIMATION_GAP.md +133 -0
- package/informe-contrato-mithril-lynx.md +206 -0
- package/package.json +1 -9
- package/plugin.js +33 -0
- package/prompts-analisis-contrato-mithril-lynx.md +141 -0
- package/src/apply-patch.js +408 -68
- package/src/backends/virtual-backend.js +120 -22
- package/src/background.d.ts +40 -1
- package/src/background.js +116 -7
- package/src/channel.js +32 -7
- package/src/commit.js +9 -0
- package/src/dev-reload-client.js +46 -0
- package/src/dev-transport-noop.js +3 -0
- package/src/fake-dom.js +559 -61
- package/src/list-attributes.js +69 -0
- package/src/list-runtime.js +425 -0
- package/src/main-thread.js +33 -3
- package/src/mount-redraw.d.ts +10 -0
- package/src/mount-redraw.js +69 -3
- package/src/patch-protocol.js +52 -13
- package/src/reload/version.js +10 -1
- package/src/request.d.ts +2 -0
- package/src/request.js +59 -3
- package/src/route.d.ts +52 -5
- package/src/route.js +236 -59
- package/src/testing.d.ts +14 -1
- package/src/testing.js +79 -11
- package/test/event-render-coalescing.test.ts +117 -0
- package/test/gesture.test.ts +75 -0
- package/test/input.test.ts +388 -0
- package/test/list.test.ts +422 -100
- package/test/patch-protocol.test.ts +41 -0
- package/test/remove-event.test.ts +26 -0
- package/test/remove-subtree-cleanup.test.ts +75 -0
- package/test/route-hot-reload.test.ts +4 -2
- package/test/route.test.ts +206 -6
- package/test/setup.ts +10 -0
- package/test/style-clear.test.ts +97 -0
- package/.omo/plans/m-request-fetch-lynx.md +0 -306
- package/.omo/plans/m-route-en-memoria.md +0 -397
- package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
- package/.omo/run-continuation/ses_f48265d07ffenKG0GAz9ZRFvY6.json +0 -10
- package/src/list-cell.d.ts +0 -20
- package/src/list-cell.js +0 -73
- package/src/list-support.d.ts +0 -13
- 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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { Op } from "../src/patch-protocol.js";
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
31
|
-
if (node
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
132
|
+
return null;
|
|
37
133
|
}
|
|
38
134
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
62
|
-
|
|
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("
|
|
66
|
-
it("
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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("
|
|
78
|
-
|
|
79
|
-
const {
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
expect(
|
|
201
|
+
attrs = {};
|
|
202
|
+
redraw();
|
|
203
|
+
expect(listHandle().getAttribute("bounces")).toBeNull();
|
|
204
|
+
});
|
|
85
205
|
|
|
86
|
-
|
|
87
|
-
const
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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("
|
|
95
|
-
const {
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
});
|