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.
Files changed (55) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/LICENSE.txt +21 -0
  5. package/PLAN_REVIEW.md +134 -0
  6. package/README.md +47 -7
  7. package/REQUEST.md +5 -2
  8. package/ROUTE.md +111 -16
  9. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  10. package/UPDATE_ANIMATION_GAP.md +133 -0
  11. package/informe-contrato-mithril-lynx.md +206 -0
  12. package/package.json +1 -9
  13. package/plugin.js +33 -0
  14. package/prompts-analisis-contrato-mithril-lynx.md +141 -0
  15. package/src/apply-patch.js +408 -68
  16. package/src/backends/virtual-backend.js +120 -22
  17. package/src/background.d.ts +40 -1
  18. package/src/background.js +116 -7
  19. package/src/channel.js +32 -7
  20. package/src/commit.js +9 -0
  21. package/src/dev-reload-client.js +46 -0
  22. package/src/dev-transport-noop.js +3 -0
  23. package/src/fake-dom.js +559 -61
  24. package/src/list-attributes.js +69 -0
  25. package/src/list-runtime.js +425 -0
  26. package/src/main-thread.js +33 -3
  27. package/src/mount-redraw.d.ts +10 -0
  28. package/src/mount-redraw.js +69 -3
  29. package/src/patch-protocol.js +52 -13
  30. package/src/reload/version.js +10 -1
  31. package/src/request.d.ts +2 -0
  32. package/src/request.js +59 -3
  33. package/src/route.d.ts +52 -5
  34. package/src/route.js +236 -59
  35. package/src/testing.d.ts +14 -1
  36. package/src/testing.js +79 -11
  37. package/test/event-render-coalescing.test.ts +117 -0
  38. package/test/gesture.test.ts +75 -0
  39. package/test/input.test.ts +388 -0
  40. package/test/list.test.ts +422 -100
  41. package/test/patch-protocol.test.ts +41 -0
  42. package/test/remove-event.test.ts +26 -0
  43. package/test/remove-subtree-cleanup.test.ts +75 -0
  44. package/test/route-hot-reload.test.ts +4 -2
  45. package/test/route.test.ts +206 -6
  46. package/test/setup.ts +10 -0
  47. package/test/style-clear.test.ts +97 -0
  48. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  49. package/.omo/plans/m-route-en-memoria.md +0 -397
  50. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  51. package/.omo/run-continuation/ses_f48265d07ffenKG0GAz9ZRFvY6.json +0 -10
  52. package/src/list-cell.d.ts +0 -20
  53. package/src/list-cell.js +0 -73
  54. package/src/list-support.d.ts +0 -13
  55. package/src/list-support.js +0 -171
@@ -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,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 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
- /** General form: seed the mapping for any id, not just the page root —
220
- * list-support.js uses this to alias a list-cell.js `containerId` (an
221
- * off-tree id from the background thread's OWN document) to the real
222
- * native wrapper element it created for that cell. */
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. A
243
- * per-cell applier (list-support.js) passes `flush: false` and issues
244
- * its own list-specific `__FlushElementTree(wrapperHandle, {...})` call
245
- * afterward instead — that one, not this one, is that cell's real
246
- * 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.
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
- 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) {
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
- __RemoveElement(handles.get(parentId), handles.get(childId));
292
- 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);
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 __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
+ }
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 __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
+ }
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
- // `"*"` is fake-dom.js's encoding of `element.style = ""`
340
- // (clear everything) — there is no bulk-clear PAPI call
341
- // validated yet, so this case is a documented gap for
342
- // F3, not a silent no-op: it throws so the gap surfaces
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] 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.",
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
- __AddEventListener(handle, type, (nativeEvent) => {
363
- onEvent?.(id, type, nativeEvent);
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
- // PAPI has no documented `__RemoveEventListener` in the
369
- // validated v1 surface (CONTRACT.md never needed it,
370
- // since mithril-lynx v1 never tore down individual
371
- // listeners outside of removing the whole element).
372
- // Left as an explicit no-op + TODO rather than a guess.
373
- i += 2;
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.CreateList: {
707
+ case Op.InvokeUIMethod: {
393
708
  const id = ops[i++];
394
- const scrollOrientation = ops[i++];
395
- const listType = ops[i++];
396
- const spanCount = ops[i++];
397
- const { handle, setCells } = createNativeList(pageId, scrollOrientation, listType, spanCount, createPatchApplier, onEvent);
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.SetListItems: {
715
+ case Op.SetInputValue: {
403
716
  const id = ops[i++];
404
- const cellsJSON = ops[i++];
405
- listSetters.get(id)(JSON.parse(cellsJSON));
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
  }