mithril-lynx 2.6.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/README.md +47 -8
  5. package/ROUTE.md +111 -16
  6. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  7. package/UPDATE_ANIMATION_GAP.md +133 -0
  8. package/package.json +1 -9
  9. package/plugin.js +33 -0
  10. package/src/apply-patch.js +369 -60
  11. package/src/backends/virtual-backend.js +120 -22
  12. package/src/background.d.ts +26 -0
  13. package/src/background.js +116 -7
  14. package/src/channel.js +22 -5
  15. package/src/commit.js +9 -0
  16. package/src/dev-reload-client.js +46 -0
  17. package/src/dev-transport-noop.js +3 -0
  18. package/src/fake-dom.js +559 -61
  19. package/src/list-attributes.js +69 -0
  20. package/src/list-runtime.js +425 -0
  21. package/src/main-thread.js +18 -1
  22. package/src/mount-redraw.js +14 -1
  23. package/src/patch-protocol.js +41 -16
  24. package/src/reload/version.js +10 -1
  25. package/src/request.js +38 -0
  26. package/src/route.d.ts +30 -2
  27. package/src/route.js +214 -56
  28. package/src/testing.d.ts +14 -1
  29. package/src/testing.js +56 -1
  30. package/test/event-render-coalescing.test.ts +117 -0
  31. package/test/gesture.test.ts +75 -0
  32. package/test/input.test.ts +388 -0
  33. package/test/list.test.ts +417 -113
  34. package/test/remove-subtree-cleanup.test.ts +75 -0
  35. package/test/route-hot-reload.test.ts +2 -1
  36. package/test/route.test.ts +201 -4
  37. package/test/style-clear.test.ts +97 -0
  38. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  39. package/.omo/plans/m-route-en-memoria.md +0 -397
  40. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  41. package/.omo/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
  42. package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +0 -10
  43. package/src/list-cell.d.ts +0 -20
  44. package/src/list-cell.js +0 -73
  45. package/src/list-support.d.ts +0 -13
  46. package/src/list-support.js +0 -197
@@ -11,7 +11,7 @@ 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) => {
@@ -32,6 +32,7 @@ describe("route.js + stable-host: a same-path re-resolve patches in place, never
32
32
 
33
33
  currentDetail = { view: () => m("text", { key: "title" }, "second") };
34
34
  route.set(route.get() as string, null, { replace: true });
35
+ await Promise.resolve(); // navigation resolves on the next microtask
35
36
 
36
37
  const flat = capturedOps.flat();
37
38
  expect(flat).not.toContain(Op.CreateElement);
@@ -37,8 +37,11 @@ function setupRealTree() {
37
37
  // isn't exported from a stable path used elsewhere yet in tests.
38
38
  import { createPatchApplier } from "../src/apply-patch.js";
39
39
 
40
+ /** Navigation resolves on the next microtask (like upstream m.route). */
41
+ const settle = () => Promise.resolve();
42
+
40
43
  describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
41
- it("mounts the default route and exposes route.get()/route.param()", () => {
44
+ it("mounts the default route and exposes route.get()/route.param()", async () => {
42
45
  setupRealTree();
43
46
  lynxTestingEnv.switchToBackgroundThread();
44
47
 
@@ -51,11 +54,12 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
51
54
  expect(route.get()).toBe("/");
52
55
 
53
56
  route.set("/users/42");
57
+ await settle();
54
58
  expect(route.get()).toBe("/users/42");
55
59
  expect(route.param("id")).toBe("42");
56
60
  });
57
61
 
58
- it("navigating replaces the screen with no orphaned nodes left behind", () => {
62
+ it("navigating replaces the screen with no orphaned nodes left behind", async () => {
59
63
  const { capturedOps } = setupRealTree();
60
64
  lynxTestingEnv.switchToBackgroundThread();
61
65
 
@@ -67,6 +71,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
67
71
  capturedOps.length = 0; // only care about the ops from the NAVIGATION below
68
72
 
69
73
  route.set("/b");
74
+ await settle();
70
75
 
71
76
  // Same spirit as test/structural-reload.test.ts, but here the whole
72
77
  // screen changes (not a sibling insert) — the decisive check is
@@ -78,7 +83,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
78
83
  expect(navOps).toContain(Op.CreateElement);
79
84
  });
80
85
 
81
- it("back()/forward() walk the in-memory history stack", () => {
86
+ it("back()/forward() walk the in-memory history stack", async () => {
82
87
  setupRealTree();
83
88
  lynxTestingEnv.switchToBackgroundThread();
84
89
 
@@ -90,15 +95,19 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
90
95
  route("/a", { "/a": A, "/b": B, "/c": C });
91
96
  route.set("/b");
92
97
  route.set("/c");
98
+ await settle();
93
99
  expect(route.get()).toBe("/c");
94
100
 
95
101
  route.back();
102
+ await settle();
96
103
  expect(route.get()).toBe("/b");
97
104
 
98
105
  route.back();
106
+ await settle();
99
107
  expect(route.get()).toBe("/a");
100
108
 
101
109
  route.forward();
110
+ await settle();
102
111
  expect(route.get()).toBe("/b");
103
112
  });
104
113
 
@@ -129,7 +138,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
129
138
  });
130
139
  });
131
140
 
132
- 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 () => {
133
142
  setupRealTree();
134
143
  lynxTestingEnv.switchToBackgroundThread();
135
144
 
@@ -144,6 +153,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
144
153
  });
145
154
  expect(typeof link.attrs.ontap).toBe("function");
146
155
  link.attrs.ontap({ currentTarget: null, redraw: true });
156
+ await settle();
147
157
  expect(route.get()).toBe("/b");
148
158
 
149
159
  const disabledLink = (route as any).Link.view({
@@ -153,3 +163,190 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
153
163
  expect(disabledLink.attrs.ontap).toBeUndefined();
154
164
  });
155
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
+ });
@@ -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
+ });