mithril-lynx 0.0.8 → 2.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 (61) hide show
  1. package/.omo/plans/m-request-fetch-lynx.md +306 -0
  2. package/.omo/plans/m-route-en-memoria.md +397 -0
  3. package/.omo/plans/mithril-lynx-v2-desde-cero.md +548 -0
  4. package/FETCH_INVESTIGATION.md +307 -0
  5. package/README.md +32 -284
  6. package/REQUEST.md +71 -0
  7. package/ROUTE.md +71 -0
  8. package/package.json +24 -80
  9. package/plugin.d.ts +4 -27
  10. package/plugin.js +142 -359
  11. package/rstest.config.ts +27 -0
  12. package/src/apply-patch.js +179 -0
  13. package/src/backends/virtual-backend.js +80 -0
  14. package/src/background.d.ts +11 -0
  15. package/src/background.js +79 -0
  16. package/src/channel.js +41 -0
  17. package/src/commit.js +67 -0
  18. package/src/dev-reload-client.js +245 -0
  19. package/src/dev-transport-noop.js +10 -0
  20. package/src/fake-dom.js +374 -0
  21. package/src/main-thread.d.ts +1 -0
  22. package/src/main-thread.js +68 -0
  23. package/src/mount-redraw.js +67 -0
  24. package/src/patch-protocol.js +40 -0
  25. package/src/reload/version.js +28 -0
  26. package/src/request.d.ts +37 -0
  27. package/src/request.js +181 -0
  28. package/src/route.d.ts +33 -0
  29. package/src/route.js +207 -0
  30. package/test/end-to-end.test.ts +86 -0
  31. package/test/reload-version.test.ts +17 -0
  32. package/test/request.test.ts +182 -0
  33. package/test/route-hot-reload.test.ts +40 -0
  34. package/test/route.test.ts +152 -0
  35. package/test/setup.ts +25 -0
  36. package/test/structural-reload.test.ts +95 -0
  37. package/CONTRACT.md +0 -151
  38. package/LICENSE +0 -21
  39. package/background.d.ts +0 -54
  40. package/background.js +0 -169
  41. package/element.d.ts +0 -34
  42. package/element.js +0 -83
  43. package/gesture.d.ts +0 -40
  44. package/gesture.js +0 -117
  45. package/internal/constants.js +0 -26
  46. package/internal/virtual-node.js +0 -388
  47. package/list.d.ts +0 -31
  48. package/list.js +0 -185
  49. package/main-thread.d.ts +0 -43
  50. package/main-thread.js +0 -165
  51. package/navigation.d.ts +0 -35
  52. package/navigation.js +0 -76
  53. package/renderer/background.d.ts +0 -21
  54. package/renderer/background.js +0 -84
  55. package/renderer/main-thread.d.ts +0 -12
  56. package/renderer/main-thread.js +0 -175
  57. package/src/lynx-mithril-shim.d.ts +0 -16
  58. package/src/lynx-mithril-shim.js +0 -1505
  59. package/src/worklet-runtime.js +0 -82
  60. package/testing.d.ts +0 -10
  61. package/testing.js +0 -91
package/README.md CHANGED
@@ -1,85 +1,28 @@
1
1
  # mithril-lynx
