mithril-lynx 0.0.9 → 2.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 (61) hide show
  1. package/.omo/plans/m-request-fetch-lynx.md +306 -0
  2. package/.omo/plans/m-route-en-memoria.md +397 -0
  3. package/.omo/plans/mithril-lynx-v2-desde-cero.md +548 -0
  4. package/FETCH_INVESTIGATION.md +307 -0
  5. package/README.md +32 -302
  6. package/REQUEST.md +71 -0
  7. package/ROUTE.md +71 -0
  8. package/package.json +24 -80
  9. package/plugin.d.ts +4 -33
  10. package/plugin.js +108 -438
  11. package/rstest.config.ts +27 -0
  12. package/src/apply-patch.js +179 -0
  13. package/src/backends/virtual-backend.js +80 -0
  14. package/src/background.d.ts +11 -0
  15. package/src/background.js +79 -0
  16. package/src/channel.js +41 -0
  17. package/src/commit.js +67 -0
  18. package/src/dev-reload-client.js +171 -187
  19. package/src/dev-transport-noop.js +10 -0
  20. package/src/fake-dom.js +374 -0
  21. package/src/main-thread.d.ts +1 -0
  22. package/src/main-thread.js +68 -0
  23. package/src/mount-redraw.js +67 -0
  24. package/src/patch-protocol.js +40 -0
  25. package/src/reload/version.js +28 -0
  26. package/src/request.d.ts +37 -0
  27. package/src/request.js +181 -0
  28. package/src/route.d.ts +33 -0
  29. package/src/route.js +207 -0
  30. package/test/end-to-end.test.ts +86 -0
  31. package/test/reload-version.test.ts +17 -0
  32. package/test/request.test.ts +182 -0
  33. package/test/route-hot-reload.test.ts +40 -0
  34. package/test/route.test.ts +152 -0
  35. package/test/setup.ts +25 -0
  36. package/test/structural-reload.test.ts +95 -0
  37. package/CONTRACT.md +0 -151
  38. package/LICENSE +0 -21
  39. package/background.d.ts +0 -54
  40. package/background.js +0 -169
  41. package/element.d.ts +0 -34
  42. package/element.js +0 -83
  43. package/gesture.d.ts +0 -40
  44. package/gesture.js +0 -117
  45. package/internal/constants.js +0 -26
  46. package/internal/virtual-node.js +0 -388
  47. package/list.d.ts +0 -31
  48. package/list.js +0 -185
  49. package/main-thread.d.ts +0 -43
  50. package/main-thread.js +0 -165
  51. package/navigation.d.ts +0 -35
  52. package/navigation.js +0 -76
  53. package/renderer/background.d.ts +0 -21
  54. package/renderer/background.js +0 -84
  55. package/renderer/main-thread.d.ts +0 -12
  56. package/renderer/main-thread.js +0 -175
  57. package/src/lynx-mithril-shim.d.ts +0 -16
  58. package/src/lynx-mithril-shim.js +0 -1505
  59. package/src/worklet-runtime.js +0 -82
  60. package/testing.d.ts +0 -10
  61. package/testing.js +0 -91
