mithril-lynx 2.6.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/README.md +47 -8
  5. package/ROUTE.md +111 -16
  6. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  7. package/UPDATE_ANIMATION_GAP.md +133 -0
  8. package/package.json +1 -9
  9. package/plugin.js +33 -0
  10. package/src/apply-patch.js +369 -60
  11. package/src/backends/virtual-backend.js +120 -22
  12. package/src/background.d.ts +26 -0
  13. package/src/background.js +116 -7
  14. package/src/channel.js +22 -5
  15. package/src/commit.js +9 -0
  16. package/src/dev-reload-client.js +46 -0
  17. package/src/dev-transport-noop.js +3 -0
  18. package/src/fake-dom.js +559 -61
  19. package/src/list-attributes.js +69 -0
  20. package/src/list-runtime.js +425 -0
  21. package/src/main-thread.js +18 -1
  22. package/src/mount-redraw.js +14 -1
  23. package/src/patch-protocol.js +41 -16
  24. package/src/reload/version.js +10 -1
  25. package/src/request.js +38 -0
  26. package/src/route.d.ts +30 -2
  27. package/src/route.js +214 -56
  28. package/src/testing.d.ts +14 -1
  29. package/src/testing.js +56 -1
  30. package/test/event-render-coalescing.test.ts +117 -0
  31. package/test/gesture.test.ts +75 -0
  32. package/test/input.test.ts +388 -0
  33. package/test/list.test.ts +417 -113
  34. package/test/remove-subtree-cleanup.test.ts +75 -0
  35. package/test/route-hot-reload.test.ts +2 -1
  36. package/test/route.test.ts +201 -4
  37. package/test/style-clear.test.ts +97 -0
  38. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  39. package/.omo/plans/m-route-en-memoria.md +0 -397
  40. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  41. package/.omo/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
  42. package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +0 -10
  43. package/src/list-cell.d.ts +0 -20
  44. package/src/list-cell.js +0 -73
  45. package/src/list-support.d.ts +0 -13
  46. package/src/list-support.js +0 -197
