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.
- package/CHANGELOG.md +50 -0
- package/FETCH_INVESTIGATION.md +3 -2
- package/INPUT.md +115 -0
- package/README.md +47 -8
- package/ROUTE.md +111 -16
- package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
- package/UPDATE_ANIMATION_GAP.md +133 -0
- package/package.json +1 -9
- package/plugin.js +33 -0
- package/src/apply-patch.js +369 -60
- package/src/backends/virtual-backend.js +120 -22
- package/src/background.d.ts +26 -0
- package/src/background.js +116 -7
- package/src/channel.js +22 -5
- 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 +18 -1
- package/src/mount-redraw.js +14 -1
- package/src/patch-protocol.js +41 -16
- package/src/reload/version.js +10 -1
- package/src/request.js +38 -0
- package/src/route.d.ts +30 -2
- package/src/route.js +214 -56
- package/src/testing.d.ts +14 -1
- package/src/testing.js +56 -1
- 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 +417 -113
- package/test/remove-subtree-cleanup.test.ts +75 -0
- package/test/route-hot-reload.test.ts +2 -1
- package/test/route.test.ts +201 -4
- 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_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
- package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.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 -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 {
|
|
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
|
+
});
|
|
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
|
-
|
|
87
|
-
const
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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("
|
|
95
|
-
const {
|
|
96
|
-
const
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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("
|
|
107
|
-
|
|
108
|
-
const {
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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("
|
|
120
|
-
const {
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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("
|
|
138
|
-
const
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
});
|