mithril-lynx 2.0.1 → 2.5.0

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