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.
- package/CHANGELOG.md +50 -0
- package/FETCH_INVESTIGATION.md +3 -2
- package/INPUT.md +115 -0
- package/README.md +47 -8
- package/ROUTE.md +111 -16
- package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
- package/UPDATE_ANIMATION_GAP.md +133 -0
- package/package.json +1 -9
- package/plugin.js +33 -0
- package/src/apply-patch.js +369 -60
- package/src/backends/virtual-backend.js +120 -22
- package/src/background.d.ts +26 -0
- package/src/background.js +116 -7
- package/src/channel.js +22 -5
- package/src/commit.js +9 -0
- package/src/dev-reload-client.js +46 -0
- package/src/dev-transport-noop.js +3 -0
- package/src/fake-dom.js +559 -61
- package/src/list-attributes.js +69 -0
- package/src/list-runtime.js +425 -0
- package/src/main-thread.js +18 -1
- package/src/mount-redraw.js +14 -1
- package/src/patch-protocol.js +41 -16
- package/src/reload/version.js +10 -1
- package/src/request.js +38 -0
- package/src/route.d.ts +30 -2
- package/src/route.js +214 -56
- package/src/testing.d.ts +14 -1
- package/src/testing.js +56 -1
- package/test/event-render-coalescing.test.ts +117 -0
- package/test/gesture.test.ts +75 -0
- package/test/input.test.ts +388 -0
- package/test/list.test.ts +417 -113
- package/test/remove-subtree-cleanup.test.ts +75 -0
- package/test/route-hot-reload.test.ts +2 -1
- package/test/route.test.ts +201 -4
- package/test/style-clear.test.ts +97 -0
- package/.omo/plans/m-request-fetch-lynx.md +0 -306
- package/.omo/plans/m-route-en-memoria.md +0 -397
- package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
- package/.omo/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
- package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +0 -10
- package/src/list-cell.d.ts +0 -20
- package/src/list-cell.js +0 -73
- package/src/list-support.d.ts +0 -13
- 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.
|
package/FETCH_INVESTIGATION.md
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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** (
|
|
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
|
|
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()`
|
|
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
|
|
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
|
-
-
|
|
81
|
-
|
|
82
|
-
-
|
|
83
|
-
- [`ROUTE.md`](./ROUTE.md)
|
|
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
|
|
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
|
|
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
|
|
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 });
|
|
38
|
-
route.set("/detail/:id", { id: 42 }, { replace: true });
|
|
39
|
-
route.
|
|
40
|
-
route.
|
|
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
|
-
|
|
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
|
-
|
|
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()` + `
|
|
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
|
|
61
|
-
- Your own `ontap`
|
|
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"
|
|
66
|
-
- `
|
|
67
|
-
-
|
|
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
|
|
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.
|