mithril-lynx 2.0.1 → 2.5.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,167 @@
1
+ // src/list-support.js
2
+ //
3
+ // Runs ONLY on the main thread — imported from an app's own main-thread.ts,
4
+ // alongside setupRenderer() (see docs/native-papi/papi-06-virtualized-lists.md
5
+ // in mithril-lynx-ui for the full design). Registers the render function(s)
6
+ // a native virtualized list needs: the function itself can never cross the
7
+ // background-thread/main-thread boundary (real main and background threads
8
+ // are separate JS engine instances — no shared closures, only serializable
9
+ // messages), so an app registers it here by a string key instead, and
10
+ // mithril-lynx-ui's List component references that same key from
11
+ // background.ts.
12
+ //
13
+ // The native list contract this wires up (__CreateList/__UpdateListCallbacks,
14
+ // the synchronous componentAtIndex/enqueueComponent pair, recycling cells by
15
+ // a type key) is unchanged from mithril-lynx v1's own list.js — that part
16
+ // was already device-verified. What's different: each cell is rendered by a
17
+ // REAL, self-contained render pass entirely on THIS thread (the exact same
18
+ // pieces background.js uses for the app's own tree — mithril-runtime's real
19
+ // render(), fake-dom.js, a virtual backend — just applied to itself
20
+ // immediately via a nested patch applier, instead of crossing a channel).
21
+ // componentAtIndex's native contract is synchronous; a cross-thread round
22
+ // trip could never satisfy that, so this is the only architecture that can.
23
+ //
24
+ // KNOWN GAP, not solved by this file: an event handler (e.g. `ontap`)
25
+ // inside a renderItem()-produced vnode fires entirely on this thread, with
26
+ // no way back to the app's own state on the background thread — there is
27
+ // no background-thread fake-dom node for list cell content to dispatch
28
+ // through (unlike every other element in the app, which the background
29
+ // thread DOES know about). Reaching back into app state from inside a list
30
+ // cell needs a deliberate reporting convention, not built here yet.
31
+
32
+ import renderFactory from "mithril-runtime/render/render.js";
33
+ import { createLynxDocument } from "./fake-dom.js";
34
+ import { createVirtualBackend } from "./backends/virtual-backend.js";
35
+
36
+ const renderers = new Map();
37
+
38
+ /** Call once per list your app uses, from main-thread.ts — see this file's
39
+ * own header for why the render function itself can't just be imported
40
+ * from background.ts and passed across directly. */
41
+ export function registerListRenderer(key, renderItem) {
42
+ renderers.set(key, renderItem);
43
+ }
44
+
45
+ function typeKeyOf(vnode) {
46
+ return typeof vnode.tag === "string" ? vnode.tag : (vnode.tag && vnode.tag.name) || "default";
47
+ }
48
+
49
+ function makeCell(pageId, wrapperHandle, createPatchApplier) {
50
+ const backend = createVirtualBackend();
51
+ const document = createLynxDocument(backend);
52
+ const render = renderFactory();
53
+ const applier = createPatchApplier(pageId);
54
+ applier.registerPageRoot(wrapperHandle);
55
+ return { wrapper: wrapperHandle, backend, document, render, applier, typeKey: null };
56
+ }
57
+
58
+ /** Re-renders `vnode` into `cell`'s own persistent fake-dom document —
59
+ * Mithril's own diff (real, not simulated) computes the minimal update
60
+ * against whatever this cell showed before, exactly like a normal redraw,
61
+ * so recycling a cell for a different item never needs a manual "clear
62
+ * old content first" step. */
63
+ function renderCellVnode(cell, vnode) {
64
+ cell.render(cell.document, [vnode], () => {});
65
+ const ops = cell.backend.takeOps();
66
+ if (ops) cell.applier.applyPatch(ops);
67
+ }
68
+
69
+ /**
70
+ * @param {number} pageId
71
+ * @param {string} rendererKey
72
+ * @param {(pageId: number, options?: {onEvent?: Function}) => object} createPatchApplier
73
+ * - passed in rather than imported, to avoid a circular import with
74
+ * apply-patch.js (the only caller).
75
+ * @param {Function} [onEvent] - forwarded into each cell's own patch
76
+ * applier, same as the top-level one — see this file's own "known gap"
77
+ * note above for what this does NOT yet solve.
78
+ */
79
+ export function createNativeList(pageId, rendererKey, scrollOrientation, listType, spanCount, createPatchApplier, onEvent) {
80
+ const renderItem = renderers.get(rendererKey);
81
+ if (renderItem == null) {
82
+ throw new Error(
83
+ `[mithril-lynx] no list renderer registered for "${rendererKey}" — call registerListRenderer("${rendererKey}", ...) from main-thread.ts.`,
84
+ );
85
+ }
86
+
87
+ let items = [];
88
+ let count = 0;
89
+ const recycleMap = new Map(); // typeKey -> Map<sign, cell>
90
+ const signMap = new Map(); // sign -> cell
91
+
92
+ function bindFreshItem(listHandle, listId, cellIndex, opId, vnode, typeKey) {
93
+ const wrapperHandle = __CreateElement("list-item", pageId, {});
94
+ __SetAttribute(wrapperHandle, "item-key", String(cellIndex));
95
+ __AppendElement(listHandle, wrapperHandle);
96
+
97
+ const cell = makeCell(pageId, wrapperHandle, (pid) => createPatchApplier(pid, { onEvent }));
98
+ cell.typeKey = typeKey;
99
+ renderCellVnode(cell, vnode);
100
+
101
+ const sign = __GetElementUniqueID(wrapperHandle);
102
+ signMap.set(sign, cell);
103
+ __FlushElementTree(wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
104
+ return sign;
105
+ }
106
+
107
+ function bindRecycledItem(listId, cellIndex, opId, vnode, typeKey, pool) {
108
+ const [sign, cell] = pool.entries().next().value;
109
+ pool.delete(sign);
110
+ __SetAttribute(cell.wrapper, "item-key", String(cellIndex));
111
+ cell.typeKey = typeKey;
112
+ renderCellVnode(cell, vnode);
113
+ signMap.set(sign, cell);
114
+ __FlushElementTree(cell.wrapper, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
115
+ return sign;
116
+ }
117
+
118
+ function componentAtIndex(listHandle, listId, cellIndex, opId) {
119
+ if (cellIndex < 0 || cellIndex >= count) {
120
+ throw new Error(`[mithril-lynx] list: cellIndex ${cellIndex} out of range (itemCount=${count})`);
121
+ }
122
+ const vnode = renderItem(items[cellIndex], cellIndex);
123
+ const typeKey = typeKeyOf(vnode);
124
+ const pool = recycleMap.get(typeKey);
125
+ if (pool && pool.size > 0) return bindRecycledItem(listId, cellIndex, opId, vnode, typeKey, pool);
126
+ return bindFreshItem(listHandle, listId, cellIndex, opId, vnode, typeKey);
127
+ }
128
+
129
+ function enqueueComponent(_listHandle, _listId, sign) {
130
+ const cell = signMap.get(sign);
131
+ if (cell == null) return;
132
+ signMap.delete(sign);
133
+ if (!recycleMap.has(cell.typeKey)) recycleMap.set(cell.typeKey, new Map());
134
+ recycleMap.get(cell.typeKey).set(sign, cell);
135
+ }
136
+
137
+ function sendListInfo(listHandle, listId, insertAction, removeAction, updateAction) {
138
+ __SetAttribute(listHandle, "update-list-info", { insertAction, removeAction, updateAction });
139
+ __UpdateListCallbacks(listHandle, componentAtIndex, enqueueComponent);
140
+ }
141
+
142
+ const listHandle = __CreateList(pageId, componentAtIndex, enqueueComponent, {});
143
+ const listId = __GetElementUniqueID(listHandle);
144
+ __SetAttribute(listHandle, "scroll-orientation", scrollOrientation);
145
+ __SetAttribute(listHandle, "list-type", listType);
146
+ __SetAttribute(listHandle, "span-count", String(spanCount));
147
+
148
+ listHandle.__setItems = (nextItems) => {
149
+ const nextCount = nextItems.length;
150
+ items = nextItems;
151
+ if (nextCount === count) return;
152
+ if (nextCount > count) {
153
+ sendListInfo(
154
+ listHandle,
155
+ listId,
156
+ Array.from({ length: nextCount - count }, (_, i) => ({ position: count + i, type: "cell", "item-key": String(count + i) })),
157
+ [],
158
+ [],
159
+ );
160
+ } else {
161
+ sendListInfo(listHandle, listId, [], Array.from({ length: count - nextCount }, (_, i) => nextCount + i), []);
162
+ }
163
+ count = nextCount;
164
+ };
165
+
166
+ return listHandle;
167
+ }
@@ -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,27 @@ 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. rendererKey looks up a
47
+ // render function registered on the MAIN thread (mithril-lynx/
48
+ // list-support's registerListRenderer()) — the function itself can't
49
+ // cross the thread boundary, only this string key can.
50
+ CreateList: 16, // id, rendererKey, scrollOrientation, listType, spanCount
51
+ SetListItems: 17, // id, itemsJSON (items must be JSON-serializable — they DO cross the boundary, as data)
31
52
  });
