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.
Files changed (46) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/README.md +47 -8
  5. package/ROUTE.md +111 -16
  6. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  7. package/UPDATE_ANIMATION_GAP.md +133 -0
  8. package/package.json +1 -9
  9. package/plugin.js +33 -0
  10. package/src/apply-patch.js +369 -60
  11. package/src/backends/virtual-backend.js +120 -22
  12. package/src/background.d.ts +26 -0
  13. package/src/background.js +116 -7
  14. package/src/channel.js +22 -5
  15. package/src/commit.js +9 -0
  16. package/src/dev-reload-client.js +46 -0
  17. package/src/dev-transport-noop.js +3 -0
  18. package/src/fake-dom.js +559 -61
  19. package/src/list-attributes.js +69 -0
  20. package/src/list-runtime.js +425 -0
  21. package/src/main-thread.js +18 -1
  22. package/src/mount-redraw.js +14 -1
  23. package/src/patch-protocol.js +41 -16
  24. package/src/reload/version.js +10 -1
  25. package/src/request.js +38 -0
  26. package/src/route.d.ts +30 -2
  27. package/src/route.js +214 -56
  28. package/src/testing.d.ts +14 -1
  29. package/src/testing.js +56 -1
  30. package/test/event-render-coalescing.test.ts +117 -0
  31. package/test/gesture.test.ts +75 -0
  32. package/test/input.test.ts +388 -0
  33. package/test/list.test.ts +417 -113
  34. package/test/remove-subtree-cleanup.test.ts +75 -0
  35. package/test/route-hot-reload.test.ts +2 -1
  36. package/test/route.test.ts +201 -4
  37. package/test/style-clear.test.ts +97 -0
  38. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  39. package/.omo/plans/m-route-en-memoria.md +0 -397
  40. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  41. package/.omo/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
  42. package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +0 -10
  43. package/src/list-cell.d.ts +0 -20
  44. package/src/list-cell.js +0 -73
  45. package/src/list-support.d.ts +0 -13
  46. 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 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.
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 (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 this 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.
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: 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.
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 = component != null ? m(component, attrs) : null;
46
- return currentResolver ? currentResolver.render(vnode) : vnode;
62
+ var vnode = m(component, attrs);
63
+ if (currentResolver) return currentResolver.render(vnode);
64
+ return [vnode];
47
65
  },
48
66
  };
49
67
 
50
- function resolveRoute(path, data) {
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 (data) Object.assign(parsed.params, data);
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 path against the new table —
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
- resolveRoute(currentPath != null ? currentPath : defaultRoute, null);
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
- path = buildPathname(path, data);
246
+ var entry = { path: buildPathname(path, data), state: (options && options.state) || null };
151
247
  if (options && options.replace) {
152
- history[Math.max(historyIndex, 0)] = path;
248
+ history[Math.max(historyIndex, 0)] = entry;
153
249
  } else {
154
250
  history = history.slice(0, historyIndex + 1);
155
- history.push(path);
251
+ history.push(entry);
156
252
  historyIndex = history.length - 1;
157
253
  }
158
- if (ready) resolveRoute(path, null);
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
- * `back()`/`forward()` walk the SAME in-memory stack `route.set` writes
172
- * to — this is the "no native back button" answer from plan §5.7/F0:
173
- * static analysis of the installed Lynx runtime found no hardware/
174
- * gesture "back" event exposed to JS (only `onAppEnterBackground`,
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
- resolveRoute(history[historyIndex], null);
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
- resolveRoute(history[historyIndex], null);
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()+ontap` and Vue
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 selector = a.selector || "view";
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 a.ontap === "function") result = a.ontap.call(e.currentTarget, e);
212
- if (result !== false) {
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(buildPathname(a.href, a.params), null, a.options);
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(pageId: unknown, options?: { onEvent?: (id: unknown, type: string, payload: unknown) => void }): PatchApplier;
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 ops (Op.SetGestureDetector/Op.CreateList) are covered by the testing
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
+ });