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.
Files changed (55) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/LICENSE.txt +21 -0
  5. package/PLAN_REVIEW.md +134 -0
  6. package/README.md +47 -7
  7. package/REQUEST.md +5 -2
  8. package/ROUTE.md +111 -16
  9. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  10. package/UPDATE_ANIMATION_GAP.md +133 -0
  11. package/informe-contrato-mithril-lynx.md +206 -0
  12. package/package.json +1 -9
  13. package/plugin.js +33 -0
  14. package/prompts-analisis-contrato-mithril-lynx.md +141 -0
  15. package/src/apply-patch.js +408 -68
  16. package/src/backends/virtual-backend.js +120 -22
  17. package/src/background.d.ts +40 -1
  18. package/src/background.js +116 -7
  19. package/src/channel.js +32 -7
  20. package/src/commit.js +9 -0
  21. package/src/dev-reload-client.js +46 -0
  22. package/src/dev-transport-noop.js +3 -0
  23. package/src/fake-dom.js +559 -61
  24. package/src/list-attributes.js +69 -0
  25. package/src/list-runtime.js +425 -0
  26. package/src/main-thread.js +33 -3
  27. package/src/mount-redraw.d.ts +10 -0
  28. package/src/mount-redraw.js +69 -3
  29. package/src/patch-protocol.js +52 -13
  30. package/src/reload/version.js +10 -1
  31. package/src/request.d.ts +2 -0
  32. package/src/request.js +59 -3
  33. package/src/route.d.ts +52 -5
  34. package/src/route.js +236 -59
  35. package/src/testing.d.ts +14 -1
  36. package/src/testing.js +79 -11
  37. package/test/event-render-coalescing.test.ts +117 -0
  38. package/test/gesture.test.ts +75 -0
  39. package/test/input.test.ts +388 -0
  40. package/test/list.test.ts +422 -100
  41. package/test/patch-protocol.test.ts +41 -0
  42. package/test/remove-event.test.ts +26 -0
  43. package/test/remove-subtree-cleanup.test.ts +75 -0
  44. package/test/route-hot-reload.test.ts +4 -2
  45. package/test/route.test.ts +206 -6
  46. package/test/setup.ts +10 -0
  47. package/test/style-clear.test.ts +97 -0
  48. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  49. package/.omo/plans/m-route-en-memoria.md +0 -397
  50. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  51. package/.omo/run-continuation/ses_f48265d07ffenKG0GAz9ZRFvY6.json +0 -10
  52. package/src/list-cell.d.ts +0 -20
  53. package/src/list-cell.js +0 -73
  54. package/src/list-support.d.ts +0 -13
  55. 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 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;
@@ -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
- history = [defaultRoute];
120
- historyIndex = 0;
121
- ready = true;
122
- resolveRoute(defaultRoute, null);
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
- path = buildPathname(path, data);
246
+ var entry = { path: buildPathname(path, data), state: (options && options.state) || null };
134
247
  if (options && options.replace) {
135
- history[Math.max(historyIndex, 0)] = path;
248
+ history[Math.max(historyIndex, 0)] = entry;
136
249
  } else {
137
250
  history = history.slice(0, historyIndex + 1);
138
- history.push(path);
251
+ history.push(entry);
139
252
  historyIndex = history.length - 1;
140
253
  }
141
- if (ready) resolveRoute(path, null);
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
- * `back()`/`forward()` walk the SAME in-memory stack `route.set` writes
155
- * to — this is the "no native back button" answer from plan §5.7/F0:
156
- * static analysis of the installed Lynx runtime found no hardware/
157
- * gesture "back" event exposed to JS (only `onAppEnterBackground`,
158
- * which is app-lifecycle, not navigation) — so an app's own explicit
159
- * back affordance (a `route.Link`/button calling this) is the only
160
- * way back navigation happens. Confirming this holds on a real device
161
- * is F4/F5 of the plan, not done yet.
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
- resolveRoute(history[historyIndex], null);
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
- resolveRoute(history[historyIndex], null);
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()+ontap` and Vue
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 selector = a.selector || "view";
181
- var rest = {};
182
- for (var key in a) {
183
- if (key !== "selector" && key !== "options" && key !== "params" && key !== "href" && key !== "ontap") {
184
- rest[key] = a[key];
185
- }
186
- }
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 a.ontap === "function") result = a.ontap.call(e.currentTarget, e);
193
- 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) {
194
371
  e.redraw = false;
195
- route.set(buildPathname(a.href, a.params), null, a.options);
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(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,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 exactly what apply-patch.js calls (no gesture/list support — those
28
- * don't exist in mithril-lynx yet, see the main README's known gaps):
29
- * @lynx-js/testing-environment already implements __CreateView/__CreateText/
30
- * __CreateElement/__CreateRawText/__AppendElement/__InsertElementBefore/
31
- * __RemoveElement/__SetAttribute/__SetClasses/__AddInlineStyle/
32
- * __FlushElementTree/__GetElementUniqueID — the one real gap is
33
- * __AddEventListener (the testing environment only implements the
34
- * string/worklet-event __AddEvent family that ReactLynx uses; mithril-lynx
35
- * binds real JS function listeners directly).
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
- target.__AddEventListener = (node, name, handler) => {
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
- target.__RemoveEventListener = (node, name, handler) => {
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
+ });