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
|
@@ -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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mithril-lynx",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Mithril.js on Lynx: real mithril/render/render.js driven through a Lynx-backed fake DOM, with an explicit single commit hook (no conditional global flush) and three reload modes (data-light, structural-light, full). A complete rewrite of the previous mithril-lynx (0.0.x).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,14 +32,6 @@
|
|
|
32
32
|
"./testing": {
|
|
33
33
|
"types": "./src/testing.d.ts",
|
|
34
34
|
"default": "./src/testing.js"
|
|
35
|
-
},
|
|
36
|
-
"./list-support": {
|
|
37
|
-
"types": "./src/list-support.d.ts",
|
|
38
|
-
"default": "./src/list-support.js"
|
|
39
|
-
},
|
|
40
|
-
"./list-cell": {
|
|
41
|
-
"types": "./src/list-cell.d.ts",
|
|
42
|
-
"default": "./src/list-cell.js"
|
|
43
35
|
}
|
|
44
36
|
},
|
|
45
37
|
"scripts": {
|
package/plugin.js
CHANGED
|
@@ -52,6 +52,10 @@ const DEV_TRANSPORT_NOOP_PATH = path.join(
|
|
|
52
52
|
* Builds the query string the in-bundle dev-reload client reads its config
|
|
53
53
|
* from — unchanged from v1's version (build-tooling glue, not part of the
|
|
54
54
|
* bug this rewrite is about).
|
|
55
|
+
* @param {Object} api - The Rsbuild plugin API.
|
|
56
|
+
* @param {Object} environment - The current build environment.
|
|
57
|
+
* @param {string} entryName - The entry (page) name.
|
|
58
|
+
* @returns {string} The query string, without the leading `?`.
|
|
55
59
|
*/
|
|
56
60
|
function createDevReloadClientQuery(api, environment, entryName) {
|
|
57
61
|
const config = environment.config ?? {};
|
|
@@ -83,6 +87,12 @@ function createDevReloadClientQuery(api, environment, entryName) {
|
|
|
83
87
|
return params.toString();
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Finds the first existing file among `candidates` in `dir`.
|
|
92
|
+
* @param {string} dir - The directory to search.
|
|
93
|
+
* @param {string[]} candidates - File names in priority order.
|
|
94
|
+
* @returns {string|null} The full path of the first match, or `null`.
|
|
95
|
+
*/
|
|
86
96
|
function findSibling(dir, candidates) {
|
|
87
97
|
for (const name of candidates) {
|
|
88
98
|
const candidate = path.join(dir, name);
|
|
@@ -91,6 +101,12 @@ function findSibling(dir, candidates) {
|
|
|
91
101
|
return null;
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Walks up from a resolved file to the root of the named package.
|
|
106
|
+
* @param {string} resolvedFile - A file inside the package.
|
|
107
|
+
* @param {string} expectedName - The package `name` to look for.
|
|
108
|
+
* @returns {string|null} The package root directory, or `null` when not found within 10 levels.
|
|
109
|
+
*/
|
|
94
110
|
function packageRootOf(resolvedFile, expectedName) {
|
|
95
111
|
let dir = path.dirname(resolvedFile);
|
|
96
112
|
for (let i = 0; i < 10; i++) {
|
|
@@ -107,12 +123,24 @@ function packageRootOf(resolvedFile, expectedName) {
|
|
|
107
123
|
return null;
|
|
108
124
|
}
|
|
109
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Creates the Rsbuild plugin that builds a mithril-lynx app's background and main-thread bundles.
|
|
128
|
+
* @param {Object} [options] - Plugin options.
|
|
129
|
+
* @param {string} [options.targetSdkVersion="3.5"] - Lynx target SDK version.
|
|
130
|
+
* @param {boolean} [options.liveReload=true] - Whether to inject the dev live-reload client in dev builds.
|
|
131
|
+
* @returns {{name: string, setup: (api: Object) => void}} The Rsbuild plugin.
|
|
132
|
+
*/
|
|
110
133
|
export function pluginMithrilLynx(options = {}) {
|
|
111
134
|
const targetSdkVersion = options.targetSdkVersion ?? "3.5";
|
|
112
135
|
const liveReload = options.liveReload ?? true;
|
|
113
136
|
|
|
114
137
|
return {
|
|
115
138
|
name: PLUGIN_NAME,
|
|
139
|
+
/**
|
|
140
|
+
* @param {Object} api - The Rsbuild plugin API.
|
|
141
|
+
* @returns {void}
|
|
142
|
+
* @throws {Error} If an entry has no sibling `background.ts`/`background.js`.
|
|
143
|
+
*/
|
|
116
144
|
setup(api) {
|
|
117
145
|
api.expose(Symbol.for("LynxTemplatePlugin"), { LynxTemplatePlugin });
|
|
118
146
|
|
|
@@ -240,6 +268,11 @@ export function pluginMithrilLynx(options = {}) {
|
|
|
240
268
|
chain.plugin("encode").use(LynxEncodePlugin, []);
|
|
241
269
|
|
|
242
270
|
chain.plugin("before-encode").use({
|
|
271
|
+
/**
|
|
272
|
+
* Taps the template plugin's `beforeEncode` hook to attach the background and main-thread assets.
|
|
273
|
+
* @param {Object} compiler - The bundler compiler.
|
|
274
|
+
* @returns {void}
|
|
275
|
+
*/
|
|
243
276
|
apply(compiler) {
|
|
244
277
|
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
|
|
245
278
|
const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation);
|