2
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/).
3
+ Mithril.js rendered through [Lynx](https://lynxjs.org)'s Element PAPI, with genuine automatic redraw and three real reload modes — the two things the previous version of this project never fully got right.
4
4
 
5
- ## Getting started
5
+ > **Note:** this is a complete, from-scratch rewrite of the previous `mithril-lynx` (internally, "v2") — not an incremental patch on top of it. The old implementation is retired; no more fixes land against that design. See "Why a rewrite" below for exactly what justified starting over instead of patching it again.
6
6
 
7
- ```bash
8
- npm create mithril-lynx@latest
9
- ```
10
-
11
- Scaffolds a new app via [`create-mithril-lynx`](https://github.com/carlos-sweb/create-mithril-lynx) — pick **Hello World** (a tap-to-animate logo demo, the Mithril analog of Lynx's own React hello-world), **Blank** (a single line of text, nothing else), or **Basic Activity** (two screens wired with `mithril-lynx/navigation`, confirmed working end to end on a real device), in TypeScript or JavaScript. That's the fastest way to a running app; the rest of this README documents the framework itself for once you're inside one.
12
-
13
- ## What this package is
14
-
15
- `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.
16
-
17
- `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.
18
-
19
- 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.
20
-
21
- 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:
22
-
23
- - **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.
24
- - **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.
25
- - **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).
26
-
27
- ## Usage — main-thread-owned
28
-
29
- ```js
30
- import shim from "mithril-lynx";
31
- import m from "mithril";
32
-
33
- // The native engine unconditionally calls a global processData(initData)
34
- // on every __RenderPage/__UpdatePage — install a pass-through default
35
- // (mithril-lynx/main-thread does this for you in data-channel mode, but
36
- // this bare pattern doesn't go through that module).
37
- Object.assign(globalThis, { processData: (data) => data });
38
-
39
- const engine = lynx.getEngine();
40
- engine.addEventListener("__RenderPage", () => {
41
- const page = __CreatePage("0", 0);
42
- shim.renderToPage(page, m(MyComponent));
43
- });
44
- ```
45
-
46
- Subsequent UI updates flow through `shim.redraw()` — **never plain `m.redraw()`**, which is a no-op in this shim-based architecture, and **never automatically after an `on*` handler either**: every event the shim hands to a handler is normalized with `redraw: false` on purpose (confirmed on real hardware 2026-09-10 — an `ontap` handler that only mutates local main-thread state, with no round trip through a data-channel/renderer-mode push, silently doesn't repaint until `shim.redraw()` is called from inside the handler itself; no error is thrown, since Mithril's own `EventDict.handleEvent` just skips the auto-redraw when `ev.redraw === false`). This is deliberate — a real DOM redraws cheaply enough that auto-redraw-per-event is a reasonable default there; a full Lynx PAPI diff pass on every touch event is not something to default to. `mithril-lynx/navigation`'s `push`/`pop`/`replace` already do this internally (see its source), which is why call sites using only that module never need to think about it.
47
-
48
- ## Usage — data-channel mode
49
-
50
- `main-thread.ts`:
51
-
52
- ```js
53
- import { setupApp, getData, dispatchToBackground } from "mithril-lynx/main-thread";
54
- import m from "mithril";
55
-
56
- const Counter = {
57
- view: () => m("text", { ontap: () => dispatchToBackground("increment") }, String(getData()?.count ?? 0)),
58
- };
59
-
60
- setupApp({ root: () => m(Counter) });
61
- ```
7
+ ## Why a rewrite
62
8
 
63
- `background.ts` (sibling file — `mithril-lynx/plugin` picks it up automatically as a second bundle):
9
+ The old implementation's core bugs all traced back to the same root cause: whether a redraw actually reached the main thread depended on runtime conditions (`typeof globalThis.__FlushElementTree === "function"`, which render mode was active, load order) instead of a fixed contract. Every fix added another conditional on top of the last one. Reading ReactLynx's own source (not just its docs) showed its actual patch-channel/reload design is a different architecture, not an incremental improvement on the old one — so this rewrite starts over with that contract from the first commit:
64
10
 
65
- ```js
66
- import { setupBackground, getData, setData, setBackgroundEventHandler } from "mithril-lynx/background";
67
-
68
- setupBackground();
69
- setData({ count: 0 }, { shouldSyncToMainThread: false });
70
- setBackgroundEventHandler((handlerName) => {
71
- if (handlerName === "increment") setData({ count: (getData().count ?? 0) + 1 });
72
- });
73
- ```
11
+ - **One thread model, not three.** The old version had main-thread-owned, data-channel, and renderer modes. This one has exactly one: the real `mithril/render/render.js` (via [`mithril-runtime`](https://github.com/carlos-sweb/mithril-runtime)) always runs on the **background** thread against a virtual tree; the **main thread** only ever replays patches onto real Element PAPI nodes and forwards native events back. No app view code ever runs on the main thread.
12
+ - **One explicit commit hook, not a conditional global.** `src/commit.js` installs exactly one commit callback per `renderApp()` lifetime, set up once by the code that owns the render. Asking to commit before one is installed throws immediately, on the same tick, with a message naming exactly what's missing — never a silently frozen screen.
13
+ - **Three reload modes, correctly separated** (see `.omo/plans/mithril-lynx-v2-desde-cero.md` for the full device-verified story):
14
+ - **A — data reload**: text/props/CSS edits. Already validated in the old version; rebuilt on the new core.
15
+ - **B — structural reload**: adding/removing/reordering tree nodes. The old version assumed this *had* to be a full reload; this one lets Mithril's own real diff (running in the background against a real tree) produce the right Create/Insert/Remove ops instead — no separate wire-protocol mode needed, just reconciliation that doesn't discard nodes that didn't change.
16
+ - **C — full reload**: fallback for what A/B can't resolve (new imports, changed dependencies, an unrecoverable error). Same CDP `Page.reload` mechanism stabilized in the old version's 0.0.9, rewritten on the new core.
74
17
 
75
- `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.
18
+ **Deliberately not carried over from the old version (yet)**: gestures, list virtualization (Tier 2), the imperative ref helpers, and the old stack-based `navigation` module. Those were real, device-verified capabilities there — this rewrite's scope so far is specifically the redraw/reload core plus routing and networking (see below). Reimplementing the rest on this core is future work, not something this rewrite claims to already cover.
76
19
 
77
- ## Usage — renderer mode
20
+ ## Usage
78
21
 
79
22
  `main-thread.ts`:
80
23
 
81
24
  ```js
82
- import { setupRenderer } from "mithril-lynx/renderer/main-thread";
25
+ import { setupRenderer } from "mithril-lynx/main-thread";
83
26
 
84
27
  setupRenderer();
85
28
  ```
@@ -87,240 +30,45 @@ setupRenderer();
87
30
  `background.ts`:
88
31
 
89
32
  ```js
90
- import { renderApp, redraw } from "mithril-lynx/renderer/background";
91
- import m from "mithril";
33
+ import { renderApp } from "mithril-lynx/background";
34
+ import m from "mithril-runtime";
92
35
 
93
36
  let count = 0;
94
37
  const Counter = {
95
- view: () => m("text", { ontap: () => { count += 1; redraw(); } }, String(count)),
38
+ view: () => m("text", { ontap: () => { count += 1; } }, String(count)),
96
39
  };
97
40
 
98
41
  renderApp({ root: () => m(Counter) });
99
42
  ```
100
43
 
101
- 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.
102
-
103
- ## Refs / native imperative bridge
104
-
105
- 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:
106
-
107
- - **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).
108
-
109
- ```js
110
- import { wrapElement } from "mithril-lynx/element";
111
-
112
- m("input", {
113
- oncreate: (vnode) => wrapElement(vnode.dom).invoke("focus"),
114
- });
115
- ```
116
-
117
- **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.
118
-
119
- - **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.
120
-
121
- ```js
122
- import { createRef } from "mithril-lynx/background";
123
-
124
- const input = createRef("#my-input");
125
- await input.invoke("focus"); // resolves with success data, rejects with failure data
126
- ```
127
-
128
- **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.
129
-
130
- **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.
131
-
132
- ## Cross-thread function calls (worklet substitute)
133
-
134
- ReactLynx's Main Thread Scripting (worklets) exists to solve two different problems, and only one of them needs a compiler:
135
-
136
- - **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.
137
- - **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:
138
-
139
- ```js
140
- // main-thread.ts
141
- import { registerHandler } from "mithril-lynx/main-thread";
142
- registerHandler("flashBackground", (color) => { /* ... */ });
143
- ```
144
- ```js
145
- // background.ts
146
- import { runOnMainThread } from "mithril-lynx/background";
147
- await runOnMainThread("flashBackground", "red");
148
- ```
149
-
150
- 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.
151
-
152
- **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.
153
-
154
- ## Gestures
155
-
156
- `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.
157
-
158
- ```js
159
- import { createGesture, GestureType } from "mithril-lynx/gesture";
160
-
161
- m("view", {
162
- oncreate: (vnode) => {
163
- createGesture(vnode.dom, {
164
- type: GestureType.PAN, // or the string "pan"
165
- callbacks: {
166
- onStart: () => { /* ... */ },
167
- onUpdate: () => { /* ... */ },
168
- },
169
- });
170
- },
171
- });
172
- ```
173
-
174
- 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).
175
-
176
- `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.
177
-
178
- **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.
179
-
180
- ## Lists
181
-
182
- Two tiers, matching the real complexity spread in Lynx's own `list` examples:
183
-
184
- - **Tier 1 (prefer this)**: `<list>`/`<list-item>` need no code at all — they're just ordinary tags through the existing shim:
185
-
186
- ```js
187
- m("list", { class: "my-list" },
188
- items.map((item) => m("list-item", { key: item.id }, [ItemView(item)])))
189
- ```
190
-
191
- 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.
192
-
193
- - **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:
194
-
195
- ```js
196
- import { createList } from "mithril-lynx/list";
197
-
198
- m("view", {
199
- oncreate: (vnode) => {
200
- const list = createList(vnode.dom, {
201
- itemCount: items.length,
202
- renderItem: (index) => m("text", { class: "cell" }, items[index].label),
203
- });
204
- vnode.dom.appendChild(list);
205
- },
206
- });
207
- ```
208
-
209
- 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.
210
-
211
- `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.
212
-
213
- **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.
214
-
215
- **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.
216
-
217
- ## Navigation
218
-
219
- `mithril-lynx/navigation`'s `createNavigator({ initial, initialAttrs? })` is a stack-based, in-memory screen navigator — deliberately **not** built on `m.route` (see "Known permanent gaps" below: Lynx pages have no URL/History API for `m.route` to hook into). Android's own Activity navigation doesn't need URLs either — it's a plain back-stack — so this is that idea directly, not a URL-shaped abstraction forced onto an environment with no URLs.
220
-
221
- ```js
222
- import { createNavigator } from "mithril-lynx/navigation";
223
- import m from "mithril";
224
-
225
- const Home = {
226
- view: (vnode) => m("view", { ontap: () => vnode.attrs.nav.push(Details, { id: 42 }) }, [
227
- m("text", null, "Go to details"),
228
- ]),
229
- };
230
- const Details = {
231
- view: (vnode) => m("view", { ontap: () => vnode.attrs.nav.pop() }, [
232
- m("text", null, "Details for #" + vnode.attrs.id + " — tap to go back"),
233
- ]),
234
- };
235
-
236
- const nav = createNavigator({ initial: Home });
237
- shim.renderToPage(page, m(nav.Navigator)); // main-thread-owned mode
238
- ```
239
-
240
- Every screen the navigator renders receives its own `attrs` plus a `nav` prop (`push`/`pop`/`replace`/`canGoBack`/`depth`), so screens don't need to import the navigator instance separately to navigate onward. Only the top of the stack is ever mounted — previous screens are torn down, not kept alive offscreen (matching how most single-activity/single-page navigators behave); a popped screen that needs to remember its own state should keep that state somewhere the app already owns (a module-level store, `background.js`'s data store, etc.), not rely on its own component instance surviving the pop.
241
-
242
- Built on `shim.redraw()` alone, so it works unmodified in all three rendering modes (main-thread-owned, data-channel, renderer) — `nav.push()`/`pop()`/`replace()` just trigger whichever redraw mechanism that mode already uses.
44
+ `setupRenderer()` needs no app-specific code — it's generic, wired once via `mithril-lynx/plugin` (the Rspeedy/Rsbuild plugin building the two-bundle main-thread/background app). All app logic, including the whole Mithril component tree, lives in `background.ts`. A tap handler that mutates state repaints the screen with **no explicit `redraw()` call anywhere in the view** — real Mithril's own `render(dom, vnodes, redraw)` contract does that automatically after any event, the same mechanism `@lynx-js/react` relies on (see `test/end-to-end.test.ts` for this exact scenario running against real Lynx PAPI via `@lynx-js/testing-environment`, not a mock).
243
45
 
244
- **Deliberately out of scope for v1**: screen transition animations (left entirely to the app's own CSS/styling on whatever wraps `nav.Navigator`), and hardware back-button integration (no documented Lynx PAPI hook for it was found — wire a screen's own back-affordance to `nav.pop()` instead, as in the example above).
46
+ `mithril-runtime` — not the official `mithril` package — is the `peerDependency` here: a maintained fork with `m.route`/`m.trust`/`m.request` stripped at the source (see its own README for why), since those three need Lynx-specific replacements anyway (routing and networking below; `m.trust` has no replacement — see "Known gaps").
245
47
 
246
- ## Custom fonts
48
+ ## Routing
247
49
 
248
- Use a plain CSS `@font-face` rule — not `lynx.addFont()`. That JS API (background-thread-only) is for loading a font dynamically *after* mount, matching [`lynx-family/lynx-examples`](https://github.com/lynx-family/lynx-examples)'s own `examples/text/src/custom_font`, which calls it from `componentDidMount` and re-renders via `setState` once its callback fires. For a font known at build time (the common case), `examples/text/src/font_face` is the pattern to copy — a declarative `@font-face`, no JS:
50
+ `m.route`, reimplemented as in-memory history (Lynx has no URL/`window.history` for a real one to hook into) while keeping the rest of the real `m.route` API shape. See [`ROUTE.md`](./ROUTE.md) for the full API and usage.
249
51
 
250
- ```css
251
- @font-face {
252
- font-family: "Ubuntu Mono";
253
- src: url("./assets/fonts/ubuntu-mono-400.ttf");
254
- }
255
-
256
- @font-face {
257
- font-family: "Ubuntu Mono";
258
- font-weight: 700;
259
- src: url("./assets/fonts/ubuntu-mono-700.ttf");
260
- }
261
-
262
- :root {
263
- font-family: "Ubuntu Mono"; /* needs enableCSSInheritance, see below */
264
- }
265
- ```
52
+ ## Networking
266
53
 
267
- Three gotchas, all confirmed on real hardware:
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).
268
55
 
269
- - **The font file must be `.ttf`, not `.woff2`** (2026-09-10). A `.woff2` `@font-face` compiles fine — a valid `url('data:font/woff2;base64,...')` lands in the bundle, no build error, no runtime error — but the native text renderer silently never applies it, even with a maximally-distinctive test font (swapping the default sans-serif for a cursive/marker-style face produced zero visual change). Re-pointing the exact same rule at a `.ttf` of the same font worked immediately, no other change needed. Fontsource-distributed packages only ship woff/woff2; get a `.ttf` from the font's original source instead (e.g. [`google/fonts`](https://github.com/google/fonts) for anything Google-Fonts-hosted).
270
- - **`font-family` set on `:root` (or any ancestor) does not cascade to descendants by default** (2026-09-10). `@lynx-js/config-rsbuild-plugin`'s `pluginLynxConfig()` has an `enableCSSInheritance` option that's off unless set explicitly; without it, only the exact element the property is set on gets it — confirmed by setting `font-family: serif` on `:root` and seeing zero change anywhere in the tree. Turn it on (`pluginLynxConfig({ enableCSSInheritance: true })` in `lynx.config.ts`) to use a single `:root` declaration instead of repeating `font-family` on every class.
271
- - **A declarative `@font-face` resolves synchronously on the first native `__FlushElementTree()` call, and that cost scales with how many text nodes/CSS rules end up resolving it** (2026-09-10/11) — up to +2s of cold start observed on a mid/low-end device (Samsung Galaxy A07) applying a font broadly via a `text{}` type selector, root-caused via node-count bisection and a native-Android control app with zero Lynx dependencies (~30-100ms there for the same font on the same number of views). Filed upstream as [lynx-family/lynx#9431](https://github.com/lynx-family/lynx/issues/9431), which also documents a working native-side workaround (not a mithril-lynx-level fix — it lives in the consuming Android app, calling `FontFaceManager.getInstance().prefetchFont(...)` before `renderTemplateUrl()` to warm Lynx's typeface cache ahead of the JS bundle even starting): brought cold start down to ~750-800ms, indistinguishable from the fontless baseline, in `indicadores-android` (shipped there, not part of this package). Check that issue for upstream maintainer responses before re-investigating the same symptom.
56
+ ## Known gaps
272
57
 
273
- ## Known permanent gaps
274
-
275
- - `m.trust` / innerHTML vnodes — no Lynx PAPI equivalent to raw innerHTML injection.
276
- - `m.route` — Lynx pages aren't URL-addressable the way DOM `history` is.
277
-
278
- ## Known gap, not permanent
279
-
280
- - `m.request` — throws (`XMLHttpRequest is not defined`) rather than silently misbehaving: it's hard-wired to a real `XMLHttpRequest`, which doesn't exist in Lynx's JS runtime (neither the main-thread Lepus/QuickJS engine nor the background JS thread). Unlike `m.trust`/`m.route` above, this isn't structural — Lynx does have its own networking primitives — it just hasn't been wrapped in a `$window`-shaped compat layer yet. Use Lynx's own networking API directly (wrapped in a `Promise`, if desired) until this exists.
281
-
282
- ## Rest of the public `m` API — what's actually used
283
-
284
- Beyond hyperscript (`m(...)`) itself, only `m.fragment` and `m.censor` are used as shipped from the real `mithril` package — both are pure data/diff logic with no DOM dependency, so they work unmodified. `m.render`, `m.mount`, and `m.redraw` are never called from the real package at all: `mithril-lynx` has its own equivalents (`shim.renderToPage()`/`shim.render()`/`shim.redraw()`, this README's own "Usage" sections) that target the Lynx Element PAPI instead of the DOM — calling the *real* `m.mount()`/`m.redraw()` does nothing here, since they're wired to `m.render()`'s own DOM-only render path, which this project's apps never invoke.
285
-
286
- ## Compat with the plain-JS ecosystem
287
-
288
- 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.
289
-
290
- **The main-thread JS engine is QuickJS-based, not V8 or a browser engine** — don't assume full modern JS API coverage just because something works under `@lynx-js/testing-environment`'s jsdom polyfill (which runs on Node's V8) or in editor tooling. Confirmed missing on real hardware: `Array.prototype.at()` (ES2022) — calling it throws a generic `TypeError: not a function` with a near-useless native backtrace (no indication it's an API-support gap rather than a framework bug), and since it only fails once an array actually has content, the crash can look like a much bigger, unrelated regression than it is. This bit a consuming app (`mithril-lynx-ui`'s demo) hard enough to cost a full debugging session before being traced back to this. Use `arr[arr.length - 1]` instead; `.slice(-N)` (pre-ES2022, negative index) is fine. If you reach for a newer Array/Object/String method, verify it on a real device build before trusting it — the jsdom suite will not catch this class of gap.
58
+ - **`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).
59
+ - **A handful of `m.request` options with no `fetch` equivalent** (`config`, `async: false`, `user`/`password`, `withCredentials`) throw immediately with a message pointing at `FETCH_INVESTIGATION.md`, rather than silently behaving differently — see `REQUEST.md`.
291
60
 
292
61
  ## Testing
293
62
 
294
63
  ```bash
295
- bun install
296
- bun run test
64
+ npm test
297
65
  ```
298
66
 
299
- 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.
300
-
301
- **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.
302
-
303
- In development, `pluginMithrilLynx()` reloads the running page after every
304
- successful rebuild instead of attempting module HMR — Mithril view code runs in
305
- the main-thread/Lepus bundle, and rspack's hot runtime can only patch modules on
306
- the background thread, so HMR could never reach it. (No synthetic background
307
- entry is created for this: the reload runs from the dev-server process, not from
308
- a chunk inside the app.)
309
-
310
- The reload goes through the Lynx DevTool connector and sends CDP `Page.reload`
311
- to the session currently serving your bundle. That reloads the existing page
312
- **in place**, so nothing is pushed onto the viewer's back stack. Two consequences
313
- worth knowing:
67
+ Runs against `@lynx-js/testing-environment`'s real Element PAPI simulation via `rstest` — `test/end-to-end.test.ts` and `test/structural-reload.test.ts` exercise real Mithril diff + real patch replay, not mocks. Device-only claims (focus/text surviving a structural reload on a real `<input>`, the three reload modes triggering correctly over a live dev session) are verified separately on a connected Android device and logged in `.omo/plans/mithril-lynx-v2-desde-cero.md` §8, not re-asserted here.
314
68
 
315
- - **Live reload reaches the device over adb, not over the LAN.** The DevTool
316
- connector talks to the adb server (it sets up an adb reverse tunnel to the
317
- device's DebugRouter). `npm run dev` and the QR-scan flow are unaffected and
318
- still work over Wi-Fi — only the automatic reload needs adb. With no adb
319
- connection the rebuild logs a warning and you reload by hand. Wireless
320
- debugging (`adb connect <ip>:5555`) satisfies this with no cable, and
321
- `ANDROID_SERIAL` picks the device when several are attached.
322
- - It is a **dev-server convenience, not a portable SDK API**: the connector is
323
- imported lazily by a dev rebuild only, and no reload code is bundled into the
324
- app in any build.
69
+ ## Reference docs
325
70
 
326
- Turn it off with `pluginMithrilLynx({ liveReload: false })`.
71
+ - `.omo/plans/mithril-lynx-v2-desde-cero.md` — the full rewrite plan: architecture decisions, the three reload modes' device verification, and the postmortem on exactly what v1 got wrong.
72
+ - `.omo/plans/m-route-en-memoria.md` — how `m.route` was designed and verified for an in-memory, URL-less environment.
73
+ - `.omo/plans/m-request-fetch-lynx.md` — the `m.request`-vs-`fetch` investigation plan and its execution log.
74
+ - [`ROUTE.md`](./ROUTE.md), [`REQUEST.md`](./REQUEST.md), [`FETCH_INVESTIGATION.md`](./FETCH_INVESTIGATION.md) — user-facing reference docs for the two Lynx-specific reimplementations.
package/REQUEST.md ADDED
@@ -0,0 +1,71 @@
1
+ # `mithril-lynx/request`
2
+
3
+ `m.request`, reimplemented as a wrapper over Lynx's own `fetch` — real `m.request` is built on `XMLHttpRequest`, which doesn't exist on Lynx. The gap between the two turned out smaller than the installed `@lynx-js/types` suggested; the full option-by-option comparison, with real-device evidence for every claim (not just docs/types, both of which were wrong at least once during that investigation), lives in [`FETCH_INVESTIGATION.md`](./FETCH_INVESTIGATION.md). This file is the practical usage doc; that one is the research record.
4
+
5
+ ```js
6
+ import request from "mithril-lynx/request";
7
+ ```
8
+
9
+ ## Basic usage
10
+
11
+ ```js
12
+ request("/users/:id", { params: { id: 42 } })
13
+ .then((user) => { /* ... */ });
14
+ ```
15
+
16
+ Matches real `m.request`: GET by default, `:param` interpolation in the URL (reusing `mithril-runtime/pathname/build.js`, the same engine `route.js` uses), automatic redraw of the currently mounted app once the request settles — unless `background: true` is passed, same as upstream.
17
+
18
+ ## Supported options
19
+
20
+ | Option | Behavior |
21
+ |---|---|
22
+ | `method`, `url`, `params` | Same as real `m.request`. |
23
+ | `body` (plain object) | JSON-encoded; `Content-Type: application/json; charset=utf-8` set automatically unless you already set one. |
24
+ | `body` (`URLSearchParams`) | Passed through unchanged — Lynx's `fetch` sets `Content-Type: application/x-www-form-urlencoded` automatically, confirmed on device. |
25
+ | `headers` | Plain object, same as upstream. |
26
+ | `responseType: "json" \| "text"` | Same as upstream (no `"blob"`/`"document"` — see below). |
27
+ | `serialize` / `deserialize` | Same as upstream. |
28
+ | `extract` | `(response, options) => any` — bypasses the status check entirely, same as upstream's `(xhr, options) => any`. Signature changes (`response` instead of `xhr`) since there's no XHR object; the purpose is identical. |
29
+ | `type` | Constructor applied to the result, unchanged from upstream. |
30
+ | `timeout` | Real cancellation, not just giving up on waiting — backed by `AbortController`, confirmed on device to actually tear down the in-flight connection (aborting 800ms into a 5-second server-side delay rejected at ~805ms, not 5000ms). |
31
+ | `background` | Same as upstream: skip the automatic redraw. |
32
+ | `.abort()` | **Not part of real `m.request`'s API** — a bonus method on the returned promise, since Lynx's `AbortController` makes it a real, working cancellation (real `m.request` only exposes this indirectly, through `config(xhr) => xhr.abort()`, which has no equivalent here — see below). |
33
+
34
+ ## Explicitly unsupported (throws immediately, never silently different)
35
+
36
+ These have no `fetch` equivalent on Lynx. Passing any of them throws right away, with a message pointing back at `FETCH_INVESTIGATION.md`, rather than quietly behaving differently from what real `m.request` would do:
37
+
38
+ - **`config(xhr)`** — `fetch` gives no live request object to mutate mid-flight.
39
+ - **`body` as `FormData`** — confirmed absent on Lynx at runtime (`typeof FormData === "undefined"`). Restructure as JSON, or a `URLSearchParams` body if the server accepts form-encoding.
40
+ - **`user` / `password`** (inline Basic Auth) — Lynx has no `btoa`, so there's no way to build the `Authorization` header even by hand.
41
+ - **`withCredentials`** — Lynx has no CORS/origin model for this to apply to.
42
+ - **`async: false`** — no synchronous `fetch` exists anywhere, browser or Lynx.
43
+
44
+ `responseType: "blob"` / `"document"` aren't in the throw-list above because they're not meaningfully requestable in the first place: Lynx's `Body` has no `.blob()`, and `"document"` has no meaning outside a browser DOM.
45
+
46
+ ## Error shape
47
+
48
+ Matches real `m.request` on a non-2xx response:
49
+
50
+ ```js
51
+ request("/missing").catch((err) => {
52
+ err.code; // response.status
53
+ err.message; // response.statusText, unless the body was a plain string
54
+ err.response; // the already-parsed body
55
+ });
56
+ ```
57
+
58
+ ## Testing
59
+
60
+ Two separate layers, deliberately not mixed:
61
+
62
+ - **Unit tests** (`test/request.test.ts`, run via `npm test`) inject a fake `fetch` via `createRequestor(fetchImpl)` — they validate the wrapper's own logic (URL building, body encoding, the unsupported-option throws, the redraw timing below) without touching the network.
63
+ - **The underlying `lynx.fetch` primitive itself** — redirects, `AbortController`, `URLSearchParams` bodies, `Headers` case-sensitivity — is validated separately against a real device and a real server, documented with the raw evidence in `FETCH_INVESTIGATION.md`.
64
+
65
+ `createRequestor()` is also there for any app that wants its own singleton (e.g. pointed at a different fake for a specific test file); `import request from "mithril-lynx/request"` is the default singleton for normal app use, matching real `m.request`'s feel.
66
+
67
+ ## A Lynx timer quirk this module works around
68
+
69
+ The automatic redraw after a request resolves is **scheduled**, not synchronous — calling it inline would repaint the screen *before* your own `.then()` callback (which is what actually stores the response in your app's state) gets to run, since that callback is chained one microtask behind `request()`'s internal one. Real Mithril sidesteps the exact same ordering issue by deferring its own redraw through `requestAnimationFrame`, which in a spec-compliant browser is guaranteed to run only after every pending microtask (including yours) has drained.
70
+
71
+ **Confirmed on a real device: Lynx's `lynx.setTimeout`/`lynx.requestAnimationFrame` do not honor that guarantee** — a scheduled callback fired *before* a chain of pending `.then()`s in every delay tested (0ms up to 16ms). This module works around it with an empirically-chosen 50ms delay, which is a safety margin, not a scheduling guarantee. See `FETCH_INVESTIGATION.md` §4.6 for the raw evidence and exact test code. If you build your own async-then-redraw logic anywhere else in an app using this framework, the same caveat applies.
package/ROUTE.md ADDED
@@ -0,0 +1,71 @@
1
+ # `mithril-lynx/route`
2
+
3
+ `m.route`, reimplemented for an environment with no URL bar and no `window.history` — Lynx pages aren't URL-addressable, so there's nothing for a real `popstate`-based router to hook into. This is not a limitation specific to Mithril: it's why React Router ships `MemoryRouter` and Vue Router ships `createMemoryHistory()` for exactly this kind of environment. `route.js` follows the same pattern — an in-memory array standing in for the browser's session history — while keeping the rest of the real `m.route` API shape, so route-using view code doesn't need to be rewritten, just re-imported.
4
+
5
+ ```js
6
+ import route from "mithril-lynx/route";
7
+ ```
8
+
9
+ ## Setup
10
+
11
+ ```js
12
+ route("/", {
13
+ "/": Home,
14
+ "/detail/:id": Detail,
15
+ });
16
+ ```
17
+
18
+ Unlike real Mithril, this call takes no `root` DOM argument — this package has exactly one `renderApp()` for the app's whole lifetime (see the main README's architecture section), so `route(...)` calls it internally the first time a route resolves. `defaultRoute` (`"/"` above) is both the fallback for an unmatched path **and** the screen the app starts on — there's no browser URL to read an initial path from, so this is the Lynx equivalent of React Router's `initialEntries={["/"]}`.
19
+
20
+ Route values can be a plain component, or a resolver object with `onmatch`/`render`, exactly like real Mithril:
21
+
22
+ ```js
23
+ route("/", {
24
+ "/": Home,
25
+ "/settings": {
26
+ onmatch: (params) => requiresAuth() ? SettingsPage : route.SKIP,
27
+ render: (vnode) => m(Layout, vnode),
28
+ },
29
+ });
30
+ ```
31
+
32
+ `route.SKIP` falls through to the next matching route, same as upstream.
33
+
34
+ ## Navigating
35
+
36
+ ```js
37
+ route.set("/detail/:id", { id: 42 }); // pushes a new history entry
38
+ route.set("/detail/:id", { id: 42 }, { replace: true }); // overwrites the current one
39
+ route.get(); // current resolved path, e.g. "/detail/42"
40
+ route.param("id"); // "42" — or route.param() for the whole params object
41
+ ```
42
+
43
+ `route.back()` / `route.forward()` walk the same in-memory history stack `route.set` writes to. **These do not exist on real Mithril** — they're new here because Lynx has no hardware/gesture "back" button exposed to JS (only app-lifecycle events like `onAppEnterBackground`, not navigation), so an app's own back affordance has to call something explicit. Wire a screen's back button to `route.back()`.
44
+
45
+ `route.prefix` exists only so app code defensively ported from a real Mithril app (`m.route.prefix = ""`) doesn't throw on import — there's no URL bar for a prefix to apply to, so setting it does nothing.
46
+
47
+ ## Links
48
+
49
+ Lynx has no `<a>`/`onclick` — `route.Link` renders a tap-driven element instead (the same shape ReactLynx's `useNavigate()` + `ontap` pattern and Vue Lynx's custom `RouterLink` slot use):
50
+
51
+ ```js
52
+ m(route.Link, { href: "/detail/:id", params: { id: 42 } }, [
53
+ m("text", null, "Go to detail"),
54
+ ]),
55
+ ```
56
+
57
+ - `selector` picks the rendered tag (default `"view"`).
58
+ - `params` interpolates into `href` the same way `route.set`'s second argument does.
59
+ - `options` is passed straight through to the underlying `route.set` call (e.g. `{ replace: true }`).
60
+ - `disabled: true` renders the element with no `ontap` at all, rather than an `ontap` that no-ops.
61
+ - Your own `ontap` still runs first; returning `false` from it cancels the navigation (matches real Mithril's `m.route.Link` behavior).
62
+
63
+ ## Known differences from real `m.route`
64
+
65
+ - No `root` argument to the setup call (see "Setup" above) — architectural, not an oversight.
66
+ - `route.back()`/`route.forward()` are new additions, not part of the real `m.route` API — see "Navigating" above for why Lynx needs them.
67
+ - History is in-memory only: it does not survive a full app restart, and there is no deep-linking from outside the app (nothing external can set the initial path — it's always `defaultRoute`).
68
+
69
+ ## Device verification
70
+
71
+ Navigation (Home → Detail with param interpolation, `route.Link` taps, `back()`/`forward()`), hot-reload while sitting on a non-default route (`module.hot.accept` + `route.set(route.get(), null, { replace: true })` to re-resolve after swapping a screen module), and confirmation that navigating away tears down the previous screen's nodes cleanly (via real patch ops — `Op.RemoveChild`/`Op.CreateElement`, not comparing CDP node ids, which are not stable identity across separate `DOM.getDocument()` calls) are all covered on a real connected Android device. See `.omo/plans/m-route-en-memoria.md` §4–§6 for the full research (how React Native/Vue-on-Lynx handle navigation) and the device evidence.