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
|
@@ -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
|
+
});
|
|
@@ -11,11 +11,12 @@ import { Op } from "../src/patch-protocol.js";
|
|
|
11
11
|
// else. This test locks that in.
|
|
12
12
|
|
|
13
13
|
describe("route.js + stable-host: a same-path re-resolve patches in place, never recreates", () => {
|
|
14
|
-
it("swapping the live-bound view (module.hot.accept's job) produces only a SetText, no Create/Remove", () => {
|
|
14
|
+
it("swapping the live-bound view (module.hot.accept's job) produces only a SetText, no Create/Remove", async () => {
|
|
15
15
|
lynxTestingEnv.switchToMainThread();
|
|
16
16
|
const capturedOps: unknown[][] = [];
|
|
17
17
|
lynx.getJSContext().addEventListener("MithrilLynx:Patch", (event: any) => {
|
|
18
|
-
|
|
18
|
+
// event.data is [PROTOCOL_VERSION, ...ops] — drop the version prefix.
|
|
19
|
+
capturedOps.push(event.data.slice(1));
|
|
19
20
|
});
|
|
20
21
|
|
|
21
22
|
lynxTestingEnv.switchToBackgroundThread();
|
|
@@ -31,6 +32,7 @@ describe("route.js + stable-host: a same-path re-resolve patches in place, never
|
|
|
31
32
|
|
|
32
33
|
currentDetail = { view: () => m("text", { key: "title" }, "second") };
|
|
33
34
|
route.set(route.get() as string, null, { replace: true });
|
|
35
|
+
await Promise.resolve(); // navigation resolves on the next microtask
|
|
34
36
|
|
|
35
37
|
const flat = capturedOps.flat();
|
|
36
38
|
expect(flat).not.toContain(Op.CreateElement);
|
package/test/route.test.ts
CHANGED
|
@@ -22,9 +22,12 @@ function setupRealTree() {
|
|
|
22
22
|
// tree inspected.
|
|
23
23
|
lynxTestingEnv.switchToMainThread();
|
|
24
24
|
lynx.getJSContext().addEventListener("MithrilLynx:Patch", (event: any) => {
|
|
25
|
-
|
|
25
|
+
// event.data is [PROTOCOL_VERSION, ...ops] — mirror main-thread.js's
|
|
26
|
+
// own onPatch by stripping the version prefix before applying.
|
|
27
|
+
const ops = event.data.slice(1);
|
|
28
|
+
capturedOps.push(ops);
|
|
26
29
|
lynxTestingEnv.switchToMainThread();
|
|
27
|
-
applier.applyPatch(
|
|
30
|
+
applier.applyPatch(ops);
|
|
28
31
|
});
|
|
29
32
|
|
|
30
33
|
return { applier, capturedOps };
|
|
@@ -34,8 +37,11 @@ function setupRealTree() {
|
|
|
34
37
|
// isn't exported from a stable path used elsewhere yet in tests.
|
|
35
38
|
import { createPatchApplier } from "../src/apply-patch.js";
|
|
36
39
|
|
|
40
|
+
/** Navigation resolves on the next microtask (like upstream m.route). */
|
|
41
|
+
const settle = () => Promise.resolve();
|
|
42
|
+
|
|
37
43
|
describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
38
|
-
it("mounts the default route and exposes route.get()/route.param()", () => {
|
|
44
|
+
it("mounts the default route and exposes route.get()/route.param()", async () => {
|
|
39
45
|
setupRealTree();
|
|
40
46
|
lynxTestingEnv.switchToBackgroundThread();
|
|
41
47
|
|
|
@@ -48,11 +54,12 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
48
54
|
expect(route.get()).toBe("/");
|
|
49
55
|
|
|
50
56
|
route.set("/users/42");
|
|
57
|
+
await settle();
|
|
51
58
|
expect(route.get()).toBe("/users/42");
|
|
52
59
|
expect(route.param("id")).toBe("42");
|
|
53
60
|
});
|
|
54
61
|
|
|
55
|
-
it("navigating replaces the screen with no orphaned nodes left behind", () => {
|
|
62
|
+
it("navigating replaces the screen with no orphaned nodes left behind", async () => {
|
|
56
63
|
const { capturedOps } = setupRealTree();
|
|
57
64
|
lynxTestingEnv.switchToBackgroundThread();
|
|
58
65
|
|
|
@@ -64,6 +71,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
64
71
|
capturedOps.length = 0; // only care about the ops from the NAVIGATION below
|
|
65
72
|
|
|
66
73
|
route.set("/b");
|
|
74
|
+
await settle();
|
|
67
75
|
|
|
68
76
|
// Same spirit as test/structural-reload.test.ts, but here the whole
|
|
69
77
|
// screen changes (not a sibling insert) — the decisive check is
|
|
@@ -75,7 +83,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
75
83
|
expect(navOps).toContain(Op.CreateElement);
|
|
76
84
|
});
|
|
77
85
|
|
|
78
|
-
it("back()/forward() walk the in-memory history stack", () => {
|
|
86
|
+
it("back()/forward() walk the in-memory history stack", async () => {
|
|
79
87
|
setupRealTree();
|
|
80
88
|
lynxTestingEnv.switchToBackgroundThread();
|
|
81
89
|
|
|
@@ -87,15 +95,19 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
87
95
|
route("/a", { "/a": A, "/b": B, "/c": C });
|
|
88
96
|
route.set("/b");
|
|
89
97
|
route.set("/c");
|
|
98
|
+
await settle();
|
|
90
99
|
expect(route.get()).toBe("/c");
|
|
91
100
|
|
|
92
101
|
route.back();
|
|
102
|
+
await settle();
|
|
93
103
|
expect(route.get()).toBe("/b");
|
|
94
104
|
|
|
95
105
|
route.back();
|
|
106
|
+
await settle();
|
|
96
107
|
expect(route.get()).toBe("/a");
|
|
97
108
|
|
|
98
109
|
route.forward();
|
|
110
|
+
await settle();
|
|
99
111
|
expect(route.get()).toBe("/b");
|
|
100
112
|
});
|
|
101
113
|
|
|
@@ -126,7 +138,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
126
138
|
});
|
|
127
139
|
});
|
|
128
140
|
|
|
129
|
-
it("route.Link navigates on tap, and disabled Links don't wire ontap", () => {
|
|
141
|
+
it("route.Link navigates on tap, and disabled Links don't wire ontap", async () => {
|
|
130
142
|
setupRealTree();
|
|
131
143
|
lynxTestingEnv.switchToBackgroundThread();
|
|
132
144
|
|
|
@@ -141,6 +153,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
141
153
|
});
|
|
142
154
|
expect(typeof link.attrs.ontap).toBe("function");
|
|
143
155
|
link.attrs.ontap({ currentTarget: null, redraw: true });
|
|
156
|
+
await settle();
|
|
144
157
|
expect(route.get()).toBe("/b");
|
|
145
158
|
|
|
146
159
|
const disabledLink = (route as any).Link.view({
|
|
@@ -150,3 +163,190 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
150
163
|
expect(disabledLink.attrs.ontap).toBeUndefined();
|
|
151
164
|
});
|
|
152
165
|
});
|
|
166
|
+
|
|
167
|
+
// Parity fixes found by ROUTE_CONTRACT_ANALYSIS.md — each one was first
|
|
168
|
+
// reproduced against the previous route.js, then fixed.
|
|
169
|
+
describe("route.js: parity with Mithril 2.3.8's m.route", () => {
|
|
170
|
+
function mountRoute(defaultRoute: string, routes: Record<string, unknown>) {
|
|
171
|
+
setupRealTree();
|
|
172
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
173
|
+
const route = createRoute();
|
|
174
|
+
route(defaultRoute, routes);
|
|
175
|
+
return route;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it("a redirect from a page's oninit (a guard) works instead of throwing mid-render", async () => {
|
|
179
|
+
let route: ReturnType<typeof createRoute> | null = null;
|
|
180
|
+
const Guarded = { oninit: () => route!.set("/login"), view: () => m("text", null, "secret") };
|
|
181
|
+
route = mountRoute("/", { "/": { view: () => m("text", null, "home") }, "/secret": Guarded, "/login": { view: () => m("text", null, "login") } });
|
|
182
|
+
|
|
183
|
+
route!.set("/secret");
|
|
184
|
+
await settle();
|
|
185
|
+
await settle();
|
|
186
|
+
expect(route!.get()).toBe("/login");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("several navigations in one tick resolve once, to the last one", async () => {
|
|
190
|
+
const views: string[] = [];
|
|
191
|
+
const page = (name: string) => ({ view: () => (views.push(name), m("text", null, name)) });
|
|
192
|
+
const route = mountRoute("/a", { "/a": page("a"), "/b": page("b"), "/c": page("c") });
|
|
193
|
+
views.length = 0;
|
|
194
|
+
|
|
195
|
+
route.set("/b");
|
|
196
|
+
route.set("/c");
|
|
197
|
+
expect(route.get()).toBe("/a"); // not resolved yet, like upstream
|
|
198
|
+
await settle();
|
|
199
|
+
expect(route.get()).toBe("/c");
|
|
200
|
+
expect(views).toEqual(["c"]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("a changed :key param remounts the page (fresh state)", async () => {
|
|
204
|
+
const inits: string[] = [];
|
|
205
|
+
const Page = { oninit: (v: any) => inits.push(v.attrs.key), view: () => m("text", null, "p") };
|
|
206
|
+
const route = mountRoute("/p/a", { "/p/:key": Page });
|
|
207
|
+
|
|
208
|
+
route.set("/p/:key", { key: "b" });
|
|
209
|
+
await settle();
|
|
210
|
+
expect(inits).toEqual(["a", "b"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("options.state is merged into params and restored by back()/forward()", async () => {
|
|
214
|
+
const route = mountRoute("/", { "/": { view: () => m("text", null, "h") }, "/d": { view: () => m("text", null, "d") } });
|
|
215
|
+
|
|
216
|
+
route.set("/d", null, { state: { from: "home" } } as any);
|
|
217
|
+
await settle();
|
|
218
|
+
expect(route.param("from")).toBe("home");
|
|
219
|
+
|
|
220
|
+
route.back();
|
|
221
|
+
await settle();
|
|
222
|
+
expect(route.param("from")).toBeUndefined();
|
|
223
|
+
|
|
224
|
+
route.forward();
|
|
225
|
+
await settle();
|
|
226
|
+
expect(route.param("from")).toBe("home");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("route.get() returns the decoded path, params stay decoded", async () => {
|
|
230
|
+
const route = mountRoute("/", { "/": { view: () => m("text", null, "h") }, "/d/:id": { view: () => m("text", null, "d") } });
|
|
231
|
+
route.set("/d/:id", { id: "a b/c" });
|
|
232
|
+
await settle();
|
|
233
|
+
expect(route.get()).toBe("/d/a b/c");
|
|
234
|
+
expect(route.param("id")).toBe("a b/c");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("route.Link runs its lifecycle hooks once (not copied onto the rendered element)", () => {
|
|
238
|
+
let oncreates = 0;
|
|
239
|
+
setupRealTree();
|
|
240
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
241
|
+
const route = createRoute();
|
|
242
|
+
route("/", { "/": { view: () => m((route as any).Link, { href: "/", oncreate: () => oncreates++, key: "l" }, m("text", null, "l")) } });
|
|
243
|
+
expect(oncreates).toBe(1);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("route.Link: preventDefault() cancels navigation, and an EventListener object works as ontap", async () => {
|
|
247
|
+
const route = mountRoute("/a", { "/a": { view: () => m("text", null, "a") }, "/b": { view: () => m("text", null, "b") } });
|
|
248
|
+
const tap = (ontap: unknown) => {
|
|
249
|
+
const link = (route as any).Link.view({ attrs: { href: "/b", ontap }, children: [] });
|
|
250
|
+
const e: any = { currentTarget: null, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
|
251
|
+
link.attrs.ontap(e);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
tap((e: any) => e.preventDefault());
|
|
255
|
+
await settle();
|
|
256
|
+
expect(route.get()).toBe("/a");
|
|
257
|
+
|
|
258
|
+
let handled = false;
|
|
259
|
+
tap({ handleEvent: () => { handled = true; } });
|
|
260
|
+
await settle();
|
|
261
|
+
expect(handled).toBe(true);
|
|
262
|
+
expect(route.get()).toBe("/b");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("rejects param names not separated by '/', '.' or '-', and a missing default route", () => {
|
|
266
|
+
setupRealTree();
|
|
267
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
268
|
+
expect(() => createRoute()("/", { "/": { view: () => m("text", null, "x") }, "/:a:b": { view: () => m("text", null, "y") } })).toThrow(SyntaxError);
|
|
269
|
+
expect(() => createRoute()(null as any, { "/": { view: () => m("text", null, "x") } })).toThrow(/defaultRoute is required/);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Opt-in Android back button bridge: the host sends a global event when back
|
|
274
|
+
// is pressed (only while its OnBackPressedCallback is enabled), and learns
|
|
275
|
+
// through onCanGoBackChange when there is history to go back to.
|
|
276
|
+
describe("route.listenBackButton()", () => {
|
|
277
|
+
function withFakeEmitter() {
|
|
278
|
+
const listeners = new Map<string, Set<Function>>();
|
|
279
|
+
const emitter = {
|
|
280
|
+
addListener: (name: string, fn: Function) => (listeners.get(name) ?? listeners.set(name, new Set()).get(name)!).add(fn),
|
|
281
|
+
removeListener: (name: string, fn: Function) => listeners.get(name)?.delete(fn),
|
|
282
|
+
emit: (name: string) => listeners.get(name)?.forEach((fn) => fn()),
|
|
283
|
+
count: (name: string) => listeners.get(name)?.size ?? 0,
|
|
284
|
+
};
|
|
285
|
+
const original = (lynx as any).getJSModule;
|
|
286
|
+
(lynx as any).getJSModule = (name: string) => (name === "GlobalEventEmitter" ? emitter : original?.call(lynx, name));
|
|
287
|
+
return { emitter, restore: () => ((lynx as any).getJSModule = original) };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function mountTwoPages() {
|
|
291
|
+
setupRealTree();
|
|
292
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
293
|
+
const route = createRoute();
|
|
294
|
+
route("/", { "/": { view: () => m("text", null, "home") }, "/detail": { view: () => m("text", null, "detail") } });
|
|
295
|
+
return route;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
it("the back event goes back, and onCanGoBackChange reports each change once", async () => {
|
|
299
|
+
const route = mountTwoPages();
|
|
300
|
+
const { emitter, restore } = withFakeEmitter();
|
|
301
|
+
const changes: boolean[] = [];
|
|
302
|
+
const stop = route.listenBackButton({ onCanGoBackChange: (can: boolean) => changes.push(can) });
|
|
303
|
+
|
|
304
|
+
route.set("/detail");
|
|
305
|
+
route.set("/detail", null, { replace: true }); // no change in canGoBack
|
|
306
|
+
await settle();
|
|
307
|
+
emitter.emit("mithrilLynx:back");
|
|
308
|
+
await settle();
|
|
309
|
+
|
|
310
|
+
expect(route.get()).toBe("/");
|
|
311
|
+
expect(changes).toEqual([false, true, false]);
|
|
312
|
+
stop();
|
|
313
|
+
restore();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("stop() unsubscribes; a custom event name is honored", () => {
|
|
317
|
+
const route = mountTwoPages();
|
|
318
|
+
const { emitter, restore } = withFakeEmitter();
|
|
319
|
+
const stop = route.listenBackButton({ eventName: "hostBack" });
|
|
320
|
+
expect(emitter.count("hostBack")).toBe(1);
|
|
321
|
+
stop();
|
|
322
|
+
expect(emitter.count("hostBack")).toBe(0);
|
|
323
|
+
restore();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("a throwing onCanGoBackChange (e.g. a missing native module) never breaks navigation", async () => {
|
|
327
|
+
const route = mountTwoPages();
|
|
328
|
+
const { restore } = withFakeEmitter();
|
|
329
|
+
const error = console.error;
|
|
330
|
+
console.error = () => {};
|
|
331
|
+
route.listenBackButton({ onCanGoBackChange: () => { throw new Error("no module"); } });
|
|
332
|
+
route.set("/detail");
|
|
333
|
+
await settle();
|
|
334
|
+
console.error = error;
|
|
335
|
+
expect(route.get()).toBe("/detail");
|
|
336
|
+
restore();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("without GlobalEventEmitter it only warns and does nothing", () => {
|
|
340
|
+
const route = mountTwoPages();
|
|
341
|
+
const original = (lynx as any).getJSModule;
|
|
342
|
+
(lynx as any).getJSModule = () => undefined;
|
|
343
|
+
const warn = console.warn;
|
|
344
|
+
const warnings: string[] = [];
|
|
345
|
+
console.warn = (msg: string) => warnings.push(msg);
|
|
346
|
+
const stop = route.listenBackButton();
|
|
347
|
+
console.warn = warn;
|
|
348
|
+
(lynx as any).getJSModule = original;
|
|
349
|
+
expect(warnings.some((w) => w.includes("GlobalEventEmitter"))).toBe(true);
|
|
350
|
+
expect(() => stop()).not.toThrow();
|
|
351
|
+
});
|
|
352
|
+
});
|
package/test/setup.ts
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
// for everyone else (src/testing.js's installTestingPolyfills) — see that
|
|
5
5
|
// file's header for what it covers and why.
|
|
6
6
|
|
|
7
|
+
import { afterEach } from "@rstest/core";
|
|
7
8
|
import { installTestingPolyfills } from "../src/testing.js";
|
|
9
|
+
import { unregister } from "../src/mount-redraw.js";
|
|
8
10
|
|
|
9
11
|
globalThis.onInjectMainThreadGlobals = installTestingPolyfills;
|
|
12
|
+
|
|
13
|
+
// mount-redraw.js is a single-slot singleton and register() now fails fast
|
|
14
|
+
// on a second registration (R2) — clear it between tests so each test's own
|
|
15
|
+
// renderApp() starts from a clean slate instead of colliding with the
|
|
16
|
+
// previous test's still-registered redraw.
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
unregister();
|
|
19
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "@rstest/core";
|
|
2
|
+
import m from "mithril-runtime";
|
|
3
|
+
import { createPatchApplier } from "../src/apply-patch.js";
|
|
4
|
+
import { renderApp } from "../src/background.js";
|
|
5
|
+
import { unregister } from "../src/mount-redraw.js";
|
|
6
|
+
import { forEachOp, Op } from "../src/patch-protocol.js";
|
|
7
|
+
|
|
8
|
+
// Mithril clears a whole style with `element.style = ""` (a style prop that
|
|
9
|
+
// disappears, or unconditionally before a first object style). There is no
|
|
10
|
+
// device-validated bulk-clear PAPI, so fake-dom.js must expand the clear into
|
|
11
|
+
// one RemoveStyleProperty per property actually applied — and emit nothing
|
|
12
|
+
// when there is nothing to clear.
|
|
13
|
+
|
|
14
|
+
function mount(styleOf: () => Record<string, string> | undefined) {
|
|
15
|
+
lynxTestingEnv.switchToMainThread();
|
|
16
|
+
const pageId = __GetElementUniqueID(__CreatePage());
|
|
17
|
+
const applier = createPatchApplier(pageId);
|
|
18
|
+
applier.registerPageRoot(__CreateView(pageId));
|
|
19
|
+
|
|
20
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
21
|
+
unregister();
|
|
22
|
+
const patches: unknown[][] = [];
|
|
23
|
+
const app = renderApp({
|
|
24
|
+
root: () => {
|
|
25
|
+
const style = styleOf();
|
|
26
|
+
return m("view", style ? { style } : {});
|
|
27
|
+
},
|
|
28
|
+
sendPatch: (ops) => patches.push(ops),
|
|
29
|
+
subscribeEvents: () => {},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Applies every patch produced since the last call on the main thread
|
|
33
|
+
* (which must not throw) and returns the style ops they carried. */
|
|
34
|
+
let applied = 0;
|
|
35
|
+
const flushToMainThread = () => {
|
|
36
|
+
const fresh = patches.slice(applied);
|
|
37
|
+
applied = patches.length;
|
|
38
|
+
lynxTestingEnv.switchToMainThread();
|
|
39
|
+
for (const ops of fresh) applier.applyPatch(ops);
|
|
40
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
41
|
+
const removed: string[] = [];
|
|
42
|
+
const set: string[] = [];
|
|
43
|
+
for (const ops of fresh) {
|
|
44
|
+
forEachOp(ops, (opcode, args) => {
|
|
45
|
+
if (opcode === Op.RemoveStyleProperty) removed.push(args[1] as string);
|
|
46
|
+
if (opcode === Op.SetStyleProperty) set.push(args[1] as string);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return { removed, set };
|
|
50
|
+
};
|
|
51
|
+
return { app, flushToMainThread };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("clearing a whole style expands into per-property removals", () => {
|
|
55
|
+
it("removing the style prop of a styled element no longer throws", () => {
|
|
56
|
+
let style: Record<string, string> | undefined = { color: "red" };
|
|
57
|
+
const { app, flushToMainThread } = mount(() => style);
|
|
58
|
+
flushToMainThread();
|
|
59
|
+
|
|
60
|
+
style = undefined;
|
|
61
|
+
app.redraw();
|
|
62
|
+
expect(flushToMainThread().removed).toEqual(["color"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("clearing {color, width} removes both properties", () => {
|
|
66
|
+
let style: Record<string, string> | undefined = { color: "red", width: "10px" };
|
|
67
|
+
const { app, flushToMainThread } = mount(() => style);
|
|
68
|
+
flushToMainThread();
|
|
69
|
+
|
|
70
|
+
style = undefined;
|
|
71
|
+
app.redraw();
|
|
72
|
+
expect(flushToMainThread().removed.sort()).toEqual(["color", "width"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("clearing a style whose properties were already removed one by one emits nothing", () => {
|
|
76
|
+
let style: Record<string, string> | undefined = { color: "red", width: "10px" };
|
|
77
|
+
const { app, flushToMainThread } = mount(() => style);
|
|
78
|
+
flushToMainThread();
|
|
79
|
+
|
|
80
|
+
// Object -> object: Mithril removes the missing keys individually.
|
|
81
|
+
style = {};
|
|
82
|
+
app.redraw();
|
|
83
|
+
expect(flushToMainThread().removed.sort()).toEqual(["color", "width"]);
|
|
84
|
+
|
|
85
|
+
// Now the whole style goes away: `style = ""` with nothing left to clear.
|
|
86
|
+
style = undefined;
|
|
87
|
+
app.redraw();
|
|
88
|
+
expect(flushToMainThread().removed).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("an element that never had a style gets no clear op on its first styled render", () => {
|
|
92
|
+
const { flushToMainThread } = mount(() => ({ color: "red" }));
|
|
93
|
+
const { removed, set } = flushToMainThread();
|
|
94
|
+
expect(removed).toEqual([]);
|
|
95
|
+
expect(set).toEqual(["color"]);
|
|
96
|
+
});
|
|
97
|
+
});
|