package/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ ## 3.0.0
4
+
5
+ Everything since 2.6.2.
6
+
7
+ ### Breaking changes
8
+
9
+ - **Native `<list>`:** `list` and `list-item` are ordinary Mithril elements. The `Op.CreateList`/`Op.SetListItems` protocol and the `mithril-lynx/list-cell` / `mithril-lynx/list-support` entry points are gone. Render `m("list", …, items.map((it) => m("list-item", { key: it.id, "item-key": it.id }, …)))` instead. See the README.
10
+ - **`route.set()` is asynchronous,** as in Mithril's `m.route`. The history changes right away, but the new screen resolves on the next microtask, and `route.get()` returns the previous path until then. Several `set()` calls in the same tick resolve only the last one.
11
+ - **`route(defaultRoute, routes)` requires `defaultRoute`** and throws a `TypeError` without it. Route templates with badly separated params (`/:a:b`) throw a `SyntaxError`, as upstream does.
12
+ - **Native events on `<input>`/`<textarea>`:** an `input` event that doesn't change the text is no longer forwarded. That covers native's echo of `setValue`, and the `input ""` a `<textarea>` fires on creation. `input` now means "the text changed", as on the web.
13
+ - **The patch protocol is now `0x4d4e`:** the main-thread and background bundles must be rebuilt together.
14
+
15
+ ### New
16
+
17
+ - **`<input>` / `<textarea>`:**
18
+ - `value` works like the web's controlled input: sent with the native `setValue` only when it differs from the field, never echoing typed text, and guarded against racing keystrokes.
19
+ - `autofocus` focuses the field once, on creation.
20
+ - See [`INPUT.md`](./INPUT.md).
21
+ - **Native UI methods on any element:** `vnode.dom.invoke(method, params)` returns a promise, and `vnode.dom.focus()` / `vnode.dom.blur()` call the native methods. They run on the main thread right after the patch's flush, so they're safe from `oncreate`, with no `id`, `setTimeout` or selector query (new ops `Op.InvokeUIMethod`, `Op.SetInputValue`).
22
+ - **Native `<list>`:**
23
+ - every `<list>` / `<list-item>` attribute is passed with its real type (`src/list-attributes.js`);
24
+ - items are attached only when native asks for them (`src/list-runtime.js`, a port of @lynx-js/react's element-template list);
25
+ - verified on device up to ~2,000 items.
26
+ - **`route`:**
27
+ - `options.state`;
28
+ - a changed `:key` param recreates the page;
29
+ - `route.canGoBack()`;
30
+ - `route.listenBackButton()` for an opt-in Android back button, with a Kotlin host recipe in [`ROUTE.md`](./ROUTE.md).
31
+ - **`mithril-lynx/testing`:** a stand-in for `__InvokeUIMethod`, plus `uiMethodCalls` and `setUIMethodResponder()`.
32
+
33
+ ### Fixes
34
+
35
+ - **`route.Link`:** runs its lifecycle hooks once, honors `e.preventDefault()` and `{ handleEvent }` listeners, and doesn't copy `key`/hooks onto the rendered element.
36
+ - **`route` redirects:** a redirect from inside a render (`route.set()` in `oninit`) no longer throws "Node is currently being rendered to".
37
+ - **`route.get()`:** returns the decoded path.
38
+ - **Removed subtrees:** release every descendant's bookkeeping on both threads, where before only the root's was released.
39
+ - **Gesture worklets:** unregistered when their detector or element goes away.
40
+ - **Clearing a whole style** (`style` removed, or replaced by a string) expands into per-property removals.
41
+ - **List removals:** a removed on-screen list item is detached only after native processed its removal (a device-verified crash fix).
42
+
43
+ ### Performance
44
+
45
+ - The automatic redraw after a high-frequency event (`gesturemove`, `touchmove`, `scroll`) is coalesced to one per frame. Every handler still runs.
46
+
47
+ ### Known gaps
48
+
49
+ - `<list update-animation="default">` is not safe on lists that remove on-screen items — see [`UPDATE_ANIMATION_GAP.md`](./UPDATE_ANIMATION_GAP.md).
50
+ - `route` has no deep-link support (an initial path from the host) yet.
@@ -2,7 +2,8 @@
2
2
 
3
3
  Documento de referencia, no un plan — el plan que consumió esta
4
4
  investigación (con su narrativa de spikes y decisiones) está en
5
- [`.omo/plans/m-request-fetch-lynx.md`](.omo/plans/m-request-fetch-lynx.md).
5
+ `.omo/plans/m-request-fetch-lynx.md` (retirado del árbol en `71a6670`; se lee con
6
+ `git show 71a6670^:.omo/plans/m-request-fetch-lynx.md`).
6
7
  Este archivo es el consolidado técnico: qué es cada API, qué dice cada
7
8
  fuente, y qué se confirmó de verdad en un device real — para consultar
8
9
  sin tener que reconstruir la investigación de nuevo.
@@ -302,6 +303,6 @@ los 4-5 puntos sin equivalente, con una sugerencia directa de usar
302
303
  - `fetch` de Lynx (doc): <https://lynxjs.org/api/lynx-api/global/fetch.html>
303
304
  - Tipos reales: `@lynx-js/types/types/background-thread/{fetch,lynx}.d.ts`
304
305
  - Código fuente real de `m.request`: `node_modules/mithril/request/request.js`
305
- - Plan que usa esta investigación: [`.omo/plans/m-request-fetch-lynx.md`](.omo/plans/m-request-fetch-lynx.md)
306
+ - Plan que usa esta investigación: `.omo/plans/m-request-fetch-lynx.md` (en el historial de git: `git show 71a6670^:.omo/plans/m-request-fetch-lynx.md`)
306
307
  - Implementación resultante: `src/request.js` (ver ese archivo para el
307
308
  estado actual de qué de esta tabla ya está construido)
package/INPUT.md ADDED
@@ -0,0 +1,115 @@
1
+ # `<input>`, `<textarea>` and native UI methods
2
+
3
+ Since mithril-lynx 3.0.0.
4
+
5
+ On the web, a Mithril field is just attributes:
6
+
7
+ ```js
8
+ m("input", { value: name, autofocus: true, oninput: (e) => (name = e.target.value) })
9
+ ```
10
+
11
+ Lynx's native `<input>` and `<textarea>` have no `value` or `autofocus` attribute. The text is set and focus is given only through **UI methods** (`setValue`, `focus`, `blur`, `getValue`, `setSelectionRange`), which native exposes through `lynx.createSelectorQuery().select("#id").invoke(...)`. Until now that meant:
12
+
13
+ - giving the field an `id`;
14
+ - calling the selector query by hand;
15
+ - wrapping the call in a `setTimeout`, because in `oncreate` the element doesn't exist natively yet.
16
+
17
+ mithril-lynx now does this for you, so the web form works as is:
18
+
19
+ ```js
20
+ m("input", {
21
+ value: name, // sent with setValue when it changes
22
+ autofocus: true, // focuses once, on creation
23
+ placeholder: "Name",
24
+ oninput: (e) => (name = e.detail.value), // Lynx puts the payload under `detail`
25
+ })
26
+ ```
27
+
28
+ ## `value`
29
+
30
+ - `value` works like the web's controlled input. When the value you render differs from what the field holds, mithril-lynx sends one `setValue`.
31
+ - The field's `vnode.dom.value` is synced from every event it fires (`input`, `focus`, `blur`, `confirm`) before your handler runs.
32
+ - Echo suppression is built in:
33
+ - Mithril only writes `value` when it differs from `dom.value`, so text the user just typed is never sent back to native, and the cursor doesn't jump.
34
+ - A value your app *changes* (clearing the field, forcing uppercase, trimming) is sent once.
35
+ - Setting `value` to `null`/`undefined`, or dropping the attribute, clears the field (as on the web).
36
+ - To leave the field **uncontrolled**, don't pass `value`. Read the text from `e.detail.value` in `oninput`, from `vnode.dom.value`, or with `vnode.dom.invoke("getValue")`.
37
+ - For an initial text only, pass `value` on the first render and stop passing it afterwards.
38
+
39
+ ### Keystrokes racing a programmatic value
40
+
41
+ Your handler runs on the background thread, while the user keeps typing on the main thread. Take a transform like `toUpperCase()`: by the time `"AB"` reaches native, the user may have typed `"abc"`, and sending `"AB"` would erase the `c`.
42
+
43
+ To prevent that:
44
+ - the main thread counts each field's native `input` events and forwards the count with every event;
45
+ - a `setValue` carries the count it was computed from;
46
+ - a `setValue` computed before the latest keystroke is dropped, and that keystroke's own event re-renders and sends the up-to-date value.
47
+
48
+ `@lynx-js/lynx-ui-input` solves the same race by locking the field `readonly` on the main thread while the value round-trips.
49
+
50
+ ## Focus
51
+
52
+ - `autofocus: true` focuses the field once, when it is created (HTML semantics). Redraws never refocus it.
53
+ - `vnode.dom.focus()` and `vnode.dom.blur()` call the native methods. They are safe in `oncreate`: the call travels with the patch that creates the element and runs right after that patch is flushed.
54
+ - Called from a timer or a promise, outside any render, the call is sent on its own at the end of the current task.
55
+ - Mithril's post-render focus restoration never triggers them (the fake document has no `activeElement`).
56
+
57
+ Use `autofocus`, not `focus: true`. `focus` is the element's method, and an attribute with that name replaces it.
58
+
59
+ ## `vnode.dom.invoke(method, params)` — any element's UI methods
60
+
61
+ This is the promise form of `createSelectorQuery().select(...).invoke(...)`, for any element, with no `id`:
62
+
63
+ ```js
64
+ const { value, selectionStart } = await field.invoke("getValue");
65
+ await scroller.invoke("scrollTo", { offset: 0, smooth: true }); // <scroll-view>
66
+ await list.invoke("scrollToPosition", { position: 10 }); // <list>
67
+ ```
68
+
69
+ - It resolves with the method's `data`.
70
+ - It rejects with an `Error` whose `code`/`data` are native's, or when the element is removed before the call runs.
71
+ - `setSelectionRange(start, end)` is a shorthand on fields.
72
+
73
+ ## How it works
74
+
75
+ `src/fake-dom.js` gives `input`/`textarea` a real `value` property. `focus()`, `blur()` and `invoke()` record two new patch ops:
76
+ - `Op.SetInputValue` (`id, value, seq`);
77
+ - `Op.InvokeUIMethod` (`id, method, params, callbackId`).
78
+
79
+ `src/apply-patch.js` queues them and runs them with the main-thread PAPI `__InvokeUIMethod`:
80
+ - they run after the patch's `__FlushElementTree()`, then flush once more;
81
+ - this is the order ReactLynx's main-thread `Element.invoke()` uses.
82
+
83
+ Results come back on the forwarded-event channel as a reserved `mithrilLynx:invokeResult` event.
84
+
85
+ How other frameworks handle this:
86
+ - **Vue Lynx** 0.5.1: `v-model` writes a `value` attribute and calls `setValue`, with no race guard.
87
+ - **`@lynx-js/lynx-ui-input`**: calls `setValue` from a React effect.
88
+ - **ReactLynx core**: no special handling.
89
+
90
+ ## Known limits
91
+
92
+ - **IME composition:** `e.detail.isComposing` is passed through. A controlled field that *transforms* the text while composing (for example, uppercase during CJK input) will interrupt the composition. Skip the transform while `isComposing` is true.
93
+ - **`setValue`'s `cursor` param** (iOS/Android) isn't exposed through `value`. Use `invoke("setValue", { value, cursor })`.
94
+ - `<input>`/`<textarea>` need the XElement input artifacts on the Android/iOS host (see mithril-lynx-ui's README); without them the field renders at zero size.
95
+ - **`input` fires only when the text changes, as on the web.** Native fires `input` events by itself, and mithril-lynx drops them on the main thread before they reach your handler. Two cases seen on Android:
96
+ - every `setValue` is echoed back as an `input`;
97
+ - a `<textarea>` fires `input ""` (twice) while it is first flushed, before the patch's `setValue` runs.
98
+
99
+ Forwarded, the `""` would reach your `oninput` and wipe a controlled field's state. It would also count as a keystroke, so the race guard above would drop the field's initial text. An event that only changes `isComposing` is still forwarded.
100
+
101
+ ## Device verification
102
+
103
+ Checked on a Samsung SM-A075M (Android), Lynx Go, over `rspeedy dev`:
104
+
105
+ - **Initial value:** the initial `value` on an `<input>` and on a `<textarea>` shows on first paint, with no `id` or timer.
106
+ - **Autofocus:** `autofocus` leaves the caret in the field.
107
+ - **Fast typing, plain field:** `adb shell input text` with 37 characters produced the exact text, with no lost characters.
108
+ - **Fast typing, uppercase field:** 45 characters, each one round-tripping a `setValue`, came out exactly right, with the caret staying at the end.
109
+ - **Clear:** a `value` cleared from a button tap empties the field, and typing afterwards works.
110
+ - **mithril-lynx-ui components:** its `Input` (controlled, uppercase: 21 fast characters) and its `TextArea` with `defaultValue` both work. The `TextArea` is the case that exposed the spurious `input ""` above.
111
+ - **`getValue`:** `invoke("getValue")` resolves `{ value, selectionStart, selectionEnd, isComposing }`.
112
+ - **Focus and blur:** `dom.blur()` / `dom.focus()` hide and show the keyboard (`dumpsys input_method` reports `mInputShown`).
113
+ - **No crashes:** no `Fatal signal`.
114
+
115
+ Unit tests: `test/input.test.ts`.
package/README.md CHANGED
@@ -4,18 +4,37 @@ Mithril.js rendered through [Lynx](https://lynxjs.org)'s Element PAPI, with genu
4
4
 
5
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
+ ## What's new in 3.0.0
8
+
9
+ - **Native `<list>` / `<list-item>` as ordinary Mithril elements** — keyed items, every attribute with its real type, attached on demand by native. Breaking: replaces `Op.CreateList`/`Op.SetListItems` and the `list-cell`/`list-support` entry points. See below.
10
+ - **`<input>` / `<textarea>` like on the web** — `value`, `autofocus`, and `vnode.dom.focus()`/`blur()`/`invoke(method, params)` for any element's native UI methods, with no `id`, `setTimeout` or selector query. See [`INPUT.md`](./INPUT.md).
11
+ - **`route` parity with Mithril's `m.route`** — asynchronous `set()`, `options.state`, `:key` remounts, `route.Link` fixes, plus `canGoBack()` and an opt-in Android back button (`listenBackButton()`). Breaking: `set()` now resolves on the next microtask. See [`ROUTE.md`](./ROUTE.md).
12
+ - Leak fixes, render coalescing for high-frequency events, and a style-clear fix.
13
+
14
+ Full list: [`CHANGELOG.md`](./CHANGELOG.md). The main-thread and background bundles must be rebuilt together (patch protocol `0x4d4e`).
15
+
7
16
  ## Why a rewrite
8
17
 
9
18
  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:
10
19
 
11
20
  - **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
21
  - **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):
22
+ - **Three reload modes, correctly separated** (the full device-verified story is in the rewrite plan — see "Reference docs"):
14
23
  - **A — data reload**: text/props/CSS edits. Already validated in the old version; rebuilt on the new core.
15
24
  - **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
25
  - **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.
17
26
 
18
- **Carried over as code, not yet device-verified**: gestures and list virtualization (Tier 2) exist in this rewrite — `src/apply-patch.js`'s `Op.SetGestureDetector`/`Op.CreateList` cases, `src/list-cell.js`, `src/list-support.js` — and are exported as `mithril-lynx/list-support` and `mithril-lynx/list-cell`. The new arena-claim gesture path is explicitly unverified on a real device (see the note in `apply-patch.js`). **Deliberately not carried over at all (yet)**: the imperative ref helpers and the old stack-based `navigation` module. Those were real, device-verified capabilities in v1 — 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.
27
+ **Carried over as code, not yet device-verified**: gestures (`src/apply-patch.js`'s `Op.SetGestureDetector`). The new arena-claim gesture path is explicitly unverified on a real device (see the note in `apply-patch.js`). The native `<list>` below is device-verified.
28
+
29
+ **Native `<list>` (3.0.0+)**: `list` and `list-item` are ordinary Mithril elements — render them like any other, key the items, and Mithril's own keyed diff drives the native list:
30
+
31
+ ```js
32
+ m("list", { "list-type": "single", "span-count": 1, "scroll-orientation": "vertical",
33
+ "lower-threshold-item-count": 2, onscrolltolower: loadMore },
34
+ items.map((it) => m("list-item", { key: it.id, "item-key": it.id }, row(it))))
35
+ ```
36
+
37
+ Every documented `<list>` attribute (`bounces`, `item-snap`, `sticky`, `update-animation`, …) and `<list-item>` attribute (`full-span`, `sticky-top`/`-bottom`, `estimated-main-axis-size-px`, `reuse-identifier`, `recyclable`) is passed with its real type — booleans and objects included (`src/list-attributes.js` is the catalog). Events use the usual `on*` attrs (`onscroll`, `onscrolltoupper`, `onscrolltolower`, `onscrollstatechange`, `onlayoutcomplete`, `onsnap`); methods (`scrollToPosition`, `scrollBy`, `autoScroll`, `getVisibleCells`) are called with `vnode.dom.invoke(method, params)` (see "Forms and native element methods"), or a selector query on the list's `id`. `item-key` must be unique and stable — use the same value as `key`. **Known gap:** `update-animation="default"` is not safe yet on lists that remove on-screen items — see [`UPDATE_ANIMATION_GAP.md`](./UPDATE_ANIMATION_GAP.md). The main-thread side (`src/list-runtime.js`) follows @lynx-js/react's element-template list: each item keeps its own element tree and is attached only when native asks for it. 3.0.0 removes the previous `Op.CreateList`/`Op.SetListItems` protocol and the `mithril-lynx/list-cell` / `mithril-lynx/list-support` entry points. **Deliberately not carried over at all (yet)**: the old stack-based `navigation` module (in-memory `route` plus the opt-in Android back button cover navigation — see [`ROUTE.md`](./ROUTE.md)); v1's imperative ref helpers are replaced by `vnode.dom.invoke()`/`focus()`/`blur()`. Those were real, device-verified capabilities in v1 — 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.
19
38
 
20
39
  ## Usage
21
40
 
@@ -53,6 +72,10 @@ renderApp({ root: () => m(Counter) });
53
72
 
54
73
  `m.request`, reimplemented as a wrapper over Lynx's own `fetch`. See [`REQUEST.md`](./REQUEST.md) for the full API, and [`FETCH_INVESTIGATION.md`](./FETCH_INVESTIGATION.md) for the complete option-by-option gap analysis against the real `m.request` spec, backed by real-device evidence rather than docs/types alone (which were wrong twice during that investigation).
55
74
 
75
+ ## Forms and native element methods
76
+
77
+ `m("input", { value, autofocus: true, oninput })` works as on the web: `value` is sent with the native `setValue` method only when it differs from what the field holds (text the user typed is never echoed back), `autofocus` focuses once on creation, and every element's `vnode.dom` has `focus()`, `blur()` and a promise-based `invoke(method, params)` for native UI methods — no `id`, `setTimeout` or `createSelectorQuery()`. See [`INPUT.md`](./INPUT.md).
78
+
56
79
  ## Custom fonts
57
80
 
58
81
  Use a plain CSS `@font-face` rule — not `lynx.addFont()` (that JS API only fires post-mount, too late to win the first-frame race). Three gotchas, all confirmed on real hardware and inherited unchanged from the previous mithril-lynx (none of this is architecture-specific):
@@ -65,7 +88,7 @@ Use a plain CSS `@font-face` rule — not `lynx.addFont()` (that JS API only fir
65
88
 
66
89
  - **`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).
67
90
  - **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`.
68
- - **The event object passed to handlers is a synthesized snapshot, not a live DOM event.** `preventDefault()` and `stopPropagation()` on it are no-ops, and events do not bubble — the fake DOM (`src/fake-dom.js`) dispatches directly to the single node the native event targeted. Code ported from the web that calls `e.preventDefault()` (e.g. form submit) will silently do nothing. `e.redraw = false` still works, and is how `route.Link` opts out of the post-tap redraw.
91
+ - **The event object passed to handlers is a synthesized snapshot, not a live DOM event.** Lynx has no default actions: `preventDefault()` only sets `e.defaultPrevented` (which `route.Link` honors, as upstream does), and `stopPropagation()` is a no-op — events do not bubble; the fake DOM (`src/fake-dom.js`) dispatches directly to the single node the native event targeted. Code ported from the web that relies on `e.preventDefault()` stopping a browser action (e.g. form submit) has nothing to stop. `e.redraw = false` still works, and is how `route.Link` opts out of the post-tap redraw.
69
92
 
70
93
  ## Testing
71
94
 
@@ -73,12 +96,28 @@ Use a plain CSS `@font-face` rule — not `lynx.addFont()` (that JS API only fir
73
96
  npm test
74
97
  ```
75
98
 
76
- 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.
99
+ 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 the rewrite plan's §8 (see "Reference docs"), not re-asserted here. Device checks of the 3.0.0 features are recorded in each feature's own doc (`INPUT.md`, `ROUTE.md`, `UPDATE_ANIMATION_GAP.md`).
77
100
 
78
101
  ## Reference docs
79
102
 
80
- - `.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.
81
- - `.omo/plans/m-route-en-memoria.md` — how `m.route` was designed and verified for an in-memory, URL-less environment.
82
- - `.omo/plans/m-request-fetch-lynx.md` — the `m.request`-vs-`fetch` investigation plan and its execution log.
83
- - [`ROUTE.md`](./ROUTE.md), [`REQUEST.md`](./REQUEST.md), [`FETCH_INVESTIGATION.md`](./FETCH_INVESTIGATION.md) — user-facing reference docs for the two Lynx-specific reimplementations.
103
+ User-facing:
104
+
105
+ - [`CHANGELOG.md`](./CHANGELOG.md) — what changed in each release, breaking changes first.
106
+ - [`ROUTE.md`](./ROUTE.md) — `mithril-lynx/route`: the in-memory `m.route`, asynchronous `set()`, `route.Link`, and the opt-in Android back button with its Kotlin host recipe.
107
+ - [`INPUT.md`](./INPUT.md) — `<input>`/`<textarea>` (`value`, `autofocus`) and `vnode.dom.invoke()`/`focus()`/`blur()` for any element's native UI methods.
108
+ - [`REQUEST.md`](./REQUEST.md) — `mithril-lynx/request`: `m.request` over Lynx's `fetch`.
84
109
  - [`ANDROID_APK_GUIDE.md`](./ANDROID_APK_GUIDE.md) — building a native Android host and APK from scratch, Gradle-CLI only, including the `.ttf` cold-start hack from "Custom fonts" above. Automated end to end by `create-mithril-lynx --android`.
110
+
111
+ Investigations and known gaps:
112
+
113
+ - [`ROUTE_CONTRACT_ANALYSIS.md`](./ROUTE_CONTRACT_ANALYSIS.md) — `route` checked against Mithril 2.3.8's `m.route`, item by item, plus what Lynx offers for native navigation.
114
+ - [`FETCH_INVESTIGATION.md`](./FETCH_INVESTIGATION.md) — the option-by-option gap analysis of `m.request` against Lynx's `fetch`, backed by real-device evidence.
115
+ - [`UPDATE_ANIMATION_GAP.md`](./UPDATE_ANIMATION_GAP.md) — the open `<list update-animation>` gap: device evidence, hypotheses and next steps.
116
+
117
+ Historical plans (removed from the tree in `71a6670`, still in git history):
118
+
119
+ - `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.
120
+ - `m-route-en-memoria.md` — how `m.route` was first designed and verified for an in-memory, URL-less environment.
121
+ - `m-request-fetch-lynx.md` — the `m.request`-vs-`fetch` investigation plan and its execution log.
122
+
123
+ Read one with `git show 71a6670^:.omo/plans/<file>`.
package/ROUTE.md CHANGED
@@ -1,11 +1,21 @@
1
1
  # `mithril-lynx/route`
2
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.
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 `popstate`-based router to hook into — which is why ReactLynx's documented routers do the same thing: React Router with `MemoryRouter`, TanStack Router with `createMemoryHistory()`. `route.js` keeps history in an in-memory array and otherwise follows the real `m.route` API and behavior (Mithril 2.3.8), so route-using view code only needs to be re-imported. [`ROUTE_CONTRACT_ANALYSIS.md`](./ROUTE_CONTRACT_ANALYSIS.md) has the full parity table.
4
4
 
5
5
  ```js
6
6
  import route from "mithril-lynx/route";
7
7
  ```
8
8
 
9
+ **Changed in 3.0.0:**
10
+ - **Breaking:** `route.set()` is asynchronous, as in Mithril (see "Navigating").
11
+ - A changed `:key` param recreates the page.
12
+ - `options.state` is supported.
13
+ - `route.Link` runs its lifecycle hooks once, honors `e.preventDefault()` and accepts a `{ handleEvent }` object.
14
+ - `route.get()` returns the decoded path.
15
+ - New: `route.canGoBack()` and `route.listenBackButton()` (see "Android back button").
16
+
17
+ [`ROUTE_CONTRACT_ANALYSIS.md`](./ROUTE_CONTRACT_ANALYSIS.md) has the details.
18
+
9
19
  ## Setup
10
20
 
11
21
  ```js
@@ -15,7 +25,7 @@ route("/", {
15
25
  });
16
26
  ```
17
27
 
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={["/"]}`.
28
+ Unlike real Mithril, this call takes no `root` DOM argument — this package has exactly one `renderApp()` for the app's whole lifetime, so `route(...)` calls it internally the first time a route resolves. `defaultRoute` (`"/"` above) is required: it's 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 — the Lynx equivalent of React Router's `initialEntries={["/"]}`).
19
29
 
20
30
  Route values can be a plain component, or a resolver object with `onmatch`/`render`, exactly like real Mithril:
21
31
 
@@ -29,24 +39,105 @@ route("/", {
29
39
  });
30
40
  ```
31
41
 
32
- `route.SKIP` falls through to the next matching route, same as upstream.
42
+ `route.SKIP` falls through to the next matching route. Route templates follow upstream's rules: they must start with `/`, and params must be separated by `/`, `.` or `-` (`/:a:b` throws a `SyntaxError`). A `:key` param works as in Mithril: when it changes, the page is recreated with fresh state.
33
43
 
34
44
  ## Navigating
35
45
 
36
46
  ```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
47
+ route.set("/detail/:id", { id: 42 }); // pushes a new history entry
48
+ route.set("/detail/:id", { id: 42 }, { replace: true }); // overwrites the current one
49
+ route.set("/detail/:id", { id: 42 }, { state: { from: "home" } }); // state is merged into params
50
+ route.get(); // current path, decoded, e.g. "/detail/42"
51
+ route.param("id"); // "42" — or route.param() for the whole params object
52
+ ```
53
+
54
+ **`route.set()` is asynchronous, like `m.route`:** the history changes right away, but the new screen resolves on the next microtask, and several navigations in the same tick resolve only the last one. `route.get()` still returns the previous path until then. This is what makes a redirect from inside a render safe (e.g. `route.set("/login")` in a page's `oninit`).
55
+
56
+ `options.state` is stored with the history entry, merged into the route's params, and restored by `back()`/`forward()`. `options.title` is accepted and ignored.
57
+
58
+ `route.back()` / `route.forward()` walk the same in-memory history. **These are not part of `m.route`** — there's no browser back button. Both return `true` when they navigated and `false` at the start/end of the stack, and `route.canGoBack()` tells whether `back()` would navigate, so a back affordance can enable itself.
59
+
60
+ `route.prefix` exists only so code ported from a real Mithril app (`m.route.prefix = ""`) doesn't throw; it has no effect.
61
+
62
+ ## Android back button
63
+
64
+ The system back button doesn't reach JS on its own (Lynx only exposes it inside an `<overlay>`, through `bindrequestclose`), so by default pressing back closes the app from any screen. `route.listenBackButton()` connects it, opt-in, through the same channel apps already use for host → JS events (`sendGlobalEvent` → `GlobalEventEmitter`):
65
+
66
+ 1. The host sends a global event when back is pressed, from an `OnBackPressedCallback` that starts **disabled**.
67
+ 2. JS calls `route.back()` when the event arrives.
68
+ 3. JS tells the host whether there's history to go back to (`onCanGoBackChange`); the host enables its callback only while there is. At the first screen the callback is disabled, so Android's default (closing the app) applies.
69
+
70
+ Nothing blocks when this isn't set up: without the host part, the event never arrives; without the JS part, the callback stays disabled; without `GlobalEventEmitter`, it logs a warning and does nothing.
71
+
72
+ **JS** (e.g. in `background.ts`, after `route(...)`):
73
+
74
+ ```js
75
+ route.listenBackButton({
76
+ // eventName: "mithrilLynx:back", // the default
77
+ onCanGoBackChange: (canGoBack) => NativeModules.NavModule?.setCanGoBack(canGoBack),
78
+ });
79
+ ```
80
+
81
+ It returns a function that stops listening (useful for HMR).
82
+
83
+ **Android host** (Kotlin):
84
+
85
+ ```kotlin
86
+ // MainActivity.kt
87
+ import androidx.activity.OnBackPressedCallback
88
+ import com.lynx.react.bridge.JavaOnlyArray
89
+
90
+ class MainActivity : AppCompatActivity() {
91
+ companion object {
92
+ // Disabled until JS reports there is history to go back to.
93
+ var backCallback: OnBackPressedCallback? = null
94
+ }
95
+
96
+ override fun onCreate(savedInstanceState: Bundle?) {
97
+ super.onCreate(savedInstanceState)
98
+ val builder = LynxViewBuilder()
99
+ builder.registerModule("NavModule", NavModule::class.java)
100
+ val lynxView = builder.build(this)
101
+ // ... load the bundle, setContentView(lynxView)
102
+
103
+ val callback = object : OnBackPressedCallback(false) {
104
+ override fun handleOnBackPressed() {
105
+ lynxView.sendGlobalEvent("mithrilLynx:back", JavaOnlyArray())
106
+ }
107
+ }
108
+ onBackPressedDispatcher.addCallback(this, callback)
109
+ backCallback = callback
110
+ }
111
+
112
+ override fun onDestroy() {
113
+ backCallback = null
114
+ super.onDestroy()
115
+ }
116
+ }
41
117
  ```
42
118
 
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()`. Both return `true` when they navigated and `false` at the top/end of the stack (a silent no-op at the boundary), so a back/forward affordance can enable/disable itself from the return value.
119
+ ```kotlin
120
+ // NavModule.kt
121
+ import android.content.Context
122
+ import android.os.Handler
123
+ import android.os.Looper
124
+ import com.lynx.jsbridge.LynxMethod
125
+ import com.lynx.jsbridge.LynxModule
126
+
127
+ class NavModule(context: Context) : LynxModule(context) {
128
+ @LynxMethod
129
+ fun setCanGoBack(canGoBack: Boolean) {
130
+ // Called from the JS thread; the callback must be touched on the UI thread.
131
+ Handler(Looper.getMainLooper()).post { MainActivity.backCallback?.isEnabled = canGoBack }
132
+ }
133
+ }
134
+ ```
44
135
 
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.
136
+ Because the callback is only enabled while there's history, this also works with Android's predictive back gesture.
46
137
 
47
138
  ## Links
48
139
 
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):
140
+ Lynx has no `<a>`/`onclick` — `route.Link` renders a tap-driven element instead (the same shape as ReactLynx's `useNavigate()` + `bindtap` pattern):
50
141
 
51
142
  ```js
52
143
  m(route.Link, { href: "/detail/:id", params: { id: 42 } }, [
@@ -57,15 +148,19 @@ m(route.Link, { href: "/detail/:id", params: { id: 42 } }, [
57
148
  - `selector` picks the rendered tag (default `"view"`).
58
149
  - `params` interpolates into `href` the same way `route.set`'s second argument does.
59
150
  - `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).
151
+ - `disabled: true` renders the element with no `ontap` at all.
152
+ - Your own `ontap` runs first (a function or a `{ handleEvent }` object); returning `false` from it, or calling `e.preventDefault()`, cancels the navigation.
153
+ - `key` and lifecycle hooks (`oncreate`, …) stay on the Link itself and aren't copied onto the rendered element, as in upstream.
62
154
 
63
155
  ## Known differences from real `m.route`
64
156
 
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`).
157
+ - No `root` argument to the setup call, and `defaultRoute` is required (see "Setup").
158
+ - `onclick` on a Link is `ontap` here (Lynx's tap event).
159
+ - `back()`, `forward()`, `canGoBack()` and `listenBackButton()` are additions.
160
+ - `route.prefix` has no effect; `options.title` is ignored.
161
+ - History is in-memory only: it doesn't survive an app restart.
162
+ - **Deep links aren't supported yet.** The host *can* pass data to a page (`initData` at `loadTemplate`, `updateData`, `globalProps`), but `route` doesn't read an initial path from it — the app always starts at `defaultRoute`. Tracked in `ROUTE_CONTRACT_ANALYSIS.md`.
68
163
 
69
164
  ## Device verification
70
165
 
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.
166
+ 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, not CDP node ids, which are not stable identity across separate `DOM.getDocument()` calls) were covered on a real Android device before 3.0.0. The asynchronous `set()`, the parity fixes and `listenBackButton()` are covered by unit tests (`test/route.test.ts`). The back button was checked on the same device with a real app (`non-contact`, a debug build wired as in "Android back button" above): back on the first screen closes the app, back on a pushed screen returns to the previous one, and an in-app `route.back()` updates `canGoBack` so the next system back closes the app.