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.
- package/.omo/plans/m-request-fetch-lynx.md +306 -0
- package/.omo/plans/m-route-en-memoria.md +397 -0
- package/.omo/plans/mithril-lynx-v2-desde-cero.md +548 -0
- package/FETCH_INVESTIGATION.md +307 -0
- package/README.md +32 -284
- package/REQUEST.md +71 -0
- package/ROUTE.md +71 -0
- package/package.json +24 -80
- package/plugin.d.ts +4 -27
- package/plugin.js +142 -359
- package/rstest.config.ts +27 -0
- package/src/apply-patch.js +179 -0
- package/src/backends/virtual-backend.js +80 -0
- package/src/background.d.ts +11 -0
- package/src/background.js +79 -0
- package/src/channel.js +41 -0
- package/src/commit.js +67 -0
- package/src/dev-reload-client.js +245 -0
- package/src/dev-transport-noop.js +10 -0
- package/src/fake-dom.js +374 -0
- package/src/main-thread.d.ts +1 -0
- package/src/main-thread.js +68 -0
- package/src/mount-redraw.js +67 -0
- package/src/patch-protocol.js +40 -0
- package/src/reload/version.js +28 -0
- package/src/request.d.ts +37 -0
- package/src/request.js +181 -0
- package/src/route.d.ts +33 -0
- package/src/route.js +207 -0
- package/test/end-to-end.test.ts +86 -0
- package/test/reload-version.test.ts +17 -0
- package/test/request.test.ts +182 -0
- package/test/route-hot-reload.test.ts +40 -0
- package/test/route.test.ts +152 -0
- package/test/setup.ts +25 -0
- package/test/structural-reload.test.ts +95 -0
- package/CONTRACT.md +0 -151
- package/LICENSE +0 -21
- package/background.d.ts +0 -54
- package/background.js +0 -169
- package/element.d.ts +0 -34
- package/element.js +0 -83
- package/gesture.d.ts +0 -40
- package/gesture.js +0 -117
- package/internal/constants.js +0 -26
- package/internal/virtual-node.js +0 -388
- package/list.d.ts +0 -31
- package/list.js +0 -185
- package/main-thread.d.ts +0 -43
- package/main-thread.js +0 -165
- package/navigation.d.ts +0 -35
- package/navigation.js +0 -76
- package/renderer/background.d.ts +0 -21
- package/renderer/background.js +0 -84
- package/renderer/main-thread.d.ts +0 -12
- package/renderer/main-thread.js +0 -175
- package/src/lynx-mithril-shim.d.ts +0 -16
- package/src/lynx-mithril-shim.js +0 -1505
- package/src/worklet-runtime.js +0 -82
- package/testing.d.ts +0 -10
- 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
|
+
});
|