mithril-lynx 2.6.0 → 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/LICENSE.txt +21 -0
- package/PLAN_REVIEW.md +134 -0
- package/README.md +47 -7
- package/REQUEST.md +5 -2
- package/ROUTE.md +111 -16
- package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
- package/UPDATE_ANIMATION_GAP.md +133 -0
- package/informe-contrato-mithril-lynx.md +206 -0
- package/package.json +1 -9
- package/plugin.js +33 -0
- package/prompts-analisis-contrato-mithril-lynx.md +141 -0
- package/src/apply-patch.js +408 -68
- package/src/backends/virtual-backend.js +120 -22
- package/src/background.d.ts +40 -1
- package/src/background.js +116 -7
- package/src/channel.js +32 -7
- 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 +33 -3
- package/src/mount-redraw.d.ts +10 -0
- package/src/mount-redraw.js +69 -3
- package/src/patch-protocol.js +52 -13
- package/src/reload/version.js +10 -1
- package/src/request.d.ts +2 -0
- package/src/request.js +59 -3
- package/src/route.d.ts +52 -5
- package/src/route.js +236 -59
- package/src/testing.d.ts +14 -1
- package/src/testing.js +79 -11
- 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 +422 -100
- package/test/patch-protocol.test.ts +41 -0
- package/test/remove-event.test.ts +26 -0
- package/test/remove-subtree-cleanup.test.ts +75 -0
- package/test/route-hot-reload.test.ts +4 -2
- package/test/route.test.ts +206 -6
- package/test/setup.ts +10 -0
- 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_f48265d07ffenKG0GAz9ZRFvY6.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 -171
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,40 +268,205 @@ 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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
281
|
+
// id -> Map<event type, listener callback>. `__AddEventListener` needs the
|
|
282
|
+
// exact callback reference back when `__RemoveEventListener` runs, so the
|
|
283
|
+
// listener cannot be an inline closure re-created per Op.AddEvent — it is
|
|
284
|
+
// stored here and reused by the Op.RemoveEvent case.
|
|
285
|
+
const eventListeners = new Map();
|
|
286
|
+
|
|
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
|
+
*/
|
|
223
370
|
function registerRoot(id, handle) {
|
|
224
371
|
handles.set(id, handle);
|
|
225
372
|
}
|
|
226
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
|
+
*/
|
|
227
379
|
function registerPageRoot(pageElementHandle) {
|
|
228
380
|
registerRoot(0, pageElementHandle);
|
|
229
381
|
}
|
|
230
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
|
+
*/
|
|
231
388
|
function createElementHandle(tag) {
|
|
232
389
|
if (tag === "view") return __CreateView(pageId);
|
|
233
390
|
if (tag === "text") return __CreateText(pageId);
|
|
234
391
|
return __CreateElement(tag, pageId, {});
|
|
235
392
|
}
|
|
236
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
|
+
|
|
237
459
|
/**
|
|
238
460
|
* Applies one commit's worth of ops, then — unless this applier was
|
|
239
461
|
* created with `flush: false` (see this function's own constructor
|
|
240
462
|
* options above) — flushes exactly once with the bare, whole-page
|
|
241
463
|
* `__FlushElementTree()`; that call is the real commit for the ONE
|
|
242
|
-
* top-level applier per page, never looked up through a global.
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
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.
|
|
247
470
|
*/
|
|
248
471
|
function applyPatch(ops) {
|
|
249
472
|
for (let i = 0; i < ops.length; ) {
|
|
@@ -252,6 +475,14 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
252
475
|
case Op.CreateElement: {
|
|
253
476
|
const tag = ops[i++];
|
|
254
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 });
|
|
255
486
|
handles.set(id, createElementHandle(tag));
|
|
256
487
|
break;
|
|
257
488
|
}
|
|
@@ -278,18 +509,40 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
278
509
|
const refId = ops[i++];
|
|
279
510
|
const parent = handles.get(parentId);
|
|
280
511
|
const child = handles.get(childId);
|
|
281
|
-
|
|
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) {
|
|
282
525
|
__AppendElement(parent, child);
|
|
283
526
|
} else {
|
|
284
527
|
__InsertElementBefore(parent, child, handles.get(refId));
|
|
285
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);
|
|
286
536
|
break;
|
|
287
537
|
}
|
|
288
538
|
case Op.RemoveChild: {
|
|
289
539
|
const parentId = ops[i++];
|
|
290
540
|
const childId = ops[i++];
|
|
291
|
-
|
|
292
|
-
|
|
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);
|
|
293
546
|
break;
|
|
294
547
|
}
|
|
295
548
|
case Op.SetAttribute: {
|
|
@@ -306,7 +559,11 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
306
559
|
// in this rewrite's own test suite had set `id` before.
|
|
307
560
|
if (name === "class") __SetClasses(handle, value == null ? "" : value);
|
|
308
561
|
else if (name === "id") __SetID(handle, value == null ? null : value);
|
|
309
|
-
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
|
+
}
|
|
310
567
|
break;
|
|
311
568
|
}
|
|
312
569
|
case Op.RemoveAttribute: {
|
|
@@ -315,7 +572,11 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
315
572
|
const handle = handles.get(id);
|
|
316
573
|
if (name === "class") __SetClasses(handle, "");
|
|
317
574
|
else if (name === "id") __SetID(handle, null);
|
|
318
|
-
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
|
+
}
|
|
319
580
|
break;
|
|
320
581
|
}
|
|
321
582
|
case Op.SetAttributeNS: {
|
|
@@ -336,14 +597,14 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
336
597
|
case Op.RemoveStyleProperty: {
|
|
337
598
|
const id = ops[i++];
|
|
338
599
|
const name = ops[i++];
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// 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.
|
|
344
604
|
if (name === "*") {
|
|
345
605
|
throw new Error(
|
|
346
|
-
"[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.",
|
|
347
608
|
);
|
|
348
609
|
}
|
|
349
610
|
__AddInlineStyle(handles.get(id), name, "");
|
|
@@ -359,18 +620,62 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
359
620
|
const id = ops[i++];
|
|
360
621
|
const type = ops[i++];
|
|
361
622
|
const handle = handles.get(id);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
623
|
+
/**
|
|
624
|
+
* Forwards a native event to the background thread.
|
|
625
|
+
* @param {*} nativeEvent - The native event object.
|
|
626
|
+
* @returns {void}
|
|
627
|
+
*/
|
|
628
|
+
const listener = (nativeEvent) => {
|
|
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
|
+
}
|
|
644
|
+
};
|
|
645
|
+
let byType = eventListeners.get(id);
|
|
646
|
+
if (byType == null) eventListeners.set(id, (byType = new Map()));
|
|
647
|
+
byType.set(type, listener);
|
|
648
|
+
__AddEventListener(handle, type, listener, {});
|
|
365
649
|
break;
|
|
366
650
|
}
|
|
367
651
|
case Op.RemoveEvent: {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
652
|
+
const id = ops[i++];
|
|
653
|
+
const type = ops[i++];
|
|
654
|
+
const handle = handles.get(id);
|
|
655
|
+
const byType = eventListeners.get(id);
|
|
656
|
+
const listener = byType ? byType.get(type) : undefined;
|
|
657
|
+
if (typeof __RemoveEventListener === "function") {
|
|
658
|
+
// Native Fiber requires the options argument (>= 4 params) and
|
|
659
|
+
// derives the binding slot from it — pass the same `{}` the
|
|
660
|
+
// Op.AddEvent case uses so add/remove target the same slot.
|
|
661
|
+
if (listener != null) __RemoveEventListener(handle, type, listener, {});
|
|
662
|
+
} else if (listener != null) {
|
|
663
|
+
// Failing loudly is deliberate: without __RemoveEventListener a
|
|
664
|
+
// remove→re-add cycle on a kept element would accumulate
|
|
665
|
+
// duplicate native listeners and fire the handler N times per
|
|
666
|
+
// event. A silent no-op here is exactly the class of bug the
|
|
667
|
+
// whole project refuses to ship.
|
|
668
|
+
throw new Error(
|
|
669
|
+
"[mithril-lynx] __RemoveEventListener is not available on this runtime, " +
|
|
670
|
+
"so Op.RemoveEvent cannot be applied — a conditional event handler " +
|
|
671
|
+
"would leak duplicate listeners. Keep the handler constant or " +
|
|
672
|
+
"remove the whole element instead.",
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
if (byType != null) {
|
|
676
|
+
byType.delete(type);
|
|
677
|
+
if (byType.size === 0) eventListeners.delete(id);
|
|
678
|
+
}
|
|
374
679
|
break;
|
|
375
680
|
}
|
|
376
681
|
case Op.SetGestureDetector: {
|
|
@@ -379,7 +684,10 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
379
684
|
const gestureType = ops[i++];
|
|
380
685
|
const arenaPolicy = ops[i++];
|
|
381
686
|
const handle = handles.get(id);
|
|
382
|
-
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);
|
|
383
691
|
break;
|
|
384
692
|
}
|
|
385
693
|
case Op.RemoveGestureDetector: {
|
|
@@ -387,29 +695,46 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
387
695
|
const gestureId = ops[i++];
|
|
388
696
|
const handle = handles.get(id);
|
|
389
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
|
+
}
|
|
390
705
|
break;
|
|
391
706
|
}
|
|
392
|
-
case Op.
|
|
707
|
+
case Op.InvokeUIMethod: {
|
|
393
708
|
const id = ops[i++];
|
|
394
|
-
const
|
|
395
|
-
const
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
handles.set(id, handle);
|
|
399
|
-
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 });
|
|
400
713
|
break;
|
|
401
714
|
}
|
|
402
|
-
case Op.
|
|
715
|
+
case Op.SetInputValue: {
|
|
403
716
|
const id = ops[i++];
|
|
404
|
-
const
|
|
405
|
-
|
|
717
|
+
const value = ops[i++];
|
|
718
|
+
const seq = ops[i++];
|
|
719
|
+
pendingInvokes.push({ id, method: "setValue", params: { value }, callbackId: 0, seq });
|
|
406
720
|
break;
|
|
407
721
|
}
|
|
408
722
|
default:
|
|
409
723
|
throw new Error(`[mithril-lynx] Unknown patch opcode: ${opcode}`);
|
|
410
724
|
}
|
|
411
725
|
}
|
|
726
|
+
// Each list that changed tells native about it before the commit.
|
|
727
|
+
for (const list of lists.values()) list.flushUpdates();
|
|
412
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();
|
|
413
738
|
}
|
|
414
739
|
|
|
415
740
|
return {
|
|
@@ -423,9 +748,24 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
423
748
|
* recording (mithril-lynx-v1's own installTestingPolyfills wraps
|
|
424
749
|
* every `__`-prefixed call regardless of which package called it, so
|
|
425
750
|
* this is how a v2 test finds "which of those calls targeted THIS
|
|
426
|
-
* 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
|
+
*/
|
|
427
755
|
getHandle(id) {
|
|
428
756
|
return handles.get(id);
|
|
429
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
|
+
},
|
|
430
770
|
};
|
|
431
771
|
}
|