mithril-lynx 2.6.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/README.md +47 -8
  5. package/ROUTE.md +111 -16
  6. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  7. package/UPDATE_ANIMATION_GAP.md +133 -0
  8. package/package.json +1 -9
  9. package/plugin.js +33 -0
  10. package/src/apply-patch.js +369 -60
  11. package/src/backends/virtual-backend.js +120 -22
  12. package/src/background.d.ts +26 -0
  13. package/src/background.js +116 -7
  14. package/src/channel.js +22 -5
  15. package/src/commit.js +9 -0
  16. package/src/dev-reload-client.js +46 -0
  17. package/src/dev-transport-noop.js +3 -0
  18. package/src/fake-dom.js +559 -61
  19. package/src/list-attributes.js +69 -0
  20. package/src/list-runtime.js +425 -0
  21. package/src/main-thread.js +18 -1
  22. package/src/mount-redraw.js +14 -1
  23. package/src/patch-protocol.js +41 -16
  24. package/src/reload/version.js +10 -1
  25. package/src/request.js +38 -0
  26. package/src/route.d.ts +30 -2
  27. package/src/route.js +214 -56
  28. package/src/testing.d.ts +14 -1
  29. package/src/testing.js +56 -1
  30. package/test/event-render-coalescing.test.ts +117 -0
  31. package/test/gesture.test.ts +75 -0
  32. package/test/input.test.ts +388 -0
  33. package/test/list.test.ts +417 -113
  34. package/test/remove-subtree-cleanup.test.ts +75 -0
  35. package/test/route-hot-reload.test.ts +2 -1
  36. package/test/route.test.ts +201 -4
  37. package/test/style-clear.test.ts +97 -0
  38. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  39. package/.omo/plans/m-route-en-memoria.md +0 -397
  40. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  41. package/.omo/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
  42. package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +0 -10
  43. package/src/list-cell.d.ts +0 -20
  44. package/src/list-cell.js +0 -73
  45. package/src/list-support.d.ts +0 -13
  46. package/src/list-support.js +0 -197
@@ -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": "2.6.2",
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);