mithril-lynx 2.6.0 → 3.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/CHANGELOG.md +50 -0
- package/FETCH_INVESTIGATION.md +3 -2
- package/INPUT.md +115 -0
- package/LICENSE.txt +21 -0
- package/PLAN_REVIEW.md +134 -0
- package/README.md +47 -7
- package/REQUEST.md +5 -2
- package/ROUTE.md +111 -16
- package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
- package/UPDATE_ANIMATION_GAP.md +133 -0
- package/informe-contrato-mithril-lynx.md +206 -0
- package/package.json +1 -9
- package/plugin.js +33 -0
- package/prompts-analisis-contrato-mithril-lynx.md +141 -0
- package/src/apply-patch.js +408 -68
- package/src/backends/virtual-backend.js +120 -22
- package/src/background.d.ts +40 -1
- package/src/background.js +116 -7
- package/src/channel.js +32 -7
- package/src/commit.js +9 -0
- package/src/dev-reload-client.js +46 -0
- package/src/dev-transport-noop.js +3 -0
- package/src/fake-dom.js +559 -61
- package/src/list-attributes.js +69 -0
- package/src/list-runtime.js +425 -0
- package/src/main-thread.js +33 -3
- package/src/mount-redraw.d.ts +10 -0
- package/src/mount-redraw.js +69 -3
- package/src/patch-protocol.js +52 -13
- package/src/reload/version.js +10 -1
- package/src/request.d.ts +2 -0
- package/src/request.js +59 -3
- package/src/route.d.ts +52 -5
- package/src/route.js +236 -59
- package/src/testing.d.ts +14 -1
- package/src/testing.js +79 -11
- package/test/event-render-coalescing.test.ts +117 -0
- package/test/gesture.test.ts +75 -0
- package/test/input.test.ts +388 -0
- package/test/list.test.ts +422 -100
- package/test/patch-protocol.test.ts +41 -0
- package/test/remove-event.test.ts +26 -0
- package/test/remove-subtree-cleanup.test.ts +75 -0
- package/test/route-hot-reload.test.ts +4 -2
- package/test/route.test.ts +206 -6
- package/test/setup.ts +10 -0
- package/test/style-clear.test.ts +97 -0
- package/.omo/plans/m-request-fetch-lynx.md +0 -306
- package/.omo/plans/m-route-en-memoria.md +0 -397
- package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
- package/.omo/run-continuation/ses_f48265d07ffenKG0GAz9ZRFvY6.json +0 -10
- package/src/list-cell.d.ts +0 -20
- package/src/list-cell.js +0 -73
- package/src/list-support.d.ts +0 -13
- package/src/list-support.js +0 -171
package/src/route.js
CHANGED
|
@@ -1,62 +1,97 @@
|
|
|
1
1
|
// src/route.js
|
|
2
2
|
//
|
|
3
3
|
// In-memory `m.route`, replacing `window.history`/`popstate` with a plain
|
|
4
|
-
// array — the
|
|
5
|
-
// Router's `createMemoryHistory()`
|
|
6
|
-
// .
|
|
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.
|
|
4
|
+
// array — the same pattern ReactLynx's documented routers use (React
|
|
5
|
+
// Router's `MemoryRouter`, TanStack Router's `createMemoryHistory()`):
|
|
6
|
+
// Lynx has no `window.location`/History API, so history lives in a JS array.
|
|
10
7
|
//
|
|
11
|
-
// Structured to mirror real Mithril's `api/router.js` as closely as
|
|
12
|
-
// possible
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
8
|
+
// Structured to mirror real Mithril's `api/router.js` (2.3.8) as closely as
|
|
9
|
+
// possible — same resolution flow (`resolveRoute`/`loop`/`lastUpdate`),
|
|
10
|
+
// resolution deferred after `route.set()` like upstream's `fireAsync`, the
|
|
11
|
+
// matched component wrapped in a keyed fragment, `options.state` merged into
|
|
12
|
+
// params, and `route.Link` censoring lifecycle hooks — so porting route-using
|
|
13
|
+
// app code only requires swapping the import. See ROUTE_CONTRACT_ANALYSIS.md
|
|
14
|
+
// for the full parity table. Deliberate differences: no `root` argument (one
|
|
15
|
+
// `renderApp()` for the app's whole lifetime), a required `defaultRoute`
|
|
16
|
+
// (there is no URL to start from), `back()`/`forward()`/`canGoBack()`, and
|
|
17
|
+
// the opt-in Android back button bridge `listenBackButton()`.
|
|
20
18
|
|
|
21
19
|
import m from "mithril-runtime";
|
|
22
20
|
import buildPathname from "mithril-runtime/pathname/build.js";
|
|
23
21
|
import parsePathname from "mithril-runtime/pathname/parse.js";
|
|
24
22
|
import compileTemplate from "mithril-runtime/pathname/compileTemplate.js";
|
|
23
|
+
import censor from "mithril-runtime/util/censor.js";
|
|
24
|
+
import decodeURIComponentSafe from "mithril-runtime/util/decodeURIComponentSafe.js";
|
|
25
25
|
import { renderApp } from "./background.js";
|
|
26
26
|
|
|
27
|
+
/** Default global event name for {@link createRoute}'s `listenBackButton`. */
|
|
28
|
+
export const BACK_EVENT_NAME = "mithrilLynx:back";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates an in-memory router with the `m.route` API.
|
|
32
|
+
* @returns {Function & {SKIP: Object, set: Function, get: Function, param: Function, prefix: string, back: Function, forward: Function, canGoBack: Function, listenBackButton: Function, Link: Object}} The `route` function.
|
|
33
|
+
*/
|
|
27
34
|
export function createRoute() {
|
|
28
35
|
var compiled, fallbackRoute;
|
|
29
36
|
var component, attrs, currentPath, currentResolver;
|
|
30
37
|
var lastUpdate = null;
|
|
31
38
|
var ready = false;
|
|
32
39
|
var hasBeenResolved = false;
|
|
40
|
+
var scheduled = false;
|
|
33
41
|
var app = null;
|
|
34
42
|
|
|
35
|
-
// The in-memory equivalent of the browser's session history:
|
|
36
|
-
//
|
|
37
|
-
// overwrites the
|
|
38
|
-
// `history.replaceState` vs `history.pushState
|
|
39
|
-
// browser underneath it.
|
|
43
|
+
// The in-memory equivalent of the browser's session history: one
|
|
44
|
+
// `{ path, state }` entry per navigation. `route.set(path, data,
|
|
45
|
+
// {replace: true})` overwrites the current entry instead of pushing —
|
|
46
|
+
// same semantics as `history.replaceState` vs `history.pushState`.
|
|
40
47
|
var history = [];
|
|
41
48
|
var historyIndex = -1;
|
|
42
49
|
|
|
50
|
+
// listenBackButton() subscribers, told whenever canGoBack() changes.
|
|
51
|
+
var backWatchers = new Set();
|
|
52
|
+
|
|
43
53
|
var RouterRoot = {
|
|
54
|
+
/**
|
|
55
|
+
* Renders the current route's component, through its resolver's
|
|
56
|
+
* `render` when present. Like upstream, the component is wrapped in a
|
|
57
|
+
* fragment so its `key` (a `:key` route param) is honored: a new key
|
|
58
|
+
* remounts the page.
|
|
59
|
+
* @returns {*} The vnode(s) to render.
|
|
60
|
+
*/
|
|
44
61
|
view() {
|
|
45
|
-
var vnode =
|
|
46
|
-
|
|
62
|
+
var vnode = m(component, attrs);
|
|
63
|
+
if (currentResolver) return currentResolver.render(vnode);
|
|
64
|
+
return [vnode];
|
|
47
65
|
},
|
|
48
66
|
};
|
|
49
67
|
|
|
50
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Finds the route matching `path` and renders it, starting the app on the first resolution.
|
|
70
|
+
* @param {string} path - The path to resolve (as stored in history: encoded).
|
|
71
|
+
* @param {Object|null} state - The entry's state, merged into the parsed params.
|
|
72
|
+
* @returns {void}
|
|
73
|
+
* @throws {Error} If the default route cannot be resolved.
|
|
74
|
+
*/
|
|
75
|
+
function resolveRoute(path, state) {
|
|
51
76
|
var parsed = parsePathname(path);
|
|
52
|
-
if (
|
|
77
|
+
if (state) Object.assign(parsed.params, state);
|
|
53
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Logs a failed `onmatch` and navigates to the default route.
|
|
81
|
+
* @param {*} e - The error to log.
|
|
82
|
+
* @returns {void}
|
|
83
|
+
*/
|
|
54
84
|
function reject(e) {
|
|
55
85
|
if (typeof console !== "undefined") console.error(e);
|
|
56
86
|
route.set(fallbackRoute, null, { replace: true });
|
|
57
87
|
}
|
|
58
88
|
|
|
59
89
|
loop(0);
|
|
90
|
+
/**
|
|
91
|
+
* Tries each compiled route from `i` onward.
|
|
92
|
+
* @param {number} i - The index to start from.
|
|
93
|
+
* @returns {void}
|
|
94
|
+
*/
|
|
60
95
|
function loop(i) {
|
|
61
96
|
for (; i < compiled.length; i++) {
|
|
62
97
|
if (compiled[i].check(parsed)) {
|
|
@@ -68,7 +103,7 @@ export function createRoute() {
|
|
|
68
103
|
? comp
|
|
69
104
|
: "view";
|
|
70
105
|
attrs = parsed.params;
|
|
71
|
-
currentPath = path;
|
|
106
|
+
currentPath = decodeURIComponentSafe(path);
|
|
72
107
|
lastUpdate = null;
|
|
73
108
|
currentResolver = payload.render ? payload : null;
|
|
74
109
|
if (hasBeenResolved) {
|
|
@@ -97,18 +132,66 @@ export function createRoute() {
|
|
|
97
132
|
}
|
|
98
133
|
}
|
|
99
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Resolves the current history entry on the next microtask, once — like
|
|
137
|
+
* upstream's `fireAsync`. Several navigations in one tick resolve only
|
|
138
|
+
* the last one, and a navigation started during a render (a redirect in
|
|
139
|
+
* a page's `oninit`) runs after that render instead of re-entering it.
|
|
140
|
+
* @returns {void}
|
|
141
|
+
*/
|
|
142
|
+
function scheduleResolve() {
|
|
143
|
+
if (scheduled) return;
|
|
144
|
+
scheduled = true;
|
|
145
|
+
Promise.resolve().then(() => {
|
|
146
|
+
scheduled = false;
|
|
147
|
+
var entry = history[historyIndex];
|
|
148
|
+
resolveRoute(entry.path, entry.state);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Tells every listenBackButton() subscriber the current canGoBack()
|
|
154
|
+
* value, if it changed for them. A throwing callback (e.g. a missing
|
|
155
|
+
* native module) is logged and never breaks navigation.
|
|
156
|
+
* @returns {void}
|
|
157
|
+
*/
|
|
158
|
+
function notifyBackWatchers() {
|
|
159
|
+
var can = route.canGoBack();
|
|
160
|
+
for (var watcher of backWatchers) {
|
|
161
|
+
if (watcher.last === can) continue;
|
|
162
|
+
watcher.last = can;
|
|
163
|
+
try {
|
|
164
|
+
watcher.onChange(can);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (typeof console !== "undefined") console.error("[mithril-lynx] route: onCanGoBackChange threw:", e);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
100
171
|
/**
|
|
101
172
|
* @param {string} defaultRoute - Both the fallback for an unmatched path
|
|
102
173
|
* AND the screen the app starts on — there is no browser URL to read
|
|
103
174
|
* an initial path from, so this is the one path the app always starts at
|
|
104
175
|
* (the closest in-memory equivalent of React Router's
|
|
105
|
-
* `initialEntries={["/"]}`).
|
|
176
|
+
* `initialEntries={["/"]}`). Required, unlike upstream.
|
|
106
177
|
* @param {Record<string, unknown>} routes - Same shape as real
|
|
107
178
|
* `m.route`: `{ "/path/:param": Component | { onmatch, render } }`.
|
|
179
|
+
* @returns {void}
|
|
180
|
+
* @throws {TypeError} If `defaultRoute` is missing.
|
|
181
|
+
* @throws {SyntaxError} If a route does not start with `/`, or has two params not separated by `/`, `.` or `-`.
|
|
182
|
+
* @throws {ReferenceError} If the default route matches no known route.
|
|
108
183
|
*/
|
|
109
184
|
function route(defaultRoute, routes) {
|
|
185
|
+
if (typeof defaultRoute !== "string") {
|
|
186
|
+
throw new TypeError(
|
|
187
|
+
"[mithril-lynx] route(defaultRoute, routes): defaultRoute is required — Lynx has no URL to start from.",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
110
190
|
compiled = Object.keys(routes).map((r) => {
|
|
111
191
|
if (r[0] !== "/") throw new SyntaxError("Routes must start with a '/'.");
|
|
192
|
+
if (/:([^\/\.-]+)(\.{3})?:/.test(r)) {
|
|
193
|
+
throw new SyntaxError("Route parameter names must be separated with either '/', '.', or '-'.");
|
|
194
|
+
}
|
|
112
195
|
return { route: r, payload: routes[r], check: compileTemplate(r) };
|
|
113
196
|
});
|
|
114
197
|
fallbackRoute = defaultRoute;
|
|
@@ -116,33 +199,71 @@ export function createRoute() {
|
|
|
116
199
|
if (!compiled.some((entry) => entry.check(defaultData))) {
|
|
117
200
|
throw new ReferenceError("Default route doesn't match any known routes.");
|
|
118
201
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
202
|
+
if (!ready) {
|
|
203
|
+
history = [{ path: defaultRoute, state: null }];
|
|
204
|
+
historyIndex = 0;
|
|
205
|
+
ready = true;
|
|
206
|
+
resolveRoute(defaultRoute, null);
|
|
207
|
+
notifyBackWatchers();
|
|
208
|
+
} else {
|
|
209
|
+
// Re-registration (e.g. HMR of the route module): keep the history
|
|
210
|
+
// stack and re-resolve the CURRENT entry against the new table —
|
|
211
|
+
// the same thing the documented HMR pattern does by hand with
|
|
212
|
+
// route.set(route.get(), null, {replace: true}). Resolving
|
|
213
|
+
// defaultRoute here instead would silently jump the screen back to
|
|
214
|
+
// the initial route and discard the user's back/forward stack.
|
|
215
|
+
var entry = history[historyIndex];
|
|
216
|
+
resolveRoute(entry.path, entry.state);
|
|
217
|
+
}
|
|
123
218
|
}
|
|
124
219
|
|
|
125
220
|
route.SKIP = {};
|
|
126
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Navigates to a path, pushing (or, with `replace`, overwriting) a
|
|
224
|
+
* history entry. The history changes right away; the new screen is
|
|
225
|
+
* resolved on the next microtask, like upstream — so `route.get()` still
|
|
226
|
+
* returns the previous path until then.
|
|
227
|
+
* @param {string} path - The path or template to navigate to.
|
|
228
|
+
* @param {Object|null} [data] - Params to fill the path template with (extra keys become the query string).
|
|
229
|
+
* @param {{replace?: boolean, state?: Object, title?: string}} [options] - Navigation options. `state` is merged into the route's params and restored by back()/forward(); `title` is accepted and ignored.
|
|
230
|
+
* @returns {void}
|
|
231
|
+
* @throws {Error} If called before `route(defaultRoute, routes)`.
|
|
232
|
+
*/
|
|
127
233
|
route.set = function (path, data, options) {
|
|
234
|
+
if (!ready) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
"[mithril-lynx] route.set() called before route(defaultRoute, routes) — " +
|
|
237
|
+
"the route table does not exist yet, so this navigation would be " +
|
|
238
|
+
"silently dropped. Call route() first.",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
128
241
|
if (lastUpdate != null) {
|
|
129
242
|
options = options || {};
|
|
130
243
|
options.replace = true;
|
|
131
244
|
}
|
|
132
245
|
lastUpdate = null;
|
|
133
|
-
|
|
246
|
+
var entry = { path: buildPathname(path, data), state: (options && options.state) || null };
|
|
134
247
|
if (options && options.replace) {
|
|
135
|
-
history[Math.max(historyIndex, 0)] =
|
|
248
|
+
history[Math.max(historyIndex, 0)] = entry;
|
|
136
249
|
} else {
|
|
137
250
|
history = history.slice(0, historyIndex + 1);
|
|
138
|
-
history.push(
|
|
251
|
+
history.push(entry);
|
|
139
252
|
historyIndex = history.length - 1;
|
|
140
253
|
}
|
|
141
|
-
|
|
254
|
+
scheduleResolve();
|
|
255
|
+
notifyBackWatchers();
|
|
142
256
|
};
|
|
143
257
|
|
|
258
|
+
/**
|
|
259
|
+
* @returns {string|undefined} The current (decoded) path, or `undefined` before the first resolution.
|
|
260
|
+
*/
|
|
144
261
|
route.get = () => currentPath;
|
|
145
262
|
|
|
263
|
+
/**
|
|
264
|
+
* @param {string} [key] - A param name.
|
|
265
|
+
* @returns {*} That param's value, or all params when `key` is omitted.
|
|
266
|
+
*/
|
|
146
267
|
route.param = (key) => (attrs && key != null ? attrs[key] : attrs);
|
|
147
268
|
|
|
148
269
|
// No URL bar in Lynx — kept as an assignable no-op so app code ported
|
|
@@ -151,52 +272,108 @@ export function createRoute() {
|
|
|
151
272
|
route.prefix = "";
|
|
152
273
|
|
|
153
274
|
/**
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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.
|
|
275
|
+
* Moves one entry back in the in-memory history. Not part of `m.route`:
|
|
276
|
+
* there is no browser back button — wire an in-app back affordance to
|
|
277
|
+
* this, and the Android back button through listenBackButton().
|
|
278
|
+
* @returns {boolean} `true` when the history moved back, `false` when already at the start.
|
|
162
279
|
*/
|
|
163
280
|
route.back = function () {
|
|
164
|
-
if (historyIndex <= 0) return;
|
|
281
|
+
if (historyIndex <= 0) return false;
|
|
165
282
|
historyIndex--;
|
|
166
|
-
|
|
283
|
+
scheduleResolve();
|
|
284
|
+
notifyBackWatchers();
|
|
285
|
+
return true;
|
|
167
286
|
};
|
|
287
|
+
/**
|
|
288
|
+
* Moves one entry forward in the in-memory history.
|
|
289
|
+
* @returns {boolean} `true` when the history moved forward, `false` when already at the end.
|
|
290
|
+
*/
|
|
168
291
|
route.forward = function () {
|
|
169
|
-
if (historyIndex >= history.length - 1) return;
|
|
292
|
+
if (historyIndex >= history.length - 1) return false;
|
|
170
293
|
historyIndex++;
|
|
171
|
-
|
|
294
|
+
scheduleResolve();
|
|
295
|
+
notifyBackWatchers();
|
|
296
|
+
return true;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @returns {boolean} Whether back() would navigate (there is an earlier entry).
|
|
301
|
+
*/
|
|
302
|
+
route.canGoBack = () => historyIndex > 0;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Opt-in bridge for the Android back button. The host sends a global
|
|
306
|
+
* event (`LynxView.sendGlobalEvent(eventName, ...)`) when back is pressed
|
|
307
|
+
* and its OnBackPressedCallback is enabled; this calls `route.back()`.
|
|
308
|
+
* `onCanGoBackChange` is called with the current canGoBack() right away
|
|
309
|
+
* and whenever it changes, so the host can enable its callback only while
|
|
310
|
+
* there is history — with none, Android's default (closing the app)
|
|
311
|
+
* applies. Nothing here blocks: without a host that sends the event, or
|
|
312
|
+
* without GlobalEventEmitter, it does nothing. See ROUTE.md for the
|
|
313
|
+
* Android host side.
|
|
314
|
+
* @param {{eventName?: string, onCanGoBackChange?: (canGoBack: boolean) => void}} [options] - The global event name (default `"mithrilLynx:back"`) and the host notification callback.
|
|
315
|
+
* @returns {() => void} Stops listening.
|
|
316
|
+
*/
|
|
317
|
+
route.listenBackButton = function (options) {
|
|
318
|
+
var eventName = (options && options.eventName) || BACK_EVENT_NAME;
|
|
319
|
+
var onChange = options && options.onCanGoBackChange;
|
|
320
|
+
var emitter = null;
|
|
321
|
+
try {
|
|
322
|
+
emitter = typeof lynx !== "undefined" && typeof lynx.getJSModule === "function" ? lynx.getJSModule("GlobalEventEmitter") : null;
|
|
323
|
+
} catch (e) {
|
|
324
|
+
emitter = null;
|
|
325
|
+
}
|
|
326
|
+
if (emitter == null || typeof emitter.addListener !== "function") {
|
|
327
|
+
if (typeof console !== "undefined") {
|
|
328
|
+
console.warn("[mithril-lynx] route.listenBackButton(): GlobalEventEmitter is not available — back button events will not be received.");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
var onBack = () => {
|
|
332
|
+
route.back();
|
|
333
|
+
};
|
|
334
|
+
if (emitter != null && typeof emitter.addListener === "function") emitter.addListener(eventName, onBack);
|
|
335
|
+
var watcher = null;
|
|
336
|
+
if (typeof onChange === "function") {
|
|
337
|
+
watcher = { onChange, last: undefined };
|
|
338
|
+
backWatchers.add(watcher);
|
|
339
|
+
if (ready) notifyBackWatchers();
|
|
340
|
+
}
|
|
341
|
+
return function stop() {
|
|
342
|
+
if (emitter != null && typeof emitter.removeListener === "function") emitter.removeListener(eventName, onBack);
|
|
343
|
+
if (watcher != null) backWatchers.delete(watcher);
|
|
344
|
+
};
|
|
172
345
|
};
|
|
173
346
|
|
|
174
347
|
// Lynx has no `<a>`/`onclick` — this renders a tap-driven element
|
|
175
|
-
// instead, the same shape ReactLynx's `useNavigate()+
|
|
176
|
-
// Lynx's custom `RouterLink` slot use (plan §1–§2, §5.4).
|
|
348
|
+
// instead, the same shape ReactLynx's `useNavigate()+bindtap` pattern uses.
|
|
177
349
|
route.Link = {
|
|
350
|
+
/**
|
|
351
|
+
* Renders a tap-driven element that navigates to `href`. Like
|
|
352
|
+
* upstream, `key` and lifecycle hooks stay on the Link component and
|
|
353
|
+
* are not copied onto the rendered element (they would run twice).
|
|
354
|
+
* @param {{attrs: {href: string, params?: Object, options?: Object, selector?: string, disabled?: boolean, ontap?: Function|{handleEvent: Function}}, children: *}} vnode - The Mithril vnode.
|
|
355
|
+
* @returns {*} The rendered vnode.
|
|
356
|
+
*/
|
|
178
357
|
view(vnode) {
|
|
179
358
|
var a = vnode.attrs;
|
|
180
|
-
var
|
|
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
|
-
}
|
|
359
|
+
var rest = censor(a, ["selector", "options", "params", "href", "ontap"]);
|
|
187
360
|
var disabled = Boolean(a.disabled);
|
|
188
361
|
rest.disabled = disabled;
|
|
189
362
|
if (!disabled) {
|
|
363
|
+
var ontap = a.ontap;
|
|
364
|
+
var href = buildPathname(a.href, a.params);
|
|
365
|
+
var options = a.options;
|
|
190
366
|
rest.ontap = function (e) {
|
|
191
367
|
var result;
|
|
192
|
-
if (typeof
|
|
193
|
-
if (
|
|
368
|
+
if (typeof ontap === "function") result = ontap.call(e.currentTarget, e);
|
|
369
|
+
else if (ontap != null && typeof ontap === "object" && typeof ontap.handleEvent === "function") ontap.handleEvent(e);
|
|
370
|
+
if (result !== false && !e.defaultPrevented) {
|
|
194
371
|
e.redraw = false;
|
|
195
|
-
route.set(
|
|
372
|
+
route.set(href, null, options);
|
|
196
373
|
}
|
|
197
374
|
};
|
|
198
375
|
}
|
|
199
|
-
return m(selector, rest, vnode.children);
|
|
376
|
+
return m(a.selector || "view", rest, vnode.children);
|
|
200
377
|
},
|
|
201
378
|
};
|
|
202
379
|
|
package/src/testing.d.ts
CHANGED
|
@@ -4,13 +4,26 @@ export interface PatchApplier {
|
|
|
4
4
|
/** The real PAPI element handle for a given background-side id, or
|
|
5
5
|
* `undefined` if nothing was ever created for it. */
|
|
6
6
|
getHandle(id: unknown): unknown;
|
|
7
|
+
/** Neutralizes every native list's callbacks (for `__DestroyLifetime`). */
|
|
8
|
+
dispose(): void;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
/** See apply-patch.js's own header for the exact op vocabulary this replays. */
|
|
10
|
-
export function createPatchApplier(
|
|
12
|
+
export function createPatchApplier(
|
|
13
|
+
pageId: unknown,
|
|
14
|
+
options?: { onEvent?: (id: unknown, type: string, payload: unknown, seq?: number) => void; flush?: boolean },
|
|
15
|
+
): PatchApplier;
|
|
11
16
|
|
|
12
17
|
/** Installs the @lynx-js/testing-environment PAPI gap-fill mithril-lynx
|
|
13
18
|
* needs, on the main-thread globals object it hands to
|
|
14
19
|
* `onInjectMainThreadGlobals`. See testing.js's own header for exactly what
|
|
15
20
|
* this covers. */
|
|
16
21
|
export function installTestingPolyfills(target: any): void;
|
|
22
|
+
|
|
23
|
+
/** Every `__InvokeUIMethod` call made through the testing stand-in, in order. */
|
|
24
|
+
export const uiMethodCalls: { element: unknown; method: string; params: Record<string, unknown> }[];
|
|
25
|
+
|
|
26
|
+
/** Sets how the `__InvokeUIMethod` stand-in answers (`null`: `{ code: 0 }` to every call). */
|
|
27
|
+
export function setUIMethodResponder(
|
|
28
|
+
responder: ((element: unknown, method: string, params: Record<string, unknown>) => { code: number; data?: unknown }) | null,
|
|
29
|
+
): void;
|
package/src/testing.js
CHANGED
|
@@ -21,28 +21,96 @@
|
|
|
21
21
|
|
|
22
22
|
export { createPatchApplier } from "./apply-patch.js";
|
|
23
23
|
|
|
24
|
+
/** Every `__InvokeUIMethod` call made through the stand-in below, in order
|
|
25
|
+
* (`{ element, method, params }`) — module-level, so it survives the testing
|
|
26
|
+
* environment re-injecting the main-thread globals. Clear it with
|
|
27
|
+
* `uiMethodCalls.length = 0`. */
|
|
28
|
+
export const uiMethodCalls = [];
|
|
29
|
+
|
|
30
|
+
let uiMethodResponder = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sets how the `__InvokeUIMethod` stand-in answers: `responder(element,
|
|
34
|
+
* method, params)` returns the `{ code, data }` passed to the callback.
|
|
35
|
+
* `null` (the default) answers `{ code: 0, data: undefined }` to every call.
|
|
36
|
+
* @param {((element: unknown, method: string, params: Object) => {code: number, data?: unknown})|null} responder - The responder.
|
|
37
|
+
* @returns {void}
|
|
38
|
+
*/
|
|
39
|
+
export function setUIMethodResponder(responder) {
|
|
40
|
+
uiMethodResponder = responder;
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
/**
|
|
25
44
|
* Installs the polyfill on the main-thread globals object
|
|
26
45
|
* @lynx-js/testing-environment hands to onInjectMainThreadGlobals. Scoped
|
|
27
|
-
* to
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
46
|
+
* to the one real gap: @lynx-js/testing-environment already implements
|
|
47
|
+
* __CreateView/__CreateText/__CreateElement/__CreateRawText/__AppendElement/
|
|
48
|
+
* __InsertElementBefore/__RemoveElement/__SetAttribute/__SetClasses/
|
|
49
|
+
* __AddInlineStyle/__FlushElementTree/__GetElementUniqueID — the missing
|
|
50
|
+
* piece is __AddEventListener/__RemoveEventListener (the testing environment
|
|
51
|
+
* only implements the string/worklet-event __AddEvent family that ReactLynx
|
|
52
|
+
* uses; mithril-lynx binds real JS function listeners directly). Gesture and
|
|
53
|
+
* list elements (Op.SetGestureDetector, `list`/`list-item`) are covered by the testing
|
|
54
|
+
* environment itself, not by this polyfill — see test/gesture.test.ts and
|
|
55
|
+
* test/list.test.ts.
|
|
56
|
+
* @param {{lynx: Object}} target - The main-thread globals object from `@lynx-js/testing-environment`.
|
|
57
|
+
* @returns {void}
|
|
36
58
|
*/
|
|
37
59
|
export function installTestingPolyfills(target) {
|
|
38
60
|
target.lynx.getEngine = target.lynx.getNative;
|
|
39
61
|
|
|
40
|
-
|
|
62
|
+
// Faithful stand-ins for the native `__AddEventListener` /
|
|
63
|
+
// `__RemoveEventListener` PAPIs (element, name, callback, options). The
|
|
64
|
+
// options object is accepted and ignored here — the real native side
|
|
65
|
+
// requires it (FiberRemoveEventListener throws "param size should >= 4"
|
|
66
|
+
// without it), so __RemoveEventListener mirrors that arity requirement to
|
|
67
|
+
// keep the test environment honest about the wire contract.
|
|
68
|
+
/**
|
|
69
|
+
* Stand-in for the native `__AddEventListener` PAPI.
|
|
70
|
+
* @param {Object} node - The element.
|
|
71
|
+
* @param {string} name - The event type.
|
|
72
|
+
* @param {Function} handler - The listener.
|
|
73
|
+
* @param {Object} _options - Accepted and ignored.
|
|
74
|
+
* @returns {void}
|
|
75
|
+
*/
|
|
76
|
+
target.__AddEventListener = (node, name, handler, _options) => {
|
|
41
77
|
node.__vanillaListeners ??= {};
|
|
42
78
|
(node.__vanillaListeners[name] ??= new Set()).add(handler);
|
|
43
79
|
};
|
|
44
80
|
|
|
45
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Stand-in for the native `__RemoveEventListener` PAPI.
|
|
83
|
+
* @param {Object} node - The element.
|
|
84
|
+
* @param {string} name - The event type.
|
|
85
|
+
* @param {Function} handler - The listener to remove.
|
|
86
|
+
* @param {Object} _options - Required (four arguments), mirroring native.
|
|
87
|
+
* @returns {void}
|
|
88
|
+
* @throws {Error} If called with fewer than four arguments.
|
|
89
|
+
*/
|
|
90
|
+
target.__RemoveEventListener = function (node, name, handler, _options) {
|
|
91
|
+
if (arguments.length < 4) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
"[mithril-lynx/testing] __RemoveEventListener requires 4 params " +
|
|
94
|
+
"(element, name, callback, options) — matching FiberRemoveEventListener.",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
46
97
|
node.__vanillaListeners?.[name]?.delete(handler);
|
|
47
98
|
};
|
|
99
|
+
|
|
100
|
+
// Stand-in for the native `__InvokeUIMethod` PAPI (element, method,
|
|
101
|
+
// params, callback), which the testing environment lacks: records the
|
|
102
|
+
// call and answers synchronously (native answers later; nothing in
|
|
103
|
+
// mithril-lynx depends on either timing).
|
|
104
|
+
/**
|
|
105
|
+
* Stand-in for the native `__InvokeUIMethod` PAPI.
|
|
106
|
+
* @param {Object} element - The element.
|
|
107
|
+
* @param {string} method - The UI method name.
|
|
108
|
+
* @param {Object} params - Its parameters.
|
|
109
|
+
* @param {(res: {code: number, data?: unknown}) => void} callback - Receives the result.
|
|
110
|
+
* @returns {void}
|
|
111
|
+
*/
|
|
112
|
+
target.__InvokeUIMethod = (element, method, params, callback) => {
|
|
113
|
+
uiMethodCalls.push({ element, method, params });
|
|
114
|
+
callback(uiMethodResponder ? uiMethodResponder(element, method, params) : { code: 0, data: undefined });
|
|
115
|
+
};
|
|
48
116
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, rstest } from "@rstest/core";
|
|
2
|
+
import m from "mithril-runtime";
|
|
3
|
+
import { renderApp } from "../src/background.js";
|
|
4
|
+
import { unregister } from "../src/mount-redraw.js";
|
|
5
|
+
|
|
6
|
+
// High-frequency events (gesturemove, touchmove, scroll) must reach the app's
|
|
7
|
+
// handler once per sample, but must NOT trigger one full render + patch per
|
|
8
|
+
// sample — the redraw they ask for is coalesced to at most one per frame.
|
|
9
|
+
// Everything else still renders synchronously.
|
|
10
|
+
|
|
11
|
+
type Router = (event: { data: { id: number; type: string; payload?: object } }) => void;
|
|
12
|
+
|
|
13
|
+
function mountCounter() {
|
|
14
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
15
|
+
unregister();
|
|
16
|
+
const moves: number[] = [];
|
|
17
|
+
let count = 0;
|
|
18
|
+
let patches = 0;
|
|
19
|
+
let router: Router | null = null;
|
|
20
|
+
const app = renderApp({
|
|
21
|
+
root: () =>
|
|
22
|
+
m(
|
|
23
|
+
"view",
|
|
24
|
+
{
|
|
25
|
+
ongesturemove: (e: any) => {
|
|
26
|
+
moves.push(e.clientX);
|
|
27
|
+
count++;
|
|
28
|
+
if (e.skipRedraw) e.redraw = false;
|
|
29
|
+
},
|
|
30
|
+
ongestureup: () => {
|
|
31
|
+
count++;
|
|
32
|
+
},
|
|
33
|
+
ontap: () => {
|
|
34
|
+
count++;
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
m("text", String(count)),
|
|
38
|
+
),
|
|
39
|
+
sendPatch: () => {
|
|
40
|
+
patches++;
|
|
41
|
+
},
|
|
42
|
+
subscribeEvents: (handler: Router) => {
|
|
43
|
+
router = handler;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
const id = (app.document.firstChild as any)._id as number;
|
|
47
|
+
const send = (type: string, payload?: object) => router!({ data: { id, type, payload } });
|
|
48
|
+
return { app, moves, send, patches: () => patches };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Fake timers: the coalesced render uses a 16ms timer when
|
|
52
|
+
// lynx.requestAnimationFrame is absent (as in the testing environment), so
|
|
53
|
+
// advancing the clock makes "one frame later" deterministic.
|
|
54
|
+
const nextFrame = () => rstest.advanceTimersByTime(16);
|
|
55
|
+
|
|
56
|
+
describe("renders after high-frequency events are coalesced per frame", () => {
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
rstest.useFakeTimers();
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
rstest.useRealTimers();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("five gesturemoves in one tick: every handler runs, one render follows", () => {
|
|
65
|
+
const { moves, send, patches } = mountCounter();
|
|
66
|
+
const before = patches();
|
|
67
|
+
|
|
68
|
+
for (let x = 1; x <= 5; x++) send("gesturemove", { clientX: x });
|
|
69
|
+
expect(moves).toEqual([1, 2, 3, 4, 5]);
|
|
70
|
+
expect(patches()).toBe(before);
|
|
71
|
+
|
|
72
|
+
nextFrame();
|
|
73
|
+
expect(patches()).toBe(before + 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("gestureup after moves renders immediately and cancels the pending frame render", () => {
|
|
77
|
+
const { send, patches } = mountCounter();
|
|
78
|
+
const before = patches();
|
|
79
|
+
|
|
80
|
+
send("gesturemove", { clientX: 1 });
|
|
81
|
+
send("gesturemove", { clientX: 2 });
|
|
82
|
+
send("gestureup");
|
|
83
|
+
expect(patches()).toBe(before + 1);
|
|
84
|
+
|
|
85
|
+
nextFrame();
|
|
86
|
+
expect(patches()).toBe(before + 1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("a tap still renders synchronously", () => {
|
|
90
|
+
const { send, patches } = mountCounter();
|
|
91
|
+
const before = patches();
|
|
92
|
+
|
|
93
|
+
send("tap");
|
|
94
|
+
expect(patches()).toBe(before + 1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("an explicit redraw() between moves and the frame makes the frame render a no-op", () => {
|
|
98
|
+
const { app, send, patches } = mountCounter();
|
|
99
|
+
const before = patches();
|
|
100
|
+
|
|
101
|
+
send("gesturemove", { clientX: 1 });
|
|
102
|
+
app.redraw();
|
|
103
|
+
expect(patches()).toBe(before + 1);
|
|
104
|
+
|
|
105
|
+
nextFrame();
|
|
106
|
+
expect(patches()).toBe(before + 1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("a move whose handler sets e.redraw = false schedules no render at all", () => {
|
|
110
|
+
const { send, patches } = mountCounter();
|
|
111
|
+
const before = patches();
|
|
112
|
+
|
|
113
|
+
send("gesturemove", { clientX: 1, skipRedraw: true });
|
|
114
|
+
nextFrame();
|
|
115
|
+
expect(patches()).toBe(before);
|
|
116
|
+
});
|
|
117
|
+
});
|