32
53
 
33
54
  /**
@@ -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
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import m from "mithril";
3
+ import { createPatchApplier } from "../src/apply-patch.js";
4
+ import { registerListRenderer } from "../src/list-support.js";
5
+ import { Op } from "../src/patch-protocol.js";
6
+
7
+ // Op.CreateList end-to-end: registerListRenderer() (the main-thread.ts side
8
+ // of the design — see docs/native-papi/papi-06-virtualized-lists.md in
9
+ // mithril-lynx-ui) plus a raw CreateList/SetListItems op sequence applied
10
+ // directly (mithril-lynx-ui's own List component is what normally produces
11
+ // these ops from the background thread; this test drives apply-patch.js
12
+ // directly, the same level end-to-end.test.ts already operates at).
13
+ //
14
+ // componentAtIndex/enqueueComponent are native's own synchronous contract —
15
+ // driven directly here, exactly like mithril-lynx-ui's own list.test.ts
16
+ // already does against mithril-lynx-v1's list.js.
17
+
18
+ function requestCell(listHandle: any, index: number, opId = 1) {
19
+ const listId = __GetElementUniqueID(listHandle);
20
+ return listHandle.componentAtIndex(listHandle, listId, index, opId);
21
+ }
22
+
23
+ function releaseCell(listHandle: any, sign: unknown) {
24
+ const listId = __GetElementUniqueID(listHandle);
25
+ listHandle.enqueueComponent(listHandle, listId, sign);
26
+ }
27
+
28
+ function textOf(node: any): string {
29
+ if (node == null) return "";
30
+ let out = "";
31
+ for (let child = node.firstChild; child != null; child = child.nextSibling) {
32
+ out += child.nodeType === 3 ? child.nodeValue : textOf(child);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ function setupList(rendererKey: string) {
38
+ lynxTestingEnv.switchToMainThread();
39
+ const pageId = __GetElementUniqueID(__CreatePage());
40
+ const applier = createPatchApplier(pageId);
41
+ applier.registerPageRoot(__CreateView(pageId));
42
+ applier.applyPatch([Op.CreateList, 1, rendererKey, "vertical", "single", 1]);
43
+ const listHandle = applier.getHandle(1) as any;
44
+ return { applier, listHandle };
45
+ }
46
+
47
+ function setItems(applier: ReturnType<typeof createPatchApplier>, items: unknown[]) {
48
+ applier.applyPatch([Op.SetListItems, 1, JSON.stringify(items)]);
49
+ }
50
+
51
+ describe("Op.CreateList (native virtualized list support)", () => {
52
+ it("throws a clear error for an unregistered renderer key", () => {
53
+ lynxTestingEnv.switchToMainThread();
54
+ const pageId = __GetElementUniqueID(__CreatePage());
55
+ const applier = createPatchApplier(pageId);
56
+ applier.registerPageRoot(__CreateView(pageId));
57
+
58
+ expect(() => applier.applyPatch([Op.CreateList, 1, "nonexistent-key", "vertical", "single", 1])).toThrow(
59
+ /no list renderer registered for "nonexistent-key"/,
60
+ );
61
+ });
62
+
63
+ it("renders real content per cell via the registered renderer, driven by componentAtIndex", () => {
64
+ registerListRenderer("basic", (item: string, index: number) => m("text", {}, `${index}:${item}`));
65
+
66
+ const { applier, listHandle } = setupList("basic");
67
+ setItems(applier, ["a", "b", "c"]);
68
+
69
+ requestCell(listHandle, 0);
70
+ requestCell(listHandle, 1);
71
+ const cellWrapper = listHandle.children[1]; // second appended cell -> index 1
72
+ expect(textOf(cellWrapper)).toBe("1:b");
73
+ });
74
+
75
+ it("recycles a cell for a different index, and its content updates to match", () => {
76
+ registerListRenderer("recycle-basic", (item: string, index: number) => m("text", {}, `${index}:${item}`));
77
+
78
+ const { applier, listHandle } = setupList("recycle-basic");
79
+ setItems(applier, ["a", "b", "c", "d"]);
80
+
81
+ const signA = requestCell(listHandle, 0);
82
+ const wrapperA = listHandle.children[0];
83
+ expect(textOf(wrapperA)).toBe("0:a");
84
+
85
+ releaseCell(listHandle, signA);
86
+ const signD = requestCell(listHandle, 3);
87
+
88
+ // Recycled: the SAME sign/wrapper comes back, now showing the new index's content.
89
+ expect(signD).toBe(signA);
90
+ expect(textOf(wrapperA)).toBe("3:d");
91
+ });
92
+
93
+ it("SetListItems with a larger array requests the newly available indices without error", () => {
94
+ registerListRenderer("grow", (item: string, index: number) => m("text", {}, `${index}:${item}`));
95
+
96
+ const { applier, listHandle } = setupList("grow");
97
+
98
+ setItems(applier, ["a", "b"]);
99
+ expect(() => requestCell(listHandle, 1)).not.toThrow();
100
+ expect(() => requestCell(listHandle, 2)).toThrow(/cellIndex 2 out of range/);
101
+
102
+ setItems(applier, ["a", "b", "c"]);
103
+ expect(() => requestCell(listHandle, 2)).not.toThrow();
104
+ });
105
+ });
package/test/setup.ts CHANGED
@@ -1,25 +1,9 @@
1
1
  // test/setup.ts
2
2
  //
3
- // The minimal gap-fill on top of @lynx-js/testing-environment's own PAPI
4
- // polyfill same idea as the previous mithril-lynx's testing.js, scoped
5
- // down to only what apply-patch.js actually calls so far (no gestures/lists
6
- // yet, see the plan's non-goals). `@lynx-js/testing-environment` already
7
- // implements __CreateView/__CreateText/__CreateElement/__CreateRawText/
8
- // __AppendElement/__InsertElementBefore/__RemoveElement/__SetAttribute/
9
- // __SetClasses/__AddInlineStyle/__FlushElementTree/__GetElementUniqueID —
10
- // the one real gap is __AddEventListener (the testing environment only
11
- // implements the string/worklet-event __AddEvent family that ReactLynx
12
- // uses; mithril-lynx binds real JS function listeners directly).
3
+ // This package's own tests use the exact same polyfill it now publishes
4
+ // for everyone else (src/testing.js's installTestingPolyfills) see that
5
+ // file's header for what it covers and why.
13
6
 
14
- globalThis.onInjectMainThreadGlobals = (target: any) => {
15
- target.lynx.getEngine = target.lynx.getNative;
7
+ import { installTestingPolyfills } from "../src/testing.js";
16
8
 
17
- target.__AddEventListener = (node: any, name: string, handler: (...args: unknown[]) => unknown) => {
18
- node.__vanillaListeners ??= {};
19
- (node.__vanillaListeners[name] ??= new Set()).add(handler);
20
- };
21
-
22
- target.__RemoveEventListener = (node: any, name: string, handler: unknown) => {
23
- node.__vanillaListeners?.[name]?.delete(handler);
24
- };
25
- };
9
+ globalThis.onInjectMainThreadGlobals = installTestingPolyfills;