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,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
|
+
}
|
package/src/request.d.ts
ADDED
|
@@ -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
|
+
});
|