mithril-lynx 2.0.2 → 2.6.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.
@@ -0,0 +1,73 @@
1
+ // src/list-cell.js
2
+ //
3
+ // Runs ONLY on the background thread — the counterpart to list-support.js's
4
+ // main-thread half. Turns one `renderItem(item, index)` call into a
5
+ // self-contained set of construction ops, using the app's OWN document/
6
+ // backend/render (the same ones background.js already created for the rest
7
+ // of the tree) — never a separate, isolated render pipeline. That's the
8
+ // whole point: the item's real fake-dom nodes end up registered in the same
9
+ // `document._nodesById` map as everything else in the app, so a forwarded
10
+ // native event for one of them dispatches through `onEventFromMainThread`
11
+ // exactly like any other element's event already does. No extra reporting
12
+ // convention needed.
13
+ //
14
+ // The main thread never calls renderItem() itself — see list-support.js.
15
+
16
+ import { Op, forEachOp } from "./patch-protocol.js";
17
+
18
+ function typeKeyOf(vnode) {
19
+ return typeof vnode.tag === "string" ? vnode.tag : (vnode.tag && vnode.tag.name) || "default";
20
+ }
21
+
22
+ /** The real handle ids inserted directly under `containerId` — what a
23
+ * recycled cell on the main thread needs to remove before it can attach a
24
+ * different item's content into the same native wrapper (see
25
+ * list-support.js's clearWrapperChildren). Scanning the ops after the fact,
26
+ * instead of tracking during render, keeps this file from needing any
27
+ * backend-internal access beyond `captureOps` itself. */
28
+ function findTopLevelChildIds(ops, containerId) {
29
+ const ids = [];
30
+ forEachOp(ops, (opcode, args) => {
31
+ if (opcode === Op.InsertBefore && args[0] === containerId) ids.push(args[1]);
32
+ });
33
+ return ids;
34
+ }
35
+
36
+ /**
37
+ * @param {import("./fake-dom.js").LynxDocument} document - the app's own
38
+ * document (e.g. `vnode.dom.ownerDocument` from inside a component) — NOT
39
+ * a separate one. Sharing it is what keeps a list cell's ids in the same
40
+ * `_nodesById` space as the rest of the app.
41
+ * @param {(dom: object, vnodes: unknown[], redraw: () => void) => void} render
42
+ * - a `mithril-runtime/render/render.js` instance. Callers typically keep
43
+ * one shared instance per `<List>` (see mithril-lynx-ui's list.js), not
44
+ * one per cell — render() is designed to manage multiple independent
45
+ * containers safely.
46
+ * @param {() => void} redraw - forwarded as render()'s own redraw callback,
47
+ * so a `ontap` (etc.) handler inside the rendered cell triggers a REAL
48
+ * app-wide redraw afterward, exactly like any other event in the app —
49
+ * see mithril-lynx/mount-redraw's `redraw()`.
50
+ * @param {(item: unknown, index: number) => unknown} renderItem
51
+ * @param {unknown} item
52
+ * @param {number} index
53
+ * @returns {{ typeKey: string, containerId: number, ops: unknown[], rootChildIds: number[] }}
54
+ */
55
+ export function renderListCell(document, render, redraw, renderItem, item, index) {
56
+ // An ordinary element in the SAME document, never inserted into the
57
+ // visible tree (no `appendChild`/`insertBefore` call targets it) — its
58
+ // own `Op.CreateElement` never gets captured below since it's created
59
+ // OUTSIDE the capture, before there's anything to record; only what
60
+ // render() does INSIDE it is captured. The main thread's list-support.js
61
+ // treats `containerId` as an alias for whatever real native wrapper it
62
+ // creates for this cell, the same way apply-patch.js's top-level applier
63
+ // treats id 0 as an alias for the real page element.
64
+ const container = document.createElement("list-cell-root");
65
+ const vnode = renderItem(item, index);
66
+ const typeKey = typeKeyOf(vnode);
67
+
68
+ const ops = document.captureOps(() => {
69
+ render(container, [vnode], redraw);
70
+ });
71
+
72
+ return { typeKey, containerId: container._id, ops, rootChildIds: findTopLevelChildIds(ops, container._id) };
73
+ }
@@ -0,0 +1,13 @@
1
+ // Ambient declaration for the ESM src/list-support.js — the main-thread
2
+ // half of native list support. Not meant to be imported directly by app
3
+ // code; apply-patch.js's own Op.CreateList case is the only caller. See
4
+ // list-cell.d.ts for the background-thread half an app actually uses.
5
+
6
+ export function createNativeList(
7
+ pageId: number,
8
+ scrollOrientation: string,
9
+ listType: string,
10
+ spanCount: number,
11
+ createPatchApplier: (pageId: number, options?: { onEvent?: Function; flush?: boolean }) => object,
12
+ onEvent?: Function,
13
+ ): { handle: unknown; setCells: (cells: unknown[]) => void };
@@ -0,0 +1,171 @@
1
+ // src/list-support.js
2
+ //
3
+ // Runs ONLY on the main thread — the counterpart to list-cell.js's
4
+ // background-thread half. Wires up the native list contract
5
+ // (__CreateList/__UpdateListCallbacks, the synchronous componentAtIndex/
6
+ // enqueueComponent pair, recycling cells by a type key) — unchanged from
7
+ // mithril-lynx v1's own device-verified list.js.
8
+ //
9
+ // What's different from v1, and from this file's own first version: a
10
+ // cell's content is never rendered here. `componentAtIndex` only replays
11
+ // ops the background thread already computed (list-cell.js), the same way
12
+ // apply-patch.js's own top-level applyPatch replays the app's own tree —
13
+ // this file has no dependency on mithril-runtime, fake-dom.js, or a virtual
14
+ // backend at all anymore, because it never runs a render pass of its own.
15
+ // componentAtIndex(itemCount) cost is therefore the cost of creating/
16
+ // updating real elements from an already-known op list, same as any other
17
+ // patch — not a render.
18
+ //
19
+ // Recycling a cell for a different item clears its real children (removed
20
+ // by the handle ids list-cell.js recorded — see clearWrapperChildren below)
21
+ // and replays the new item's ops fresh; this is a real teardown-and-rebuild
22
+ // of that cell's content, not a diff against what was there before. A real
23
+ // Mithril diff on recycle would need a persistent per-slot fake-dom
24
+ // document kept in sync with which native cell native actually chose to
25
+ // reuse — information only native has, on the main thread, which the
26
+ // background thread (where the diff would have to run) can't see without a
27
+ // round trip. Not implemented.
28
+
29
+ export function createNativeList(pageId, scrollOrientation, listType, spanCount, createPatchApplier, onEvent) {
30
+ let cells = []; // current cells, indexed by cellIndex — see patch-protocol.js's Op.SetListItems
31
+ let count = 0;
32
+ const signMap = new Map(); // sign -> { wrapperHandle, applier, rootChildIds, cellIndex, typeKey }
33
+ const recycleMap = new Map(); // typeKey -> Map<sign, entry> (entries currently off-screen, available to reuse)
34
+
35
+ function replayCell(wrapperHandle, cell) {
36
+ const applier = createPatchApplier(pageId, { onEvent, flush: false });
37
+ applier.registerRoot(cell.containerId, wrapperHandle);
38
+ applier.applyPatch(cell.ops);
39
+ return applier;
40
+ }
41
+
42
+ function clearWrapperChildren(entry) {
43
+ for (const id of entry.rootChildIds) {
44
+ const handle = entry.applier.getHandle(id);
45
+ if (handle != null) __RemoveElement(entry.wrapperHandle, handle);
46
+ }
47
+ }
48
+
49
+ function bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush) {
50
+ const wrapperHandle = __CreateElement("list-item", pageId, {});
51
+ __SetAttribute(wrapperHandle, "item-key", String(cellIndex));
52
+ __AppendElement(listHandle, wrapperHandle);
53
+
54
+ const applier = replayCell(wrapperHandle, cell);
55
+ const sign = __GetElementUniqueID(wrapperHandle);
56
+ signMap.set(sign, { wrapperHandle, applier, rootChildIds: cell.rootChildIds, cellIndex, typeKey: cell.typeKey });
57
+ if (flush) __FlushElementTree(wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
58
+ return sign;
59
+ }
60
+
61
+ function bindRecycledItem(listId, cellIndex, opId, cell, pool, flush) {
62
+ const [sign, entry] = pool.entries().next().value;
63
+ pool.delete(sign);
64
+ clearWrapperChildren(entry);
65
+ __SetAttribute(entry.wrapperHandle, "item-key", String(cellIndex));
66
+ const applier = replayCell(entry.wrapperHandle, cell);
67
+ entry.applier = applier;
68
+ entry.rootChildIds = cell.rootChildIds;
69
+ entry.cellIndex = cellIndex;
70
+ signMap.set(sign, entry);
71
+ if (flush) __FlushElementTree(entry.wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
72
+ return sign;
73
+ }
74
+
75
+ function bindCell(listHandle, listId, cellIndex, opId, flush) {
76
+ if (cellIndex < 0 || cellIndex >= count) {
77
+ throw new Error(`[mithril-lynx] list: cellIndex ${cellIndex} out of range (itemCount=${count})`);
78
+ }
79
+ const cell = cells[cellIndex];
80
+ const pool = recycleMap.get(cell.typeKey);
81
+ if (pool && pool.size > 0) return bindRecycledItem(listId, cellIndex, opId, cell, pool, flush);
82
+ return bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush);
83
+ }
84
+
85
+ function componentAtIndex(listHandle, listId, cellIndex, opId) {
86
+ return bindCell(listHandle, listId, cellIndex, opId, true);
87
+ }
88
+
89
+ /**
90
+ * The batched form of componentAtIndex — real native calls this (not a
91
+ * fallback: confirmed against `@lynx-js/react`'s own
92
+ * componentAtIndexFactory/attachListItemAtIndex, which always registers
93
+ * BOTH forms) for several cells at once, expecting exactly ONE
94
+ * `__FlushElementTree` covering all of them (`operationIDs`/`elementIDs`
95
+ * arrays), not one per cell — the real reason a native list's initial,
96
+ * simultaneously-visible cells need this at all.
97
+ */
98
+ function componentAtIndexes(listHandle, listId, cellIndexes, operationIDs) {
99
+ const elementIDs = cellIndexes.map((cellIndex, i) => bindCell(listHandle, listId, cellIndex, operationIDs[i], false));
100
+ __FlushElementTree(listHandle, { triggerLayout: true, operationIDs, elementIDs, listID: listId });
101
+ }
102
+
103
+ function enqueueComponent(_listHandle, _listId, sign) {
104
+ const entry = signMap.get(sign);
105
+ if (entry == null) return;
106
+ signMap.delete(sign);
107
+ if (!recycleMap.has(entry.typeKey)) recycleMap.set(entry.typeKey, new Map());
108
+ recycleMap.get(entry.typeKey).set(sign, entry);
109
+ }
110
+
111
+ function sendListInfo(listHandle, listId, insertAction, removeAction, updateAction) {
112
+ __SetAttribute(listHandle, "update-list-info", { insertAction, removeAction, updateAction });
113
+ __UpdateListCallbacks(listHandle, componentAtIndex, enqueueComponent, componentAtIndexes);
114
+ }
115
+
116
+ const listHandle = __CreateList(pageId, componentAtIndex, enqueueComponent, {}, componentAtIndexes);
117
+ const listId = __GetElementUniqueID(listHandle);
118
+ __SetAttribute(listHandle, "scroll-orientation", scrollOrientation);
119
+ __SetAttribute(listHandle, "list-type", listType);
120
+ __SetAttribute(listHandle, "span-count", String(spanCount));
121
+
122
+ // Re-flushes every currently-attached (on-screen) cell with its newest
123
+ // ops — e.g. after a redraw triggered by a tap inside a cell, or any
124
+ // other app state change that reaches this list's items. Unconditional,
125
+ // same as List's own onupdate already unconditionally resends the whole
126
+ // `items` array on every relevant redraw (see mithril-lynx-ui's
127
+ // list/list.js) — a real "did this cell's content actually change"
128
+ // check would need to compare `ops` arrays, not implemented yet. Cells
129
+ // that aren't attached right now just pick up their new content next
130
+ // time componentAtIndex asks for that index.
131
+ function refreshAttachedCells(nextCells) {
132
+ for (const [sign, entry] of signMap) {
133
+ const nextCell = nextCells[entry.cellIndex];
134
+ clearWrapperChildren(entry);
135
+ entry.applier = replayCell(entry.wrapperHandle, nextCell);
136
+ entry.rootChildIds = nextCell.rootChildIds;
137
+ entry.typeKey = nextCell.typeKey;
138
+ signMap.set(sign, entry);
139
+ __FlushElementTree(entry.wrapperHandle, { triggerLayout: false });
140
+ }
141
+ }
142
+
143
+ function setCells(nextCells) {
144
+ const nextCount = nextCells.length;
145
+ refreshAttachedCells(nextCells);
146
+ cells = nextCells;
147
+ if (nextCount === count) return;
148
+ if (nextCount > count) {
149
+ sendListInfo(
150
+ listHandle,
151
+ listId,
152
+ Array.from({ length: nextCount - count }, (_, i) => ({ position: count + i, type: "cell", "item-key": String(count + i) })),
153
+ [],
154
+ [],
155
+ );
156
+ } else {
157
+ sendListInfo(listHandle, listId, [], Array.from({ length: count - nextCount }, (_, i) => nextCount + i), []);
158
+ }
159
+ count = nextCount;
160
+ }
161
+
162
+ // Returned separately, not as a property stuck onto `listHandle` itself:
163
+ // confirmed on real hardware (unlike @lynx-js/testing-environment's
164
+ // simulated PAPI) that a native list's own returned handle does not
165
+ // reliably hold a custom property across calls — assigning one there
166
+ // and reading it back later from apply-patch.js's Op.SetListItems case
167
+ // threw "TypeError: not a function" on the very first SetListItems, even
168
+ // with zero items. apply-patch.js keeps `setCells` in its own map
169
+ // instead, keyed the same way `handles` already is.
170
+ return { handle: listHandle, setCells };
171
+ }
@@ -0,0 +1,13 @@
1
+ /** Called by `renderApp()` right after it creates its own redraw function.
2
+ * Public so a reusable component library (not just this package's own
3
+ * request.js) can trigger a redraw of whichever app is currently mounted
4
+ * after an async state change (a timer/animation callback, a promise) that
5
+ * didn't happen inside a real event handler — the case mithril-lynx's own
6
+ * auto-redraw-after-event contract doesn't cover. */
7
+ export function register(redraw: () => void): void;
8
+
9
+ /** No-op before any app has mounted. Scheduled, not synchronous — see
10
+ * mount-redraw.js's own header for why a synchronous call here would race
11
+ * a caller's own pending `.then()`/callback that hasn't stored its result
12
+ * yet. */
13
+ export function redraw(): void;
@@ -15,6 +15,13 @@
15
15
  // for the app's whole lifetime (plan §3.1), so "the current redraw
