mithril-lynx 2.6.2 → 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/README.md +47 -8
- package/ROUTE.md +111 -16
- package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
- package/UPDATE_ANIMATION_GAP.md +133 -0
- package/package.json +1 -9
- package/plugin.js +33 -0
- package/src/apply-patch.js +369 -60
- package/src/backends/virtual-backend.js +120 -22
- package/src/background.d.ts +26 -0
- package/src/background.js +116 -7
- package/src/channel.js +22 -5
- 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 +18 -1
- package/src/mount-redraw.js +14 -1
- package/src/patch-protocol.js +41 -16
- package/src/reload/version.js +10 -1
- package/src/request.js +38 -0
- package/src/route.d.ts +30 -2
- package/src/route.js +214 -56
- package/src/testing.d.ts +14 -1
- package/src/testing.js +56 -1
- 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 +417 -113
- package/test/remove-subtree-cleanup.test.ts +75 -0
- package/test/route-hot-reload.test.ts +2 -1
- package/test/route.test.ts +201 -4
- 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_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
- package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.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 -197
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;
|
|
@@ -117,23 +200,36 @@ export function createRoute() {
|
|
|
117
200
|
throw new ReferenceError("Default route doesn't match any known routes.");
|
|
118
201
|
}
|
|
119
202
|
if (!ready) {
|
|
120
|
-
history = [defaultRoute];
|
|
203
|
+
history = [{ path: defaultRoute, state: null }];
|
|
121
204
|
historyIndex = 0;
|
|
122
205
|
ready = true;
|
|
123
206
|
resolveRoute(defaultRoute, null);
|
|
207
|
+
notifyBackWatchers();
|
|
124
208
|
} else {
|
|
125
209
|
// Re-registration (e.g. HMR of the route module): keep the history
|
|
126
|
-
// stack and re-resolve the CURRENT
|
|
210
|
+
// stack and re-resolve the CURRENT entry against the new table —
|
|
127
211
|
// the same thing the documented HMR pattern does by hand with
|
|
128
212
|
// route.set(route.get(), null, {replace: true}). Resolving
|
|
129
213
|
// defaultRoute here instead would silently jump the screen back to
|
|
130
214
|
// the initial route and discard the user's back/forward stack.
|
|
131
|
-
|
|
215
|
+
var entry = history[historyIndex];
|
|
216
|
+
resolveRoute(entry.path, entry.state);
|
|
132
217
|
}
|
|
133
218
|
}
|
|
134
219
|
|
|
135
220
|
route.SKIP = {};
|
|
136
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
|
+
*/
|
|
137
233
|
route.set = function (path, data, options) {
|
|
138
234
|
if (!ready) {
|
|
139
235
|
throw new Error(
|
|
@@ -147,19 +243,27 @@ export function createRoute() {
|
|
|
147
243
|
options.replace = true;
|
|
148
244
|
}
|
|
149
245
|
lastUpdate = null;
|
|
150
|
-
|
|
246
|
+
var entry = { path: buildPathname(path, data), state: (options && options.state) || null };
|
|
151
247
|
if (options && options.replace) {
|
|
152
|
-
history[Math.max(historyIndex, 0)] =
|
|
248
|
+
history[Math.max(historyIndex, 0)] = entry;
|
|
153
249
|
} else {
|
|
154
250
|
history = history.slice(0, historyIndex + 1);
|
|
155
|
-
history.push(
|
|
251
|
+
history.push(entry);
|
|
156
252
|
historyIndex = history.length - 1;
|
|
157
253
|
}
|
|
158
|
-
|
|
254
|
+
scheduleResolve();
|
|
255
|
+
notifyBackWatchers();
|
|
159
256
|
};
|
|
160
257
|
|
|
258
|
+
/**
|
|
259
|
+
* @returns {string|undefined} The current (decoded) path, or `undefined` before the first resolution.
|
|
260
|
+
*/
|
|
161
261
|
route.get = () => currentPath;
|
|
162
262
|
|
|
263
|
+
/**
|
|
264
|
+
* @param {string} [key] - A param name.
|
|
265
|
+
* @returns {*} That param's value, or all params when `key` is omitted.
|
|
266
|
+
*/
|
|
163
267
|
route.param = (key) => (attrs && key != null ? attrs[key] : attrs);
|
|
164
268
|
|
|
165
269
|
// No URL bar in Lynx — kept as an assignable no-op so app code ported
|
|
@@ -168,54 +272,108 @@ export function createRoute() {
|
|
|
168
272
|
route.prefix = "";
|
|
169
273
|
|
|
170
274
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* which is app-lifecycle, not navigation) — so an app's own explicit
|
|
176
|
-
* back affordance (a `route.Link`/button calling this) is the only
|
|
177
|
-
* way back navigation happens. Confirming this holds on a real device
|
|
178
|
-
* 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.
|
|
179
279
|
*/
|
|
180
280
|
route.back = function () {
|
|
181
281
|
if (historyIndex <= 0) return false;
|
|
182
282
|
historyIndex--;
|
|
183
|
-
|
|
283
|
+
scheduleResolve();
|
|
284
|
+
notifyBackWatchers();
|
|
184
285
|
return true;
|
|
185
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
|
+
*/
|
|
186
291
|
route.forward = function () {
|
|
187
292
|
if (historyIndex >= history.length - 1) return false;
|
|
188
293
|
historyIndex++;
|
|
189
|
-
|
|
294
|
+
scheduleResolve();
|
|
295
|
+
notifyBackWatchers();
|
|
190
296
|
return true;
|
|
191
297
|
};
|
|
192
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
|
+
};
|
|
345
|
+
};
|
|
346
|
+
|
|
193
347
|
// Lynx has no `<a>`/`onclick` — this renders a tap-driven element
|
|
194
|
-
// instead, the same shape ReactLynx's `useNavigate()+
|
|
195
|
-
// Lynx's custom `RouterLink` slot use (plan §1–§2, §5.4).
|
|
348
|
+
// instead, the same shape ReactLynx's `useNavigate()+bindtap` pattern uses.
|
|
196
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
|
+
*/
|
|
197
357
|
view(vnode) {
|
|
198
358
|
var a = vnode.attrs;
|
|
199
|
-
var
|
|
200
|
-
var rest = {};
|
|
201
|
-
for (var key in a) {
|
|
202
|
-
if (key !== "selector" && key !== "options" && key !== "params" && key !== "href" && key !== "ontap") {
|
|
203
|
-
rest[key] = a[key];
|
|
204
|
-
}
|
|
205
|
-
}
|
|
359
|
+
var rest = censor(a, ["selector", "options", "params", "href", "ontap"]);
|
|
206
360
|
var disabled = Boolean(a.disabled);
|
|
207
361
|
rest.disabled = disabled;
|
|
208
362
|
if (!disabled) {
|
|
363
|
+
var ontap = a.ontap;
|
|
364
|
+
var href = buildPathname(a.href, a.params);
|
|
365
|
+
var options = a.options;
|
|
209
366
|
rest.ontap = function (e) {
|
|
210
367
|
var result;
|
|
211
|
-
if (typeof
|
|
212
|
-
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) {
|
|
213
371
|
e.redraw = false;
|
|
214
|
-
route.set(
|
|
372
|
+
route.set(href, null, options);
|
|
215
373
|
}
|
|
216
374
|
};
|
|
217
375
|
}
|
|
218
|
-
return m(selector, rest, vnode.children);
|
|
376
|
+
return m(a.selector || "view", rest, vnode.children);
|
|
219
377
|
},
|
|
220
378
|
};
|
|
221
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,6 +21,25 @@
|
|
|
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
|
|
@@ -31,9 +50,11 @@ export { createPatchApplier } from "./apply-patch.js";
|
|
|
31
50
|
* piece is __AddEventListener/__RemoveEventListener (the testing environment
|
|
32
51
|
* only implements the string/worklet-event __AddEvent family that ReactLynx
|
|
33
52
|
* uses; mithril-lynx binds real JS function listeners directly). Gesture and
|
|
34
|
-
* list
|
|
53
|
+
* list elements (Op.SetGestureDetector, `list`/`list-item`) are covered by the testing
|
|
35
54
|
* environment itself, not by this polyfill — see test/gesture.test.ts and
|
|
36
55
|
* test/list.test.ts.
|
|
56
|
+
* @param {{lynx: Object}} target - The main-thread globals object from `@lynx-js/testing-environment`.
|
|
57
|
+
* @returns {void}
|
|
37
58
|
*/
|
|
38
59
|
export function installTestingPolyfills(target) {
|
|
39
60
|
target.lynx.getEngine = target.lynx.getNative;
|
|
@@ -44,11 +65,28 @@ export function installTestingPolyfills(target) {
|
|
|
44
65
|
// requires it (FiberRemoveEventListener throws "param size should >= 4"
|
|
45
66
|
// without it), so __RemoveEventListener mirrors that arity requirement to
|
|
46
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
|
+
*/
|
|
47
76
|
target.__AddEventListener = (node, name, handler, _options) => {
|
|
48
77
|
node.__vanillaListeners ??= {};
|
|
49
78
|
(node.__vanillaListeners[name] ??= new Set()).add(handler);
|
|
50
79
|
};
|
|
51
80
|
|
|
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
|
+
*/
|
|
52
90
|
target.__RemoveEventListener = function (node, name, handler, _options) {
|
|
53
91
|
if (arguments.length < 4) {
|
|
54
92
|
throw new Error(
|
|
@@ -58,4 +96,21 @@ export function installTestingPolyfills(target) {
|
|
|
58
96
|
}
|
|
59
97
|
node.__vanillaListeners?.[name]?.delete(handler);
|
|
60
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
|
+
};
|
|
61
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
|
+
});
|