mithril-lynx 2.6.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/FETCH_INVESTIGATION.md +3 -2
- package/INPUT.md +115 -0
- package/README.md +47 -8
- package/ROUTE.md +111 -16
- package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
- package/UPDATE_ANIMATION_GAP.md +133 -0
- package/package.json +1 -9
- package/plugin.js +33 -0
- package/src/apply-patch.js +369 -60
- package/src/backends/virtual-backend.js +120 -22
- package/src/background.d.ts +26 -0
- package/src/background.js +116 -7
- package/src/channel.js +22 -5
- package/src/commit.js +9 -0
- package/src/dev-reload-client.js +46 -0
- package/src/dev-transport-noop.js +3 -0
- package/src/fake-dom.js +559 -61
- package/src/list-attributes.js +69 -0
- package/src/list-runtime.js +425 -0
- package/src/main-thread.js +18 -1
- package/src/mount-redraw.js +14 -1
- package/src/patch-protocol.js +41 -16
- package/src/reload/version.js +10 -1
- package/src/request.js +38 -0
- package/src/route.d.ts +30 -2
- package/src/route.js +214 -56
- package/src/testing.d.ts +14 -1
- package/src/testing.js +56 -1
- package/test/event-render-coalescing.test.ts +117 -0
- package/test/gesture.test.ts +75 -0
- package/test/input.test.ts +388 -0
- package/test/list.test.ts +417 -113
- package/test/remove-subtree-cleanup.test.ts +75 -0
- package/test/route-hot-reload.test.ts +2 -1
- package/test/route.test.ts +201 -4
- package/test/style-clear.test.ts +97 -0
- package/.omo/plans/m-request-fetch-lynx.md +0 -306
- package/.omo/plans/m-route-en-memoria.md +0 -397
- package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
- package/.omo/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
- package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +0 -10
- package/src/list-cell.d.ts +0 -20
- package/src/list-cell.js +0 -73
- package/src/list-support.d.ts +0 -13
- package/src/list-support.js +0 -197
package/src/apply-patch.js
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// low-level interpreter of the flat op array straight onto the real
|
|
7
7
|
// Element PAPI, in the spirit of ReactLynx's own `snapshotPatchApply.js`
|
|
8
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.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
9
|
+
// codes, one real PAPI call per case, nothing else. The one element that
|
|
10
|
+
// needs more is Lynx's native `<list>`: its `list-item` children are not
|
|
11
|
+
// inserted natively here but handed to list-runtime.js, which tells native
|
|
12
|
+
// about them through `update-list-info` and attaches each one only when
|
|
13
|
+
// native asks for it (componentAtIndex).
|
|
14
14
|
//
|
|
15
15
|
// The exact `__Create*`/pageId contract below (one `pageId` shared by every
|
|
16
16
|
// element on a page, `__CreateView`/`__CreateText`/generic `__CreateElement`
|
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
// allows (§2 non-goals) — the bug this rewrite fixes lives in the
|
|
23
23
|
// commit/reload layer (commit.js, reload/*.js), never in this mapping.
|
|
24
24
|
|
|
25
|
-
import { Op } from "./patch-protocol.js";
|
|
26
|
-
import {
|
|
25
|
+
import { FORM_FIELD_TAGS, INVOKE_RESULT_EVENT, Op } from "./patch-protocol.js";
|
|
26
|
+
import { LIST_ITEM_VIRTUAL_ATTRIBUTES } from "./list-attributes.js";
|
|
27
|
+
import { createListRuntime, isListItemPlatformAttribute } from "./list-runtime.js";
|
|
27
28
|
|
|
28
29
|
// --- Native gesture support (Op.SetGestureDetector) ------------------------
|
|
29
30
|
//
|
|
@@ -47,6 +48,10 @@ const GestureState = { active: 1, fail: 2, end: 3 };
|
|
|
47
48
|
// this project's own predecessor, mithril-lynx v1's gesture.js), not
|
|
48
49
|
// specific to any one package — every gesture callback has to be wrapped
|
|
49
50
|
// through this registry before being handed to __SetGestureDetector.
|
|
51
|
+
/**
|
|
52
|
+
* Installs the global worklet registry native requires (`registerWorklet`/`runWorklet`) if not already present.
|
|
53
|
+
* @returns {void}
|
|
54
|
+
*/
|
|
50
55
|
function ensureWorkletRuntime() {
|
|
51
56
|
if (globalThis.lynxWorkletImpl !== undefined) return;
|
|
52
57
|
globalThis.lynxWorkletImpl = { _workletMap: {} };
|
|
@@ -66,6 +71,11 @@ function ensureWorkletRuntime() {
|
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
let nextWorkletId = 1;
|
|
74
|
+
/**
|
|
75
|
+
* Registers a gesture callback in the worklet registry under a fresh id.
|
|
76
|
+
* @param {Function} fn - The callback native should invoke.
|
|
77
|
+
* @returns {{_wkltId: string}} The worklet context object to hand to `__SetGestureDetector`.
|
|
78
|
+
*/
|
|
69
79
|
function wrapWorkletCallback(fn) {
|
|
70
80
|
ensureWorkletRuntime();
|
|
71
81
|
const id = "mithril-lynx-gesture-" + nextWorkletId++;
|
|
@@ -73,6 +83,18 @@ function wrapWorkletCallback(fn) {
|
|
|
73
83
|
return { _wkltId: id };
|
|
74
84
|
}
|
|
75
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Deletes worklets from the registry. A native callback that still arrives
|
|
88
|
+
* for a deleted id is a no-op: `runWorklet` ignores unknown ids.
|
|
89
|
+
* @param {string[]} ids - The worklet ids to delete.
|
|
90
|
+
* @returns {void}
|
|
91
|
+
*/
|
|
92
|
+
function unregisterWorklets(ids) {
|
|
93
|
+
const impl = globalThis.lynxWorkletImpl;
|
|
94
|
+
if (impl === undefined) return;
|
|
95
|
+
for (const id of ids) delete impl._workletMap[id];
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
/**
|
|
77
99
|
* A small, generic claim/release policy — covers the two real shapes this
|
|
78
100
|
* project's consumers need, not an arbitrary one:
|
|
@@ -89,6 +111,8 @@ function wrapWorkletCallback(fn) {
|
|
|
89
111
|
* device-verified sheet.js/swipe-action.js/swiper.js already did; the NEW
|
|
90
112
|
* part, evaluating it here instead of in app code, has not been confirmed
|
|
91
113
|
* to feel the same on-device.
|
|
114
|
+
* @param {{mode?: "claim"|"axis-lock", axis?: "horizontal"|"vertical", referenceMoves?: number}} [policy] - The claim/release policy; defaults to `{ mode: "claim" }`.
|
|
115
|
+
* @returns {{onDown: (x: number, y: number, consume: (claim: boolean) => void) => void, onMove: (x: number, y: number, consume: (claim: boolean) => void, fail: () => void) => void}} The tracker driven by native touch events.
|
|
92
116
|
*/
|
|
93
117
|
function createArenaTracker(policy) {
|
|
94
118
|
const mode = (policy && policy.mode) || "claim";
|
|
@@ -98,6 +122,13 @@ function createArenaTracker(policy) {
|
|
|
98
122
|
let decided = false;
|
|
99
123
|
|
|
100
124
|
return {
|
|
125
|
+
/**
|
|
126
|
+
* Handles touches-down: claims the gesture and records the reference point when applicable.
|
|
127
|
+
* @param {number} x - Touch `clientX`.
|
|
128
|
+
* @param {number} y - Touch `clientY`.
|
|
129
|
+
* @param {(claim: boolean) => void} consume - Claims (`true`) or releases (`false`) the gesture arena.
|
|
130
|
+
* @returns {void}
|
|
131
|
+
*/
|
|
101
132
|
onDown(x, y, consume) {
|
|
102
133
|
consume(true);
|
|
103
134
|
if (mode === "axis-lock" && (policy.referenceMoves || 0) === 0) {
|
|
@@ -105,6 +136,14 @@ function createArenaTracker(policy) {
|
|
|
105
136
|
refY = y;
|
|
106
137
|
}
|
|
107
138
|
},
|
|
139
|
+
/**
|
|
140
|
+
* Handles touches-move: for `axis-lock`, decides once whether the dominant axis matches, otherwise releases and fails the gesture.
|
|
141
|
+
* @param {number} x - Touch `clientX`.
|
|
142
|
+
* @param {number} y - Touch `clientY`.
|
|
143
|
+
* @param {(claim: boolean) => void} consume - Claims (`true`) or releases (`false`) the gesture arena.
|
|
144
|
+
* @param {() => void} fail - Marks the gesture as failed.
|
|
145
|
+
* @returns {void}
|
|
146
|
+
*/
|
|
108
147
|
onMove(x, y, consume, fail) {
|
|
109
148
|
if (mode !== "axis-lock" || decided) return;
|
|
110
149
|
if ((policy.referenceMoves || 0) === 1 && movesSeen === 0) {
|
|
@@ -135,15 +174,34 @@ function createArenaTracker(policy) {
|
|
|
135
174
|
};
|
|
136
175
|
}
|
|
137
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Registers a native gesture detector on an element and forwards its touch events to the background thread.
|
|
179
|
+
* @param {*} handle - The real PAPI element handle.
|
|
180
|
+
* @param {number} id - The background-side element id.
|
|
181
|
+
* @param {number} gestureId - The gesture id allocated on the background thread.
|
|
182
|
+
* @param {string|number} gestureType - A gesture type name (e.g. `"pan"`) or its numeric code.
|
|
183
|
+
* @param {{mode?: string, axis?: string, referenceMoves?: number}} [arenaPolicy] - The claim/release policy.
|
|
184
|
+
* @param {(id: number, type: string, payload: unknown) => void} [onEvent] - Receives `gesturedown`/`gesturemove`/`gestureup` events.
|
|
185
|
+
* @returns {string[]} The ids of the worklets registered for this detector, to pass to `unregisterWorklets` when it goes away.
|
|
186
|
+
*/
|
|
138
187
|
function registerGestureDetector(handle, id, gestureId, gestureType, arenaPolicy, onEvent) {
|
|
139
188
|
const tracker = createArenaTracker(arenaPolicy);
|
|
140
189
|
const gestureTypeCode = typeof gestureType === "string" ? GESTURE_TYPE_CODES[gestureType] : gestureType;
|
|
141
190
|
|
|
191
|
+
/**
|
|
192
|
+
* @param {*} controller - The native gesture controller passed to the callback.
|
|
193
|
+
* @param {boolean} shouldClaim - Whether to claim (`true`) or release (`false`) the arena.
|
|
194
|
+
* @returns {void}
|
|
195
|
+
*/
|
|
142
196
|
function consume(controller, shouldClaim) {
|
|
143
197
|
if (controller != null && typeof controller.__ConsumeGesture === "function") {
|
|
144
198
|
controller.__ConsumeGesture(handle, gestureId, { consume: shouldClaim, inner: false });
|
|
145
199
|
}
|
|
146
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* @param {*} controller - The native gesture controller passed to the callback.
|
|
203
|
+
* @returns {void}
|
|
204
|
+
*/
|
|
147
205
|
function fail(controller) {
|
|
148
206
|
if (controller != null && typeof controller.__SetGestureState === "function") {
|
|
149
207
|
controller.__SetGestureState(handle, gestureId, GestureState.fail);
|
|
@@ -155,6 +213,10 @@ function registerGestureDetector(handle, id, gestureId, gestureType, arenaPolicy
|
|
|
155
213
|
// rather than having a consumer approximate it from receipt time on
|
|
156
214
|
// the background thread, which would fold cross-thread forwarding
|
|
157
215
|
// latency into a velocity computation.
|
|
216
|
+
/**
|
|
217
|
+
* @param {{params?: {clientX?: number, clientY?: number, timestamp?: number}}} [event] - A native touch/gesture event.
|
|
218
|
+
* @returns {{clientX: number|undefined, clientY: number|undefined, timestamp: number|undefined}} The event's coordinates and timestamp.
|
|
219
|
+
*/
|
|
158
220
|
function coordsOf(event) {
|
|
159
221
|
const p = (event && event.params) || {};
|
|
160
222
|
return { clientX: p.clientX, clientY: p.clientY, timestamp: p.timestamp };
|
|
@@ -176,15 +238,12 @@ function registerGestureDetector(handle, id, gestureId, gestureType, arenaPolicy
|
|
|
176
238
|
},
|
|
177
239
|
};
|
|
178
240
|
|
|
241
|
+
const wrapped = Object.keys(callbacks).map((name) => ({ name, callback: wrapWorkletCallback(callbacks[name]) }));
|
|
242
|
+
|
|
179
243
|
__SetAttribute(handle, "has-react-gesture", true);
|
|
180
244
|
__SetAttribute(handle, "flatten", false);
|
|
181
|
-
__SetGestureDetector(
|
|
182
|
-
|
|
183
|
-
gestureId,
|
|
184
|
-
gestureTypeCode,
|
|
185
|
-
{ callbacks: Object.keys(callbacks).map((name) => ({ name, callback: wrapWorkletCallback(callbacks[name]) })) },
|
|
186
|
-
{},
|
|
187
|
-
);
|
|
245
|
+
__SetGestureDetector(handle, gestureId, gestureTypeCode, { callbacks: wrapped }, {});
|
|
246
|
+
return wrapped.map((entry) => entry.callback._wkltId);
|
|
188
247
|
}
|
|
189
248
|
|
|
190
249
|
/**
|
|
@@ -192,17 +251,16 @@ function registerGestureDetector(handle, id, gestureId, gestureType, arenaPolicy
|
|
|
192
251
|
* page this applier is attached to. Every element this applier creates
|
|
193
252
|
* belongs to that one page — see CONTRACT.md / lynx-mithril-shim.js.
|
|
194
253
|
* @param {object} [options]
|
|
195
|
-
* @param {
|
|
254
|
+
* @param {(id: number, type: string, payload: unknown, seq?: number) => void} [options.onEvent] -
|
|
255
|
+
* Forwards a native event to the background thread. `seq` is set for
|
|
256
|
+
* events of an <input>/<textarea>: the number of native `input` events
|
|
257
|
+
* that field has fired so far (see Op.SetInputValue). Also carries
|
|
258
|
+
* Op.InvokeUIMethod results, as `INVOKE_RESULT_EVENT` events on id 0.
|
|
196
259
|
* @param {boolean} [options.flush] - Whether `applyPatch` calls the bare,
|
|
197
260
|
* whole-page `__FlushElementTree()` after applying its ops. Defaults to
|
|
198
261
|
* `true` — the right default for the ONE real top-level applier per page
|
|
199
|
-
* (main-thread.js's own use). `false`
|
|
200
|
-
*
|
|
201
|
-
* `__FlushElementTree(wrapperHandle, {triggerLayout, operationID,
|
|
202
|
-
* elementID, listID})` call list-support.js already makes right after —
|
|
203
|
-
* calling the bare, whole-page flush too, from inside native's own
|
|
204
|
-
* synchronous componentAtIndex callback, is a second, unrelated flush this
|
|
205
|
-
* applier was never meant to trigger on that call site's behalf.
|
|
262
|
+
* (main-thread.js's own use). `false` when the caller commits by itself.
|
|
263
|
+
* @returns {{registerPageRoot: (pageElementHandle: *) => void, registerRoot: (id: number, handle: *) => void, applyPatch: (ops: unknown[]) => void, getHandle: (id: number) => *, dispose: () => void}} The patch applier.
|
|
206
264
|
*/
|
|
207
265
|
export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
208
266
|
// id (as allocated by the background's virtual backend) -> real PAPI
|
|
@@ -210,11 +268,15 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
210
268
|
// fake-dom.js's LynxDocument) — pre-seeded here so the very first
|
|
211
269
|
// InsertBefore/AppendChild targeting id 0 has somewhere real to land.
|
|
212
270
|
const handles = new Map();
|
|
213
|
-
// list id -> its
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
const
|
|
271
|
+
// list id -> its list-runtime.js state — kept here, not as a property on
|
|
272
|
+
// the list's own handle: a real native list handle does not reliably hold
|
|
273
|
+
// a custom property across calls (confirmed on device), only a plain Map
|
|
274
|
+
// does.
|
|
275
|
+
const lists = new Map();
|
|
276
|
+
// list-item id -> its platform info (item-key, full-span, sticky-*, …),
|
|
277
|
+
// collected from its attribute ops. Kept even before the item is inserted
|
|
278
|
+
// into a list: Mithril sets an element's attributes before inserting it.
|
|
279
|
+
const itemInfo = new Map();
|
|
218
280
|
|
|
219
281
|
// id -> Map<event type, listener callback>. `__AddEventListener` needs the
|
|
220
282
|
// exact callback reference back when `__RemoveEventListener` runs, so the
|
|
@@ -222,34 +284,189 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
222
284
|
// stored here and reused by the Op.RemoveEvent case.
|
|
223
285
|
const eventListeners = new Map();
|
|
224
286
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
287
|
+
// id -> Map<gestureId, worklet ids>. Every worklet closure holds its
|
|
288
|
+
// element's native handle, so the registry entries must be deleted when
|
|
289
|
+
// the detector (Op.RemoveGestureDetector) or its element (releaseSubtree)
|
|
290
|
+
// goes away — otherwise the global registry keeps the handle alive forever.
|
|
291
|
+
const gestureWorklets = new Map();
|
|
292
|
+
|
|
293
|
+
// The tree shape as seen through InsertBefore/RemoveChild ops. Mithril's
|
|
294
|
+
// removeDOM (render.js) only ever calls removeChild on the ROOT of a
|
|
295
|
+
// removed subtree — descendants never get an Op.RemoveChild of their own —
|
|
296
|
+
// so without this, every descendant's `handles`/`eventListeners`/
|
|
297
|
+
// `lists`/`itemInfo`/`gestureWorklets` entry would outlive the removal, and
|
|
298
|
+
// `handles` (plus the worklet registry) would keep the whole detached
|
|
299
|
+
// native subtree reachable from JS.
|
|
300
|
+
const childrenOf = new Map(); // parent id -> Set<child id>
|
|
301
|
+
const parentOf = new Map(); // child id -> parent id
|
|
302
|
+
|
|
303
|
+
// <input>/<textarea> id -> { seq, value, composing }:
|
|
304
|
+
// - `value`/`composing`: the text (and IME state) JS last knew the field
|
|
305
|
+
// to hold — from its last forwarded `input` event or the last setValue
|
|
306
|
+
// run here. A native `input` event that doesn't change them is not
|
|
307
|
+
// forwarded: native fires such events by itself (a <textarea> fires
|
|
308
|
+
// `input ""` while it is first flushed; every setValue is echoed back
|
|
309
|
+
// as an `input`), and on the web an `input` event only means the user
|
|
310
|
+
// changed the text. Forwarding them would hand the app a spurious ""
|
|
311
|
+
// at creation — wiping a controlled field's state.
|
|
312
|
+
// - `seq`: how many text-changing `input` events it has fired. Forwarded
|
|
313
|
+
// with every event of the field, echoed back by the background in
|
|
314
|
+
// Op.SetInputValue: a value computed before the latest keystroke is
|
|
315
|
+
// stale and dropped (the keystroke's own event re-renders), which keeps
|
|
316
|
+
// a programmatic value from overwriting text the user typed meanwhile —
|
|
317
|
+
// the job lynx-ui's Input does with a main-thread readonly lock.
|
|
318
|
+
const fields = new Map();
|
|
319
|
+
|
|
320
|
+
// Op.InvokeUIMethod / Op.SetInputValue calls of the current patch, in op
|
|
321
|
+
// order. They run after the patch's flush: a UI method needs the native
|
|
322
|
+
// element to exist, and an element created by this same patch only does
|
|
323
|
+
// once the element tree has been flushed.
|
|
324
|
+
let pendingInvokes = [];
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Drops every per-id entry for `id` and all of its known descendants.
|
|
328
|
+
* Iterative (explicit stack) so a deep subtree can't overflow the call
|
|
329
|
+
* stack. Native listeners and gesture detectors are not removed one by
|
|
330
|
+
* one: they live on the native elements, which go away with the detached
|
|
331
|
+
* subtree once JS stops holding their handles (including through the
|
|
332
|
+
* gesture worklets, which are unregistered here). A removed native list
|
|
333
|
+
* also gets its callbacks neutralized.
|
|
334
|
+
* @param {number} id - The root of the removed subtree.
|
|
335
|
+
* @returns {void}
|
|
336
|
+
*/
|
|
337
|
+
function releaseSubtree(id) {
|
|
338
|
+
const stack = [id];
|
|
339
|
+
while (stack.length > 0) {
|
|
340
|
+
const current = stack.pop();
|
|
341
|
+
const children = childrenOf.get(current);
|
|
342
|
+
if (children != null) {
|
|
343
|
+
for (const childId of children) stack.push(childId);
|
|
344
|
+
childrenOf.delete(current);
|
|
345
|
+
}
|
|
346
|
+
parentOf.delete(current);
|
|
347
|
+
handles.delete(current);
|
|
348
|
+
fields.delete(current);
|
|
349
|
+
eventListeners.delete(current);
|
|
350
|
+
itemInfo.delete(current);
|
|
351
|
+
const list = lists.get(current);
|
|
352
|
+
if (list != null) {
|
|
353
|
+
list.destroy();
|
|
354
|
+
lists.delete(current);
|
|
355
|
+
}
|
|
356
|
+
const gestures = gestureWorklets.get(current);
|
|
357
|
+
if (gestures != null) {
|
|
358
|
+
for (const workletIds of gestures.values()) unregisterWorklets(workletIds);
|
|
359
|
+
gestureWorklets.delete(current);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** General form: seed the mapping for any id, not just the page root
|
|
365
|
+
* (tests use it to mount a subtree under an element they created).
|
|
366
|
+
* @param {number} id - The background-side id to alias.
|
|
367
|
+
* @param {*} handle - The real PAPI element handle it maps to.
|
|
368
|
+
* @returns {void}
|
|
369
|
+
*/
|
|
229
370
|
function registerRoot(id, handle) {
|
|
230
371
|
handles.set(id, handle);
|
|
231
372
|
}
|
|
232
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Maps the reserved id `0` to the real page element.
|
|
376
|
+
* @param {*} pageElementHandle - The page element created by `__CreatePage`.
|
|
377
|
+
* @returns {void}
|
|
378
|
+
*/
|
|
233
379
|
function registerPageRoot(pageElementHandle) {
|
|
234
380
|
registerRoot(0, pageElementHandle);
|
|
235
381
|
}
|
|
236
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Creates the real PAPI element for a tag.
|
|
385
|
+
* @param {string} tag - The tag name (`view` and `text` use their dedicated creators).
|
|
386
|
+
* @returns {*} The new element handle.
|
|
387
|
+
*/
|
|
237
388
|
function createElementHandle(tag) {
|
|
238
389
|
if (tag === "view") return __CreateView(pageId);
|
|
239
390
|
if (tag === "text") return __CreateText(pageId);
|
|
240
391
|
return __CreateElement(tag, pageId, {});
|
|
241
392
|
}
|
|
242
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Records one platform attribute of a list item: it travels to native in
|
|
396
|
+
* `update-list-info` (list-runtime.js), and is also set on the element
|
|
397
|
+
* itself unless it is one of the list-only virtual attributes.
|
|
398
|
+
* @param {number} id - The list-item id.
|
|
399
|
+
* @param {string} name - A platform attribute name.
|
|
400
|
+
* @param {*} value - Its value; `null` removes it.
|
|
401
|
+
* @returns {void}
|
|
402
|
+
*/
|
|
403
|
+
function setItemInfo(id, name, value) {
|
|
404
|
+
const info = itemInfo.get(id);
|
|
405
|
+
if (value == null) delete info[name];
|
|
406
|
+
else info[name] = value;
|
|
407
|
+
const list = lists.get(parentOf.get(id));
|
|
408
|
+
if (list != null) list.updateInfo(id, info);
|
|
409
|
+
if (!LIST_ITEM_VIRTUAL_ATTRIBUTES.includes(name)) __SetAttribute(handles.get(id), name, value == null ? null : value);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Sends an Op.InvokeUIMethod result back to the background thread.
|
|
414
|
+
* @param {number} callbackId - The background's callback id (never 0).
|
|
415
|
+
* @param {number} code - `0` on success, the native error code otherwise.
|
|
416
|
+
* @param {unknown} data - The method's result, or the error detail.
|
|
417
|
+
* @returns {void}
|
|
418
|
+
*/
|
|
419
|
+
function sendInvokeResult(callbackId, code, data) {
|
|
420
|
+
onEvent?.(0, INVOKE_RESULT_EVENT, { callbackId, code, data });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Runs the UI method calls queued by the patch just applied, in op order.
|
|
425
|
+
* A call whose element was removed by the same patch is skipped (and its
|
|
426
|
+
* caller told so); a SetInputValue made stale by a newer keystroke is
|
|
427
|
+
* dropped.
|
|
428
|
+
* @returns {boolean} Whether any native method was called.
|
|
429
|
+
*/
|
|
430
|
+
function runPendingInvokes() {
|
|
431
|
+
if (pendingInvokes.length === 0) return false;
|
|
432
|
+
const calls = pendingInvokes;
|
|
433
|
+
pendingInvokes = [];
|
|
434
|
+
let invoked = false;
|
|
435
|
+
for (const call of calls) {
|
|
436
|
+
const handle = handles.get(call.id);
|
|
437
|
+
if (handle == null) {
|
|
438
|
+
if (call.callbackId) sendInvokeResult(call.callbackId, -1, "mithril-lynx: the element was removed before the UI method could run");
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const field = call.seq !== undefined ? fields.get(call.id) : undefined;
|
|
442
|
+
if (field != null) {
|
|
443
|
+
if (call.seq < field.seq) continue;
|
|
444
|
+
field.value = call.params.value;
|
|
445
|
+
}
|
|
446
|
+
if (typeof __InvokeUIMethod !== "function") {
|
|
447
|
+
if (call.callbackId) sendInvokeResult(call.callbackId, -1, "mithril-lynx: __InvokeUIMethod is not available on this runtime");
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const callbackId = call.callbackId;
|
|
451
|
+
__InvokeUIMethod(handle, call.method, call.params ?? {}, (res) => {
|
|
452
|
+
if (callbackId) sendInvokeResult(callbackId, res?.code ?? 0, res?.data);
|
|
453
|
+
});
|
|
454
|
+
invoked = true;
|
|
455
|
+
}
|
|
456
|
+
return invoked;
|
|
457
|
+
}
|
|
458
|
+
|
|
243
459
|
/**
|
|
244
460
|
* Applies one commit's worth of ops, then — unless this applier was
|
|
245
461
|
* created with `flush: false` (see this function's own constructor
|
|
246
462
|
* options above) — flushes exactly once with the bare, whole-page
|
|
247
463
|
* `__FlushElementTree()`; that call is the real commit for the ONE
|
|
248
|
-
* top-level applier per page, never looked up through a global.
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
464
|
+
* top-level applier per page, never looked up through a global. Every
|
|
465
|
+
* native list that changed sends its `update-list-info` just before
|
|
466
|
+
* that flush.
|
|
467
|
+
* @param {unknown[]} ops - A flat op array (opcode followed by its arguments, repeated).
|
|
468
|
+
* @returns {void}
|
|
469
|
+
* @throws {Error} If an unknown opcode is encountered, or `Op.RemoveEvent` needs `__RemoveEventListener` and it is unavailable.
|
|
253
470
|
*/
|
|
254
471
|
function applyPatch(ops) {
|
|
255
472
|
for (let i = 0; i < ops.length; ) {
|
|
@@ -258,6 +475,14 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
258
475
|
case Op.CreateElement: {
|
|
259
476
|
const tag = ops[i++];
|
|
260
477
|
const id = ops[i++];
|
|
478
|
+
if (tag === "list") {
|
|
479
|
+
const list = createListRuntime(pageId);
|
|
480
|
+
lists.set(id, list);
|
|
481
|
+
handles.set(id, list.handle);
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
if (tag === "list-item") itemInfo.set(id, {});
|
|
485
|
+
else if (FORM_FIELD_TAGS.includes(tag)) fields.set(id, { seq: 0, value: "", composing: false });
|
|
261
486
|
handles.set(id, createElementHandle(tag));
|
|
262
487
|
break;
|
|
263
488
|
}
|
|
@@ -284,18 +509,40 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
284
509
|
const refId = ops[i++];
|
|
285
510
|
const parent = handles.get(parentId);
|
|
286
511
|
const child = handles.get(childId);
|
|
287
|
-
|
|
512
|
+
const previousParentId = parentOf.get(childId);
|
|
513
|
+
const list = lists.get(parentId);
|
|
514
|
+
// Leaving a list for another parent: the old list drops it
|
|
515
|
+
// (and detaches it natively if it was on screen).
|
|
516
|
+
if (previousParentId !== undefined && previousParentId !== parentId) {
|
|
517
|
+
const previousList = lists.get(previousParentId);
|
|
518
|
+
if (previousList != null) previousList.remove(childId);
|
|
519
|
+
else if (list != null) __RemoveElement(handles.get(previousParentId), child);
|
|
520
|
+
}
|
|
521
|
+
if (list != null) {
|
|
522
|
+
// List children are attached natively only on demand.
|
|
523
|
+
list.insert(childId, child, itemInfo.get(childId) ?? {}, refId);
|
|
524
|
+
} else if (refId === -1) {
|
|
288
525
|
__AppendElement(parent, child);
|
|
289
526
|
} else {
|
|
290
527
|
__InsertElementBefore(parent, child, handles.get(refId));
|
|
291
528
|
}
|
|
529
|
+
// A move (the child already had a parent) detaches it from
|
|
530
|
+
// the old parent's set first.
|
|
531
|
+
if (previousParentId !== undefined) childrenOf.get(previousParentId)?.delete(childId);
|
|
532
|
+
let siblings = childrenOf.get(parentId);
|
|
533
|
+
if (siblings == null) childrenOf.set(parentId, (siblings = new Set()));
|
|
534
|
+
siblings.add(childId);
|
|
535
|
+
parentOf.set(childId, parentId);
|
|
292
536
|
break;
|
|
293
537
|
}
|
|
294
538
|
case Op.RemoveChild: {
|
|
295
539
|
const parentId = ops[i++];
|
|
296
540
|
const childId = ops[i++];
|
|
297
|
-
|
|
298
|
-
|
|
541
|
+
const list = lists.get(parentId);
|
|
542
|
+
if (list != null) list.remove(childId);
|
|
543
|
+
else __RemoveElement(handles.get(parentId), handles.get(childId));
|
|
544
|
+
childrenOf.get(parentId)?.delete(childId);
|
|
545
|
+
releaseSubtree(childId);
|
|
299
546
|
break;
|
|
300
547
|
}
|
|
301
548
|
case Op.SetAttribute: {
|
|
@@ -312,7 +559,11 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
312
559
|
// in this rewrite's own test suite had set `id` before.
|
|
313
560
|
if (name === "class") __SetClasses(handle, value == null ? "" : value);
|
|
314
561
|
else if (name === "id") __SetID(handle, value == null ? null : value);
|
|
315
|
-
else
|
|
562
|
+
else if (itemInfo.has(id) && isListItemPlatformAttribute(name)) setItemInfo(id, name, value);
|
|
563
|
+
else {
|
|
564
|
+
if (name === "update-animation" && lists.has(id)) lists.get(id).setUpdateAnimation(value);
|
|
565
|
+
__SetAttribute(handle, name, value);
|
|
566
|
+
}
|
|
316
567
|
break;
|
|
317
568
|
}
|
|
318
569
|
case Op.RemoveAttribute: {
|
|
@@ -321,7 +572,11 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
321
572
|
const handle = handles.get(id);
|
|
322
573
|
if (name === "class") __SetClasses(handle, "");
|
|
323
574
|
else if (name === "id") __SetID(handle, null);
|
|
324
|
-
else
|
|
575
|
+
else if (itemInfo.has(id) && isListItemPlatformAttribute(name)) setItemInfo(id, name, null);
|
|
576
|
+
else {
|
|
577
|
+
if (name === "update-animation" && lists.has(id)) lists.get(id).setUpdateAnimation(null);
|
|
578
|
+
__SetAttribute(handle, name, null);
|
|
579
|
+
}
|
|
325
580
|
break;
|
|
326
581
|
}
|
|
327
582
|
case Op.SetAttributeNS: {
|
|
@@ -342,14 +597,14 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
342
597
|
case Op.RemoveStyleProperty: {
|
|
343
598
|
const id = ops[i++];
|
|
344
599
|
const name = ops[i++];
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// as a test failure rather than a mystery on-device.
|
|
600
|
+
// fake-dom.js expands `element.style = ""` into one op per
|
|
601
|
+
// applied property, so a bulk `"*"` name reaching here means
|
|
602
|
+
// a desynced or foreign op producer — a protocol invariant
|
|
603
|
+
// violation, not a gap to paper over.
|
|
350
604
|
if (name === "*") {
|
|
351
605
|
throw new Error(
|
|
352
|
-
"[mithril-lynx]
|
|
606
|
+
"[mithril-lynx] Protocol error: bulk style clear ('*') should never reach apply-patch — " +
|
|
607
|
+
"fake-dom.js must expand it to individual RemoveStyleProperty ops.",
|
|
353
608
|
);
|
|
354
609
|
}
|
|
355
610
|
__AddInlineStyle(handles.get(id), name, "");
|
|
@@ -365,8 +620,27 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
365
620
|
const id = ops[i++];
|
|
366
621
|
const type = ops[i++];
|
|
367
622
|
const handle = handles.get(id);
|
|
623
|
+
/**
|
|
624
|
+
* Forwards a native event to the background thread.
|
|
625
|
+
* @param {*} nativeEvent - The native event object.
|
|
626
|
+
* @returns {void}
|
|
627
|
+
*/
|
|
368
628
|
const listener = (nativeEvent) => {
|
|
369
|
-
|
|
629
|
+
const field = fields.get(id);
|
|
630
|
+
if (field != null) {
|
|
631
|
+
if (type === "input") {
|
|
632
|
+
const detail = (nativeEvent && nativeEvent.detail) || {};
|
|
633
|
+
const value = typeof detail.value === "string" ? detail.value : field.value;
|
|
634
|
+
const composing = detail.isComposing === true;
|
|
635
|
+
if (value === field.value && composing === field.composing) return;
|
|
636
|
+
field.seq++;
|
|
637
|
+
field.value = value;
|
|
638
|
+
field.composing = composing;
|
|
639
|
+
}
|
|
640
|
+
onEvent?.(id, type, nativeEvent, field.seq);
|
|
641
|
+
} else {
|
|
642
|
+
onEvent?.(id, type, nativeEvent);
|
|
643
|
+
}
|
|
370
644
|
};
|
|
371
645
|
let byType = eventListeners.get(id);
|
|
372
646
|
if (byType == null) eventListeners.set(id, (byType = new Map()));
|
|
@@ -410,7 +684,10 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
410
684
|
const gestureType = ops[i++];
|
|
411
685
|
const arenaPolicy = ops[i++];
|
|
412
686
|
const handle = handles.get(id);
|
|
413
|
-
registerGestureDetector(handle, id, gestureId, gestureType, arenaPolicy, onEvent);
|
|
687
|
+
const workletIds = registerGestureDetector(handle, id, gestureId, gestureType, arenaPolicy, onEvent);
|
|
688
|
+
let byGesture = gestureWorklets.get(id);
|
|
689
|
+
if (byGesture == null) gestureWorklets.set(id, (byGesture = new Map()));
|
|
690
|
+
byGesture.set(gestureId, workletIds);
|
|
414
691
|
break;
|
|
415
692
|
}
|
|
416
693
|
case Op.RemoveGestureDetector: {
|
|
@@ -418,29 +695,46 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
418
695
|
const gestureId = ops[i++];
|
|
419
696
|
const handle = handles.get(id);
|
|
420
697
|
if (typeof __RemoveGestureDetector === "function") __RemoveGestureDetector(handle, gestureId);
|
|
698
|
+
const byGesture = gestureWorklets.get(id);
|
|
699
|
+
const workletIds = byGesture ? byGesture.get(gestureId) : undefined;
|
|
700
|
+
if (workletIds != null) {
|
|
701
|
+
unregisterWorklets(workletIds);
|
|
702
|
+
byGesture.delete(gestureId);
|
|
703
|
+
if (byGesture.size === 0) gestureWorklets.delete(id);
|
|
704
|
+
}
|
|
421
705
|
break;
|
|
422
706
|
}
|
|
423
|
-
case Op.
|
|
707
|
+
case Op.InvokeUIMethod: {
|
|
424
708
|
const id = ops[i++];
|
|
425
|
-
const
|
|
426
|
-
const
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
handles.set(id, handle);
|
|
430
|
-
listSetters.set(id, setCells);
|
|
709
|
+
const method = ops[i++];
|
|
710
|
+
const params = ops[i++];
|
|
711
|
+
const callbackId = ops[i++];
|
|
712
|
+
pendingInvokes.push({ id, method, params, callbackId });
|
|
431
713
|
break;
|
|
432
714
|
}
|
|
433
|
-
case Op.
|
|
715
|
+
case Op.SetInputValue: {
|
|
434
716
|
const id = ops[i++];
|
|
435
|
-
const
|
|
436
|
-
|
|
717
|
+
const value = ops[i++];
|
|
718
|
+
const seq = ops[i++];
|
|
719
|
+
pendingInvokes.push({ id, method: "setValue", params: { value }, callbackId: 0, seq });
|
|
437
720
|
break;
|
|
438
721
|
}
|
|
439
722
|
default:
|
|
440
723
|
throw new Error(`[mithril-lynx] Unknown patch opcode: ${opcode}`);
|
|
441
724
|
}
|
|
442
725
|
}
|
|
726
|
+
// Each list that changed tells native about it before the commit.
|
|
727
|
+
for (const list of lists.values()) list.flushUpdates();
|
|
443
728
|
if (flush) __FlushElementTree();
|
|
729
|
+
// On-screen items a list just removed can only be detached once native
|
|
730
|
+
// has processed that removal (see list-runtime.js's remove()).
|
|
731
|
+
let detached = false;
|
|
732
|
+
for (const list of lists.values()) if (list.detachRemoved()) detached = true;
|
|
733
|
+
if (detached && flush) __FlushElementTree();
|
|
734
|
+
// UI methods run once the elements they target exist natively; a
|
|
735
|
+
// method's own UI changes are committed by one more flush (the same
|
|
736
|
+
// invoke-then-flush order as ReactLynx's main-thread Element.invoke()).
|
|
737
|
+
if (runPendingInvokes() && flush) __FlushElementTree();
|
|
444
738
|
}
|
|
445
739
|
|
|
446
740
|
return {
|
|
@@ -454,9 +748,24 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
454
748
|
* recording (mithril-lynx-v1's own installTestingPolyfills wraps
|
|
455
749
|
* every `__`-prefixed call regardless of which package called it, so
|
|
456
750
|
* this is how a v2 test finds "which of those calls targeted THIS
|
|
457
|
-
* element") — never needed by application code.
|
|
751
|
+
* element") — never needed by application code.
|
|
752
|
+
* @param {number} id - The background-side element id.
|
|
753
|
+
* @returns {*} The real element handle, or `undefined` when none exists.
|
|
754
|
+
*/
|
|
458
755
|
getHandle(id) {
|
|
459
756
|
return handles.get(id);
|
|
460
757
|
},
|
|
758
|
+
/**
|
|
759
|
+
* Neutralizes every native list's callbacks — for the page's
|
|
760
|
+
* `__DestroyLifetime`, after which native must not call back into
|
|
761
|
+
* this applier's state.
|
|
762
|
+
* @returns {void}
|
|
763
|
+
*/
|
|
764
|
+
dispose() {
|
|
765
|
+
for (const list of lists.values()) list.destroy();
|
|
766
|
+
lists.clear();
|
|
767
|
+
fields.clear();
|
|
768
|
+
pendingInvokes = [];
|
|
769
|
+
},
|
|
461
770
|
};
|
|
462
771
|
}
|