16
16
  // function" is a single slot, not a list.
17
17
  //
18
+ // Also exported publicly (`mithril-lynx/mount-redraw`), not just used
19
+ // internally by request.js — any reusable component (not just this
20
+ // package's own code) that mutates state from an async callback outside a
21
+ // real event handler (a timer, a promise, an animation frame) needs the
22
+ // exact same "redraw whichever app is mounted" call this module already
23
+ // provides; there is no reason to make library authors reinvent it.
24
+ //
18
25
  // `redraw()` schedules instead of calling `currentRedraw()` inline — same
19
26
  // reason real Mithril's version schedules through the platform's
20
27
  // requestAnimationFrame instead of rendering synchronously: `request.js`'s
@@ -28,6 +28,28 @@ export const Op = Object.freeze({
28
28
  SetText: 11, // id, value (nodeValue on a text node)
29
29
  AddEvent: 12, // id, type
30
30
  RemoveEvent: 13, // id, type
31
+ // gestureId, gestureType, arenaPolicy — registers a real native gesture
32
+ // detector on the main thread. arenaPolicy is a small, JSON-serializable
33
+ // description of when to claim/release the gesture arena, evaluated
34
+ // synchronously on the main thread against just the event's own
35
+ // coordinates (no background-thread round trip) — see
36
+ // docs/native-papi/papi-05-native-gestures.md in mithril-lynx-ui for
37
+ // the full design writeup and why this is deliberately narrower than a
38
+ // generic remote-controller RPC. Resulting onTouchesDown/Move/Up events
39
+ // are forwarded to the background thread as plain events (type
40
+ // "gesturedown"/"gesturemove"/"gestureup"), through the exact same
41
+ // channel any other native event already uses — nothing new on the
42
+ // background-thread side.
43
+ SetGestureDetector: 14, // id, gestureId, gestureType, arenaPolicy
44
+ RemoveGestureDetector: 15, // id, gestureId
45
+ // A native virtualized list — see docs/native-papi/papi-06-virtualized-lists.md
46
+ // in mithril-lynx-ui for the full design. Each list item is rendered on
47
+ // the BACKGROUND thread, through the app's own real render pass
48
+ // (list-cell.js), same as everything else in the tree — the main thread
49
+ // never runs renderItem() itself, only replays the ops that rendering
50
+ // already produced (apply-patch.js's Op.CreateList case + list-support.js).
51
+ CreateList: 16, // id, scrollOrientation, listType, spanCount
52
+ SetListItems: 17, // id, cellsJSON — cellsJSON = JSON.stringify(cells), cells: Array<{ typeKey, containerId, ops, rootChildIds }>, one entry per current item, computed by list-cell.js's renderListCell(). `ops` is a flat op array in this SAME encoding, scoped to `containerId` as its root parent id.
31
53
  });
