mithril-lynx 0.0.8 → 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 -284
  6. package/REQUEST.md +71 -0
  7. package/ROUTE.md +71 -0
  8. package/package.json +24 -80
  9. package/plugin.d.ts +4 -27
  10. package/plugin.js +142 -359
  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 +245 -0
  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,17 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import { getReloadVersion, increaseReloadVersion, isStaleVersion } from "../src/reload/version.js";
3
+
4
+ describe("reload version guard (replaces v1's hotStatus race-prone flag)", () => {
5
+ it("discards a patch stamped with an older version than the current one", () => {
6
+ const v1 = getReloadVersion();
7
+ expect(isStaleVersion(v1)).toBe(false); // not stale yet, nothing bumped
8
+
9
+ increaseReloadVersion();
10
+ // A patch built before this reload (stamped with the old version)
11
+ // must now be treated as stale — this is the exact scenario of the
12
+ // v1 "race de doble-build": two builds in flight, the older one's
13
+ // patch must lose without needing a status flag at all.
14
+ expect(isStaleVersion(v1)).toBe(true);
15
+ expect(isStaleVersion(getReloadVersion())).toBe(false);
16
+ });
17
+ });
@@ -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
+ });