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.
Files changed (46) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/README.md +47 -8
  5. package/ROUTE.md +111 -16
  6. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  7. package/UPDATE_ANIMATION_GAP.md +133 -0
  8. package/package.json +1 -9
  9. package/plugin.js +33 -0
  10. package/src/apply-patch.js +369 -60
  11. package/src/backends/virtual-backend.js +120 -22
  12. package/src/background.d.ts +26 -0
  13. package/src/background.js +116 -7
  14. package/src/channel.js +22 -5
  15. package/src/commit.js +9 -0
  16. package/src/dev-reload-client.js +46 -0
  17. package/src/dev-transport-noop.js +3 -0
  18. package/src/fake-dom.js +559 -61
  19. package/src/list-attributes.js +69 -0
  20. package/src/list-runtime.js +425 -0
  21. package/src/main-thread.js +18 -1
  22. package/src/mount-redraw.js +14 -1
  23. package/src/patch-protocol.js +41 -16
  24. package/src/reload/version.js +10 -1
  25. package/src/request.js +38 -0
  26. package/src/route.d.ts +30 -2
  27. package/src/route.js +214 -56
  28. package/src/testing.d.ts +14 -1
  29. package/src/testing.js +56 -1
  30. package/test/event-render-coalescing.test.ts +117 -0
  31. package/test/gesture.test.ts +75 -0
  32. package/test/input.test.ts +388 -0
  33. package/test/list.test.ts +417 -113
  34. package/test/remove-subtree-cleanup.test.ts +75 -0
  35. package/test/route-hot-reload.test.ts +2 -1
  36. package/test/route.test.ts +201 -4
  37. package/test/style-clear.test.ts +97 -0
  38. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  39. package/.omo/plans/m-route-en-memoria.md +0 -397
  40. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  41. package/.omo/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
  42. package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +0 -10
  43. package/src/list-cell.d.ts +0 -20
  44. package/src/list-cell.js +0 -73
  45. package/src/list-support.d.ts +0 -13
  46. package/src/list-support.js +0 -197
@@ -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. Op.CreateList's own
10
- // cell content (list-support.js) is no exception to that: componentAtIndex
11
- // replays ops the background thread already computed (list-cell.js), the
12
- // same way this function replays the app's own top-level tree — see
13
- // docs/native-papi/papi-06-virtualized-lists.md in mithril-lynx-ui.
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 { createNativeList } from "./list-support.js";
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
- handle,
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 {Function} [options.onEvent]
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` for a per-cell applier
200
- * (list-support.js): a list cell's real commit point is the list-specific
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 setCells(cells) function (list-support.js) — kept here,
214
- // not as a property on the list's own handle: a real native list handle
215
- // does not reliably hold a custom property across calls (confirmed on
216
- // device), only `handles` (a plain Map) does.
217
- const listSetters = new Map();
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
- /** General form: seed the mapping for any id, not just the page root —
226
- * list-support.js uses this to alias a list-cell.js `containerId` (an
227
- * off-tree id from the background thread's OWN document) to the real
228
- * native wrapper element it created for that cell. */
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. A
249
- * per-cell applier (list-support.js) passes `flush: false` and issues
250
- * its own list-specific `__FlushElementTree(wrapperHandle, {...})` call
251
- * afterward instead — that one, not this one, is that cell's real
252
- * commit point.
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
- if (refId === -1) {
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
- __RemoveElement(handles.get(parentId), handles.get(childId));
298
- handles.delete(childId);
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 __SetAttribute(handle, name, value);
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 __SetAttribute(handle, name, null);
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
- // `"*"` is fake-dom.js's encoding of `element.style = ""`
346
- // (clear everything) — there is no bulk-clear PAPI call
347
- // validated yet, so this case is a documented gap for
348
- // F3, not a silent no-op: it throws so the gap surfaces
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] Clearing the whole `style` object at once is not implemented yet (F3 TODO) — set individual properties to \"\" instead.",
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
- onEvent?.(id, type, 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
+ }
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.CreateList: {
707
+ case Op.InvokeUIMethod: {
424
708
  const id = ops[i++];
425
- const scrollOrientation = ops[i++];
426
- const listType = ops[i++];
427
- const spanCount = ops[i++];
428
- const { handle, setCells } = createNativeList(pageId, scrollOrientation, listType, spanCount, createPatchApplier, onEvent);
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.SetListItems: {
715
+ case Op.SetInputValue: {
434
716
  const id = ops[i++];
435
- const cells = ops[i++];
436
- listSetters.get(id)(cells);
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
  }