@@ -0,0 +1,182 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import { createRequestor } from "../src/request.js";
3
+ import { register as registerRedraw } from "../src/mount-redraw.js";
4
+
5
+ // The underlying primitive (lynx.fetch itself — redirects, AbortController,
6
+ // URLSearchParams bodies, Headers case-sensitivity) is validated on a real
7
+ // device, not here — see FETCH_INVESTIGATION.md. These tests are about the
8
+ // WRAPPER's own logic: does it build the right RequestInit, handle the
9
+ // response/error shape correctly, and refuse the options that have no
10
+ // fetch equivalent. A fake `fetch` (jsdom's real Response/Headers/
11
+ // AbortController under the hood, same as a real fetch, just no network)
12
+ // is injected via createRequestor() for exactly this reason.
13
+
14
+ function fakeResponse(body: unknown, init: { status?: number; statusText?: string } = {}) {
15
+ const status = init.status ?? 200;
16
+ return {
17
+ ok: status >= 200 && status < 300,
18
+ status,
19
+ statusText: init.statusText ?? "",
20
+ json: async () => body,
21
+ text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
22
+ };
23
+ }
24
+
25
+ describe("request.js: m.request-shaped wrapper over lynx.fetch", () => {
26
+ it("interpolates :params into the URL and defaults to GET", async () => {
27
+ let seenUrl: string | undefined;
28
+ let seenInit: any;
29
+ const request = createRequestor((url, init) => {
30
+ seenUrl = url;
31
+ seenInit = init;
32
+ return Promise.resolve(fakeResponse({ id: 42 }));
33
+ });
34
+
35
+ const result = await request("/users/:id", { params: { id: 42 } });
36
+ expect(seenUrl).toBe("/users/42");
37
+ expect(seenInit.method).toBe("GET");
38
+ expect(result).toEqual({ id: 42 });
39
+ });
40
+
41
+ it("JSON-encodes a plain object body and sets Content-Type", async () => {
42
+ let seenInit: any;
43
+ const request = createRequestor((_url, init) => {
44
+ seenInit = init;
45
+ return Promise.resolve(fakeResponse({ ok: true }));
46
+ });
47
+
48
+ await request("/things", { method: "post", body: { name: "widget" } });
49
+ expect(seenInit.method).toBe("POST");
50
+ expect(seenInit.body).toBe(JSON.stringify({ name: "widget" }));
51
+ expect(seenInit.headers["Content-Type"]).toBe("application/json; charset=utf-8");
52
+ });
53
+
54
+ it("passes a URLSearchParams body through unchanged (confirmed working on device)", async () => {
55
+ let seenInit: any;
56
+ const request = createRequestor((_url, init) => {
57
+ seenInit = init;
58
+ return Promise.resolve(fakeResponse({ ok: true }));
59
+ });
60
+
61
+ const usp = new URLSearchParams({ foo: "bar" });
62
+ await request("/form", { method: "post", body: usp });
63
+ expect(seenInit.body).toBe(usp);
64
+ });
65
+
66
+ it("rejects a FormData body immediately — confirmed absent on Lynx", async () => {
67
+ const request = createRequestor(() => {
68
+ throw new Error("should never reach fetch");
69
+ });
70
+ const fd = new FormData();
71
+ expect(() => request("/upload", { method: "post", body: fd })).toThrow(/FormData/);
72
+ });
73
+
74
+ for (const [name, options] of [
75
+ ["config", { config: () => {} }],
76
+ ["async: false", { async: false }],
77
+ ["user", { user: "alice" }],
78
+ ["password", { password: "secret" }],
79
+ ["withCredentials", { withCredentials: true }],
80
+ ] as const) {
81
+ it(`rejects "${name}" immediately — no fetch equivalent`, () => {
82
+ const request = createRequestor(() => {
83
+ throw new Error("should never reach fetch");
84
+ });
85
+ expect(() => request("/x", options as any)).toThrow();
86
+ });
87
+ }
88
+
89
+ it("builds the real m.request error shape on a non-2xx response", async () => {
90
+ const request = createRequestor(() =>
91
+ Promise.resolve(fakeResponse({ reason: "nope" }, { status: 404, statusText: "Not Found" })),
92
+ );
93
+
94
+ await expect(request("/missing")).rejects.toMatchObject({
95
+ code: 404,
96
+ response: { reason: "nope" },
97
+ });
98
+ });
99
+
100
+ it("extract() bypasses the status check entirely, like real m.request", async () => {
101
+ const request = createRequestor(() => Promise.resolve(fakeResponse(null, { status: 500 })));
102
+ const result = await request("/x", {
103
+ extract: (response: any) => ({ sawStatus: response.status }),
104
+ });
105
+ expect(result).toEqual({ sawStatus: 500 });
106
+ });
107
+
108
+ it("applies a type constructor to the result", async () => {
109
+ class User {
110
+ name: string;
111
+ constructor(data: { name: string }) {
112
+ this.name = data.name;
113
+ }
114
+ }
115
+ const request = createRequestor(() => Promise.resolve(fakeResponse({ name: "Ada" })));
116
+ const result = await request<User>("/user", { type: User });
117
+ expect(result).toBeInstanceOf(User);
118
+ expect(result.name).toBe("Ada");
119
+ });
120
+
121
+ it("redraws the currently mounted app after resolving, unless background: true", async () => {
122
+ // redraw() is scheduled (50ms delay), not synchronous or a 0ms timer
123
+ // — see mount-redraw.js's top comment: a synchronous redraw runs
124
+ // BEFORE this test's own .then below (chained onto request()'s
125
+ // *returned* promise, one microtask behind request.js's internal
126
+ // redraw call), and device testing showed Lynx's timer/rAF fire
127
+ // before even a single pending microtask regardless of delay, up to
128
+ // 16ms — 50ms was the first value that reliably lost that race.
129
+ // Confirmed as a real device bug: the UI froze on "loading" forever
130
+ // because the state update arrived after the (then too-early) render.
131
+ let redraws = 0;
132
+ registerRedraw(() => {
133
+ redraws++;
134
+ });
135
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
136
+ const request = createRequestor(() => Promise.resolve(fakeResponse({})));
137
+
138
+ await request("/x");
139
+ await wait(60);
140
+ expect(redraws).toBe(1);
141
+
142
+ await request("/x", { background: true });
143
+ await wait(60);
144
+ expect(redraws).toBe(1); // unchanged
145
+ });
146
+
147
+ it("timeout aborts the underlying fetch via AbortController, not just the wait", async () => {
148
+ let sawAbort = false;
149
+ const request = createRequestor(
150
+ (_url, init) =>
151
+ new Promise((_resolve, reject) => {
152
+ init.signal.addEventListener("abort", () => {
153
+ sawAbort = true;
154
+ const error = new Error("This operation was aborted");
155
+ error.name = "AbortError";
156
+ reject(error);
157
+ });
158
+ }),
159
+ );
160
+
161
+ await expect(request("/slow", { timeout: 5 })).rejects.toMatchObject({ name: "AbortError" });
162
+ expect(sawAbort).toBe(true);
163
+ });
164
+
165
+ it("exposes .abort() on the returned promise as a bonus (real m.request has no direct equivalent)", async () => {
166
+ let sawAbort = false;
167
+ const request = createRequestor(
168
+ (_url, init) =>
169
+ new Promise((_resolve, reject) => {
170
+ init.signal.addEventListener("abort", () => {
171
+ sawAbort = true;
172
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
173
+ });
174
+ }),
175
+ );
176
+
177
+ const promise = request("/slow");
178
+ promise.catch(() => {}); // don't let the eventual rejection be unhandled
179
+ promise.abort();
180
+ expect(sawAbort).toBe(true);
181
+ });
182
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import m from "mithril-runtime";
3
+ import { createRoute } from "../src/route.js";
4
+ import { Op } from "../src/patch-protocol.js";
5
+
6
+ // Regression test for a real, on-device-confirmed question (m-route-en-memoria.md
7
+ // §10 F6): does a stable-host screen re-render itself in place after
8
+ // module.hot.accept swaps its module, or does routing accidentally force a
9
+ // full teardown/recreate? Verified for real via a device trace: the actual
10
+ // patch for this exact shape was `[Op.SetText, id, "new text"]` — nothing
11
+ // else. This test locks that in.
12
+
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", () => {
15
+ lynxTestingEnv.switchToMainThread();
16
+ const capturedOps: unknown[][] = [];
17
+ lynx.getJSContext().addEventListener("MithrilLynxV2:Patch", (event: any) => {
18
+ capturedOps.push(event.data);
19
+ });
20
+
21
+ lynxTestingEnv.switchToBackgroundThread();
22
+ const route = createRoute();
23
+ // Same shape as the real app's background.ts: a stable host object
24
+ // wrapping a live-bound `current*` reference, re-pointed by
25
+ // module.hot.accept and re-resolved via route.set(..., {replace}).
26
+ let currentDetail = { view: () => m("text", { key: "title" }, "first") };
27
+ const DetailHost = { view: () => currentDetail.view() };
28
+
29
+ route("/detail", { "/detail": DetailHost });
30
+ capturedOps.length = 0; // only the re-resolve below matters
31
+
32
+ currentDetail = { view: () => m("text", { key: "title" }, "second") };
33
+ route.set(route.get() as string, null, { replace: true });
34
+
35
+ const flat = capturedOps.flat();
36
+ expect(flat).not.toContain(Op.CreateElement);
37
+ expect(flat).not.toContain(Op.RemoveChild);
38
+ expect(flat).toContain(Op.SetText);
39
+ });
40
+ });
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import m from "mithril-runtime";
3
+ import { createRoute } from "../src/route.js";
4
+ import { Op } from "../src/patch-protocol.js";
5
+
6
+ // createRoute() calls renderApp() (src/background.js) internally on first
7
+ // match, which unconditionally uses the real cross-thread channel
8
+ // (channel.js -> lynx.getCoreContext()/getJSContext()) — so every test
9
+ // here runs against @lynx-js/testing-environment's real PAPI, exactly like
10
+ // test/end-to-end.test.ts, not a mock.
11
+
12
+ function setupRealTree() {
13
+ lynxTestingEnv.switchToMainThread();
14
+ const pageId = __GetElementUniqueID(__CreatePage());
15
+ const applier = createPatchApplier(pageId);
16
+ applier.registerPageRoot(__CreateView(pageId));
17
+
18
+ lynxTestingEnv.switchToBackgroundThread();
19
+ const capturedOps: unknown[][] = [];
20
+ // Same pattern channel.js's real sendPatchToMainThread uses, captured
21
+ // on the main-thread side so ops can be applied and the resulting real
22
+ // tree inspected.
23
+ lynxTestingEnv.switchToMainThread();
24
+ lynx.getJSContext().addEventListener("MithrilLynxV2:Patch", (event: any) => {
25
+ capturedOps.push(event.data);
26
+ lynxTestingEnv.switchToMainThread();
27
+ applier.applyPatch(event.data);
28
+ });
29
+
30
+ return { applier, capturedOps };
31
+ }
32
+
33
+ // createPatchApplier is imported dynamically per-test-file below since it
34
+ // isn't exported from a stable path used elsewhere yet in tests.
35
+ import { createPatchApplier } from "../src/apply-patch.js";
36
+
37
+ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
38
+ it("mounts the default route and exposes route.get()/route.param()", () => {
39
+ setupRealTree();
40
+ lynxTestingEnv.switchToBackgroundThread();
41
+
42
+ const route = createRoute();
43
+ const Home = { view: () => m("text", null, "home") };
44
+ const User = { view: () => m("text", null, "user:" + route.param("id")) };
45
+
46
+ route("/", { "/": Home, "/users/:id": User });
47
+
48
+ expect(route.get()).toBe("/");
49
+
50
+ route.set("/users/42");
51
+ expect(route.get()).toBe("/users/42");
52
+ expect(route.param("id")).toBe("42");
53
+ });
54
+
55
+ it("navigating replaces the screen with no orphaned nodes left behind", () => {
56
+ const { capturedOps } = setupRealTree();
57
+ lynxTestingEnv.switchToBackgroundThread();
58
+
59
+ const route = createRoute();
60
+ const A = { view: () => m("view", { key: "a" }, [m("text", null, "A")]) };
61
+ const B = { view: () => m("view", { key: "b" }, [m("text", null, "B")]) };
62
+
63
+ route("/a", { "/a": A, "/b": B });
64
+ capturedOps.length = 0; // only care about the ops from the NAVIGATION below
65
+
66
+ route.set("/b");
67
+
68
+ // Same spirit as test/structural-reload.test.ts, but here the whole
69
+ // screen changes (not a sibling insert) — the decisive check is
70
+ // that A's subtree is actually torn down (RemoveChild present, and
71
+ // no id from A gets reused for a create), not left dangling while
72
+ // B's is appended on top of it.
73
+ const navOps = capturedOps.flat();
74
+ expect(navOps).toContain(Op.RemoveChild);
75
+ expect(navOps).toContain(Op.CreateElement);
76
+ });
77
+
78
+ it("back()/forward() walk the in-memory history stack", () => {
79
+ setupRealTree();
80
+ lynxTestingEnv.switchToBackgroundThread();
81
+
82
+ const route = createRoute();
83
+ const A = { view: () => m("text", null, "A") };
84
+ const B = { view: () => m("text", null, "B") };
85
+ const C = { view: () => m("text", null, "C") };
86
+
87
+ route("/a", { "/a": A, "/b": B, "/c": C });
88
+ route.set("/b");
89
+ route.set("/c");
90
+ expect(route.get()).toBe("/c");
91
+
92
+ route.back();
93
+ expect(route.get()).toBe("/b");
94
+
95
+ route.back();
96
+ expect(route.get()).toBe("/a");
97
+
98
+ route.forward();
99
+ expect(route.get()).toBe("/b");
100
+ });
101
+
102
+ it("onmatch + SKIP falls through to the next matching route", () => {
103
+ setupRealTree();
104
+ lynxTestingEnv.switchToBackgroundThread();
105
+
106
+ const route = createRoute();
107
+ const Fallback = { view: () => m("text", null, "fallback") };
108
+ let calls = 0;
109
+
110
+ route("/x", {
111
+ "/x": {
112
+ onmatch() {
113
+ calls++;
114
+ return route.SKIP;
115
+ },
116
+ },
117
+ "/:any...": Fallback,
118
+ });
119
+
120
+ return new Promise<void>((resolve) => {
121
+ setTimeout(() => {
122
+ expect(calls).toBe(1);
123
+ expect(route.get()).toBe("/x");
124
+ resolve();
125
+ }, 0);
126
+ });
127
+ });
128
+
129
+ it("route.Link navigates on tap, and disabled Links don't wire ontap", () => {
130
+ setupRealTree();
131
+ lynxTestingEnv.switchToBackgroundThread();
132
+
133
+ const route = createRoute();
134
+ const A = { view: () => m("text", null, "A") };
135
+ const B = { view: () => m("text", null, "B") };
136
+ route("/a", { "/a": A, "/b": B });
137
+
138
+ const link = (route as any).Link.view({
139
+ attrs: { href: "/b" },
140
+ children: ["Go"],
141
+ });
142
+ expect(typeof link.attrs.ontap).toBe("function");
143
+ link.attrs.ontap({ currentTarget: null, redraw: true });
144
+ expect(route.get()).toBe("/b");
145
+
146
+ const disabledLink = (route as any).Link.view({
147
+ attrs: { href: "/a", disabled: true },
148
+ children: ["Go"],
149
+ });
150
+ expect(disabledLink.attrs.ontap).toBeUndefined();
151
+ });
152
+ });
package/test/setup.ts ADDED
@@ -0,0 +1,25 @@
1
+ // test/setup.ts
2
+ //
3
+ // The minimal gap-fill on top of @lynx-js/testing-environment's own PAPI
4
+ // polyfill — same idea as mithril-lynx v1's testing.js, scoped down to only
5
+ // what v2's apply-patch.js actually calls so far (no gestures/lists yet,
6
+ // see the v2 plan's non-goals). `@lynx-js/testing-environment` already
7
+ // implements __CreateView/__CreateText/__CreateElement/__CreateRawText/
8
+ // __AppendElement/__InsertElementBefore/__RemoveElement/__SetAttribute/
9
+ // __SetClasses/__AddInlineStyle/__FlushElementTree/__GetElementUniqueID —
10
+ // the one real gap is __AddEventListener (the testing environment only
11
+ // implements the string/worklet-event __AddEvent family that ReactLynx
12
+ // uses; mithril-lynx binds real JS function listeners directly).
13
+
14
+ globalThis.onInjectMainThreadGlobals = (target: any) => {
15
+ target.lynx.getEngine = target.lynx.getNative;
16
+
17
+ target.__AddEventListener = (node: any, name: string, handler: (...args: unknown[]) => unknown) => {
18
+ node.__vanillaListeners ??= {};
19
+ (node.__vanillaListeners[name] ??= new Set()).add(handler);
20
+ };
21
+
22
+ target.__RemoveEventListener = (node: any, name: string, handler: unknown) => {
23
+ node.__vanillaListeners?.[name]?.delete(handler);
24
+ };
25
+ };
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import m from "mithril";
3
+ import { Op } from "../src/patch-protocol.js";
4
+ import { createVirtualBackend } from "../src/backends/virtual-backend.js";
5
+ import { createLynxDocument } from "../src/fake-dom.js";
6
+ import renderFactory from "mithril/render/render.js";
7
+
8
+ // Tests the v2 plan's §3.6 hypothesis directly, WITHOUT a device: does
9
+ // re-rendering the SAME root/document with a structurally different tree
10
+ // (a sibling inserted next to an unrelated, focused-in-spirit node) reuse
11
+ // the unrelated node's id — i.e. does it survive as the SAME element,
12
+ // rather than being torn down and recreated? This is exactly what "reload
13
+ // B (estructural) doesn't lose the input's focus" depends on, and it's
14
+ // mithril's own diff doing the work, not a hand-written reconciler (see
15
+ // mithril-lynx-v2-desde-cero.md §3.6).
16
+ //
17
+ // This does not replace on-device verification (F4) — it verifies the
18
+ // PART of the hypothesis that's actually testable off-device: id/identity
19
+ // stability across a structural re-render. Whether the real native
20
+ // `<input>` keeps keyboard focus and in-progress text is a device-only
21
+ // question (F4's real acceptance criterion).
22
+
23
+ describe("structural re-render reuses unrelated nodes (v2 plan §3.6 hypothesis)", () => {
24
+ it("does not recreate a sibling `input`-like node when a new node is inserted next to it", () => {
25
+ const backend = createVirtualBackend();
26
+ const document = createLynxDocument(backend);
27
+ const render = renderFactory();
28
+
29
+ function redraw() {
30
+ render(document, view(), redraw);
31
+ }
32
+
33
+ // `key` on every sibling is load-bearing here, not decoration: this is
34
+ // exactly the discipline the plan's §3.6 calls out as required for
35
+ // Mithril's own (unkeyed-diff) middle-insertion trap to not apply —
36
+ // without keys, an unkeyed diff treats "insert in the middle" as
37
+ // "index 1 changed tag" and recreates everything from that index on,
38
+ // which is a real Mithril behavior, not a v2 bug. The un-keyed case
39
+ // is deliberately NOT what this test asserts.
40
+ let showExtra = false;
41
+ function view() {
42
+ const children = [m("view", { key: "a", class: "a" }, "A")];
43
+ if (showExtra) children.push(m("view", { key: "extra", class: "extra" }, "EXTRA"));
44
+ children.push(m("input", { key: "input", class: "focused-input" }));
45
+ return m("view", { class: "root" }, children);
46
+ }
47
+
48
+ redraw();
49
+ backend.takeOps(); // discard the initial patch — we only care about the DELTA below
50
+
51
+ // Capture the input's id BEFORE the structural change, by walking
52
+ // the fake-dom tree the same way app code never has to (test-only
53
+ // introspection): the root element's last child is the input.
54
+ const rootEl = document.firstChild as any;
55
+ const inputBefore = rootEl._children[rootEl._children.length - 1];
56
+ const inputIdBefore = inputBefore._id;
57
+
58
+ showExtra = true;
59
+ redraw();
60
+ const deltaOps = backend.takeOps() ?? [];
61
+
62
+ // The decisive check: no RemoveChild/CreateElement op in the delta
63
+ // touches the input's id — it was reused in place, only a new
64
+ // sibling was created and inserted before it.
65
+ const ARITY = {
66
+ [Op.CreateElement]: 2,
67
+ [Op.CreateElementNS]: 3,
68
+ [Op.CreateText]: 2,
69
+ [Op.InsertBefore]: 3,
70
+ [Op.RemoveChild]: 2,
71
+ [Op.SetAttribute]: 3,
72
+ [Op.RemoveAttribute]: 2,
73
+ [Op.SetAttributeNS]: 4,
74
+ [Op.SetStyleProperty]: 3,
75
+ [Op.RemoveStyleProperty]: 2,
76
+ [Op.SetText]: 2,
77
+ [Op.AddEvent]: 2,
78
+ [Op.RemoveEvent]: 2,
79
+ } as Record<number, number>;
80
+ for (let i = 0; i < deltaOps.length; ) {
81
+ const opcode = deltaOps[i++] as number;
82
+ const args = deltaOps.slice(i, i + ARITY[opcode]);
83
+ if (opcode === Op.CreateElement) {
84
+ expect(args[1]).not.toBe(inputIdBefore);
85
+ } else if (opcode === Op.RemoveChild) {
86
+ expect(args[1]).not.toBe(inputIdBefore);
87
+ }
88
+ i += ARITY[opcode];
89
+ }
90
+
91
+ const rootElAfter = document.firstChild as any;
92
+ const inputAfter = rootElAfter._children[rootElAfter._children.length - 1];
93
+ expect(inputAfter._id).toBe(inputIdBefore);
94
+ });
95
+ });
package/CONTRACT.md DELETED
@@ -1,151 +0,0 @@
1
- # Mithril 2.3.8 `render/render.js` — Extracted Contract
2
-
3
- Source: `node_modules/mithril/render/render.js` (910 lines), mithril **2.3.8**.
4
- All line numbers cite that file unless prefixed with `hyperscript.js:` (which cites `node_modules/mithril/render/hyperscript.js`).
5
-
6
- ---
7
-
8
- ## a. Factory signature & how the render function is obtained
9
-
10
- - **Line 8**: `module.exports = function() {` — the module exports a **zero-argument factory function**.
11
- - The factory closes over module-level mutable state:
12
- - `currentRedraw` (line 14) — the active `redraw` callback, captured by `EventDict` for auto-redraw.
13
- - `currentRender` (line 15) — a per-render generation marker object (used by `delayedRemoval`).
14
- - `currentDOM` (line 880) — the DOM node currently being rendered to (reentrancy lock).
15
- - The **render function is the closure returned by the factory**: lines 882–909, `return function(dom, vnodes, redraw) { ... }`.
16
- - Module dependencies (lines 3–6): `./vnode`, `./delayedRemoval`, `./domFor`, `./cachedAttrsIsStaticMap`.
17
- - The factory pattern means each call to `require("mithril/render/render")()` produces an **independent renderer instance** with its own `currentRedraw`/`currentDOM`/`currentRender` state.
18
-
19
- ## b. Render function signature: `render(dom, vnodes, redraw)`
20
-
21
- Defined at **line 882**: `return function(dom, vnodes, redraw) {`
22
-
23
- | Param | Contract |
24
- |---|---|
25
- | `dom` | The DOM element to render into. **Line 883**: throws `TypeError("DOM element being rendered to does not exist.")` if falsy. **Lines 884–886**: throws `TypeError("Node is currently being rendered to and thus is locked.")` if `currentDOM != null && dom.contains(currentDOM)` (reentrancy guard). |
26
- | `vnodes` | A vnode or array of vnodes. **Line 899**: normalized via `Vnode.normalizeChildren(Array.isArray(vnodes) ? vnodes : [vnodes])`. |
27
- | `redraw` | Optional. **Line 894**: `currentRedraw = typeof redraw === "function" ? redraw : undefined`. Consumed by `EventDict.handleEvent` (lines 811–817) to auto-redraw after events. |
28
-
29
- Body sequence:
30
- 1. **Line 898**: first render into a node clears it — `if (dom.vnodes == null) dom.textContent = ""`.
31
- 2. **Line 900**: `updateNodes(dom, dom.vnodes, vnodes, hooks, null, namespace === "http://www.w3.org/1999/xhtml" ? undefined : namespace)` — diffs old (`dom.vnodes`) vs new (`vnodes`); the XHTML namespace is normalized to `undefined` (which enables the property-key path in `hasPropertyKey`).
32
- 3. **Line 901**: `dom.vnodes = vnodes` — **prior vnodes are stored on the DOM node itself**.
33
- 4. **Line 903**: focus restoration — if `document.activeElement` changed and the old active element still has `.focus`, it is refocused.
34
- 5. **Line 904**: post-render hooks (`oncreate`/`onupdate`) flushed in order.
35
- 6. **Lines 905–908**: `finally` restores `currentRedraw`/`currentDOM` to their previous values.
36
-
37
- ## c. DOM surface accessed on the `dom` parameter
38
-
39
- All access goes through the `dom` node passed to `render()` (or its descendants). `getDocument(dom)` (lines 17–19) returns `dom.ownerDocument`.
40
-
41
- | API | Lines | Usage |
42
- |---|---|---|
43
- | `ownerDocument` | 18 | `getDocument()`; source of all document-level factories |
44
- | `createTextNode` | 76 | `createText` — `vnode.dom = getDocument(parent).createTextNode(vnode.children)` |
45
- | `createElement` / `createElementNS` | 120–122 | `createElement` for HTML, `createElementNS(ns, tag)` for svg/math; `{is: is}` third arg for custom elements |
46
- | `createDocumentFragment` | 96, 104, 551 | `createHTML` (96), `createFragment` (104), `moveDOM` for multi-node moves (551) |
47
- | `insertBefore` / `appendChild` | 558–561 | `insertDOM`: `insertBefore(dom, nextSibling)` if `nextSibling != null`, else `appendChild(dom)` |
48
- | `removeChild` | 610–617 | `removeDOM`: single `removeChild(vnode.dom)` or per-node via `domFor` for fragments |
49
- | `nodeValue` | 422 | `updateText` — `old.dom.nodeValue = vnode.children` |
50
- | `value` | 654–658, 666, 699, 703 | Read for same-value coercion skips (input/textarea/select/option); written via generic `vnode.dom[key] = value` (666); select late-attrs (699, 703) |
51
- | `checked` | 730 | Only as a key name in `isFormAttribute`; written via generic property path (666) |
52
- | `selectedIndex` | 685, 699, 702, 707 | `removeAttr` guard (685), `setLateSelectAttrs` (699, 702, 707) |
53
- | `className` | 672, 681, 693 | `setAttr` maps `className` → `"class"` attribute (672); `removeAttr` excludes it from property-null path (681) and maps to `"class"` (693) |
54
- | `setAttribute` | 665, 669–670, 672 | input `type` (665), boolean attrs (669–670), generic attrs (672) |
55
- | `removeAttribute` | 670, 693 | boolean-false (670), generic removal (693) |
56
- | `setAttributeNS` | 645 | `xlink:`-prefixed keys → `setAttributeNS("http://www.w3.org/1999/xlink", key.slice(6), value)` |
57
- | `style` | 646, 678, 747–787 | `updateStyle` dual-mode (see §f) |
58
- | `innerHTML` | 89, 92, 571 | `createHTML` (89 svg-wrapped, 92 plain), contenteditable sync (571) |
59
- | `textContent` | 898 | First-render clear |
60
- | `firstChild` | 90, 94, 98, 109 | `createHTML` unwrap (90, 94, 98), `createFragment` dom anchor (109) |
61
- | `parentNode` | 730 | `isFormAttribute` — `option` whose parent is the active element |
62
- | `contains` | 884 | Reentrancy lock check |
63
- | `namespaceURI` | 891 | Namespace detection for the diff call |
64
- | `focus` | 903 | Focus restoration |
65
- | `nextSibling` | domFor.js:12 | Fragment iteration in `domFor` |
66
-
67
- **Not used (verified by grep across the whole package):**
68
- - `getAttribute` — render.js only *writes* attributes (`setAttribute`/`removeAttribute`/`setAttributeNS`); it never reads them.
69
- - `nodeType` — appears nowhere in mithril. Do not rely on it in a reimplementation.
70
-
71
- ## d. Prior-vnode storage & diffing of repeated `render()` calls
72
-
73
- **Storage**: old vnodes live on the DOM node as `dom.vnodes` — read at line 898 (first-render check) and 900 (diff input), written at line 901.
74
-
75
- **`updateNodes(parent, old, vnodes, hooks, nextSibling, ns)`** (lines 270–395):
76
-
77
- 1. **Trivial cases** (271–273): `old === vnodes` or both null → no-op; `old` empty → create all; `vnodes` empty → remove all.
78
- 2. **Keyed detection** (275–276): lists are keyed iff `old[0].key != null` / `vnodes[0].key != null` (first non-null node, 278–279).
79
- 3. **Keyed/unkeyed mismatch** (280–282): remove all old + create all new.
80
- 4. **Unkeyed diff** (283–299): walk the common length index-by-index; `o === v` or both null → skip; null old → create; null new → remove; else `updateNode`. Tails handled by `removeNodes` (298) / `createNodes` (299).
81
- 5. **Keyed diff** (300–392), with the documented optimizations (comment block 184–268):
82
- - **Bottom-up tail match** (305–312): while tail keys equal, update in place — identical tails are guaranteed part of the LIS, so no moves (tail optimization, comment 244–245).
83
- - **Top-down head match** (314–320): same for the head.
84
- - **Swaps & reversals** (322–336): two-node cross-swap fast path.
85
- - **Bottom-up again** (338–345): re-check tails after head/tail consumption.
86
- - **Leftovers** (346–347): remove remaining old or create remaining new.
87
- - **LIS-based middle diff** (348–391): builds `oldIndices` (350–351), maps new keys → old indices via `getKeyMap` (352–365; impl 478–488), nulls matched old entries, removes unmatched old (367), creates all if nothing matched (368), then either moves non-LIS nodes (`makeLisIndices`, 370–383; impl 494–534, lifted from ivi) or a simple create loop when order was preserved (384–390).
88
- 6. **`getNextSibling`** (536–541): next sibling is found by scanning the *old* list forward from `i+1` for a node with a `dom` — this is what makes top-down DOM insertion correct.
89
- 7. **`moveDOM`** (544–556): moves single nodes directly; multi-node fragments are moved via a `createDocumentFragment` + `domFor` loop.
90
-
91
- **`updateNode`** (396–419): same `tag` + same `is` → in-place update (state/events carried over at 399–400; `shouldNotUpdate` short-circuit at 401, impl 852–878); otherwise `removeNode` + `createNode`. Per-tag updates: `updateText` (420–425), `updateHTML` (426–435), `updateFragment` (436–450), `updateElement` (451–461), `updateComponent` (462–477).
92
-
93
- ## e. How `m()` (hyperscript) creates events & attrs
94
-
95
- **Hyperscript side** (`render/hyperscript.js`):
96
- - Selector parsing: `compileSelector` (hyperscript.js:21–42) — `#id`, `.class`, `[attr]`, `[attr=value]`; `class` → `className` (hyperscript.js:38); form-attribute keys (`value`/`checked`/`selectedIndex`/`selected`) mark the attrs object as non-static (hyperscript.js:17–19, 34).
97
- - `class` attr → `className` (hyperscript.js:54–57); `input[type]` reordered first (hyperscript.js:69–74, workaround for #2622); `vnode.is = attrs.is` (hyperscript.js:77).
98
-
99
- **Event side** (render.js):
100
- - **Dispatch rule** (line 644): any attr key starting with `on` (`key[0] === "o" && key[1] === "n"`) is routed to `updateEvent` (826–842), never to the DOM attribute path.
101
- - **`EventDict`** (800–823): a per-element event listener object, prototype `Object.create(null)` (804). Constructor captures `this._ = currentRedraw` (802). `handleEvent(ev)` (805–823):
102
- - Looks up `this["on" + ev.type]` (806).
103
- - Function handler → called with `ev.currentTarget` as `this` (808); object handler → `handler.handleEvent(ev)` (809).
104
- - Auto-redraw: if `this._ != null` and `ev.redraw !== false`, calls the captured redraw (811–812); also after the handler's returned promise resolves (813–817).
105
- - `return false` → `ev.preventDefault()` + `ev.stopPropagation()` (819–822).
106
- - **`updateEvent`** (826–842): `addEventListener(key.slice(2), vnode.events, false)` — the **EventDict object itself is the listener** (831, 839); handlers stored as `vnode.events[key]`; removal via `removeEventListener` (834). `vnode.events` is carried across updates (line 400).
107
-
108
- **Attribute side** (render.js):
109
- - **`setAttr`** (642–674) precedence: skip `key`/null-value/lifecycle (643) → `on*` events (644) → `xlink:` (645) → `style` (646) → **`hasPropertyKey`** (647–666) → attribute fallback (667–673).
110
- - **`hasPropertyKey`** (735–744): property assignment only when `ns === undefined` AND (custom element: tag contains `-` or `vnode.is`, OR key not in the browser-bug blacklist `href`/`list`/`form`/`width`/`height`) AND `key in vnode.dom`.
111
- - Property path (666): `vnode.dom[key] = value`, with `value` coercion guards (648–663: input/textarea/select/option same-value skip; file-input read-only warning at 661) and `input[type]` forced through `setAttribute` (665).
112
- - Attribute path (667–673): boolean → `setAttribute(key, "")` / `removeAttribute(key)`; else `setAttribute(key === "className" ? "class" : key, value)`.
113
- - **`removeAttr`** (675–695): property-null path excludes `className`, `title`, `value` (option/select edge), `input[type]`; else `removeAttribute` with `className` → `"class"` mapping.
114
- - **`updateAttrs`** (709–728): removals first (713–722), then sets (723–727); warns on reused attrs objects (714–716).
115
- - **`isFormAttribute`** (729–731): `value`/`checked`/`selectedIndex`/`selected` (with active-element/option-parent conditions) — these bypass the `old === value` skip so form state always syncs.
116
-
117
- ## f. `updateStyle()` dual-mode (lines 747–787)
118
-
119
- `updateStyle(element, old, style)`:
120
-
121
- | Case | Lines | Behavior |
122
- |---|---|---|
123
- | `old === style` | 748–749 | No-op |
124
- | `style == null` | 750–752 | `element.style = ""` (clear) |
125
- | `typeof style !== "object"` | 753–755 | `element.style = style` (string passthrough) |
126
- | `old` missing/string, `style` object | 756–766 | Clear, then for each key: **`key.includes("-")` → `element.style.setProperty(key, String(value))`** (763); **else `element.style[key] = String(value)`** (764) |
127
- | Both objects | 767–786 | Remove stale keys first (772–777: `removeProperty` for dash-case, `= ""` for camelCase), then set changed keys (779–785: same dual-mode) |
128
-
129
- Key contract points:
130
- - **Dash-case keys** (`-` in the name) → `setProperty` / `removeProperty`; **camelCase keys** → direct `style[k]` assignment.
131
- - All values coerced with `String()` (763–764, 781).
132
- - Removal happens before setting (770–771) to avoid dash-case/camelCase aliasing bugs.
133
-
134
- ## g. Version & packaging
135
-
136
- - **Version**: `2.3.8` (package.json `version` field).
137
- - **No `main` / `module` field** in package.json (verified — only `unpkg`/`jsdelivr`/`repository`/`license`/`scripts`/`devDependencies`). The package is consumed by **file-path require**: `require("mithril/render/render")` resolves directly to `node_modules/mithril/render/render.js`.
138
- - Entry points: `index.js` (browser bundle), `render.js` (top-level re-export of `render/render.js`), `hyperscript.js` (re-export of `render/hyperscript.js` + `trust`/`fragment`).
139
- - 2.3.8 is the latest mithril release on npm (as of this scaffold's dependency pin).
140
-
141
- ---
142
-
143
- ## Reimplementation checklist (what a Lynx port must honor)
144
-
145
- 1. Factory `module.exports = function()` returning `function(dom, vnodes, redraw)` with per-instance `currentRedraw`/`currentRender`/`currentDOM` state.
146
- 2. Store prior vnodes on the target node (`dom.vnodes`); first render clears via `textContent = ""`.
147
- 3. Diff pipeline: trivial cases → keyed detection → unkeyed walk → keyed (tail/head/swaps/LIS) → leftover create/remove.
148
- 4. DOM surface: `createTextNode`, `createElement(NS)`, `createDocumentFragment`, `insertBefore`/`appendChild`, `removeChild`, `nodeValue`, `value`, `checked`, `selectedIndex`, `className`, `setAttribute`/`removeAttribute`/`setAttributeNS`, `style`, `innerHTML`, `textContent`, `firstChild`, `parentNode`, `ownerDocument`, `namespaceURI`, `contains`, `focus`. **No `getAttribute`, no `nodeType`.**
149
- 5. Events: `on*` keys → single `EventDict` object per element registered via `addEventListener`; `handleEvent` dispatches by `ev.type`, binds `this` to `ev.currentTarget`, auto-redraws, honors `return false`.
150
- 6. Attrs: `hasPropertyKey` gate (property vs attribute), `className`→`class` mapping, boolean attrs, `xlink:` namespace, `value`/`checked`/`selectedIndex` form-attribute exceptions.
151
- 7. Styles: dual-mode `updateStyle` — `setProperty` for `-` keys, `style[k] = v` for camelCase, `String()` coercion, remove-before-set.