32
54
 
33
55
  /**
@@ -38,3 +60,43 @@ export const Op = Object.freeze({
38
60
  export function pushOp(ops, opcode, ...args) {
39
61
  ops.push(opcode, ...args);
40
62
  }
63
+
64
+ // How many argument slots follow each opcode — the single source of truth
65
+ // for walking a flat ops array without re-interpreting it (apply-patch.js's
66
+ // own switch increments `i` inline instead of using this table, since it
67
+ // also needs to look at individual arg values as it goes; this exists for
68
+ // callers that only need to skip/scan, e.g. list-cell.js's
69
+ // findTopLevelChildIds(), which never applies the ops itself).
70
+ export const OP_ARITY = Object.freeze({
71
+ [Op.CreateElement]: 2, // tag, id
72
+ [Op.CreateElementNS]: 3, // ns, tag, id
73
+ [Op.CreateText]: 2, // value, id
74
+ [Op.CreateFragment]: 1, // id (never actually emitted — see fake-dom.js's LynxFragment)
75
+ [Op.InsertBefore]: 3, // parentId, childId, refId
76
+ [Op.RemoveChild]: 2, // parentId, childId
77
+ [Op.SetAttribute]: 3, // id, name, value
78
+ [Op.RemoveAttribute]: 2, // id, name
79
+ [Op.SetAttributeNS]: 4, // id, ns, name, value
80
+ [Op.SetStyleProperty]: 3, // id, name, value
81
+ [Op.RemoveStyleProperty]: 2, // id, name
82
+ [Op.SetText]: 2, // id, value
83
+ [Op.AddEvent]: 2, // id, type
84
+ [Op.RemoveEvent]: 2, // id, type
85
+ [Op.SetGestureDetector]: 4, // id, gestureId, gestureType, arenaPolicy
86
+ [Op.RemoveGestureDetector]: 2, // id, gestureId
87
+ [Op.CreateList]: 4, // id, scrollOrientation, listType, spanCount
88
+ [Op.SetListItems]: 2, // id, cellsJSON
89
+ });
90
+
91
+ /** Walks a flat ops array, calling `visit(opcode, args)` once per op — args
92
+ * is the plain slice of that op's own arguments (not including the opcode
93
+ * itself). Throws on an unknown opcode rather than silently desyncing. */
94
+ export function forEachOp(ops, visit) {
95
+ for (let i = 0; i < ops.length; ) {
96
+ const opcode = ops[i++];
97
+ const arity = OP_ARITY[opcode];
98
+ if (arity === undefined) throw new Error(`[mithril-lynx] Unknown patch opcode: ${opcode}`);
99
+ visit(opcode, ops.slice(i, i + arity));
100
+ i += arity;
101
+ }
102
+ }
@@ -0,0 +1,16 @@
1
+ export interface PatchApplier {
2
+ registerPageRoot(pageRootHandle: unknown): void;
3
+ applyPatch(ops: unknown[]): void;
4
+ /** The real PAPI element handle for a given background-side id, or
5
+ * `undefined` if nothing was ever created for it. */
6
+ getHandle(id: unknown): unknown;
7
+ }
8
+
9
+ /** 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;
11
+
12
+ /** Installs the @lynx-js/testing-environment PAPI gap-fill mithril-lynx
13
+ * needs, on the main-thread globals object it hands to
14
+ * `onInjectMainThreadGlobals`. See testing.js's own header for exactly what
15
+ * this covers. */
16
+ export function installTestingPolyfills(target: any): void;
package/src/testing.js ADDED
@@ -0,0 +1,48 @@
1
+ // src/testing.js
2
+ //
3
+ // Reusable @lynx-js/testing-environment PAPI polyfill + patch applier for
4
+ // apps/libraries built on mithril-lynx, not just this package's own test
5
+ // suite — same reason the previous mithril-lynx exposed its own
6
+ // mithril-lynx/testing: a separate package (mithril-lynx-ui, or any app)
7
+ // needs the exact same gap-fill to write a REAL end-to-end test (mount via
8
+ // renderApp(), replay the resulting ops onto real Element PAPI via
9
+ // @lynx-js/testing-environment, dispatch a real event) rather than mocking
10
+ // mithril-lynx itself.
11
+ //
12
+ // Usage, in a test setup file (e.g. test/setup.ts):
13
+ //
14
+ // import { installTestingPolyfills } from "mithril-lynx/testing";
15
+ // globalThis.onInjectMainThreadGlobals = installTestingPolyfills;
16
+ //
17
+ // createPatchApplier is re-exported here too — it's what a test needs to
18
+ // actually replay a renderApp() root's ops onto real PAPI elements (see
19
+ // this package's own test/end-to-end.test.ts for the full pattern); it has
20
+ // no other public export point.
21
+
22
+ export { createPatchApplier } from "./apply-patch.js";
23
+
24
+ /**
25
+ * Installs the polyfill on the main-thread globals object
26
+ * @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).
36
+ */
37
+ export function installTestingPolyfills(target) {
38
+ target.lynx.getEngine = target.lynx.getNative;
39
+
40
+ target.__AddEventListener = (node, name, handler) => {
41
+ node.__vanillaListeners ??= {};
42
+ (node.__vanillaListeners[name] ??= new Set()).add(handler);
43
+ };
44
+
45
+ target.__RemoveEventListener = (node, name, handler) => {
46
+ node.__vanillaListeners?.[name]?.delete(handler);
47
+ };
48
+ }
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import m from "mithril";
3
+ import { renderApp } from "../src/background.js";
4
+ import { createPatchApplier } from "../src/apply-patch.js";
5
+
6
+ // Op.SetGestureDetector end-to-end: a component calls vnode.dom's own
7
+ // setGestureDetector() (fake-dom.js) in oncreate, the resulting op gets
8
+ // replayed onto a REAL simulated __SetGestureDetector call (via
9
+ // @lynx-js/testing-environment, not a mock), and the arena-claim policy
10
+ // (apply-patch.js's own createArenaTracker) is exercised by extracting the
11
+ // registered worklet callbacks and invoking them the same way native would
12
+ // — see mithril-lynx-ui's docs/native-papi/papi-05-native-gestures.md for
13
+ // the full design writeup this implements.
14
+
15
+ function gestureCallbacksOf(handle: any): Record<string, (event: unknown, controller: unknown) => void> {
16
+ const entries = handle.gesture.config.callbacks as { name: string; callback: unknown }[];
17
+ const out: Record<string, (event: unknown, controller: unknown) => void> = {};
18
+ for (const entry of entries) {
19
+ out[entry.name] = (event, controller) => (globalThis as any).runWorklet(entry.callback, [event, controller]);
20
+ }
21
+ return out;
22
+ }
23
+
24
+ function touchEvent(clientX: number, clientY: number) {
25
+ return { params: { clientX, clientY } };
26
+ }
27
+
28
+ function makeController() {
29
+ const calls: { fn: string; args: unknown[] }[] = [];
30
+ return {
31
+ calls,
32
+ __SetGestureState(...args: unknown[]) {
33
+ calls.push({ fn: "__SetGestureState", args });
34
+ },
35
+ __ConsumeGesture(...args: unknown[]) {
36
+ calls.push({ fn: "__ConsumeGesture", args });
37
+ },
38
+ };
39
+ }
40
+
41
+ function mountWithGesture(arenaPolicy: unknown) {
42
+ lynxTestingEnv.switchToMainThread();
43
+ const pageId = __GetElementUniqueID(__CreatePage());
44
+ const receivedEvents: { type: string; clientX: number; clientY: number }[] = [];
45
+ const applier = createPatchApplier(pageId, {
46
+ onEvent: (id, type, payload: any) => {
47
+ receivedEvents.push({ type, clientX: payload.clientX, clientY: payload.clientY });
48
+ },
49
+ });
50
+ applier.registerPageRoot(__CreateView(pageId));
51
+
52
+ lynxTestingEnv.switchToBackgroundThread();
53
+ let lastOps: unknown[] | null = null;
54
+ const app = renderApp({
55
+ root: () => m("view", { class: "target", oncreate: (vnode: any) => vnode.dom.setGestureDetector("native", arenaPolicy) }),
56
+ sendPatch: (ops) => {
57
+ lastOps = ops;
58
+ },
59
+ });
60
+
61
+ lynxTestingEnv.switchToMainThread();
62
+ applier.applyPatch(lastOps as unknown[]);
63
+
64
+ const handle = applier.getHandle(1);
65
+ return { handle, callbacks: gestureCallbacksOf(handle), receivedEvents };
66
+ }
67
+
68
+ describe("Op.SetGestureDetector (native gesture support)", () => {
69
+ it("registers a real __SetGestureDetector call with the given type", () => {
70
+ const { handle } = mountWithGesture({ mode: "claim" });
71
+ expect(handle.gesture.type).toBe(7); // GESTURE_TYPE_CODES.native
72
+ });
73
+
74
+ it('"claim" policy claims on touches-down and never reconsiders', () => {
75
+ const { callbacks } = mountWithGesture({ mode: "claim" });
76
+ const controller = makeController();
77
+
78
+ callbacks.onTouchesDown(touchEvent(0, 0), controller);
79
+ callbacks.onTouchesMove(touchEvent(50, 0), controller);
80
+ callbacks.onTouchesMove(touchEvent(0, 50), controller); // even a vertical move — no reconsideration
81
+
82
+ expect(controller.calls).toEqual([{ fn: "__ConsumeGesture", args: [expect.anything(), expect.any(Number), { consume: true, inner: false }] }]);
83
+ });
84
+
85
+ it('"axis-lock" (referenceMoves: 0) decides on the first move, using touches-down as the reference', () => {
86
+ const { callbacks } = mountWithGesture({ mode: "axis-lock", axis: "horizontal", referenceMoves: 0 });
87
+ const controller = makeController();
88
+
89
+ callbacks.onTouchesDown(touchEvent(0, 0), controller);
90
+ callbacks.onTouchesMove(touchEvent(40, 5), controller); // mostly horizontal
91
+
92
+ expect(controller.calls.map((c) => c.fn)).toEqual(["__ConsumeGesture", "__ConsumeGesture"]);
93
+ expect(controller.calls[1].args[2]).toEqual({ consume: true, inner: false });
94
+ });
95
+
96
+ it('"axis-lock" releases and fails the gesture when the losing axis wins', () => {
97
+ const { callbacks } = mountWithGesture({ mode: "axis-lock", axis: "horizontal", referenceMoves: 0 });
98
+ const controller = makeController();
99
+
100
+ callbacks.onTouchesDown(touchEvent(0, 0), controller);
101
+ callbacks.onTouchesMove(touchEvent(5, 40), controller); // mostly vertical
102
+
103
+ // Claim eagerly on down, release once the axis loses, THEN fail —
104
+ // releasing the claim before failing matters: an ancestor (e.g. a
105
+ // <scroll-view>) must see the arena freed, not just "this gesture gave up".
106
+ expect(controller.calls.map((c) => c.fn)).toEqual(["__ConsumeGesture", "__ConsumeGesture", "__SetGestureState"]);
107
+ expect(controller.calls[1].args[2]).toEqual({ consume: false, inner: false });
108
+ expect(controller.calls[2].args[2]).toBe(2); // GestureState.fail (args: [handle, gestureId, state])
109
+ });
110
+
111
+ it('"axis-lock" (referenceMoves: 1) uses the first move as reference and decides on the second', () => {
112
+ const { callbacks } = mountWithGesture({ mode: "axis-lock", axis: "horizontal", referenceMoves: 1 });
113
+ const controller = makeController();
114
+
115
+ callbacks.onTouchesDown(touchEvent(0, 0), controller);
116
+ callbacks.onTouchesMove(touchEvent(10, 10), controller); // just records the reference — no decision yet
117
+ expect(controller.calls.map((c) => c.fn)).toEqual(["__ConsumeGesture"]);
118
+
119
+ callbacks.onTouchesMove(touchEvent(60, 15), controller); // horizontal relative to the FIRST move
120
+ expect(controller.calls.map((c) => c.fn)).toEqual(["__ConsumeGesture", "__ConsumeGesture"]);
121
+ });
122
+
123
+ it("forwards touches-down/move/up to the background thread as gesturedown/gesturemove/gestureup events", () => {
124
+ const { callbacks, receivedEvents } = mountWithGesture({ mode: "claim" });
125
+ const controller = makeController();
126
+
127
+ callbacks.onTouchesDown(touchEvent(1, 2), controller);
128
+ callbacks.onTouchesMove(touchEvent(3, 4), controller);
129
+ callbacks.onTouchesUp(touchEvent(5, 6), controller);
130
+
131
+ expect(receivedEvents).toEqual([
132
+ { type: "gesturedown", clientX: 1, clientY: 2 },
133
+ { type: "gesturemove", clientX: 3, clientY: 4 },
134
+ { type: "gestureup", clientX: 5, clientY: 6 },
135
+ ]);
136
+ });
137
+
138
+ it("a forwarded gesture event reaches the background-thread fake-dom node like any other event", () => {
139
+ lynxTestingEnv.switchToMainThread();
140
+ const pageId = __GetElementUniqueID(__CreatePage());
141
+ let forward: ((id: number, type: string, payload: unknown) => void) | null = null;
142
+ const applier = createPatchApplier(pageId, {
143
+ onEvent: (id, type, payload) => forward?.(id, type, payload),
144
+ });
145
+ applier.registerPageRoot(__CreateView(pageId));
146
+
147
+ lynxTestingEnv.switchToBackgroundThread();
148
+ const moves: number[] = [];
149
+ let lastOps: unknown[] | null = null;
150
+ const app = renderApp({
151
+ root: () =>
152
+ m("view", {
153
+ class: "target",
154
+ oncreate: (vnode: any) => vnode.dom.setGestureDetector("native", { mode: "claim" }),
155
+ ongesturemove: (e: any) => moves.push(e.clientX),
156
+ }),
157
+ sendPatch: (ops) => {
158
+ lastOps = ops;
159
+ },
160
+ });
161
+ forward = (id, type, payload: any) => {
162
+ const node = app.document.getNodeById(id);
163
+ node?.dispatchEvent({ type, currentTarget: node, ...payload });
164
+ };
165
+
166
+ lynxTestingEnv.switchToMainThread();
167
+ applier.applyPatch(lastOps as unknown[]);
168
+ const handle = applier.getHandle(1);
169
+ const callback = gestureCallbacksOf(handle).onTouchesMove;
170
+ const controller = makeController();
171
+ callback(touchEvent(42, 0), controller);
172
+
173
+ expect(moves).toEqual([42]);
174
+ });
175
+ });