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,67 @@
1
+ // src/mount-redraw.js
2
+ //
3
+ // A minimal version of real Mithril's `api/mount-redraw.js`: a shared
4
+ // singleton so `request.js` can trigger a redraw of whichever app is
5
+ // currently mounted, without needing a direct reference to that specific
6
+ // `renderApp()` call's handle. `background.js` registers its own
7
+ // `performRender` here right after creating it; `route.js` could too, but
8
+ // doesn't need to (it already redraws itself directly on every
9
+ // navigation) — this module exists specifically so `request.js` isn't
10
+ // coupled to "did this app mount via route() or a plain renderApp() call".
11
+ //
12
+ // Deliberately NOT a queue/pubsub of multiple mounted apps (real Mithril's
13
+ // mount-redraw.js supports that because a browser page can `m.mount()`
14
+ // several independent roots) — mithril-lynx-v2 has exactly one `renderApp()`
15
+ // for the app's whole lifetime (plan §3.1), so "the current redraw
16
+ // function" is a single slot, not a list.
17
+ //
18
+ // `redraw()` schedules instead of calling `currentRedraw()` inline — same
19
+ // reason real Mithril's version schedules through the platform's
20
+ // requestAnimationFrame instead of rendering synchronously: `request.js`'s
21
+ // own `promise.then(onSuccess)` calls this BEFORE the caller's own
22
+ // `.then()` runs (that callback is chained onto `request()`'s *returned*
23
+ // promise, one microtask hop further back) — a synchronous redraw here
24
+ // would render the screen one tick too early, before the caller has stored
25
+ // the response in its own state.
26
+ //
27
+ // DEVICE-CONFIRMED LYNX QUIRK (see FETCH_INVESTIGATION.md): unlike a spec
28
+ // browser, where a macrotask (rAF, setTimeout) is guaranteed to run only
29
+ // after every currently-queued microtask (including ones enqueued by other
30
+ // microtasks) has drained, on this Lynx background-thread runtime BOTH
31
+ // `lynx.setTimeout(fn, 0)` and `lynx.requestAnimationFrame(fn)` fire before
32
+ // even the FIRST pending microtask — confirmed with a Promise chain logging
33
+ // three chained `.then()`s against a 0ms/1ms/4ms/16ms timer and against
34
+ // `requestAnimationFrame`: the timer/rAF callback always logged first. A
35
+ // 50ms delay was the first value that reliably let a single `.then()`
36
+ // (the realistic caller shape: `request(url).then(cb)`) run first. There is
37
+ // no known Lynx primitive that defers "until microtasks finish" the way a
38
+ // spec-compliant macrotask does — this delay is an empirical safety margin,
39
+ // not a scheduling guarantee.
40
+ const REDRAW_DELAY_MS = 50;
41
+
42
+ let currentRedraw = null;
43
+ let pending = false;
44
+
45
+ function schedule(fn) {
46
+ const timer = typeof lynx !== "undefined" && typeof lynx.setTimeout === "function" ? lynx.setTimeout.bind(lynx) : setTimeout;
47
+ timer(fn, REDRAW_DELAY_MS);
48
+ }
49
+
50
+ export function register(redraw) {
51
+ currentRedraw = redraw;
52
+ }
53
+
54
+ /** What `request.js` calls after a non-background request resolves. A
55
+ * no-op before any app has mounted — a request kicked off before
56
+ * renderApp()/route() ran has nothing to redraw yet, which isn't
57
+ * necessarily a bug the way calling commit() before mounting is. Multiple
58
+ * calls within the delay window collapse into a single scheduled render,
59
+ * same debounce real Mithril's `redraw()` does with its `pending` flag. */
60
+ export function redraw() {
61
+ if (pending) return;
62
+ pending = true;
63
+ schedule(() => {
64
+ pending = false;
65
+ if (currentRedraw != null) currentRedraw();
66
+ });
67
+ }
@@ -0,0 +1,40 @@
1
+ // src/patch-protocol.js
2
+ //
3
+ // The wire vocabulary between the background thread (real Mithril diff,
4
+ // running against a virtual tree) and the main thread (applies the patch to
5
+ // real Lynx elements). Adopted from ReactLynx's `SnapshotOperation` pattern
6
+ // (see rspeedy-react-analysis/LYNX_PAPI_SPEC.md §4.3): a FLAT array of
7
+ // numbers/strings/values, not an array of `{op, ...}` objects — cheaper to
8
+ // serialize, and a pattern already proven in production at ReactLynx's scale.
9
+ //
10
+ // Every op is `[opcode, ...args]` concatenated into one flat array. `id`
11
+ // below always refers to the integer handle a node was given by
12
+ // `createVirtualBackend()` — the SAME id space is mirrored 1:1 on the
13
+ // main-thread side by `applyPatch()` (see backends/papi-backend.js), so
14
+ // nodes never need to be looked up by anything other than that integer.
15
+
16
+ export const Op = Object.freeze({
17
+ CreateElement: 0,
18
+ CreateElementNS: 1,
19
+ CreateText: 2,
20
+ CreateFragment: 3,
21
+ InsertBefore: 4, // parentId, childId, refId(-1 = append)
22
+ RemoveChild: 5, // parentId, childId
23
+ SetAttribute: 6, // id, name, value
24
+ RemoveAttribute: 7, // id, name
25
+ SetAttributeNS: 8, // id, ns, name, value
26
+ SetStyleProperty: 9, // id, name, value (dash-case, via setProperty semantics)
27
+ RemoveStyleProperty: 10, // id, name
28
+ SetText: 11, // id, value (nodeValue on a text node)
29
+ AddEvent: 12, // id, type
30
+ RemoveEvent: 13, // id, type
31
+ });
32
+
33
+ /**
34
+ * Encodes one op onto a flat ops array. Kept as a tiny helper (not a class)
35
+ * so the hot path (called on every attribute/child mutation during a real
36
+ * Mithril diff) is just array pushes — no object allocation per op.
37
+ */
38
+ export function pushOp(ops, opcode, ...args) {
39
+ ops.push(opcode, ...args);
40
+ }
@@ -0,0 +1,28 @@
1
+ // src/reload/version.js
2
+ //
3
+ // Race guard for concurrent hot-updates (method A/B, reload/hot-accept.js).
4
+ // v1 had this bug for real: two rebuilds landing close together made the
5
+ // dev client see a `module.hot.check()` still in flight and degrade to a
6
+ // full reload EVEN THOUGH each edit individually would have been light
7
+ // (mithril-lynx/.omo/plans/arquitectura-dual-reload.md, F1 "race de
8
+ // doble-build"). ReactLynx doesn't avoid the race with a status flag at
9
+ // all — it lets both builds' patches land in whatever order they arrive,
10
+ // and discards any patch whose `reloadVersion` is older than the current
11
+ // one (rspeedy-react-analysis/LYNX_PAPI_SPEC.md §5.1). This is that same
12
+ // counter, adopted directly rather than re-deriving a flag-based guard.
13
+
14
+ let version = 0;
15
+
16
+ export function getReloadVersion() {
17
+ return version;
18
+ }
19
+
20
+ export function increaseReloadVersion() {
21
+ return ++version;
22
+ }
23
+
24
+ /** True if a patch stamped with `patchVersion` is stale and must be
25
+ * dropped without being applied — the ENTIRE guard, one comparison. */
26
+ export function isStaleVersion(patchVersion) {
27
+ return typeof patchVersion === "number" && patchVersion < version;
28
+ }
@@ -0,0 +1,37 @@
1
+ export interface RequestOptions<T = any> {
2
+ method?: string;
3
+ url?: string;
4
+ params?: Record<string, unknown>;
5
+ body?: unknown;
6
+ headers?: Record<string, string>;
7
+ timeout?: number;
8
+ signal?: AbortSignal;
9
+ responseType?: "json" | "text";
10
+ serialize?: (data: unknown) => string;
11
+ deserialize?: (data: unknown) => unknown;
12
+ extract?: (response: unknown, options: RequestOptions<T>) => unknown;
13
+ type?: new (data: any) => T;
14
+ background?: boolean;
15
+ // Present on the real m.request signature but confirmed unsupported —
16
+ // listed here (rather than omitted) so passing one is a type error at
17
+ // the call site, not a surprise at runtime.
18
+ config?: never;
19
+ async?: never;
20
+ user?: never;
21
+ password?: never;
22
+ withCredentials?: never;
23
+ }
24
+
25
+ export interface RequestPromise<T> extends Promise<T> {
26
+ /** Not part of real m.request's API — free to add since Lynx's
27
+ * AbortController makes it a real, working cancellation, unlike the
28
+ * `config`-only escape hatch losing `config` takes away. */
29
+ abort(): void;
30
+ }
31
+
32
+ export type Request = <T = any>(url: string | RequestOptions<T>, options?: RequestOptions<T>) => RequestPromise<T>;
33
+
34
+ export function createRequestor(fetchImpl?: (url: string, init: RequestInit) => Promise<Response>): Request;
35
+
36
+ declare const request: Request;
37
+ export default request;
package/src/request.js ADDED
@@ -0,0 +1,181 @@
1
+ // src/request.js
2
+ //
3
+ // `m.request`-shaped wrapper over `lynx.fetch` — the subset confirmed
4
+ // faithful in FETCH_INVESTIGATION.md / .omo/plans/m-request-fetch-lynx.md.
5
+ // Every option below is either "works the same as real m.request" or
6
+ // throws immediately with a clear message pointing at the doc — never a
7
+ // silent behavior difference. See FETCH_INVESTIGATION.md §3 for the full
8
+ // option-by-option table this file implements.
9
+ //
10
+ // Explicitly NOT supported, by design, confirmed unfixable on Lynx:
11
+ // - `config(xhr)`: fetch has no live object to hand back.
12
+ // - `body` as `FormData`: confirmed absent at runtime (typeof FormData
13
+ // === "undefined"). `URLSearchParams` bodies DO work — confirmed on
14
+ // device — and need no special-casing here beyond not JSON-stringifying
15
+ // them.
16
+ // - `responseType: "blob"`/`"document"`: no `.blob()` on Lynx's `Body`;
17
+ // `"document"` has no meaning outside a browser DOM.
18
+ // - `user`/`password` (inline Basic Auth), `withCredentials`,
19
+ // `async: false`: XMLHttpRequest/browser-only concepts with no `fetch`
20
+ // equivalent, confirmed (withCredentials: no CORS model on Lynx;
21
+ // user/password: no `btoa` to build the Authorization header even if
22
+ // we wanted to guess one).
23
+
24
+ import buildPathname from "mithril-runtime/pathname/build.js";
25
+ import { redraw as sharedRedraw } from "./mount-redraw.js";
26
+
27
+ const UNSUPPORTED = [
28
+ ["config", (o) => o.config != null],
29
+ ["async: false", (o) => o.async === false],
30
+ ["user", (o) => typeof o.user === "string"],
31
+ ["password", (o) => typeof o.password === "string"],
32
+ ["withCredentials", (o) => o.withCredentials === true],
33
+ ];
34
+
35
+ function checkUnsupported(options) {
36
+ for (const [name, matches] of UNSUPPORTED) {
37
+ if (matches(options)) {
38
+ throw new Error(
39
+ `[mithril-lynx-v2/request] "${name}" is not supported — Lynx's fetch has no equivalent ` +
40
+ "(see FETCH_INVESTIGATION.md for exactly why). This throws instead of silently " +
41
+ "behaving differently from what you asked for.",
42
+ );
43
+ }
44
+ }
45
+ }
46
+
47
+ function hasHeader(headers, name) {
48
+ for (const key in headers) {
49
+ if (Object.prototype.hasOwnProperty.call(headers, key) && key.toLowerCase() === name) return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ function applyType(data, Type) {
55
+ if (typeof Type !== "function") return data;
56
+ if (Array.isArray(data)) return data.map((item) => new Type(item));
57
+ return new Type(data);
58
+ }
59
+
60
+ /**
61
+ * @param {(url: string, init: object) => Promise<Response>} [fetchImpl] -
62
+ * Defaults to `lynx.fetch`, looked up lazily (not at module load time,
63
+ * so importing this file never requires `lynx` to already exist) —
64
+ * same pattern route.js uses for `renderApp`. Tests inject a fake here
65
+ * instead of hitting a real network.
66
+ */
67
+ export function createRequestor(fetchImpl) {
68
+ const doFetch = fetchImpl || ((url, init) => lynx.fetch(url, init));
69
+
70
+ return function request(url, options) {
71
+ if (typeof url !== "string") {
72
+ options = url;
73
+ url = url.url;
74
+ } else if (options == null) {
75
+ options = {};
76
+ }
77
+ checkUnsupported(options);
78
+
79
+ if (typeof FormData !== "undefined" && options.body instanceof FormData) {
80
+ throw new Error(
81
+ "[mithril-lynx-v2/request] FormData bodies are not supported — Lynx has no FormData " +
82
+ "at runtime (confirmed absent, see FETCH_INVESTIGATION.md). Use lynx.fetch directly " +
83
+ "if you have another way to send this data, or restructure it as plain JSON.",
84
+ );
85
+ }
86
+
87
+ const method = options.method != null ? options.method.toUpperCase() : "GET";
88
+ const path = buildPathname(url, options.params);
89
+ const headers = Object.assign({}, options.headers);
90
+
91
+ let body;
92
+ if (options.body != null) {
93
+ if (typeof URLSearchParams !== "undefined" && options.body instanceof URLSearchParams) {
94
+ // Confirmed on device: fetch sets Content-Type automatically for
95
+ // this body type, exactly like a real browser.
96
+ body = options.body;
97
+ } else if (typeof options.serialize === "function") {
98
+ body = options.serialize(options.body);
99
+ } else {
100
+ body = JSON.stringify(options.body);
101
+ if (!hasHeader(headers, "content-type")) headers["Content-Type"] = "application/json; charset=utf-8";
102
+ }
103
+ }
104
+ if (typeof options.deserialize !== "function" && !hasHeader(headers, "accept")) {
105
+ headers["Accept"] = "application/json, text/*";
106
+ }
107
+
108
+ // Real cancellation/timeout — confirmed working on device (unlike
109
+ // what an earlier version of the investigation assumed): aborting
110
+ // actually tears down the in-flight connection, not just abandons
111
+ // the wait. `options.signal` (if given) is linked into our own
112
+ // controller so a caller-provided signal and our timeout can both
113
+ // trigger the same abort.
114
+ const ctrl = new AbortController();
115
+ if (options.signal) {
116
+ if (options.signal.aborted) ctrl.abort();
117
+ else options.signal.addEventListener("abort", () => ctrl.abort());
118
+ }
119
+ let timeoutId;
120
+ if (options.timeout) {
121
+ const schedule = typeof lynx !== "undefined" && typeof lynx.setTimeout === "function"
122
+ ? lynx.setTimeout.bind(lynx)
123
+ : setTimeout;
124
+ timeoutId = schedule(() => ctrl.abort(), options.timeout);
125
+ }
126
+ function clearRequestTimeout() {
127
+ if (timeoutId == null) return;
128
+ const clear = typeof lynx !== "undefined" && typeof lynx.clearTimeout === "function"
129
+ ? lynx.clearTimeout.bind(lynx)
130
+ : clearTimeout;
131
+ clear(timeoutId);
132
+ }
133
+
134
+ const responseType = options.responseType || (typeof options.extract === "function" ? "" : "json");
135
+
136
+ const promise = doFetch(path, { method, headers, body, signal: ctrl.signal }).then((response) => {
137
+ clearRequestTimeout();
138
+
139
+ if (typeof options.extract === "function") {
140
+ // Matches real m.request: extract() bypasses the status check
141
+ // entirely — it decides success/failure itself.
142
+ return options.extract(response, options);
143
+ }
144
+
145
+ const ok = response.ok || response.status === 304;
146
+ const bodyPromise = responseType === "text" ? response.text() : response.json();
147
+ return bodyPromise.then((data) => {
148
+ if (typeof options.deserialize === "function") data = options.deserialize(data);
149
+ if (!ok) {
150
+ const error = new Error(typeof data === "string" ? data : response.statusText);
151
+ error.code = response.status;
152
+ error.response = data;
153
+ throw error;
154
+ }
155
+ return applyType(data, options.type);
156
+ });
157
+ });
158
+
159
+ const result = promise.then(
160
+ (value) => {
161
+ if (options.background !== true) sharedRedraw();
162
+ return value;
163
+ },
164
+ (error) => {
165
+ clearRequestTimeout();
166
+ if (options.background !== true) sharedRedraw();
167
+ throw error;
168
+ },
169
+ );
170
+
171
+ // Not part of real m.request's API (there, you can only reach
172
+ // xhr.abort() through `config`) — free to add since we already have
173
+ // the controller, and it's exactly the escape hatch losing `config`
174
+ // takes away. Documented in FETCH_INVESTIGATION.md, not hidden.
175
+ result.abort = () => ctrl.abort();
176
+ return result;
177
+ };
178
+ }
179
+
180
+ const request = createRequestor();
181
+ export default request;
package/src/route.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { Component } from "mithril";
2
+
3
+ export interface RouteResolver {
4
+ onmatch?(args: Record<string, string>, requestedPath: string, route: string): unknown;
5
+ render?(vnode: unknown): unknown;
6
+ }
7
+
8
+ export interface RouteLinkAttrs {
9
+ href: string;
10
+ selector?: string;
11
+ params?: Record<string, unknown>;
12
+ options?: { replace?: boolean };
13
+ disabled?: boolean;
14
+ ontap?: (e: unknown) => unknown;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ export interface Route {
19
+ (defaultRoute: string, routes: Record<string, unknown | RouteResolver>): void;
20
+ set(path: string, data?: unknown, options?: { replace?: boolean }): void;
21
+ get(): string | undefined;
22
+ param(key?: string): unknown;
23
+ back(): void;
24
+ forward(): void;
25
+ prefix: string;
26
+ SKIP: unknown;
27
+ Link: Component<RouteLinkAttrs>;
28
+ }
29
+
30
+ export function createRoute(): Route;
31
+
32
+ declare const route: Route;
33
+ export default route;
package/src/route.js ADDED
@@ -0,0 +1,207 @@
1
+ // src/route.js
2
+ //
3
+ // In-memory `m.route`, replacing `window.history`/`popstate` with a plain
4
+ // array — the exact same pattern React Router's `MemoryRouter` and Vue
5
+ // Router's `createMemoryHistory()` use for Lynx (see
6
+ // .omo/plans/m-route-en-memoria.md §1–§3): both official framework
7
+ // integrations converge on "history lives in a JS array, not the browser",
8
+ // for the same reason we're doing it here — Lynx has no
9
+ // `window.location`/History API at all.
10
+ //
11
+ // Structured to mirror real Mithril's `api/router.js` as closely as
12
+ // possible (same variable names/flow for `resolveRoute`/`route.set`'s
13
+ // `hasBeenResolved` gate) so porting route-using app code only requires
14
+ // swapping the import, not relearning the control flow. The one
15
+ // unavoidable signature change: `m.route(root, defaultRoute, routes)`
16
+ // loses `root` — there is no DOM node to point it at in v2's architecture
17
+ // (a single `renderApp()` for the app's whole lifetime, plan §3.1) — see
18
+ // the plan §5.2 for why that's a deliberate, documented deviation rather
19
+ // than a fake DOM node just to keep the arg count.
20
+
21
+ import m from "mithril-runtime";
22
+ import buildPathname from "mithril-runtime/pathname/build.js";
23
+ import parsePathname from "mithril-runtime/pathname/parse.js";
24
+ import compileTemplate from "mithril-runtime/pathname/compileTemplate.js";
25
+ import { renderApp } from "./background.js";
26
+
27
+ export function createRoute() {
28
+ var compiled, fallbackRoute;
29
+ var component, attrs, currentPath, currentResolver;
30
+ var lastUpdate = null;
31
+ var ready = false;
32
+ var hasBeenResolved = false;
33
+ var app = null;
34
+
35
+ // The in-memory equivalent of the browser's session history: a plain
36
+ // stack of resolved paths. `route.set(path, data, {replace: true})`
37
+ // overwrites the top entry instead of pushing — same semantics as
38
+ // `history.replaceState` vs `history.pushState`, just without a
39
+ // browser underneath it.
40
+ var history = [];
41
+ var historyIndex = -1;
42
+
43
+ var RouterRoot = {
44
+ view() {
45
+ var vnode = component != null ? m(component, attrs) : null;
46
+ return currentResolver ? currentResolver.render(vnode) : vnode;
47
+ },
48
+ };
49
+
50
+ function resolveRoute(path, data) {
51
+ var parsed = parsePathname(path);
52
+ if (data) Object.assign(parsed.params, data);
53
+
54
+ function reject(e) {
55
+ if (typeof console !== "undefined") console.error(e);
56
+ route.set(fallbackRoute, null, { replace: true });
57
+ }
58
+
59
+ loop(0);
60
+ function loop(i) {
61
+ for (; i < compiled.length; i++) {
62
+ if (compiled[i].check(parsed)) {
63
+ var payload = compiled[i].payload;
64
+ var update = (lastUpdate = function (comp) {
65
+ if (update !== lastUpdate) return;
66
+ if (comp === route.SKIP) return loop(i + 1);
67
+ component = comp != null && (typeof comp.view === "function" || typeof comp === "function")
68
+ ? comp
69
+ : "view";
70
+ attrs = parsed.params;
71
+ currentPath = path;
72
+ lastUpdate = null;
73
+ currentResolver = payload.render ? payload : null;
74
+ if (hasBeenResolved) {
75
+ app.redraw();
76
+ } else {
77
+ hasBeenResolved = true;
78
+ app = renderApp({ root: () => m(RouterRoot) });
79
+ }
80
+ });
81
+ if (payload.view || typeof payload === "function") {
82
+ update(payload);
83
+ } else if (payload.onmatch) {
84
+ Promise.resolve()
85
+ .then(() => payload.onmatch(parsed.params, path, compiled[i].route))
86
+ .then(update, path === fallbackRoute ? undefined : reject);
87
+ } else {
88
+ update("view");
89
+ }
90
+ return;
91
+ }
92
+ }
93
+ if (path === fallbackRoute) {
94
+ throw new Error("Could not resolve default route " + fallbackRoute + ".");
95
+ }
96
+ route.set(fallbackRoute, null, { replace: true });
97
+ }
98
+ }
99
+
100
+ /**
101
+ * @param {string} defaultRoute - Both the fallback for an unmatched path
102
+ * AND the screen the app starts on — there is no browser URL to read
103
+ * an initial path from, so this is the one path v2 always starts at
104
+ * (the closest in-memory equivalent of React Router's
105
+ * `initialEntries={["/"]}`).
106
+ * @param {Record<string, unknown>} routes - Same shape as real
107
+ * `m.route`: `{ "/path/:param": Component | { onmatch, render } }`.
108
+ */
109
+ function route(defaultRoute, routes) {
110
+ compiled = Object.keys(routes).map((r) => {
111
+ if (r[0] !== "/") throw new SyntaxError("Routes must start with a '/'.");
112
+ return { route: r, payload: routes[r], check: compileTemplate(r) };
113
+ });
114
+ fallbackRoute = defaultRoute;
115
+ var defaultData = parsePathname(defaultRoute);
116
+ if (!compiled.some((entry) => entry.check(defaultData))) {
117
+ throw new ReferenceError("Default route doesn't match any known routes.");
118
+ }
119
+ history = [defaultRoute];
120
+ historyIndex = 0;
121
+ ready = true;
122
+ resolveRoute(defaultRoute, null);
123
+ }
124
+
125
+ route.SKIP = {};
126
+
127
+ route.set = function (path, data, options) {
128
+ if (lastUpdate != null) {
129
+ options = options || {};
130
+ options.replace = true;
131
+ }
132
+ lastUpdate = null;
133
+ path = buildPathname(path, data);
134
+ if (options && options.replace) {
135
+ history[Math.max(historyIndex, 0)] = path;
136
+ } else {
137
+ history = history.slice(0, historyIndex + 1);
138
+ history.push(path);
139
+ historyIndex = history.length - 1;
140
+ }
141
+ if (ready) resolveRoute(path, null);
142
+ };
143
+
144
+ route.get = () => currentPath;
145
+
146
+ route.param = (key) => (attrs && key != null ? attrs[key] : attrs);
147
+
148
+ // No URL bar in Lynx — kept as an assignable no-op so app code ported
149
+ // from a real Mithril app that defensively sets `m.route.prefix = ""`
150
+ // doesn't throw. It never affects anything here.
151
+ route.prefix = "";
152
+
153
+ /**
154
+ * `back()`/`forward()` walk the SAME in-memory stack `route.set` writes
155
+ * to — this is the "no native back button" answer from plan §5.7/F0:
156
+ * static analysis of the installed Lynx runtime found no hardware/
157
+ * gesture "back" event exposed to JS (only `onAppEnterBackground`,
158
+ * which is app-lifecycle, not navigation) — so an app's own explicit
159
+ * back affordance (a `route.Link`/button calling this) is the only
160
+ * way back navigation happens. Confirming this holds on a real device
161
+ * is F4/F5 of the plan, not done yet.
162
+ */
163
+ route.back = function () {
164
+ if (historyIndex <= 0) return;
165
+ historyIndex--;
166
+ resolveRoute(history[historyIndex], null);
167
+ };
168
+ route.forward = function () {
169
+ if (historyIndex >= history.length - 1) return;
170
+ historyIndex++;
171
+ resolveRoute(history[historyIndex], null);
172
+ };
173
+
174
+ // Lynx has no `<a>`/`onclick` — this renders a tap-driven element
175
+ // instead, the same shape ReactLynx's `useNavigate()+ontap` and Vue
176
+ // Lynx's custom `RouterLink` slot use (plan §1–§2, §5.4).
177
+ route.Link = {
178
+ view(vnode) {
179
+ var a = vnode.attrs;
180
+ var selector = a.selector || "view";
181
+ var rest = {};
182
+ for (var key in a) {
183
+ if (key !== "selector" && key !== "options" && key !== "params" && key !== "href" && key !== "ontap") {
184
+ rest[key] = a[key];
185
+ }
186
+ }
187
+ var disabled = Boolean(a.disabled);
188
+ rest.disabled = disabled;
189
+ if (!disabled) {
190
+ rest.ontap = function (e) {
191
+ var result;
192
+ if (typeof a.ontap === "function") result = a.ontap.call(e.currentTarget, e);
193
+ if (result !== false) {
194
+ e.redraw = false;
195
+ route.set(buildPathname(a.href, a.params), null, a.options);
196
+ }
197
+ };
198
+ }
199
+ return m(selector, rest, vnode.children);
200
+ },
201
+ };
202
+
203
+ return route;
204
+ }
205
+
206
+ const route = createRoute();
207
+ export default route;
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import m from "mithril";
3
+ import { renderApp } from "../src/background.js";
4
+ import { createPatchApplier } from "../src/apply-patch.js";
5
+
6
+ // Full round trip: background renders real Mithril against the virtual
7
+ // backend, the resulting ops are replayed onto REAL Lynx PAPI elements
8
+ // (via @lynx-js/testing-environment, not a mock), a simulated tap fires on
9
+ // the real element, is forwarded back to the background thread's fake-dom
10
+ // node, and Mithril's OWN automatic redraw-on-event (no explicit redraw()
11
+ // call anywhere in the view below) produces a second patch that updates
12
+ // the real tree again.
13
+ //
14
+ // This is the direct replacement for mithril-lynx v1's
15
+ // `test/renderer-integration.test.ts` — same intent, rewritten for the v2
16
+ // architecture (see mithril-lynx-v2-desde-cero.md §F1's acceptance
17
+ // criterion: this must pass from the first commit, never be fixed later).
18
+
19
+ function storedListeners(node: any, type: string): Set<(...args: any[]) => unknown> {
20
+ return node?.__vanillaListeners?.[type] ?? new Set();
21
+ }
22
+
23
+ describe("background -> apply-patch end-to-end (real PAPI, via @lynx-js/testing-environment)", () => {
24
+ it("auto-redraws after a tap with NO explicit redraw() call anywhere in the view", () => {
25
+ lynxTestingEnv.switchToMainThread();
26
+
27
+ const pageId = __GetElementUniqueID(__CreatePage());
28
+ const applier = createPatchApplier(pageId);
29
+ // id 0 (the fake-dom document root) maps to a real container the
30
+ // applier creates itself, exactly like the page's own root view.
31
+ applier.registerPageRoot(__CreateView(pageId));
32
+
33
+ let count = 0;
34
+ function root() {
35
+ return m("view", { class: "page" }, [
36
+ m(
37
+ "view",
38
+ {
39
+ class: "button",
40
+ ontap: () => {
41
+ // No redraw()/m.redraw() call here on purpose — the
42
+ // whole point of this test is that Mithril's own
43
+ // EventDict auto-redraw (CONTRACT.md §e) is what
44
+ // makes this repaint, with zero cooperation from
45
+ // app code.
46
+ count += 1;
47
+ },
48
+ },
49
+ [m("text", null, "Tap")],
50
+ ),
51
+ m("text", { class: "counter" }, String(count)),
52
+ ]);
53
+ }
54
+
55
+ lynxTestingEnv.switchToBackgroundThread();
56
+ let lastOps: unknown[] | null = null;
57
+ const app = renderApp({
58
+ root,
59
+ sendPatch: (ops) => {
60
+ lastOps = ops;
61
+ },
62
+ });
63
+ expect(lastOps).not.toBeNull();
64
+
65
+ lynxTestingEnv.switchToMainThread();
66
+ applier.applyPatch(lastOps as unknown[]);
67
+
68
+ // The decisive assertion for "auto-redraw fired": a second
69
+ // `sendPatch` call happens purely from the tap (dispatched below),
70
+ // with no explicit redraw() anywhere in `root()`'s view code.
71
+ lastOps = null;
72
+
73
+ lynxTestingEnv.switchToBackgroundThread();
74
+ // Forward the tap the same way main-thread -> background forwarding
75
+ // will in the real channel (F2/F3): find the fake-dom node for the
76
+ // button and dispatch a synthetic tap on it directly. The id is
77
+ // deterministic here (root=0 is the document; the button is the
78
+ // second element created — first is the page `view`, second the
79
+ // button `view`), matching virtual-backend.js's sequential id
80
+ // allocation starting at 1.
81
+ app.document.getNodeById(2)!.dispatchEvent({ type: "tap", currentTarget: app.document.getNodeById(2) });
82
+
83
+ expect(count).toBe(1);
84
+ expect(lastOps).not.toBeNull();
85
+ });
86
+ });