mithril-lynx 0.0.9 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -302
- package/REQUEST.md +71 -0
- package/ROUTE.md +71 -0
- package/package.json +24 -80
- package/plugin.d.ts +4 -33
- package/plugin.js +108 -438
- 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 +171 -187
- 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
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
|
+
});
|
|
@@ -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
|
+
});
|