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.
- package/.omo/run-continuation/ses_f48265d07ffenKG0GAz9ZRFvY6.json +10 -0
- package/package.json +17 -1
- package/src/apply-patch.js +268 -16
- package/src/backends/virtual-backend.js +28 -0
- package/src/fake-dom.js +99 -7
- package/src/list-cell.d.ts +20 -0
- package/src/list-cell.js +73 -0
- package/src/list-support.d.ts +13 -0
- package/src/list-support.js +171 -0
- package/src/mount-redraw.d.ts +13 -0
- package/src/mount-redraw.js +7 -0
- package/src/patch-protocol.js +62 -0
- package/src/testing.d.ts +16 -0
- package/src/testing.js +48 -0
- package/test/gesture.test.ts +175 -0
- package/test/list.test.ts +133 -0
- package/test/setup.ts +5 -21
package/src/list-cell.js
ADDED
|
@@ -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;
|
package/src/mount-redraw.js
CHANGED
|
@@ -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
|
package/src/patch-protocol.js
CHANGED
|
@@ -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
|
+
}
|
package/src/testing.d.ts
ADDED
|
@@ -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
|
+
});
|