mithril-lynx 0.0.1

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/CONTRACT.md ADDED
@@ -0,0 +1,151 @@
1
+ # Mithril 2.3.8 `render/render.js` — Extracted Contract
2
+
3
+ Source: `node_modules/mithril/render/render.js` (910 lines), mithril **2.3.8**.
4
+ All line numbers cite that file unless prefixed with `hyperscript.js:` (which cites `node_modules/mithril/render/hyperscript.js`).
5
+
6
+ ---
7
+
8
+ ## a. Factory signature & how the render function is obtained
9
+
10
+ - **Line 8**: `module.exports = function() {` — the module exports a **zero-argument factory function**.
11
+ - The factory closes over module-level mutable state:
12
+ - `currentRedraw` (line 14) — the active `redraw` callback, captured by `EventDict` for auto-redraw.
13
+ - `currentRender` (line 15) — a per-render generation marker object (used by `delayedRemoval`).
14
+ - `currentDOM` (line 880) — the DOM node currently being rendered to (reentrancy lock).
15
+ - The **render function is the closure returned by the factory**: lines 882–909, `return function(dom, vnodes, redraw) { ... }`.
16
+ - Module dependencies (lines 3–6): `./vnode`, `./delayedRemoval`, `./domFor`, `./cachedAttrsIsStaticMap`.
17
+ - The factory pattern means each call to `require("mithril/render/render")()` produces an **independent renderer instance** with its own `currentRedraw`/`currentDOM`/`currentRender` state.
18
+
19
+ ## b. Render function signature: `render(dom, vnodes, redraw)`
20
+
21
+ Defined at **line 882**: `return function(dom, vnodes, redraw) {`
22
+
23
+ | Param | Contract |
24
+ |---|---|
25
+ | `dom` | The DOM element to render into. **Line 883**: throws `TypeError("DOM element being rendered to does not exist.")` if falsy. **Lines 884–886**: throws `TypeError("Node is currently being rendered to and thus is locked.")` if `currentDOM != null && dom.contains(currentDOM)` (reentrancy guard). |
26
+ | `vnodes` | A vnode or array of vnodes. **Line 899**: normalized via `Vnode.normalizeChildren(Array.isArray(vnodes) ? vnodes : [vnodes])`. |
27
+ | `redraw` | Optional. **Line 894**: `currentRedraw = typeof redraw === "function" ? redraw : undefined`. Consumed by `EventDict.handleEvent` (lines 811–817) to auto-redraw after events. |
28
+
29
+ Body sequence:
30
+ 1. **Line 898**: first render into a node clears it — `if (dom.vnodes == null) dom.textContent = ""`.
31
+ 2. **Line 900**: `updateNodes(dom, dom.vnodes, vnodes, hooks, null, namespace === "http://www.w3.org/1999/xhtml" ? undefined : namespace)` — diffs old (`dom.vnodes`) vs new (`vnodes`); the XHTML namespace is normalized to `undefined` (which enables the property-key path in `hasPropertyKey`).
32
+ 3. **Line 901**: `dom.vnodes = vnodes` — **prior vnodes are stored on the DOM node itself**.
33
+ 4. **Line 903**: focus restoration — if `document.activeElement` changed and the old active element still has `.focus`, it is refocused.
34
+ 5. **Line 904**: post-render hooks (`oncreate`/`onupdate`) flushed in order.
35
+ 6. **Lines 905–908**: `finally` restores `currentRedraw`/`currentDOM` to their previous values.
36
+
37
+ ## c. DOM surface accessed on the `dom` parameter
38
+
39
+ All access goes through the `dom` node passed to `render()` (or its descendants). `getDocument(dom)` (lines 17–19) returns `dom.ownerDocument`.
40
+
41
+ | API | Lines | Usage |
42
+ |---|---|---|
43
+ | `ownerDocument` | 18 | `getDocument()`; source of all document-level factories |
44
+ | `createTextNode` | 76 | `createText` — `vnode.dom = getDocument(parent).createTextNode(vnode.children)` |
45
+ | `createElement` / `createElementNS` | 120–122 | `createElement` for HTML, `createElementNS(ns, tag)` for svg/math; `{is: is}` third arg for custom elements |
46
+ | `createDocumentFragment` | 96, 104, 551 | `createHTML` (96), `createFragment` (104), `moveDOM` for multi-node moves (551) |
47
+ | `insertBefore` / `appendChild` | 558–561 | `insertDOM`: `insertBefore(dom, nextSibling)` if `nextSibling != null`, else `appendChild(dom)` |
48
+ | `removeChild` | 610–617 | `removeDOM`: single `removeChild(vnode.dom)` or per-node via `domFor` for fragments |
49
+ | `nodeValue` | 422 | `updateText` — `old.dom.nodeValue = vnode.children` |
50
+ | `value` | 654–658, 666, 699, 703 | Read for same-value coercion skips (input/textarea/select/option); written via generic `vnode.dom[key] = value` (666); select late-attrs (699, 703) |
51
+ | `checked` | 730 | Only as a key name in `isFormAttribute`; written via generic property path (666) |
52
+ | `selectedIndex` | 685, 699, 702, 707 | `removeAttr` guard (685), `setLateSelectAttrs` (699, 702, 707) |
53
+ | `className` | 672, 681, 693 | `setAttr` maps `className` → `"class"` attribute (672); `removeAttr` excludes it from property-null path (681) and maps to `"class"` (693) |
54
+ | `setAttribute` | 665, 669–670, 672 | input `type` (665), boolean attrs (669–670), generic attrs (672) |
55
+ | `removeAttribute` | 670, 693 | boolean-false (670), generic removal (693) |
56
+ | `setAttributeNS` | 645 | `xlink:`-prefixed keys → `setAttributeNS("http://www.w3.org/1999/xlink", key.slice(6), value)` |
57
+ | `style` | 646, 678, 747–787 | `updateStyle` dual-mode (see §f) |
58
+ | `innerHTML` | 89, 92, 571 | `createHTML` (89 svg-wrapped, 92 plain), contenteditable sync (571) |
59
+ | `textContent` | 898 | First-render clear |
60
+ | `firstChild` | 90, 94, 98, 109 | `createHTML` unwrap (90, 94, 98), `createFragment` dom anchor (109) |
61
+ | `parentNode` | 730 | `isFormAttribute` — `option` whose parent is the active element |
62
+ | `contains` | 884 | Reentrancy lock check |
63
+ | `namespaceURI` | 891 | Namespace detection for the diff call |
64
+ | `focus` | 903 | Focus restoration |
65
+ | `nextSibling` | domFor.js:12 | Fragment iteration in `domFor` |
66
+
67
+ **Not used (verified by grep across the whole package):**
68
+ - `getAttribute` — render.js only *writes* attributes (`setAttribute`/`removeAttribute`/`setAttributeNS`); it never reads them.
69
+ - `nodeType` — appears nowhere in mithril. Do not rely on it in a reimplementation.
70
+
71
+ ## d. Prior-vnode storage & diffing of repeated `render()` calls
72
+
73
+ **Storage**: old vnodes live on the DOM node as `dom.vnodes` — read at line 898 (first-render check) and 900 (diff input), written at line 901.
74
+
75
+ **`updateNodes(parent, old, vnodes, hooks, nextSibling, ns)`** (lines 270–395):
76
+
77
+ 1. **Trivial cases** (271–273): `old === vnodes` or both null → no-op; `old` empty → create all; `vnodes` empty → remove all.
78
+ 2. **Keyed detection** (275–276): lists are keyed iff `old[0].key != null` / `vnodes[0].key != null` (first non-null node, 278–279).
79
+ 3. **Keyed/unkeyed mismatch** (280–282): remove all old + create all new.
80
+ 4. **Unkeyed diff** (283–299): walk the common length index-by-index; `o === v` or both null → skip; null old → create; null new → remove; else `updateNode`. Tails handled by `removeNodes` (298) / `createNodes` (299).
81
+ 5. **Keyed diff** (300–392), with the documented optimizations (comment block 184–268):
82
+ - **Bottom-up tail match** (305–312): while tail keys equal, update in place — identical tails are guaranteed part of the LIS, so no moves (tail optimization, comment 244–245).
83
+ - **Top-down head match** (314–320): same for the head.
84
+ - **Swaps & reversals** (322–336): two-node cross-swap fast path.
85
+ - **Bottom-up again** (338–345): re-check tails after head/tail consumption.
86
+ - **Leftovers** (346–347): remove remaining old or create remaining new.
87
+ - **LIS-based middle diff** (348–391): builds `oldIndices` (350–351), maps new keys → old indices via `getKeyMap` (352–365; impl 478–488), nulls matched old entries, removes unmatched old (367), creates all if nothing matched (368), then either moves non-LIS nodes (`makeLisIndices`, 370–383; impl 494–534, lifted from ivi) or a simple create loop when order was preserved (384–390).
88
+ 6. **`getNextSibling`** (536–541): next sibling is found by scanning the *old* list forward from `i+1` for a node with a `dom` — this is what makes top-down DOM insertion correct.
89
+ 7. **`moveDOM`** (544–556): moves single nodes directly; multi-node fragments are moved via a `createDocumentFragment` + `domFor` loop.
90
+
91
+ **`updateNode`** (396–419): same `tag` + same `is` → in-place update (state/events carried over at 399–400; `shouldNotUpdate` short-circuit at 401, impl 852–878); otherwise `removeNode` + `createNode`. Per-tag updates: `updateText` (420–425), `updateHTML` (426–435), `updateFragment` (436–450), `updateElement` (451–461), `updateComponent` (462–477).
92
+
93
+ ## e. How `m()` (hyperscript) creates events & attrs
94
+
95
+ **Hyperscript side** (`render/hyperscript.js`):
96
+ - Selector parsing: `compileSelector` (hyperscript.js:21–42) — `#id`, `.class`, `[attr]`, `[attr=value]`; `class` → `className` (hyperscript.js:38); form-attribute keys (`value`/`checked`/`selectedIndex`/`selected`) mark the attrs object as non-static (hyperscript.js:17–19, 34).
97
+ - `class` attr → `className` (hyperscript.js:54–57); `input[type]` reordered first (hyperscript.js:69–74, workaround for #2622); `vnode.is = attrs.is` (hyperscript.js:77).
98
+
99
+ **Event side** (render.js):
100
+ - **Dispatch rule** (line 644): any attr key starting with `on` (`key[0] === "o" && key[1] === "n"`) is routed to `updateEvent` (826–842), never to the DOM attribute path.
101
+ - **`EventDict`** (800–823): a per-element event listener object, prototype `Object.create(null)` (804). Constructor captures `this._ = currentRedraw` (802). `handleEvent(ev)` (805–823):
102
+ - Looks up `this["on" + ev.type]` (806).
103
+ - Function handler → called with `ev.currentTarget` as `this` (808); object handler → `handler.handleEvent(ev)` (809).
104
+ - Auto-redraw: if `this._ != null` and `ev.redraw !== false`, calls the captured redraw (811–812); also after the handler's returned promise resolves (813–817).
105
+ - `return false` → `ev.preventDefault()` + `ev.stopPropagation()` (819–822).
106
+ - **`updateEvent`** (826–842): `addEventListener(key.slice(2), vnode.events, false)` — the **EventDict object itself is the listener** (831, 839); handlers stored as `vnode.events[key]`; removal via `removeEventListener` (834). `vnode.events` is carried across updates (line 400).
107
+
108
+ **Attribute side** (render.js):
109
+ - **`setAttr`** (642–674) precedence: skip `key`/null-value/lifecycle (643) → `on*` events (644) → `xlink:` (645) → `style` (646) → **`hasPropertyKey`** (647–666) → attribute fallback (667–673).
110
+ - **`hasPropertyKey`** (735–744): property assignment only when `ns === undefined` AND (custom element: tag contains `-` or `vnode.is`, OR key not in the browser-bug blacklist `href`/`list`/`form`/`width`/`height`) AND `key in vnode.dom`.
111
+ - Property path (666): `vnode.dom[key] = value`, with `value` coercion guards (648–663: input/textarea/select/option same-value skip; file-input read-only warning at 661) and `input[type]` forced through `setAttribute` (665).
112
+ - Attribute path (667–673): boolean → `setAttribute(key, "")` / `removeAttribute(key)`; else `setAttribute(key === "className" ? "class" : key, value)`.
113
+ - **`removeAttr`** (675–695): property-null path excludes `className`, `title`, `value` (option/select edge), `input[type]`; else `removeAttribute` with `className` → `"class"` mapping.
114
+ - **`updateAttrs`** (709–728): removals first (713–722), then sets (723–727); warns on reused attrs objects (714–716).
115
+ - **`isFormAttribute`** (729–731): `value`/`checked`/`selectedIndex`/`selected` (with active-element/option-parent conditions) — these bypass the `old === value` skip so form state always syncs.
116
+
117
+ ## f. `updateStyle()` dual-mode (lines 747–787)
118
+
119
+ `updateStyle(element, old, style)`:
120
+
121
+ | Case | Lines | Behavior |
122
+ |---|---|---|
123
+ | `old === style` | 748–749 | No-op |
124
+ | `style == null` | 750–752 | `element.style = ""` (clear) |
125
+ | `typeof style !== "object"` | 753–755 | `element.style = style` (string passthrough) |
126
+ | `old` missing/string, `style` object | 756–766 | Clear, then for each key: **`key.includes("-")` → `element.style.setProperty(key, String(value))`** (763); **else `element.style[key] = String(value)`** (764) |
127
+ | Both objects | 767–786 | Remove stale keys first (772–777: `removeProperty` for dash-case, `= ""` for camelCase), then set changed keys (779–785: same dual-mode) |
128
+
129
+ Key contract points:
130
+ - **Dash-case keys** (`-` in the name) → `setProperty` / `removeProperty`; **camelCase keys** → direct `style[k]` assignment.
131
+ - All values coerced with `String()` (763–764, 781).
132
+ - Removal happens before setting (770–771) to avoid dash-case/camelCase aliasing bugs.
133
+
134
+ ## g. Version & packaging
135
+
136
+ - **Version**: `2.3.8` (package.json `version` field).
137
+ - **No `main` / `module` field** in package.json (verified — only `unpkg`/`jsdelivr`/`repository`/`license`/`scripts`/`devDependencies`). The package is consumed by **file-path require**: `require("mithril/render/render")` resolves directly to `node_modules/mithril/render/render.js`.
138
+ - Entry points: `index.js` (browser bundle), `render.js` (top-level re-export of `render/render.js`), `hyperscript.js` (re-export of `render/hyperscript.js` + `trust`/`fragment`).
139
+ - 2.3.8 is the latest mithril release on npm (as of this scaffold's dependency pin).
140
+
141
+ ---
142
+
143
+ ## Reimplementation checklist (what a Lynx port must honor)
144
+
145
+ 1. Factory `module.exports = function()` returning `function(dom, vnodes, redraw)` with per-instance `currentRedraw`/`currentRender`/`currentDOM` state.
146
+ 2. Store prior vnodes on the target node (`dom.vnodes`); first render clears via `textContent = ""`.
147
+ 3. Diff pipeline: trivial cases → keyed detection → unkeyed walk → keyed (tail/head/swaps/LIS) → leftover create/remove.
148
+ 4. DOM surface: `createTextNode`, `createElement(NS)`, `createDocumentFragment`, `insertBefore`/`appendChild`, `removeChild`, `nodeValue`, `value`, `checked`, `selectedIndex`, `className`, `setAttribute`/`removeAttribute`/`setAttributeNS`, `style`, `innerHTML`, `textContent`, `firstChild`, `parentNode`, `ownerDocument`, `namespaceURI`, `contains`, `focus`. **No `getAttribute`, no `nodeType`.**
149
+ 5. Events: `on*` keys → single `EventDict` object per element registered via `addEventListener`; `handleEvent` dispatches by `ev.type`, binds `this` to `ev.currentTarget`, auto-redraws, honors `return false`.
150
+ 6. Attrs: `hasPropertyKey` gate (property vs attribute), `className`→`class` mapping, boolean attrs, `xlink:` namespace, `value`/`checked`/`selectedIndex` form-attribute exceptions.
151
+ 7. Styles: dual-mode `updateStyle` — `setProperty` for `-` keys, `style[k] = v` for camelCase, `String()` coercion, remove-before-set.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 carlos-sweb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # mithril-lynx
2
+
3
+ Mithril.js rendered through [Lynx](https://lynxjs.org)'s Element PAPI — the core runtime layer of a Mithril-based alternative to [`@lynx-js/react`](https://lynxjs.org/react/).
4
+
5
+ ## What this package is
6
+
7
+ `src/lynx-mithril-shim.js` is a contract-complete, line-by-line port of `mithril/render/render.js@2.3.8`: the exact same diff algorithm (`createNode`/`updateNodes`/`updateNode`/keyed-diff-with-LIS/etc.) as upstream Mithril, with every DOM call it makes redirected onto Lynx's Element PAPI (`__CreateView`, `__AppendElement`, `__SetAttribute`, `__SetInlineStyles`, `__AddEventListener`, ...) instead of the browser DOM. See `CONTRACT.md` for the exhaustive, reverse-engineered spec of exactly which DOM surface Mithril's renderer touches — that document is the reference this shim is built and validated against.
8
+
9
+ `mithril` is a `peerDependency`, not a `dependency` — install it yourself (`mithril@2.3.8`) rather than relying on a copy `mithril-lynx` pulls in. The shim deep-imports mithril's own internal `emptyAttrs`/`cachedAttrsIsStaticMap` singletons (needed to correctly recognize mithril's own legitimately-reused empty attrs object); a second physical copy of `mithril` anywhere in the dependency graph breaks that recognition and produces a spurious `"Don't reuse attrs object"` console warning on every plain `m(tag, null, ...)` element, every redraw (confirmed and fixed on-device 2026-09-09 — see `DEVICE_VERIFICATION.md`). `mithril-lynx/plugin`'s `pluginMithrilLynx()` already forces a single resolution via a build-time alias, so apps using it don't need to do anything extra beyond installing `mithril` themselves.
10
+
11
+ Everything in this README is tested against `@lynx-js/testing-environment`'s jsdom-backed PAPI simulation, not a real device — several capabilities (renderer mode, gestures, list Tier 2, refs) were built from reading real `@lynx-js/react` source as ground truth without ever calling the real native PAPI they call. See `DEVICE_VERIFICATION.md` for exactly which claims are still unconfirmed on real hardware and the plan to confirm them.
12
+
13
+ This is part of a larger effort to bring Mithril to full functional parity with ReactLynx's dual-thread architecture, refs, gestures, list virtualization, and testing tooling — see the project plan for the full roadmap. Three rendering modes exist today:
14
+
15
+ - **Main-thread-owned** (`mithril-lynx` + `mithril-lynx/main-thread` alone, no `background.ts`): the app renders directly on the main thread, synchronously, on first paint. Simplest option; no dual-bundle build needed.
16
+ - **Data-channel mode** (`mithril-lynx/main-thread` + `mithril-lynx/background`, opt in by adding a sibling `background.ts` — see `mithril-lynx/plugin`): business-logic state lives on the background thread; only plain JSON data crosses the thread boundary, and the main thread's Mithril render turns it into UI. First paint waits one round trip for the background thread's initial push, in exchange for keeping business logic off the main thread.
17
+ - **Renderer mode** (`mithril-lynx/renderer/main-thread` + `mithril-lynx/renderer/background`): Mithril's diff algorithm itself runs on the background thread, against a virtual (op-log-recording) tree; the main thread replays the ops against real elements and forwards real events back by node id. This is the closest analog to ReactLynx's default architecture — full reconciliation off the main thread, not just data pushes — at the cost of first paint waiting for the background thread's initial patch (same tradeoff as data-channel mode, one level deeper).
18
+
19
+ ## Usage — main-thread-owned
20
+
21
+ ```js
22
+ import shim from "mithril-lynx";
23
+ import m from "mithril";
24
+
25
+ // The native engine unconditionally calls a global processData(initData)
26
+ // on every __RenderPage/__UpdatePage — install a pass-through default
27
+ // (mithril-lynx/main-thread does this for you in data-channel mode, but
28
+ // this bare pattern doesn't go through that module).
29
+ Object.assign(globalThis, { processData: (data) => data });
30
+
31
+ const engine = lynx.getEngine();
32
+ engine.addEventListener("__RenderPage", () => {
33
+ const page = __CreatePage("0", 0);
34
+ shim.renderToPage(page, m(MyComponent));
35
+ });
36
+ ```
37
+
38
+ Subsequent UI updates flow through `shim.redraw()` (called automatically by event handlers bound via Mithril's own `on*` attrs, since the shim ports Mithril's `EventDict`/redraw machinery verbatim) — **never plain `m.redraw()`**, which is a no-op in this shim-based architecture.
39
+
40
+ ## Usage — data-channel mode
41
+
42
+ `main-thread.ts`:
43
+
44
+ ```js
45
+ import { setupApp, getData, dispatchToBackground } from "mithril-lynx/main-thread";
46
+ import m from "mithril";
47
+
48
+ const Counter = {
49
+ view: () => m("text", { ontap: () => dispatchToBackground("increment") }, String(getData()?.count ?? 0)),
50
+ };
51
+
52
+ setupApp({ root: () => m(Counter) });
53
+ ```
54
+
55
+ `background.ts` (sibling file — `mithril-lynx/plugin` picks it up automatically as a second bundle):
56
+
57
+ ```js
58
+ import { setupBackground, getData, setData, setBackgroundEventHandler } from "mithril-lynx/background";
59
+
60
+ setupBackground();
61
+ setData({ count: 0 }, { shouldSyncToMainThread: false });
62
+ setBackgroundEventHandler((handlerName) => {
63
+ if (handlerName === "increment") setData({ count: (getData().count ?? 0) + 1 });
64
+ });
65
+ ```
66
+
67
+ `root()` is called exactly once, on the first `__RenderPage`; every later update — from the engine's `__UpdatePage` or a `background.setData()` push — flows through the shim's own `redraw()`, re-invoking the component's `view()` (not `root()` again). See `mithril-lynx/test/data-channel.test.ts` for a complete worked example and the full cross-thread test. `mithril-app` (this project's own hello-world template) uses main-thread-owned mode instead — see its `src/{main-thread,index}.js` for that simpler pattern applied end to end.
68
+
69
+ ## Usage — renderer mode
70
+
71
+ `main-thread.ts`:
72
+
73
+ ```js
74
+ import { setupRenderer } from "mithril-lynx/renderer/main-thread";
75
+
76
+ setupRenderer();
77
+ ```
78
+
79
+ `background.ts`:
80
+
81
+ ```js
82
+ import { renderApp, redraw } from "mithril-lynx/renderer/background";
83
+ import m from "mithril";
84
+
85
+ let count = 0;
86
+ const Counter = {
87
+ view: () => m("text", { ontap: () => { count += 1; redraw(); } }, String(count)),
88
+ };
89
+
90
+ renderApp({ root: () => m(Counter) });
91
+ ```
92
+
93
+ Unlike data-channel mode, `main-thread.ts` needs no app-specific code at all — `setupRenderer()` is generic; all app logic, including the Mithril component tree, lives in `background.ts`. Call `redraw()` (not `shim.redraw()`) after mutating state in a handler — it re-invokes the view and flushes the resulting patch to the main thread. See `mithril-lynx/test/renderer-ops.test.ts` (op-log assertions) and `mithril-lynx/test/renderer-integration.test.ts` (full round trip through real PAPI replay, including a tap forwarded from the main thread back to the background thread's handler) for worked examples.
94
+
95
+ ## Refs / native imperative bridge
96
+
97
+ Mithril has no `useRef`/`ref` hook system — the idiomatic way to reach a real node is Mithril's own `oncreate(vnode)`/`onupdate(vnode)` lifecycle attrs, which hand you `vnode.dom` directly. Two helpers cover the two threads:
98
+
99
+ - **Main-thread side** (`mithril-lynx/element`): `wrapElement(node)` wraps any node with a `_handle` (a real `LynxNodeWrapper`, or a raw result from `querySelector`) in ergonomic methods `render.js` itself never needs — `setStyleProperty(ies)`, `setAttribute` (mirrors the real wrapper's class/id/data-prefixed/generic special-casing), `querySelector(All)`, `animate`/`playAnimation`/`pauseAnimation`/`cancelAnimation`, and `invoke(method, params)` (wraps `__InvokeUIMethod`'s callback in a Promise).
100
+
101
+ ```js
102
+ import { wrapElement } from "mithril-lynx/element";
103
+
104
+ m("input", {
105
+ oncreate: (vnode) => wrapElement(vnode.dom).invoke("focus"),
106
+ });
107
+ ```
108
+
109
+ **Confirmed working on a real device** (see `DEVICE_VERIFICATION.md`): `invoke("boundingClientRect", {})` resolved with a real native response (`{code: 0, data: {top, left, width, height, ...}}`), confirming `__InvokeUIMethod`'s callback contract matches what this file assumes.
110
+
111
+ - **Background-thread side** (`mithril-lynx/background`'s `createRef(selector)`): the background thread has no direct native handle, so imperative calls go through Lynx's existing `lynx.createSelectorQuery().select(selector).invoke({...}).exec()` bridge — the same primitive ReactLynx's own background-thread refs ultimately use.
112
+
113
+ ```js
114
+ import { createRef } from "mithril-lynx/background";
115
+
116
+ const input = createRef("#my-input");
117
+ await input.invoke("focus"); // resolves with success data, rejects with failure data
118
+ ```
119
+
120
+ **Confirmed working on a real device 2026-09-09** (see `DEVICE_VERIFICATION.md`): a background-thread `createRef(selector).invoke("boundingClientRect", {})` resolved with a real native response, round-tripped back to the main thread through the normal data channel and displayed there.
121
+
122
+ **Known quirk, not a bug**: style patches sometimes carry a key with an empty-string value (e.g. `{ backgroundColor: "" }`) instead of omitting it entirely, when a non-dash-case style property is cleared after being set via plain assignment rather than `style.setProperty()`. This matches the real `LynxStyleProxy`'s exact behavior in main-thread-owned mode too (verified — not something renderer mode changed), and `__SetInlineStyles`/CSSOM treat an empty string as "clear this property," so it's functionally equivalent to an absent key.
123
+
124
+ ## Cross-thread function calls (worklet substitute)
125
+
126
+ ReactLynx's Main Thread Scripting (worklets) exists to solve two different problems, and only one of them needs a compiler:
127
+
128
+ - **A gesture/tap handler needs to run on the thread it's defined on** — under mithril-lynx's two-file convention, this needs *zero new mechanism*: a `main-thread:bindtap`-equivalent handler is just an ordinary function in `main-thread.ts`/`background.ts`, never mixed with the other thread's code to begin with.
129
+ - **One thread needs to trigger a named action on the other thread outside the normal render/data cycle** — this genuinely can't cross a JS-engine boundary without either a compiler (to extract and ship a closure) or an explicit registry. `registerHandler`/`runOnMainThread`/`runOnBackground` (in `mithril-lynx/main-thread` and `mithril-lynx/background`) are that registry — call/return correlated, Promise-based:
130
+
131
+ ```js
132
+ // main-thread.ts
133
+ import { registerHandler } from "mithril-lynx/main-thread";
134
+ registerHandler("flashBackground", (color) => { /* ... */ });
135
+ ```
136
+ ```js
137
+ // background.ts
138
+ import { runOnMainThread } from "mithril-lynx/background";
139
+ await runOnMainThread("flashBackground", "red");
140
+ ```
141
+
142
+ This is equal *capability* to upstream's own `runOnMainThread`/`runOnBackground` (both are async serialized RPC under the hood there too, not real closure transfer) — only worse *ergonomics*, since there's no compiler to auto-extract an inline closure at the call site. Args and return values must be JSON-serializable, and handlers must be named and registered ahead of time, on the thread they run on.
143
+
144
+ **Explicitly rejected**: reconstructing a closure via `fn.toString()` + `new Function(...)` shipped across the wire, as a sugar layer over the registry. It breaks under any minifier/bundler that renames free identifiers, can't support real closures anyway (so it wouldn't actually improve on "write a named function"), and fails only in production builds, never in dev. Not revisited without re-litigating this tradeoff.
145
+
146
+ ## Gestures
147
+
148
+ `mithril-lynx/gesture`'s `createGesture(node, options)` is a thin, same-thread wrapper over `__SetGestureDetector` — no cross-thread serialization, since gesture recognition and its callbacks all run on the main thread already. It DOES need a small amount of worklet machinery, though (see below) — native invokes gesture callbacks by looking them up in a registry, not by calling a function value directly, and `mithril-lynx` supplies a minimal, from-scratch registry for exactly this (`src/worklet-runtime.js`), not a dependency on `@lynx-js/react`'s own.
149
+
150
+ ```js
151
+ import { createGesture, GestureType } from "mithril-lynx/gesture";
152
+
153
+ m("view", {
154
+ oncreate: (vnode) => {
155
+ createGesture(vnode.dom, {
156
+ type: GestureType.PAN, // or the string "pan"
157
+ callbacks: {
158
+ onStart: () => { /* ... */ },
159
+ onUpdate: () => { /* ... */ },
160
+ },
161
+ });
162
+ },
163
+ });
164
+ ```
165
+
166
+ Each callback is actually invoked as `(event, controller) => {}` — `controller` is a native gesture-arena handle (`{__SetGestureState, __ConsumeGesture}`); most callbacks can ignore it and just take `event` (or no parameters at all, as above).
167
+
168
+ `waitFor`/`simultaneousWith`/`continueWith` take arrays of *other* `createGesture()` return values, for gesture-arena composition (e.g. a pan that only starts after a tap gesture fails). If a callback needs to notify background-owned state, call `main-thread.js`'s `runOnBackground()` (previous section) from inside it — an explicit, opt-in cross-thread hop, not something gesture composition requires structurally.
169
+
170
+ **Confirmed WORKING end-to-end on a real device 2026-09-09** (see `DEVICE_VERIFICATION.md` for the full story): a real `adb shell input swipe` across a `PAN`-gesture element drove its callbacks through start → update → end with zero errors. Getting there took three stacked fixes, in order: (1) gesture callbacks must be wrapped as worklet-ctx objects (`{_wkltId}`), not passed as plain functions — `createGesture()` does this internally via `src/worklet-runtime.js`, a small from-scratch worklet registry, transparent to callers; (2) the consuming app's `lynx.config.ts` must pass `{ enableNewGesture: true }` to `pluginLynxConfig()` — without it, native's entire gesture arena stays off and `__SetGestureDetector` calls are silently inert; (3) `worklet-runtime.js` must call callbacks positionally (`fn.bind(ctx)(...args)`), never via `Function.prototype.apply()` — the native `controller` argument throws under `apply()`'s argument marshalling specifically. (1) and (3) are internal to this package; (2) is a one-line addition an app using `mithril-lynx/gesture` must make itself.
171
+
172
+ ## Lists
173
+
174
+ Two tiers, matching the real complexity spread in Lynx's own `list` examples:
175
+
176
+ - **Tier 1 (prefer this)**: `<list>`/`<list-item>` need no code at all — they're just ordinary tags through the existing shim:
177
+
178
+ ```js
179
+ m("list", { class: "my-list" },
180
+ items.map((item) => m("list-item", { key: item.id }, [ItemView(item)])))
181
+ ```
182
+
183
+ Native does cell recycling at the native layer; Mithril's own already-tested keyed/LIS diff (`test/keyed-diff.test.ts`) computes add/remove/reorder of the `list-item` children — no different from any other keyed list.
184
+
185
+ - **Tier 2 — `mithril-lynx/list`'s `createList(parentNode, options)`** (opt in, for when you specifically need native-driven recycling, e.g. very large lists): an imperative escape hatch like `element.js`/`gesture.js` — call from `oncreate(vnode)`, attach the result yourself:
186
+
187
+ ```js
188
+ import { createList } from "mithril-lynx/list";
189
+
190
+ m("view", {
191
+ oncreate: (vnode) => {
192
+ const list = createList(vnode.dom, {
193
+ itemCount: items.length,
194
+ renderItem: (index) => m("text", { class: "cell" }, items[index].label),
195
+ });
196
+ vnode.dom.appendChild(list);
197
+ },
198
+ });
199
+ ```
200
+
201
+ The sign/recycle-pool design (cells reused by *type*, matching RecyclerView/UICollectionView semantics) and the exact `__FlushElementTree({ triggerLayout, operationID, elementID, listID })` call shape are ported from `@lynx-js/react`'s own shipped `list.js` — real, proven code. `renderItem(index)` must return a fresh vnode every time it's called (it can be called more than once for the same index, on recycling); a recycled cell's content is *diffed* into its existing DOM subtree via Mithril's own diff, not recreated — verified in `test/list.test.ts` by asserting no new `__CreateElement` calls happen on reuse.
202
+
203
+ `createList()` also sets `scroll-orientation`/`list-type`/`span-count` on the list and `item-key` on every item (all required by native, confirmed by a real device never calling `componentAtIndex` without them — see `LIST_INVESTIGATION.md`), and sends a `"update-list-info"` attribute (`{insertAction, removeAction, updateAction}`, each entry needing `position`, `type`, and `item-key`) alongside `__UpdateListCallbacks` on creation and on every `setItemCount()` call — **this is the piece that actually makes native start calling `componentAtIndex` at all**; without it, `__CreateList` silently does nothing forever.
204
+
205
+ **Confirmed WORKING on a real device 2026-09-09** (see `DEVICE_VERIFICATION.md`): 37+ cells rendered and scrolled correctly, each recycled cell showing fresh, correct, non-duplicated content.
206
+
207
+ **Deliberately out of scope for v1** (documented, not silently missing): deferred list items (ReactLynx's `defer`/`isReady` promise dance), `componentAtIndexes` batching, and independent per-item redraw after the initial bind — a bound cell's content is recomputed fresh from `renderItem(index)` only when native calls `componentAtIndex` for it (scroll-driven reuse), not automatically when app state changes.
208
+
209
+ ## Known permanent gaps
210
+
211
+ - `m.trust` / innerHTML vnodes — no Lynx PAPI equivalent to raw innerHTML injection.
212
+ - `m.route` — Lynx pages aren't URL-addressable the way DOM `history` is.
213
+
214
+ ## Compat with the plain-JS ecosystem
215
+
216
+ Mithril was never hooks-based, so — unlike React — there's no special rules-of-hooks compatibility story to build: `m.redraw()` after any state mutation already works with any plain-JS state library (a simple pub/sub store, streams, whatever). Nothing in this package needs to shim a specific state-management library for that reason; if something in the ecosystem doesn't work, it's not because of a hooks-equivalence gap.
217
+
218
+ ## Testing
219
+
220
+ ```bash
221
+ bun install
222
+ bun run test
223
+ ```
224
+
225
+ Tests run against `@lynx-js/testing-environment`'s jsdom-backed PAPI polyfill. The polyfill itself is published as `mithril-lynx/testing`'s `installTestingPolyfills()` (see `test/setup.ts` for the one-line setup) — consuming apps can use the exact same polyfill for their own tests instead of maintaining a duplicate copy; see `mithril-app/test/setup.ts` for a worked example.
226
+
227
+ **Not provided**: Fast Refresh and a devtools/inspector bundle (project plan, Phase 9 / subsystem 12) are explicitly out of scope — they're deep, compiler-driven DX features in upstream ReactLynx with no zero-compiler equivalent worth building. `rspeedy dev`'s existing full-reload-on-file-change loop is the honest substitute.
@@ -0,0 +1,54 @@
1
+ // Ambient declaration for the ESM background.js (the file itself is not
2
+ // type-checked; this describes its runtime export shape for TS consumers).
3
+
4
+ export type StoreData = Record<string, unknown>;
5
+
6
+ export interface SetDataOptions {
7
+ /** Set false to update the store without pushing a sync to the main thread. Defaults to true. */
8
+ shouldSyncToMainThread?: boolean;
9
+ }
10
+
11
+ /** Returns the background thread's mutable data store. */
12
+ export function getData<T = StoreData>(): T;
13
+
14
+ /** Merges `patch` into the store, then (by default) pushes the changed keys to the main thread. */
15
+ export function setData(patch: StoreData, options?: SetDataOptions): void;
16
+
17
+ /**
18
+ * Registers the single handler invoked for every dispatchToBackground() call
19
+ * made from the main thread, as (handlerName, data).
20
+ */
21
+ export function setBackgroundEventHandler(
22
+ handleEvent: (handlerName: string, data: unknown) => unknown,
23
+ ): void;
24
+
25
+ /**
26
+ * Wires the background thread's core-context listeners for the
27
+ * main-thread <-> background-thread data channel. Call once, at
28
+ * background.ts's top level. See the project plan, Phase 3.
29
+ */
30
+ export function setupBackground(): void;
31
+
32
+ export interface BackgroundRef {
33
+ /** Resolves with the native method's success data, rejects with its failure data. */
34
+ invoke(method: string, params?: Record<string, unknown>): Promise<unknown>;
35
+ }
36
+
37
+ /**
38
+ * A background-thread ref: imperative calls to a native element identified
39
+ * by a CSS selector, via lynx.createSelectorQuery(). See the project plan,
40
+ * Phase 5.
41
+ */
42
+ export function createRef(selector: string): BackgroundRef;
43
+
44
+ /**
45
+ * Registers a handler main-thread.js's runOnBackground(key, ...args) can
46
+ * call by name. See the project plan, Phase 6.
47
+ */
48
+ export function registerHandler(key: string, fn: (...args: unknown[]) => unknown): void;
49
+
50
+ /**
51
+ * Calls a handler main-thread.js registered via registerHandler(key, fn).
52
+ * Args and the resolved value must be JSON-serializable.
53
+ */
54
+ export function runOnMainThread<T = unknown>(key: string, ...args: unknown[]): Promise<T>;
package/background.js ADDED
@@ -0,0 +1,169 @@
1
+ // background.js
2
+ //
3
+ // Cross-thread "data-channel mode" adapter for the background thread (see
4
+ // the project plan, Phase 3). Ports
5
+ // lynx-examples/examples/vanilla/src/common/background/{setup,data,event}.ts
6
+ // into a single module — this side is framework-agnostic (Mithril never
7
+ // renders on the background thread in data-channel mode), so nothing here
8
+ // depends on the shim.
9
+ //
10
+ // A single mutable data store is diffed against its last-synced snapshot on
11
+ // every setData() call and only the changed keys are pushed to the main
12
+ // thread. Data received FROM the main thread is never echoed back (the
13
+ // reference implementation does, on every update after the first — a
14
+ // pure round-trip echo that's wasteful, and directly visible as a redundant
15
+ // extra Mithril redraw in this port, so it isn't reproduced here).
16
+
17
+ import {
18
+ callBackgroundEventName,
19
+ callBackgroundResultEventName,
20
+ callMainThreadEventName,
21
+ callMainThreadResultEventName,
22
+ destroyLifetimeEventName,
23
+ dispatchEventToBackgroundEventName,
24
+ updateDataFromBackgroundEventName,
25
+ updateDataFromMainThreadEventName,
26
+ } from "./internal/constants.js";
27
+
28
+ const data = {};
29
+ let lastSyncedData = { ...data };
30
+ let handleBackgroundEvent;
31
+
32
+ export function getData() {
33
+ return data;
34
+ }
35
+
36
+ export function setData(patch, options = {}) {
37
+ const { shouldSyncToMainThread = true } = options;
38
+ Object.assign(data, patch);
39
+ if (!shouldSyncToMainThread) {
40
+ lastSyncedData = { ...data };
41
+ return;
42
+ }
43
+ sendToMainThread();
44
+ }
45
+
46
+ function sendToMainThread() {
47
+ const patch = {};
48
+ for (const [key, value] of Object.entries(data)) {
49
+ if (value !== lastSyncedData[key]) patch[key] = value;
50
+ }
51
+ if (Object.keys(patch).length === 0) return;
52
+ lastSyncedData = { ...data };
53
+ lynx.getCoreContext().dispatchEvent({
54
+ type: updateDataFromBackgroundEventName,
55
+ data: patch,
56
+ });
57
+ }
58
+
59
+ // handleEvent(handlerName, data) — called for every dispatchToBackground()
60
+ // call made from the main thread. Only one handler at a time; Phase 6 of the
61
+ // project plan generalizes this into a full string-keyed registry with
62
+ // call/return correlation.
63
+ export function setBackgroundEventHandler(handleEvent) {
64
+ handleBackgroundEvent = handleEvent;
65
+ }
66
+
67
+ export function setupBackground() {
68
+ const coreContext = lynx.getCoreContext();
69
+
70
+ const onUpdateDataFromMainThread = (event) => {
71
+ const incoming = event.data;
72
+ if (!incoming || typeof incoming !== "object" || Array.isArray(incoming)) return;
73
+ // Never echo data back to the thread it just came from — only
74
+ // background-initiated setData() calls (elsewhere in app code)
75
+ // should sync to the main thread.
76
+ setData(incoming, { shouldSyncToMainThread: false });
77
+ };
78
+
79
+ const onDispatchToBackground = (event) => {
80
+ const payload = event.data;
81
+ if (!payload || typeof payload.handlerName !== "string") return;
82
+ handleBackgroundEvent?.(payload.handlerName, payload.data);
83
+ };
84
+
85
+ const cleanup = () => {
86
+ coreContext.removeEventListener(updateDataFromMainThreadEventName, onUpdateDataFromMainThread);
87
+ coreContext.removeEventListener(dispatchEventToBackgroundEventName, onDispatchToBackground);
88
+ coreContext.removeEventListener(destroyLifetimeEventName, cleanup);
89
+ };
90
+
91
+ coreContext.addEventListener(updateDataFromMainThreadEventName, onUpdateDataFromMainThread);
92
+ coreContext.addEventListener(dispatchEventToBackgroundEventName, onDispatchToBackground);
93
+ coreContext.addEventListener(destroyLifetimeEventName, cleanup);
94
+ }
95
+
96
+ // Refs (project plan, Phase 5): the background thread has no direct native
97
+ // handle, so imperative calls go through Lynx's existing selector-query
98
+ // bridge (the same primitive ReactLynx's own background-thread refs
99
+ // ultimately bottom out on) rather than a new protocol.
100
+ export function createRef(selector) {
101
+ return {
102
+ invoke(method, params) {
103
+ return new Promise((resolve, reject) => {
104
+ lynx.createSelectorQuery()
105
+ .select(selector)
106
+ .invoke({
107
+ method,
108
+ params: params || {},
109
+ success: (data) => resolve(data),
110
+ fail: (data) => reject(data),
111
+ })
112
+ .exec();
113
+ });
114
+ },
115
+ };
116
+ }
117
+
118
+ // Cross-thread function registry (project plan, Phase 6 — worklet
119
+ // substitute). See main-thread.js's registerHandler()/runOnBackground() for
120
+ // the full rationale; this is the mirror image for the reverse direction.
121
+ // Lazy setup on first use, same reasoning as main-thread.js.
122
+ const backgroundHandlers = new Map();
123
+ const pendingMainThreadCalls = new Map();
124
+ let nextCallId = 1;
125
+ let crossThreadCallsReady = false;
126
+
127
+ function ensureCrossThreadCalls() {
128
+ if (crossThreadCallsReady) return;
129
+ crossThreadCallsReady = true;
130
+ const coreContext = lynx.getCoreContext();
131
+
132
+ coreContext.addEventListener(callBackgroundEventName, (event) => {
133
+ const { callId, key, args } = event.data;
134
+ const fn = backgroundHandlers.get(key);
135
+ let result;
136
+ let error;
137
+ try {
138
+ result = fn ? fn(...args) : undefined;
139
+ } catch (e) {
140
+ error = e instanceof Error ? e.message : String(e);
141
+ }
142
+ coreContext.dispatchEvent({ type: callBackgroundResultEventName, data: { callId, result, error } });
143
+ });
144
+
145
+ coreContext.addEventListener(callMainThreadResultEventName, (event) => {
146
+ const { callId, result, error } = event.data;
147
+ const pending = pendingMainThreadCalls.get(callId);
148
+ if (pending == null) return;
149
+ pendingMainThreadCalls.delete(callId);
150
+ if (error != null) pending.reject(new Error(error));
151
+ else pending.resolve(result);
152
+ });
153
+ }
154
+
155
+ /** Registers a handler main-thread.js's runOnBackground(key, ...) can call by name. */
156
+ export function registerHandler(key, fn) {
157
+ ensureCrossThreadCalls();
158
+ backgroundHandlers.set(key, fn);
159
+ }
160
+
161
+ /** Calls a handler main-thread.js registered via registerHandler(key, fn), by name. Args must be JSON-serializable. */
162
+ export function runOnMainThread(key, ...args) {
163
+ ensureCrossThreadCalls();
164
+ return new Promise((resolve, reject) => {
165
+ const callId = nextCallId++;
166
+ pendingMainThreadCalls.set(callId, { resolve, reject });
167
+ lynx.getCoreContext().dispatchEvent({ type: callMainThreadEventName, data: { callId, key, args } });
168
+ });
169
+ }