mithril-lynx 2.0.2 → 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.
- package/package.json +13 -1
- package/src/apply-patch.js +230 -7
- package/src/backends/virtual-backend.js +14 -0
- package/src/fake-dom.js +96 -7
- package/src/list-support.d.ts +10 -0
- package/src/list-support.js +167 -0
- package/src/mount-redraw.d.ts +13 -0
- package/src/mount-redraw.js +7 -0
- package/src/patch-protocol.js +21 -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 +105 -0
- package/test/setup.ts +5 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mithril-lynx",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Mithril.js on Lynx: real mithril/render/render.js driven through a Lynx-backed fake DOM, with an explicit single commit hook (no conditional global flush) and three reload modes (data-light, structural-light, full). A complete rewrite of the previous mithril-lynx (0.0.x).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,6 +24,18 @@
|
|
|
24
24
|
"./request": {
|
|
25
25
|
"types": "./src/request.d.ts",
|
|
26
26
|
"default": "./src/request.js"
|
|
27
|
+
},
|
|
28
|
+
"./mount-redraw": {
|
|
29
|
+
"types": "./src/mount-redraw.d.ts",
|
|
30
|
+
"default": "./src/mount-redraw.js"
|
|
31
|
+
},
|
|
32
|
+
"./testing": {
|
|
33
|
+
"types": "./src/testing.d.ts",
|
|
34
|
+
"default": "./src/testing.js"
|
|
35
|
+
},
|
|
36
|
+
"./list-support": {
|
|
37
|
+
"types": "./src/list-support.d.ts",
|
|
38
|
+
"default": "./src/list-support.js"
|
|
27
39
|
}
|
|
28
40
|
},
|
|
29
41
|
"scripts": {
|
package/src/apply-patch.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// src/apply-patch.js
|
|
2
2
|
//
|
|
3
|
-
// The main-thread half of the patch protocol. Deliberately NOT a DOM
|
|
4
|
-
// never runs Mithril's render.js (only
|
|
5
|
-
// background.js) — it is a direct,
|
|
6
|
-
//
|
|
7
|
-
// own `snapshotPatchApply.js`
|
|
8
|
-
// §4.3): a switch over op
|
|
3
|
+
// The main-thread half of the patch protocol. Deliberately NOT a DOM for
|
|
4
|
+
// the app's OWN tree — it never runs Mithril's render.js for that (only
|
|
5
|
+
// the background thread does, see background.js) — it is a direct,
|
|
6
|
+
// low-level interpreter of the flat op array straight onto the real
|
|
7
|
+
// Element PAPI, in the spirit of ReactLynx's own `snapshotPatchApply.js`
|
|
8
|
+
// (see rspeedy-react-analysis/LYNX_PAPI_SPEC.md §4.3): a switch over op
|
|
9
|
+
// codes, one real PAPI call per case, nothing else. One deliberate
|
|
10
|
+
// exception: Op.CreateList's own cell content (see list-support.js) DOES
|
|
11
|
+
// run a real Mithril render pass right here, on this thread — the only way
|
|
12
|
+
// to satisfy native's synchronous componentAtIndex contract at all (see
|
|
13
|
+
// docs/native-papi/papi-06-virtualized-lists.md in mithril-lynx-ui).
|
|
9
14
|
//
|
|
10
15
|
// The exact `__Create*`/pageId contract below (one `pageId` shared by every
|
|
11
16
|
// element on a page, `__CreateView`/`__CreateText`/generic `__CreateElement`
|
|
@@ -18,6 +23,169 @@
|
|
|
18
23
|
// commit/reload layer (commit.js, reload/*.js), never in this mapping.
|
|
19
24
|
|
|
20
25
|
import { Op } from "./patch-protocol.js";
|
|
26
|
+
import { createNativeList } from "./list-support.js";
|
|
27
|
+
|
|
28
|
+
// --- Native gesture support (Op.SetGestureDetector) ------------------------
|
|
29
|
+
//
|
|
30
|
+
// See patch-protocol.js's own note and mithril-lynx-ui's
|
|
31
|
+
// docs/native-papi/papi-05-native-gestures.md for the full design writeup.
|
|
32
|
+
// Everything below runs on the MAIN thread, synchronously, inside a native
|
|
33
|
+
// gesture callback — never on the background thread, and never waits on a
|
|
34
|
+
// round trip to it. The arena-claim decision (arenaPolicy) is evaluated
|
|
35
|
+
// here using only the event's own coordinates; the resulting touches-down/
|
|
36
|
+
// move/up events are then forwarded to the background thread as ordinary
|
|
37
|
+
// events (via `onEvent`, the exact same callback Op.AddEvent already uses),
|
|
38
|
+
// so app code sees them as plain "gesturedown"/"gesturemove"/"gestureup"
|
|
39
|
+
// listeners with no gesture-specific machinery of its own.
|
|
40
|
+
|
|
41
|
+
const GESTURE_TYPE_CODES = { composed: -1, pan: 0, fling: 1, default: 2, tap: 3, longpress: 4, rotation: 5, pinch: 6, native: 7 };
|
|
42
|
+
const GestureState = { active: 1, fail: 2, end: 3 };
|
|
43
|
+
|
|
44
|
+
// Native does not call a gesture callback function directly — it calls a
|
|
45
|
+
// global `runWorklet(ctx, params)`, looking up the real function by
|
|
46
|
+
// `ctx._wkltId`. This is a real native requirement (confirmed on device by
|
|
47
|
+
// this project's own predecessor, mithril-lynx v1's gesture.js), not
|
|
48
|
+
// specific to any one package — every gesture callback has to be wrapped
|
|
49
|
+
// through this registry before being handed to __SetGestureDetector.
|
|
50
|
+
function ensureWorkletRuntime() {
|
|
51
|
+
if (globalThis.lynxWorkletImpl !== undefined) return;
|
|
52
|
+
globalThis.lynxWorkletImpl = { _workletMap: {} };
|
|
53
|
+
globalThis.registerWorklet = function (_type, id, fn) {
|
|
54
|
+
globalThis.lynxWorkletImpl._workletMap[id] = fn;
|
|
55
|
+
};
|
|
56
|
+
globalThis.runWorklet = function (ctx, params) {
|
|
57
|
+
if (typeof ctx !== "object" || ctx === null || !("_wkltId" in ctx)) return;
|
|
58
|
+
const fn = globalThis.lynxWorkletImpl._workletMap[ctx._wkltId];
|
|
59
|
+
if (typeof fn !== "function") return;
|
|
60
|
+
const args = Array.isArray(params) ? params : params != null ? [params] : [];
|
|
61
|
+
// A plain call, deliberately not .apply()/.call() — native's own
|
|
62
|
+
// `controller` argument throws if marshalled through either (same
|
|
63
|
+
// finding mithril-lynx v1's gesture.js already made).
|
|
64
|
+
return fn.bind(ctx)(...args);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let nextWorkletId = 1;
|
|
69
|
+
function wrapWorkletCallback(fn) {
|
|
70
|
+
ensureWorkletRuntime();
|
|
71
|
+
const id = "mithril-lynx-gesture-" + nextWorkletId++;
|
|
72
|
+
globalThis.registerWorklet("main-thread", id, fn);
|
|
73
|
+
return { _wkltId: id };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A small, generic claim/release policy — covers the two real shapes this
|
|
78
|
+
* project's consumers need, not an arbitrary one:
|
|
79
|
+
* - `{ mode: "claim" }` — claim on touches-down, never reconsider (a
|
|
80
|
+
* single-axis drag with nothing else competing for the gesture).
|
|
81
|
+
* - `{ mode: "axis-lock", axis: "horizontal" | "vertical", referenceMoves }`
|
|
82
|
+
* — claim eagerly on touches-down, then on the move `referenceMoves + 1`
|
|
83
|
+
* (0: decide using the down position as reference, right on the first
|
|
84
|
+
* move; 1: use the first move's own position as reference and decide on
|
|
85
|
+
* the second), release and fail the gesture if the dominant axis of the
|
|
86
|
+
* resulting delta doesn't match `axis`.
|
|
87
|
+
* Unverified on a real device (no device access this session) — the claim
|
|
88
|
+
* timing (down vs. first/second move) mirrors what mithril-lynx v1's own
|
|
89
|
+
* device-verified sheet.js/swipe-action.js/swiper.js already did; the NEW
|
|
90
|
+
* part, evaluating it here instead of in app code, has not been confirmed
|
|
91
|
+
* to feel the same on-device.
|
|
92
|
+
*/
|
|
93
|
+
function createArenaTracker(policy) {
|
|
94
|
+
const mode = (policy && policy.mode) || "claim";
|
|
95
|
+
let refX = null;
|
|
96
|
+
let refY = null;
|
|
97
|
+
let movesSeen = 0;
|
|
98
|
+
let decided = false;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
onDown(x, y, consume) {
|
|
102
|
+
consume(true);
|
|
103
|
+
if (mode === "axis-lock" && (policy.referenceMoves || 0) === 0) {
|
|
104
|
+
refX = x;
|
|
105
|
+
refY = y;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
onMove(x, y, consume, fail) {
|
|
109
|
+
if (mode !== "axis-lock" || decided) return;
|
|
110
|
+
if ((policy.referenceMoves || 0) === 1 && movesSeen === 0) {
|
|
111
|
+
refX = x;
|
|
112
|
+
refY = y;
|
|
113
|
+
movesSeen++;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
movesSeen++;
|
|
117
|
+
if (refX == null) return;
|
|
118
|
+
const dx = x - refX;
|
|
119
|
+
const dy = y - refY;
|
|
120
|
+
if (dx === 0 && dy === 0) return; // not enough signal yet
|
|
121
|
+
decided = true;
|
|
122
|
+
const isHorizontal = Math.abs(dx) >= Math.abs(dy);
|
|
123
|
+
const wins = policy.axis === "horizontal" ? isHorizontal : !isHorizontal;
|
|
124
|
+
if (wins) {
|
|
125
|
+
consume(true);
|
|
126
|
+
} else {
|
|
127
|
+
// Release the claim touches-down made eagerly, THEN fail —
|
|
128
|
+
// both, not just the latter: a bare fail() with the arena
|
|
129
|
+
// still marked "claimed" would keep blocking an ancestor
|
|
130
|
+
// (e.g. a <scroll-view>) from ever seeing this touch.
|
|
131
|
+
consume(false);
|
|
132
|
+
fail();
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function registerGestureDetector(handle, id, gestureId, gestureType, arenaPolicy, onEvent) {
|
|
139
|
+
const tracker = createArenaTracker(arenaPolicy);
|
|
140
|
+
const gestureTypeCode = typeof gestureType === "string" ? GESTURE_TYPE_CODES[gestureType] : gestureType;
|
|
141
|
+
|
|
142
|
+
function consume(controller, shouldClaim) {
|
|
143
|
+
if (controller != null && typeof controller.__ConsumeGesture === "function") {
|
|
144
|
+
controller.__ConsumeGesture(handle, gestureId, { consume: shouldClaim, inner: false });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function fail(controller) {
|
|
148
|
+
if (controller != null && typeof controller.__SetGestureState === "function") {
|
|
149
|
+
controller.__SetGestureState(handle, gestureId, GestureState.fail);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// timestamp: a real field native's own touch/gesture params already
|
|
153
|
+
// carry (mithril-lynx v1's gesture consumers already read
|
|
154
|
+
// event.params.timestamp for velocity calculations) — forwarded as-is
|
|
155
|
+
// rather than having a consumer approximate it from receipt time on
|
|
156
|
+
// the background thread, which would fold cross-thread forwarding
|
|
157
|
+
// latency into a velocity computation.
|
|
158
|
+
function coordsOf(event) {
|
|
159
|
+
const p = (event && event.params) || {};
|
|
160
|
+
return { clientX: p.clientX, clientY: p.clientY, timestamp: p.timestamp };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const callbacks = {
|
|
164
|
+
onTouchesDown: (event, controller) => {
|
|
165
|
+
const coords = coordsOf(event);
|
|
166
|
+
tracker.onDown(coords.clientX, coords.clientY, (claim) => consume(controller, claim));
|
|
167
|
+
onEvent?.(id, "gesturedown", coords);
|
|
168
|
+
},
|
|
169
|
+
onTouchesMove: (event, controller) => {
|
|
170
|
+
const coords = coordsOf(event);
|
|
171
|
+
tracker.onMove(coords.clientX, coords.clientY, (claim) => consume(controller, claim), () => fail(controller));
|
|
172
|
+
onEvent?.(id, "gesturemove", coords);
|
|
173
|
+
},
|
|
174
|
+
onTouchesUp: (event) => {
|
|
175
|
+
onEvent?.(id, "gestureup", coordsOf(event));
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
__SetAttribute(handle, "has-react-gesture", true);
|
|
180
|
+
__SetAttribute(handle, "flatten", false);
|
|
181
|
+
__SetGestureDetector(
|
|
182
|
+
handle,
|
|
183
|
+
gestureId,
|
|
184
|
+
gestureTypeCode,
|
|
185
|
+
{ callbacks: Object.keys(callbacks).map((name) => ({ name, callback: wrapWorkletCallback(callbacks[name]) })) },
|
|
186
|
+
{},
|
|
187
|
+
);
|
|
188
|
+
}
|
|
21
189
|
|
|
22
190
|
/**
|
|
23
191
|
* @param {number} pageId - `__GetElementUniqueID(pageElement)` of the real
|
|
@@ -101,7 +269,15 @@ export function createPatchApplier(pageId, { onEvent } = {}) {
|
|
|
101
269
|
const name = ops[i++];
|
|
102
270
|
const value = ops[i++];
|
|
103
271
|
const handle = handles.get(id);
|
|
272
|
+
// "class" and "id" each have their own dedicated PAPI call
|
|
273
|
+
// (__SetClasses/__SetID) — __SetAttribute itself rejects
|
|
274
|
+
// both ("Cannot use __SetAttribute for \"class\"/\"id\"").
|
|
275
|
+
// Found porting a component that assigns a native `id` for
|
|
276
|
+
// an imperative selector-query ref (see mithril-lynx-ui's
|
|
277
|
+
// docs/native-papi/papi-01-imperative-refs.md) — nothing
|
|
278
|
+
// in this rewrite's own test suite had set `id` before.
|
|
104
279
|
if (name === "class") __SetClasses(handle, value == null ? "" : value);
|
|
280
|
+
else if (name === "id") __SetID(handle, value == null ? null : value);
|
|
105
281
|
else __SetAttribute(handle, name, value);
|
|
106
282
|
break;
|
|
107
283
|
}
|
|
@@ -110,6 +286,7 @@ export function createPatchApplier(pageId, { onEvent } = {}) {
|
|
|
110
286
|
const name = ops[i++];
|
|
111
287
|
const handle = handles.get(id);
|
|
112
288
|
if (name === "class") __SetClasses(handle, "");
|
|
289
|
+
else if (name === "id") __SetID(handle, null);
|
|
113
290
|
else __SetAttribute(handle, name, null);
|
|
114
291
|
break;
|
|
115
292
|
}
|
|
@@ -168,6 +345,38 @@ export function createPatchApplier(pageId, { onEvent } = {}) {
|
|
|
168
345
|
i += 2;
|
|
169
346
|
break;
|
|
170
347
|
}
|
|
348
|
+
case Op.SetGestureDetector: {
|
|
349
|
+
const id = ops[i++];
|
|
350
|
+
const gestureId = ops[i++];
|
|
351
|
+
const gestureType = ops[i++];
|
|
352
|
+
const arenaPolicy = ops[i++];
|
|
353
|
+
const handle = handles.get(id);
|
|
354
|
+
registerGestureDetector(handle, id, gestureId, gestureType, arenaPolicy, onEvent);
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case Op.RemoveGestureDetector: {
|
|
358
|
+
const id = ops[i++];
|
|
359
|
+
const gestureId = ops[i++];
|
|
360
|
+
const handle = handles.get(id);
|
|
361
|
+
if (typeof __RemoveGestureDetector === "function") __RemoveGestureDetector(handle, gestureId);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
case Op.CreateList: {
|
|
365
|
+
const id = ops[i++];
|
|
366
|
+
const rendererKey = ops[i++];
|
|
367
|
+
const scrollOrientation = ops[i++];
|
|
368
|
+
const listType = ops[i++];
|
|
369
|
+
const spanCount = ops[i++];
|
|
370
|
+
handles.set(id, createNativeList(pageId, rendererKey, scrollOrientation, listType, spanCount, createPatchApplier, onEvent));
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case Op.SetListItems: {
|
|
374
|
+
const id = ops[i++];
|
|
375
|
+
const itemsJSON = ops[i++];
|
|
376
|
+
const listHandle = handles.get(id);
|
|
377
|
+
listHandle.__setItems(JSON.parse(itemsJSON));
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
171
380
|
default:
|
|
172
381
|
throw new Error(`[mithril-lynx] Unknown patch opcode: ${opcode}`);
|
|
173
382
|
}
|
|
@@ -175,5 +384,19 @@ export function createPatchApplier(pageId, { onEvent } = {}) {
|
|
|
175
384
|
__FlushElementTree();
|
|
176
385
|
}
|
|
177
386
|
|
|
178
|
-
return {
|
|
387
|
+
return {
|
|
388
|
+
registerPageRoot,
|
|
389
|
+
applyPatch,
|
|
390
|
+
/** The real PAPI element handle for a given background-side id, or
|
|
391
|
+
* `undefined` if nothing was ever created for it. Exists for tests
|
|
392
|
+
* (see mithril-lynx/testing) that need to correlate a fake-dom node's
|
|
393
|
+
* `_id` with the real element the testing environment's PAPI
|
|
394
|
+
* recording (mithril-lynx-v1's own installTestingPolyfills wraps
|
|
395
|
+
* every `__`-prefixed call regardless of which package called it, so
|
|
396
|
+
* this is how a v2 test finds "which of those calls targeted THIS
|
|
397
|
+
* element") — never needed by application code. */
|
|
398
|
+
getHandle(id) {
|
|
399
|
+
return handles.get(id);
|
|
400
|
+
},
|
|
401
|
+
};
|
|
179
402
|
}
|
|
@@ -69,6 +69,20 @@ export function createVirtualBackend() {
|
|
|
69
69
|
removeEvent(id, type) {
|
|
70
70
|
pushOp(ops, Op.RemoveEvent, id, type);
|
|
71
71
|
},
|
|
72
|
+
setGestureDetector(id, gestureId, gestureType, arenaPolicy) {
|
|
73
|
+
pushOp(ops, Op.SetGestureDetector, id, gestureId, gestureType, arenaPolicy);
|
|
74
|
+
},
|
|
75
|
+
removeGestureDetector(id, gestureId) {
|
|
76
|
+
pushOp(ops, Op.RemoveGestureDetector, id, gestureId);
|
|
77
|
+
},
|
|
78
|
+
createList(rendererKey, scrollOrientation, listType, spanCount) {
|
|
79
|
+
const id = nextId++;
|
|
80
|
+
pushOp(ops, Op.CreateList, id, rendererKey, scrollOrientation, listType, spanCount);
|
|
81
|
+
return id;
|
|
82
|
+
},
|
|
83
|
+
setListItems(id, items) {
|
|
84
|
+
pushOp(ops, Op.SetListItems, id, JSON.stringify(items));
|
|
85
|
+
},
|
|
72
86
|
/** Drains and returns the accumulated ops. Called once per commit. */
|
|
73
87
|
takeOps() {
|
|
74
88
|
if (ops.length === 0) return null;
|
package/src/fake-dom.js
CHANGED
|
@@ -65,6 +65,15 @@ class LynxContainerNode extends LynxNode {
|
|
|
65
65
|
return this._children[0] ?? null;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Real Mithril's render.js reads `fragment.childNodes.length` right
|
|
69
|
+
// after `insertDOM`'ing a multi-node children list into a
|
|
70
|
+
// `createDocumentFragment()` (createNodes' fragment-batching path) — a
|
|
71
|
+
// real DOM's `childNodes` is a live NodeList, but render.js only ever
|
|
72
|
+
// reads `.length` off it here, so the plain backing array is enough.
|
|
73
|
+
get childNodes() {
|
|
74
|
+
return this._children;
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
contains(other) {
|
|
69
78
|
let node = other;
|
|
70
79
|
while (node) {
|
|
@@ -121,6 +130,7 @@ class LynxContainerNode extends LynxNode {
|
|
|
121
130
|
function createStyleProxy(element) {
|
|
122
131
|
const methods = {
|
|
123
132
|
setProperty(name, value) {
|
|
133
|
+
element._styleEverSet = true;
|
|
124
134
|
element._backend.setStyleProperty(element._id, name, String(value));
|
|
125
135
|
},
|
|
126
136
|
removeProperty(name) {
|
|
@@ -140,6 +150,7 @@ function createStyleProxy(element) {
|
|
|
140
150
|
if (value === "" || value == null) {
|
|
141
151
|
element._backend.removeStyleProperty(element._id, name);
|
|
142
152
|
} else {
|
|
153
|
+
element._styleEverSet = true;
|
|
143
154
|
element._backend.setStyleProperty(element._id, name, String(value));
|
|
144
155
|
}
|
|
145
156
|
return true;
|
|
@@ -147,15 +158,33 @@ function createStyleProxy(element) {
|
|
|
147
158
|
});
|
|
148
159
|
}
|
|
149
160
|
|
|
161
|
+
// Module-level, not per-document: mirrors patch-protocol.js's own id spaces
|
|
162
|
+
// (element ids are per-backend, but a gesture id only needs to be unique
|
|
163
|
+
// within whatever set apply-patch.js's real __SetGestureDetector call sees
|
|
164
|
+
// on the main thread — a single incrementing counter is simplest).
|
|
165
|
+
let nextGestureId = 1;
|
|
166
|
+
|
|
150
167
|
export class LynxElement extends LynxContainerNode {
|
|
151
|
-
|
|
168
|
+
/**
|
|
169
|
+
* `listConfig`, when given, makes this a native virtualized list
|
|
170
|
+
* element instead of a plain one — see patch-protocol.js's
|
|
171
|
+
* Op.CreateList and docs/native-papi/papi-06-virtualized-lists.md
|
|
172
|
+
* (mithril-lynx-ui) for the full design. Not constructed directly;
|
|
173
|
+
* use LynxDocument#createNativeList().
|
|
174
|
+
*/
|
|
175
|
+
constructor(ownerDocument, backend, tag, ns, listConfig) {
|
|
152
176
|
super(ownerDocument);
|
|
153
177
|
this._backend = backend;
|
|
154
178
|
this.tag = tag;
|
|
155
179
|
this.namespaceURI = ns;
|
|
156
|
-
this._id =
|
|
180
|
+
this._id = listConfig
|
|
181
|
+
? backend.createList(listConfig.rendererKey, listConfig.scrollOrientation, listConfig.listType, listConfig.spanCount)
|
|
182
|
+
: ns
|
|
183
|
+
? backend.createElementNS(ns, tag)
|
|
184
|
+
: backend.createElement(tag);
|
|
157
185
|
ownerDocument._nodesById.set(this._id, this);
|
|
158
186
|
this._style = null;
|
|
187
|
+
this._styleEverSet = false;
|
|
159
188
|
this._listeners = Object.create(null);
|
|
160
189
|
// `hasPropertyKey` (CONTRACT.md §e) requires `"value" in vnode.dom` etc.
|
|
161
190
|
// to be true for the property-write fast path to apply to form
|
|
@@ -173,11 +202,20 @@ export class LynxElement extends LynxContainerNode {
|
|
|
173
202
|
set style(value) {
|
|
174
203
|
if (value == null || value === "") {
|
|
175
204
|
// `element.style = ""` (CONTRACT.md §f, lines 750-752): clear.
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
this
|
|
205
|
+
// render.js's own updateStyle() calls this UNCONDITIONALLY right
|
|
206
|
+
// before applying an object style, even on an element that never
|
|
207
|
+
// had any style at all (its "old is missing or a string, style is
|
|
208
|
+
// an object" branch — see mithril-runtime/render/render.js) — so
|
|
209
|
+
// without this guard, EVERY component with a plain object style
|
|
210
|
+
// prop (an extremely common pattern, not an edge case) would hit
|
|
211
|
+
// apply-patch.js's "bulk-clear not implemented" throw on its very
|
|
212
|
+
// first render. `_styleEverSet` (set by createStyleProxy whenever
|
|
213
|
+
// a real property is written) is exactly "was there anything to
|
|
214
|
+
// clear" — skip emitting the op at all when there wasn't; the
|
|
215
|
+
// throw still fires for the genuine case (an update actually
|
|
216
|
+
// replacing a previously-set style), where a real bulk-clear PAPI
|
|
217
|
+
// call would actually be needed and hasn't been validated yet.
|
|
218
|
+
if (this._styleEverSet) this._backend.removeStyleProperty(this._id, "*");
|
|
181
219
|
return;
|
|
182
220
|
}
|
|
183
221
|
if (typeof value !== "object") {
|
|
@@ -233,6 +271,31 @@ export class LynxElement extends LynxContainerNode {
|
|
|
233
271
|
this._backend.setAttributeNS(this._id, ns, name, value == null ? null : String(value));
|
|
234
272
|
}
|
|
235
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Registers a real native gesture detector on this element — see
|
|
276
|
+
* patch-protocol.js's Op.SetGestureDetector for the design. `type` is
|
|
277
|
+
* one of "pan"/"native"/... matching the native GestureType names.
|
|
278
|
+
* `arenaPolicy` decides claim/release timing on the main thread; the
|
|
279
|
+
* resulting touches-down/move/up events arrive back here as ordinary
|
|
280
|
+
* "gesturedown"/"gesturemove"/"gestureup" events — add plain listeners
|
|
281
|
+
* for those the same way as any other event. Returns an id to pass to
|
|
282
|
+
* removeGestureDetector().
|
|
283
|
+
*/
|
|
284
|
+
setGestureDetector(type, arenaPolicy) {
|
|
285
|
+
const gestureId = nextGestureId++;
|
|
286
|
+
this._backend.setGestureDetector(this._id, gestureId, type, arenaPolicy);
|
|
287
|
+
return gestureId;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
removeGestureDetector(gestureId) {
|
|
291
|
+
this._backend.removeGestureDetector(this._id, gestureId);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Only meaningful on an element created via LynxDocument#createNativeList(). */
|
|
295
|
+
setListItems(items) {
|
|
296
|
+
this._backend.setListItems(this._id, items);
|
|
297
|
+
}
|
|
298
|
+
|
|
236
299
|
addEventListener(type, listener) {
|
|
237
300
|
const isNew = !(type in this._listeners);
|
|
238
301
|
this._listeners[type] = listener;
|
|
@@ -298,6 +361,13 @@ export class LynxText extends LynxNode {
|
|
|
298
361
|
constructor(ownerDocument, backend, text) {
|
|
299
362
|
super(ownerDocument);
|
|
300
363
|
this._backend = backend;
|
|
364
|
+
// Mithril's render.js never reads `.nodeValue` back itself (it only
|
|
365
|
+
// ever WRITES it, on an update pass — see render.js's own updateText),
|
|
366
|
+
// so this had no effect on real rendering; it only broke anything
|
|
367
|
+
// ELSE reading a freshly-created text node's value before its first
|
|
368
|
+
// update (found writing a real device-verification test for
|
|
369
|
+
// mithril-lynx-ui — see that repo's test/v2-harness.ts).
|
|
370
|
+
this._text = text;
|
|
301
371
|
this._id = backend.createText(text);
|
|
302
372
|
}
|
|
303
373
|
|
|
@@ -367,6 +437,25 @@ export class LynxDocument extends LynxContainerNode {
|
|
|
367
437
|
createDocumentFragment() {
|
|
368
438
|
return new LynxFragment(this);
|
|
369
439
|
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* A native virtualized list — see patch-protocol.js's Op.CreateList.
|
|
443
|
+
* `rendererKey` must match a key registered on the MAIN thread via
|
|
444
|
+
* mithril-lynx/list-support's registerListRenderer() (see
|
|
445
|
+
* docs/native-papi/papi-06-virtualized-lists.md in mithril-lynx-ui):
|
|
446
|
+
* the renderer function itself can't cross the thread boundary, only
|
|
447
|
+
* this string key can. Call `.setListItems(items)` on the result to
|
|
448
|
+
* populate it — items must be JSON-serializable, since they DO cross
|
|
449
|
+
* the boundary, as data.
|
|
450
|
+
*/
|
|
451
|
+
createNativeList(rendererKey, options = {}) {
|
|
452
|
+
return new LynxElement(this, this._backend, "list", undefined, {
|
|
453
|
+
rendererKey,
|
|
454
|
+
scrollOrientation: options.scrollOrientation ?? "vertical",
|
|
455
|
+
listType: options.listType ?? "single",
|
|
456
|
+
spanCount: options.spanCount ?? 1,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
370
459
|
}
|
|
371
460
|
|
|
372
461
|
export function createLynxDocument(backend) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Ambient declaration for the ESM src/list-support.js.
|
|
2
|
+
//
|
|
3
|
+
// Import this from your app's main-thread.ts (alongside setupRenderer()
|
|
4
|
+
// from "mithril-lynx/main-thread") and call registerListRenderer() once
|
|
5
|
+
// per list your app uses — see that file's own header, and
|
|
6
|
+
// docs/native-papi/papi-06-virtualized-lists.md in mithril-lynx-ui, for
|
|
7
|
+
// why the render function itself has to be registered here rather than
|
|
8
|
+
// passed in from background.ts directly.
|
|
9
|
+
|
|
10
|
+
export function registerListRenderer(key: string, renderItem: (item: unknown, index: number) => unknown): void;
|
|
@@ -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;
|
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,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
|
/**
|
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
|
+
});
|
|
@@ -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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
15
|
-
target.lynx.getEngine = target.lynx.getNative;
|
|
7
|
+
import { installTestingPolyfills } from "../src/testing.js";
|
|
16
8
|
|
|
17
|
-
|
|
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;
|