mithril-lynx 2.6.0 → 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 (55) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/LICENSE.txt +21 -0
  5. package/PLAN_REVIEW.md +134 -0
  6. package/README.md +47 -7
  7. package/REQUEST.md +5 -2
  8. package/ROUTE.md +111 -16
  9. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  10. package/UPDATE_ANIMATION_GAP.md +133 -0
  11. package/informe-contrato-mithril-lynx.md +206 -0
  12. package/package.json +1 -9
  13. package/plugin.js +33 -0
  14. package/prompts-analisis-contrato-mithril-lynx.md +141 -0
  15. package/src/apply-patch.js +408 -68
  16. package/src/backends/virtual-backend.js +120 -22
  17. package/src/background.d.ts +40 -1
  18. package/src/background.js +116 -7
  19. package/src/channel.js +32 -7
  20. package/src/commit.js +9 -0
  21. package/src/dev-reload-client.js +46 -0
  22. package/src/dev-transport-noop.js +3 -0
  23. package/src/fake-dom.js +559 -61
  24. package/src/list-attributes.js +69 -0
  25. package/src/list-runtime.js +425 -0
  26. package/src/main-thread.js +33 -3
  27. package/src/mount-redraw.d.ts +10 -0
  28. package/src/mount-redraw.js +69 -3
  29. package/src/patch-protocol.js +52 -13
  30. package/src/reload/version.js +10 -1
  31. package/src/request.d.ts +2 -0
  32. package/src/request.js +59 -3
  33. package/src/route.d.ts +52 -5
  34. package/src/route.js +236 -59
  35. package/src/testing.d.ts +14 -1
  36. package/src/testing.js +79 -11
  37. package/test/event-render-coalescing.test.ts +117 -0
  38. package/test/gesture.test.ts +75 -0
  39. package/test/input.test.ts +388 -0
  40. package/test/list.test.ts +422 -100
  41. package/test/patch-protocol.test.ts +41 -0
  42. package/test/remove-event.test.ts +26 -0
  43. package/test/remove-subtree-cleanup.test.ts +75 -0
  44. package/test/route-hot-reload.test.ts +4 -2
  45. package/test/route.test.ts +206 -6
  46. package/test/setup.ts +10 -0
  47. package/test/style-clear.test.ts +97 -0
  48. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  49. package/.omo/plans/m-route-en-memoria.md +0 -397
  50. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  51. package/.omo/run-continuation/ses_f48265d07ffenKG0GAz9ZRFvY6.json +0 -10
  52. package/src/list-cell.d.ts +0 -20
  53. package/src/list-cell.js +0 -73
  54. package/src/list-support.d.ts +0 -13
  55. package/src/list-support.js +0 -171
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()`.
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.
@@ -0,0 +1,224 @@
1
+ # `mithril-lynx/route` — contract and parity analysis
2
+
3
+ Date: 2026-09-25. Scope: `src/route.js` (mithril-lynx 3.0.0, unpublished;
4
+ identical route code to 2.6.2) against Mithril 2.3.8's `api/router.js`, the
5
+ official Lynx documentation (`lynxjs.org/llms-full.txt`, read in full for
6
+ navigation topics), and its use in the `non-contact` production app.
7
+
8
+ Every "Verified" claim below was checked by running code against the real
9
+ `route.js` (a throwaway test through `@lynx-js/testing-environment`), not by
10
+ reading it. Claims about Lynx come from the official docs and are cited by
11
+ page.
12
+
13
+ ## 0. Status after the fixes (commit 63fbedd)
14
+
15
+ Everything in section 4 marked ❌ or ⚠️ that isn't an intentional
16
+ difference is fixed, each with a test in `test/route.test.ts` that fails on
17
+ the previous `route.js`:
18
+
19
+ | Item | Now |
20
+ |---|---|
21
+ | Redirect during a render throws | fixed — resolution deferred to a microtask, coalesced |
22
+ | `:key` change doesn't remount | fixed — keyed fragment, as upstream |
23
+ | `route.Link` runs lifecycle hooks twice | fixed — `censor` (upstream util) strips `key` + hooks |
24
+ | `route.Link` ignores `preventDefault()` / `{ handleEvent }` | fixed (forwarded events now record `defaultPrevented`) |
25
+ | `options.state` ignored | fixed — stored per entry, merged into params, restored by back/forward |
26
+ | Param-name separator check missing | fixed — same `SyntaxError` as upstream |
27
+ | `null` default route → TypeError from inside | now a clear `TypeError`; intentional (no URL to start from) |
28
+ | `get()` returns the encoded path | fixed — decoded, as upstream |
29
+ | `set()` is synchronous | now asynchronous, as upstream (**breaking**, documented in `ROUTE.md`) |
30
+ | Android back button | `route.canGoBack()` + opt-in `route.listenBackButton()`; host recipe in `ROUTE.md` |
31
+
32
+ Still open: deep links (5.2), the `non-contact` host integration (5.1),
33
+ and a device check of the asynchronous `set()` and the back button.
34
+
35
+ The rest of this document is the analysis as written before the fixes.
36
+
37
+ ## 1. Verdict
38
+
39
+ - **The model is right.** Lynx has no browser History API and no
40
+ navigation in its core. ReactLynx's two documented routers use exactly the
41
+ same in-memory approach as `mithril-lynx/route` (React Router's
42
+ `MemoryRouter`, TanStack Router's `createMemoryHistory`).
43
+ - **API parity with `m.route` is high but not complete:** 3 real bugs
44
+ (redirect during a render throws, a changed `:key` doesn't remount the
45
+ page, `route.Link` runs lifecycle hooks twice), 3 missing pieces
46
+ (`options.state`, `null` default route, param-name check) and 4 behavior
47
+ differences that aren't documented (section 4).
48
+ - **The Lynx integration has gaps:** the Android back button isn't wired
49
+ (in `non-contact`, back most likely closes the app), deep links from the
50
+ host aren't supported, and `ROUTE.md` states two things about Lynx that
51
+ the docs contradict (section 5).
52
+
53
+ ## 2. How navigation works in Lynx
54
+
55
+ From the official docs:
56
+
57
+ | Topic | What Lynx provides | Source |
58
+ |---|---|---|
59
+ | Native navigation in the core | **None.** Native navigation is delegated to "app frameworks"; the 2025 roadmap listed "native navigation" as future app-level work. | `/guide/start/build-with-app-framework.md`, `/blog/lynx-open-source-roadmap-2025.md` |
60
+ | Official app framework | **Sparkling** (TikTok): "scheme-driven multi-page routing". Each page is its own Lynx container; `open({ scheme: "hybrid://lynxview_page?bundle=…" })`, `navigate({ path, options: { params } })`, `close()` from `sparkling-navigation`. Params reach the page through initData/globalProps. | `/blog/lynx-open-source-roadmap-2026.md`, tiktok.github.io/sparkling (APIs → Navigation, Scheme) |
61
+ | Routing inside a page (ReactLynx) | React Router v6 with `MemoryRouter`; TanStack Router with `createMemoryHistory` ("required due to browser History API limitations in Lynx"). Neither has a `<Link>` in Lynx: you navigate from `bindtap`. | `/react/routing/react-router.md`, `/react/routing/tanstack-router.md` |
62
+ | Host → page data | `initData` at `LynxView.loadTemplate()`, updates via `updateData()`; `lynx.__globalProps` + `onGlobalPropsChanged`. | `/guide/use-data-from-host-platform.md`, `/api/lynx-api/lynx/lynx-global-props.md` |
63
+ | Host → page events | `LynxView/LynxContext.sendGlobalEvent(name, params)` → JS `lynx.getJSModule("GlobalEventEmitter").addListener(name, …)`. | `/api/lynx-native-api/lynx-view/send-global-event.md`, `/guide/interaction/event-handling/event-propagation.md` |
64
+ | Back button reaching JS | Only inside an `<overlay>`: `bindrequestclose` — "Callback when the back button is clicked" (Android, Desktop, Harmony; Lynx 3.5). No general back-button event. | `/api/elements/built-in/overlay.md` |
65
+
66
+ So there are two navigation models on Lynx:
67
+
68
+ 1. **In-page routing** — one page, one LynxView; screens are swapped inside
69
+ it by a memory router. This is what ReactLynx's routers and
70
+ `mithril-lynx/route` do.
71
+ 2. **Multi-page native navigation** — one container per screen, a native
72
+ stack, a real system back gesture (Sparkling). Each page is a separate
73
+ bundle entry, and data travels as initData/globalProps.
74
+
75
+ `mithril-lynx/route` implements model 1, the same one as ReactLynx. Model 2
76
+ lives in the host app/framework, not in the JS router; mithril-lynx works
77
+ inside such pages unchanged (each page is its own `renderApp()`).
78
+
79
+ ## 3. What `m.route` is (the reference)
80
+
81
+ Mithril 2.3.8 `api/router.js`, 243 lines: `route(root, defaultRoute, routes)`,
82
+ `route.set(path, data, options)`, `route.get()`, `route.param(key)`,
83
+ `route.prefix`, `route.Link`, `route.SKIP`, route resolvers
84
+ (`onmatch`/`render`), resolution deferred with `setTimeout` on `set` and
85
+ `popstate`, and `history.state` merged into params.
86
+
87
+ ## 4. Parity table
88
+
89
+ ✅ same behavior · ⚠️ differs · ❌ missing or broken.
90
+
91
+ ### Setup — `route(…)`
92
+
93
+ | Aspect | `m.route` | `mithril-lynx/route` | Status |
94
+ |---|---|---|---|
95
+ | Signature | `route(root, defaultRoute, routes)` | `route(defaultRoute, routes)` — no `root`; mounts through its own `renderApp()` | ⚠️ intentional, documented |
96
+ | Routes must start with `/` | SyntaxError | same | ✅ |
97
+ | Param-name separator check (`/:a:b`) | SyntaxError "Route parameter names must be separated with either '/', '.', or '-'." | **accepted** (verified) | ❌ missing check |
98
+ | Default route must match a route | ReferenceError | same | ✅ |
99
+ | `defaultRoute` may be `null` | allowed (skips the check) | **TypeError** "Cannot read properties of null" (verified) | ❌ |
100
+ | Calling it again (re-registration) | re-registers and re-resolves the browser URL | re-resolves the **current** path, keeps history (HMR support) | ⚠️ intentional improvement, documented in code |
101
+ | Teardown when the router unmounts | `RouterRoot.onremove` resets state | none (one `renderApp()` per app lifetime) | ⚠️ intentional |
102
+
103
+ ### Resolution
104
+
105
+ | Aspect | `m.route` | `mithril-lynx/route` | Status |
106
+ |---|---|---|---|
107
+ | Timing | `set()` schedules resolution (`setTimeout`); several `set()`s in a row coalesce | **synchronous**: `get()` returns the new path right after `set()` (verified) | ⚠️ undocumented |
108
+ | Redirect during a render (e.g. `route.set("/login")` in a page's `oninit`) | works (deferred) | **throws** "Node is currently being rendered to and thus is locked." (verified) | ❌ **bug** |
109
+ | Resolvers: `onmatch` (sync or promise), `render`, `SKIP` fallthrough | yes | same | ✅ |
110
+ | `route.set` while an `onmatch` is pending → forced `replace` | yes | same | ✅ |
111
+ | `onmatch` rejection → `console.error` + fallback with replace | yes | same | ✅ |
112
+ | Unmatched path → fallback with replace; unmatched default → Error | yes | same | ✅ |
113
+ | Component fallback when `onmatch` returns nothing | `"div"` | `"view"` | ✅ (Lynx equivalent) |
114
+ | Remount when the matched component stays but a `:key` param changes | yes — `Vnode(component, attrs.key, attrs)` wrapped in a fragment "to preserve existing key semantics" | **no** — same instance kept, state persists (verified) | ❌ **bug** |
115
+ | Path decoding | whole path decoded before matching | params decoded (verified `"a b/c"`), but `get()` returns the encoded path `"/d/a%20b%2Fc"` (verified) | ⚠️ |
116
+ | Query string → params (with `"true"`/`"false"` coercion) | yes | same (verified) | ✅ |
117
+
118
+ ### Navigation — `route.set / get / param`
119
+
120
+ | Aspect | `m.route` | `mithril-lynx/route` | Status |
121
+ |---|---|---|---|
122
+ | `set(path, data)` interpolation via `buildPathname` | yes | same | ✅ |
123
+ | `options.replace` | `replaceState` | overwrites the current history entry | ✅ |
124
+ | `options.state` | stored in `history.state`, merged into params, restored on back/forward | **ignored** (verified) | ❌ missing |
125
+ | `options.title` | passed to `pushState` (browsers mostly ignore it) | ignored | ✅ (no-op on Lynx is fine) |
126
+ | `set()` before `route()` | `location.href = …` | throws a clear error | ⚠️ intentional, documented |
127
+ | Same path pushed twice | two history entries | same (verified) | ✅ |
128
+ | `get()` | current path | same (encoded, see above) | ✅/⚠️ |
129
+ | `param(key)` / `param()` | yes | same | ✅ |
130
+ | `prefix` | `"#!"`, used in URLs | `""`, no effect | ⚠️ intentional, documented |
131
+
132
+ ### `route.Link`
133
+
134
+ | Aspect | `m.route` | `mithril-lynx/route` | Status |
135
+ |---|---|---|---|
136
+ | Default element | `a` | `view` | ✅ (Lynx equivalent) |
137
+ | Trigger | `onclick` (+ modifier-key / non-left-click checks) | `ontap` | ✅ (Lynx equivalent) — apps porting `onclick` on a Link must rename it |
138
+ | Lifecycle hooks and `key` in the Link's attrs | removed before rendering the child (`censor`: `key`, `oninit`, `oncreate`, `onbeforeupdate`, `onupdate`, `onbeforeremove`, `onremove`) | **passed to the child too**: `oncreate` ran **twice** (verified) | ❌ **bug** |
139
+ | Handler returning `false` cancels navigation | yes | yes | ✅ |
140
+ | Handler calling `preventDefault()` cancels navigation | yes (`e.defaultPrevented`) | no | ⚠️ |
141
+ | EventListener object (`{ handleEvent }`) as handler | supported | not supported | ⚠️ |
142
+ | `disabled` | no handler, `href` removed, `aria-disabled` | no handler; `disabled` passed through | ✅ |
143
+ | `params` / `options` | yes | yes | ✅ |
144
+
145
+ ### Not in `m.route` (mithril-lynx additions)
146
+
147
+ | Addition | Purpose | Status |
148
+ |---|---|---|
149
+ | `route.back()` / `route.forward()` returning `boolean` | walk the in-memory history (no browser back) | ✅ works; see 5.1 for the missing hardware-back wiring |
150
+ | HMR re-registration keeps the current path and history | hot reload without losing the screen | ✅ used by `non-contact` (`route.set(route.get(), null, { replace: true })`) |
151
+
152
+ ## 5. Gaps against what Lynx and the host offer
153
+
154
+ ### 5.1 Android back button
155
+
156
+ - `ROUTE.md` says "Lynx has no hardware/gesture back button exposed to JS".
157
+ That's incomplete: `<overlay>` has `bindrequestclose` (overlays only), and
158
+ any host can forward the back key to JS with `sendGlobalEvent` →
159
+ `GlobalEventEmitter` — `non-contact` already uses that exact channel for
160
+ its QR scanner (`NonContactScannerModule.kt` → `scanResult`).
161
+ - **`non-contact`:** `MainActivity` is a plain `AppCompatActivity` with no
162
+ back handling. Android's default then finishes the activity, so pressing
163
+ back on "Países" or "Historial" **closes the app** instead of returning to
164
+ the dialer — confirmed on the device by the app's owner.
165
+ - **Missing piece:** a documented, tested recipe (or a small helper) —
166
+ host intercepts back (`OnBackPressedCallback`), sends a global event, JS
167
+ calls `route.back()`, and if that returns `false` asks the host to finish
168
+ (a NativeModule method).
169
+
170
+ ### 5.2 Deep links / initial route from the host
171
+
172
+ - `ROUTE.md` says "nothing external can set the initial path — it's always
173
+ `defaultRoute`". The host *can* pass data (`initData` on `loadTemplate`,
174
+ `updateData` later, `globalProps`); what's missing is support in `route`
175
+ for reading an initial path from it. `main-thread.js` already registers a
176
+ pass-through `processData`, so initData reaches the page.
177
+ - **Open question:** how the background thread reads initData without
178
+ ReactLynx (ReactLynx keeps it in `lynx.__initData`); needs checking on a
179
+ device before designing the API.
180
+
181
+ ### 5.3 Multi-page native navigation
182
+
183
+ Out of scope for the router by design (section 2). A Sparkling-style host
184
+ can load several mithril-lynx pages; no change needed, but it isn't
185
+ documented or tested.
186
+
187
+ ### 5.4 Page teardown log noise
188
+
189
+ Navigating between screens logs a burst of
190
+ `DestroyLayoutNodeBeforeRemoveFromParent` errors (non-fatal). It happens
191
+ with 2.6.2 as well (64 lines on dialer → countries), so it isn't caused by
192
+ the 3.0.0 changes. It comes from the renderer removing a whole screen
193
+ subtree, not from `route.js` itself. Not investigated.
194
+
195
+ ## 6. Recommendations
196
+
197
+ In priority order:
198
+
199
+ 1. **Fix the render-time redirect bug:** resolve `route.set()` outside the
200
+ current render (defer it, as upstream does), at least when called during
201
+ a render. Add a test with the `oninit` guard pattern.
202
+ 2. **Fix `route.Link`** to strip `key` and lifecycle hooks from the child's
203
+ attrs (upstream `censor` set); honor `e.defaultPrevented`.
204
+ 3. **Restore key semantics:** render the matched component as
205
+ `[m(component, attrs)]` so a changed `:key` param remounts it.
206
+ 4. **Implement `options.state`:** store it with each history entry, merge it
207
+ into params, and restore it on `back()`/`forward()`.
208
+ 5. **Small parity items:** param-name separator check, `null` default
209
+ route, and a decoded `route.get()` — or document them as deliberate.
210
+ 6. **Back button:** ship the host recipe (5.1), then fix `non-contact`
211
+ after confirming the current behavior on the device.
212
+ 7. **Deep links:** verify how initData is read on the background thread,
213
+ then add an opt-in initial path (e.g. `route(defaultRoute, routes,
214
+ { initialPath })`).
215
+ 8. **Correct `ROUTE.md`** (back button, deep links) and list the remaining
216
+ intentional differences in one place.
217
+
218
+ ## 7. Verification plan
219
+
220
+ - Unit tests for items 1–5 through `@lynx-js/testing-environment`, following
221
+ `test/route.test.ts` (the probes used for this analysis can be turned into
222
+ them directly).
223
+ - On a device: the render-time redirect, hardware back in `non-contact`
224
+ (before and after the fix), and a deep link via initData.
@@ -0,0 +1,133 @@
1
+ # Known gap: `<list update-animation="default">`
2
+
3
+ **Status:** open. **Affects:** mithril-lynx 3.0.0 (the native `list` /
4
+ `list-item` elements). **Severity:** do not use `update-animation` in
5
+ production yet. Everything else about `<list>` is verified on a real device
6
+ (see mithril-lynx-ui's `docs/native-papi/papi-07-list-redesign.md`).
7
+
8
+ ## Summary
9
+
10
+ `update-animation="default"` asks native to animate inserts, removes and
11
+ updates. Removing an item that is **on screen** has no correct handling in
12
+ mithril-lynx yet:
13
+
14
+ - if mithril-lynx detaches the removed item's element (which is what it does
15
+ without the animation, and what native needs there), **native crashes when
16
+ the removal animation ends** (SIGSEGV);
17
+ - if mithril-lynx leaves it attached (what it does today on such lists, to
18
+ avoid the crash), **stale cells stay drawn over the list** and the removed
19
+ elements accumulate as orphan children of the list.
20
+
21
+ Inserts and updates were not seen to misbehave; the problem is removals of
22
+ items that are currently attached (on screen).
23
+
24
+ ## What mithril-lynx does today
25
+
26
+ `src/list-runtime.js` tracks the list's `update-animation` attribute
27
+ (`setUpdateAnimation()`, fed by `src/apply-patch.js`'s SetAttribute /
28
+ RemoveAttribute cases). When it is `"default"`:
29
+
30
+ - `detachRemoved()` skips detaching removed on-screen items (no crash);
31
+ - a `console.warn` is logged once per assignment, pointing here.
32
+
33
+ Without `update-animation`, removed on-screen items are detached right after
34
+ the patch's `update-list-info` flush — the contract verified on the device
35
+ (next section). Tests: `test/list.test.ts`, "a removed on-screen item is
36
+ detached only after the patch's update-list-info flush" and "with
37
+ update-animation on, a removed on-screen item is left attached".
38
+
39
+ ## Evidence (real device)
40
+
41
+ **Setup:** Samsung SM-A075M, Android 16, Lynx Go 2026.06.06
42
+ (`com.funcs.io.lynx.go`), mithril-lynx 3.0.0 (local), `non-contact`'s
43
+ country picker migrated to `list`, with sticky letter headers
44
+ (`full-span` + `sticky-top` + `reuse-identifier="header"`) and lab
45
+ attributes on the list. The bundle came from `rspeedy dev` over
46
+ `adb reverse tcp:3000 tcp:3000`, opened with `agent-lynx open`.
47
+
48
+ **Action:** type `a`, `r`, `g` in the search box (each keystroke filters ~250
49
+ rows down, removing many on-screen items), then delete the text again
50
+ (re-inserting them).
51
+
52
+ What native does with a removed on-screen item, per strategy:
53
+
54
+ | Strategy for a removed on-screen item | Without `update-animation` | With `update-animation="default"` |
55
+ |---|---|---|
56
+ | Detach it before the patch's flush | **Crash** — SIGSEGV right after `[List] Fail to erase item holder at pos = 73` | not tried (already crashes without) |
57
+ | Detach it right after the patch's flush (**current default**) | **Correct** — renders fine, only the visible items stay attached (12–14), no duplicates | **Crash** — SIGSEGV right after `Basic Animation End` |
58
+ | Never detach it (**current behavior with the animation**) | Renders fine, but removed elements stay as orphan children of the list and grow with each filter (57, then 125, with duplicated `item-key`s) | **Stale cells** drawn over the real ones (overlapping rows, persisting seconds later); 61 attached elements, duplicated `item-key`s: AD, AF, AL, AM, AO, AR, AZ, DZ, MG, h-M |
59
+
60
+ Relevant log lines of the animation crash (`adb logcat`, same run):
61
+
62
+ ```
63
+ I lynx : [List] DefaultListAdapter::BindItemHolder: enqueue component before render with item_key = AZ ...
64
+ W lynx : FiberElement::FlushActionsAsRoot maybe from a wrong parent, this tag:list-item
65
+ I lynx : SendCustomEvent event name:layoutcomplete tag:87
66
+ I lynx : Basic Animation End.
67
+ F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x3320332e31344c32
68
+ ```
69
+
70
+ The backtrace is inside `liblynx.so` with no symbols.
71
+
72
+ Other facts established on the device, true with or without the animation:
73
+
74
+ - Native **never calls `enqueueComponent`** for items removed through
75
+ `update-list-info` (instrumented: 9 on-screen items removed, 0 enqueues).
76
+ - Native logs `[List] Fail to erase item holder at pos = N` (non-fatal)
77
+ during bulk removals, whether or not mithril-lynx detaches anything.
78
+
79
+ ## How ReactLynx handles removals (for comparison)
80
+
81
+ Read from `@lynx-js/react` 0.126.1:
82
+
83
+ - **Snapshot runtime** (`runtime/lib/snapshot/snapshot/snapshot.js`,
84
+ `removeChild` on a list holder): records the removal for
85
+ `update-list-info` and **never calls `__RemoveElement`**.
86
+ - **Element-template runtime** (`runtime/lib/element-template/runtime/patch.js`,
87
+ `removeTypedListItem` → `releaseRemovedSubtreeHandles`): drops the item from
88
+ its state and releases JS handles, **without detaching** it natively.
89
+
90
+ So ReactLynx takes the "never detach" row. Whether a ReactLynx app shows the
91
+ same stale cells / orphan growth with `update-animation` on this device has
92
+ **not been tested** — that is the first thing to check (below).
93
+
94
+ ## Hypotheses (unverified)
95
+
96
+ 1. Native owns the removed cell until its removal animation finishes, then
97
+ releases it itself; detaching the element earlier frees memory the
98
+ animation still uses (fits the crash right after `Basic Animation End`).
99
+ 2. The stale cells in the "never detach" row may come from the orphan
100
+ elements still being children of the list element, so they take part in
101
+ the list's layout/render after the animation. ReactLynx may avoid this
102
+ through something mithril-lynx doesn't do (e.g. item-holder bookkeeping
103
+ specific to its runtimes), or may have the same problem.
104
+ 3. A detach deferred until after the animation (not after the flush) could
105
+ be safe — but mithril-lynx gets no signal for "animation ended"
106
+ (`Basic Animation End` is only a native log line), and a timer on the
107
+ main thread is unverified.
108
+
109
+ ## Next steps to close the gap
110
+
111
+ 1. **Baseline with ReactLynx:** build a minimal ReactLynx page with the same
112
+ filter pattern (`<list update-animation="default">`, keyed items, bulk
113
+ removals) and run it on the same device. Check for stale cells, orphan
114
+ growth (`DOM.getDocument` via `agent-lynx cdp`) and crashes. If ReactLynx
115
+ is also broken, report it upstream and keep this limitation documented.
116
+ 2. **If ReactLynx works:** diff the exact PAPI call sequence (both runtimes
117
+ log list callbacks behind `__ALOG__`; mithril-lynx can be instrumented the
118
+ same way) to find what native needs that mithril-lynx doesn't send.
119
+ 3. **Look for a completion signal:** check whether `layoutcomplete` (or any
120
+ other list event) fires after the removal animation, and whether a
121
+ deferred detach from that event is safe.
122
+ 4. **Acceptance:** the same filter stress on the device with
123
+ `update-animation="default"` — no crash, no stale cells, attached children
124
+ equal to the visible items, no duplicated `item-key`s. Then remove the
125
+ `animatesUpdates` branch and the warning, and turn the second test above
126
+ into a regular removal test.
127
+
128
+ ## Guidance until then
129
+
130
+ - Don't set `update-animation` on lists that can remove on-screen items
131
+ (filtering, deleting rows, replacing data).
132
+ - A list that only appends (a feed with "load more") is not affected by this
133
+ gap, but was not verified with the animation either.