mithril-lynx 0.0.8 → 2.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 (61) hide show
  1. package/.omo/plans/m-request-fetch-lynx.md +306 -0
  2. package/.omo/plans/m-route-en-memoria.md +397 -0
  3. package/.omo/plans/mithril-lynx-v2-desde-cero.md +548 -0
  4. package/FETCH_INVESTIGATION.md +307 -0
  5. package/README.md +32 -284
  6. package/REQUEST.md +71 -0
  7. package/ROUTE.md +71 -0
  8. package/package.json +24 -80
  9. package/plugin.d.ts +4 -27
  10. package/plugin.js +142 -359
  11. package/rstest.config.ts +27 -0
  12. package/src/apply-patch.js +179 -0
  13. package/src/backends/virtual-backend.js +80 -0
  14. package/src/background.d.ts +11 -0
  15. package/src/background.js +79 -0
  16. package/src/channel.js +41 -0
  17. package/src/commit.js +67 -0
  18. package/src/dev-reload-client.js +245 -0
  19. package/src/dev-transport-noop.js +10 -0
  20. package/src/fake-dom.js +374 -0
  21. package/src/main-thread.d.ts +1 -0
  22. package/src/main-thread.js +68 -0
  23. package/src/mount-redraw.js +67 -0
  24. package/src/patch-protocol.js +40 -0
  25. package/src/reload/version.js +28 -0
  26. package/src/request.d.ts +37 -0
  27. package/src/request.js +181 -0
  28. package/src/route.d.ts +33 -0
  29. package/src/route.js +207 -0
  30. package/test/end-to-end.test.ts +86 -0
  31. package/test/reload-version.test.ts +17 -0
  32. package/test/request.test.ts +182 -0
  33. package/test/route-hot-reload.test.ts +40 -0
  34. package/test/route.test.ts +152 -0
  35. package/test/setup.ts +25 -0
  36. package/test/structural-reload.test.ts +95 -0
  37. package/CONTRACT.md +0 -151
  38. package/LICENSE +0 -21
  39. package/background.d.ts +0 -54
  40. package/background.js +0 -169
  41. package/element.d.ts +0 -34
  42. package/element.js +0 -83
  43. package/gesture.d.ts +0 -40
  44. package/gesture.js +0 -117
  45. package/internal/constants.js +0 -26
  46. package/internal/virtual-node.js +0 -388
  47. package/list.d.ts +0 -31
  48. package/list.js +0 -185
  49. package/main-thread.d.ts +0 -43
  50. package/main-thread.js +0 -165
  51. package/navigation.d.ts +0 -35
  52. package/navigation.js +0 -76
  53. package/renderer/background.d.ts +0 -21
  54. package/renderer/background.js +0 -84
  55. package/renderer/main-thread.d.ts +0 -12
  56. package/renderer/main-thread.js +0 -175
  57. package/src/lynx-mithril-shim.d.ts +0 -16
  58. package/src/lynx-mithril-shim.js +0 -1505
  59. package/src/worklet-runtime.js +0 -82
  60. package/testing.d.ts +0 -10
  61. package/testing.js +0 -91
@@ -0,0 +1,548 @@
1
+ # Plan — mithril-lynx v2: reescritura completa, redraw automático, 3 modos de reload
2
+
3
+ > Estado (2026-09-17): **Plan completo, F0–F6 hechos y verificados.**
4
+ > F0–F2 con `rstest` (PAPI real vía `@lynx-js/testing-environment`, sin
5
+ > mocks). F3–F5 en un device Android real conectado (`adb R8YYC0VV0PV`),
6
+ > con una app de prueba (`mithril-lynx-v2-app`) — los 3 modos de reload y
7
+ > el auto-redraw de F1 confirmados con trazas de logcat, un dump de
8
+ > `uiautomator`, Y una inspección de árbol por CDP vía Lynx DevTool
9
+ > (`agent-lynx`) — tres instrumentos independientes, mismo resultado. F6
10
+ > (`create-mithril-lynx-v2`) probado generando un proyecto nuevo desde cero
11
+ > y compilándolo. Ver §8 al final de este documento para el detalle exacto
12
+ > y la evidencia cruda de cada fase. Idioma: español (consistente con el
13
+ > resto de planes de este proyecto).
14
+ >
15
+ > Decisión del usuario (2026-09-17): v2 se escribe en un directorio hermano
16
+ > nuevo, `mithril-lynx-v2/`, **no** dentro de `mithril-lynx/`. Motivo textual:
17
+ > *"no quiero hacerlo en el directorio actual, por que no quiero que queden
18
+ > cabos sueltos"*. `mithril-lynx/` (v1) queda intacto, de solo lectura, como
19
+ > referencia — no se borra, no se toca, no se le agregan más parches.
20
+ >
21
+ > Insumo principal: `../../rspeedy-react-analysis/LYNX_PAPI_SPEC.md` —
22
+ > investigación de cómo ReactLynx realmente logra redraw automático y reload
23
+ > ligero, leyendo su código fuente real (no el REPORT.md anterior, que era
24
+ > superficial). Este plan traduce esos hallazgos en decisiones de arquitectura
25
+ > para v2. Todas las citas `§N` de ese documento se refieren a él.
26
+
27
+ ---
28
+
29
+ ## 0. Por qué una v2 desde cero y no un fix de v1
30
+
31
+ v1 (`mithril-lynx/`) llegó a un punto donde cada solución generó un cabo
32
+ suelto nuevo:
33
+
34
+ - El reload ligero (F0–F2 del plan viejo, `mithril-lynx/.omo/plans/arquitectura-dual-reload.md`)
35
+ quedó funcionando para el caso feliz, pero la forma de lograrlo — monkey-
36
+ patch de `lynx.requireModuleAsync` en runtime, un `if (typeof
37
+ globalThis.__FlushElementTree !== "function")` condicional para decidir si
38
+ el hook de flush existe — son parches sobre síntomas, no un diseño.
39
+ - La regresión activa ahora mismo (auto-redraw tras un evento no llega al
40
+ main-thread; test `renderer-integration.test.ts` en rojo) es consecuencia
41
+ directa de eso: el punto de "flush" del framework es opcional/condicional
42
+ en vez de ser un contrato fijo instalado una sola vez.
43
+ - `LYNX_PAPI_SPEC.md` mostró que varias de las decisiones de v1 no son las
44
+ que usa el propio ReactLynx (canal de patch, guard de race, cómo se evita
45
+ el error de `exports`, cómo se reconcilia un reload) — no son ajustes
46
+ incrementales, son otra arquitectura. Mezclarlas a mitad de v1 hubiera
47
+ significado reescribir el core de todos modos.
48
+
49
+ Conclusión: reescribir desde cero, con el contrato correcto desde el primer
50
+ commit, es más barato que seguir apilando parches sobre v1.
51
+
52
+ ---
53
+
54
+ ## 1. Objetivo
55
+
56
+ Un framework mithril-sobre-Lynx que iguale el contrato de desarrollo de
57
+ ReactLynx en dos frentes concretos:
58
+
59
+ 1. **Redraw automático genuino.** Un handler de evento (`ontap`, etc.) que
60
+ muta estado actualiza la pantalla sin que el código de la app llame nada
61
+ — nada de `shim.redraw()`/`m.redraw()` a mano como paso obligatorio (hoy
62
+ sigue existiendo como API, igual que en React con `setState`, pero
63
+ **nunca** debe ser el único camino para que algo se pinte).
64
+ 2. **Tres modos de reload**, no dos — el usuario ya lo señaló y
65
+ `LYNX_PAPI_SPEC.md` §4.4 lo confirmó con evidencia de código real:
66
+ - **A — Light reload de datos**: texto/props/CSS. Ya validado en v1,
67
+ se rediseña sobre el core nuevo.
68
+ - **B — Light reload estructural**: agregar/quitar/reordenar nodos del
69
+ árbol (lo que v1 asumía que *tenía* que ser full reload). ReactLynx lo
70
+ resuelve mandando el nuevo template compilado por el MISMO canal de
71
+ patch (`DEV_ONLY_AddSnapshot`); v2 lo resuelve dejando que el diff
72
+ normal de Mithril (que corre en background contra un árbol real)
73
+ produzca los ops de Create/Insert/Remove que hagan falta — no necesita
74
+ un "modo" de wire protocol aparte, necesita que la reconciliación no
75
+ tire elementos que no cambiaron (ver §3.6).
76
+ - **C — Full reload**: fallback para lo que A/B no pueden resolver
77
+ (imports nuevos, dependencias cambiadas, error irrecuperable). Ya
78
+ estable en v1 desde 0.0.9 (commit `74fcf9f`, vía CDP `Page.reload`) —
79
+ se reutiliza el mecanismo, reescrito sobre el core nuevo.
80
+
81
+ ---
82
+
83
+ ## 2. No-objetivos (explícitos, para no repetir el scope creep de v1)
84
+
85
+ - **No portar código de v1.** Está permitido releer v1 y reimplementar
86
+ conceptos ya validados en device (el wrapper de Element PAPI del
87
+ main-thread, gestos, listas — ver `mithril-lynx/CONTRACT.md` y
88
+ `DEVICE_VERIFICATION.md`), pero como código nuevo escrito para el
89
+ contrato v2, no copy-paste.
90
+ - **No HMR de "worklets"/funciones main-thread-script.** mithril-lynx no
91
+ tiene ese concepto todavía; ReactLynx tampoco lo resolvió (`hmr.js`:
92
+ *"disable hmr until bugs are fixed"*, sin arreglar). No se persigue acá.
93
+ - **No compilador de templates (SWC) propio.** v2 sigue interpretando
94
+ hyperscript de Mithril en runtime, como v1 — no precompila `create()`/
95
+ `update[]` por posición como hace ReactLynx. Es una limitación de
96
+ performance aceptada conscientemente, no un bloqueo para el reload.
97
+ - **No se mantienen los 3 modos de render de v1** (main-thread-owned,
98
+ data-channel, renderer). v2 nace con **un solo modo**: vista real en
99
+ background contra árbol virtual, patch al main-thread — es el único que
100
+ puede dar reload ligero, que es el objetivo declarado. Si en el futuro
101
+ hace falta un modo "sin background" por alguna razón de performance, es
102
+ una v3, no parte de este plan.
103
+
104
+ ---
105
+
106
+ ## 3. Arquitectura (decisiones tomadas — no todas están abiertas a discusión, ver F0 para las que sí)
107
+
108
+ ### 3.1 Modelo de hilos: uno solo
109
+
110
+ Toda la vista (Mithril real, diff real) corre en el hilo **background**
111
+ contra un árbol virtual (equivalente al `VirtualNodeWrapper` que v1 ya tiene
112
+ en `internal/virtual-node.js` — reimplementado, no copiado). El
113
+ **main-thread** solo aplica un patch contra elementos PAPI reales — nunca
114
+ ejecuta lógica de vista. Esto es lo que v1 llamó "renderer mode" y ya
115
+ verificó on-device (F1/F2 del plan viejo) — la decisión acá es que en v2
116
+ **no hay alternativa**, no que se descubre de nuevo.
117
+
118
+ ### 3.2 Canal cruzado background → main: a decidir con evidencia (F0)
119
+
120
+ `LYNX_PAPI_SPEC.md` §3 documentó que ReactLynx usa
121
+ `lynx.getNativeApp().callLepusMethod(name, payload, callback)` — una
122
+ llamada nativa directa con callback de confirmación — en vez de un evento
123
+ genérico (`lynx.getCoreContext().dispatchEvent()`, que es lo que usa v1
124
+ hoy). No sabemos todavía si `callLepusMethod`/el mecanismo de "calledByNative
125
+ globals" es parte de la PAPI pública general o es infraestructura interna
126
+ exclusiva de `@lynx-js/react`. **Regla de decisión (F0.1):**
127
+
128
+ - Si `callLepusMethod` (o equivalente) funciona desde un bundle sin
129
+ `@lynx-js/react` → v2 lo adopta. Ventaja: callback de confirmación nativo,
130
+ sin depender de que el main-thread ya esté escuchando un evento.
131
+ - Si no está expuesto a terceros → v2 se queda con el canal de evento
132
+ custom que v1 ya tiene funcionando (`lynx.getCoreContext().dispatchEvent`
133
+ con nombre de evento propio) — es una degradación aceptable, no
134
+ bloqueante, porque ya está validado en device.
135
+
136
+ ### 3.3 Formato del patch: array plano de enteros, no objetos
137
+
138
+ v1 manda ops como `{op: "createElement", vid, tag}`. v2 adopta el patrón de
139
+ `SnapshotOperation` (`LYNX_PAPI_SPEC.md` §4.3): un array plano
140
+ `[opcode, ...args, opcode, ...args, ...]`, con un enum de opcodes propio
141
+ para el vocabulario mínimo de mithril-lynx (CreateElement, InsertBefore,
142
+ RemoveChild, SetAttribute, SetAttributes-batch, AddEvent, RemoveEvent).
143
+ Motivo: menos overhead de `JSON.stringify`/parseo, patrón ya probado en
144
+ producción por ReactLynx a escala. Este cambio es de bajo riesgo (es
145
+ serialización interna, no afecta la superficie pública del framework) y no
146
+ necesita spike — se implementa directo en F2.
147
+
148
+ ### 3.4 Redraw automático — el rediseño central que pidió el usuario
149
+
150
+ Este es el punto que v1 nunca resolvió bien y es la razón de fondo de la
151
+ regresión activa. Diagnóstico de v1 (ya documentado en
152
+ `mithril-lynx/AGENTS.md`): el hook de fin-de-render de Mithril
153
+ (`flushTree()`) llama a un global (`__FlushElementTree`) que **a veces
154
+ existe y a veces no**, dependiendo de si `renderApp()` ya corrió, en qué
155
+ hilo, y en qué orden — un `if (typeof globalThis.X !== "function")` es una
156
+ condición de carrera de diseño, no un detalle de implementación.
157
+
158
+ **Diseño v2:** el shim de Mithril expone un **punto de extensión explícito
159
+ y obligatorio** para "algo terminó de re-renderizar, hay que empujarlo" —
160
+ no un global condicional. Ejemplo de forma (a afinar en F1, la idea es el
161
+ contrato, no la firma exacta):
162
+
163
+ ```js
164
+ // v2/src/shim.js — análogo a Preact's options.__c (commit hook),
165
+ // pero como API de primera clase del shim, no un hack sobre `options`.
166
+ export function onCommit(callback) {
167
+ // registra `callback` como EL único punto de salida de cualquier
168
+ // pase de render/redraw — eventos, m.redraw(), aplicación de HMR.
169
+ // Lanza si ya hay uno registrado: un shim solo tiene UN consumidor
170
+ // (quien monta la app), nunca "el que llegue primero define el global".
171
+ }
172
+ ```
173
+
174
+ `renderApp()` (el entry point de background, equivalente al `renderApp` de
175
+ v1) llama `onCommit(flushToMainThread)` **una vez, de forma explícita, en
176
+ su propio código** — no hay detección implícita de "si ya existe un
177
+ flush". Si `renderApp()` nunca corrió, cualquier intento de redraw debe
178
+ fallar ruidosamente (throw), no fallar en silencio como pasa hoy en v1
179
+ (pantalla congelada sin ningún error).
180
+
181
+ **Criterio de diseño no negociable:** todo redraw —por evento, por
182
+ `m.redraw()` manual, o por HMR— pasa por el mismo único callback. Si en
183
+ algún punto del código hace falta preguntar "¿existe la función de
184
+ flush?", el diseño está mal — la pregunta correcta es "¿ya se montó la
185
+ app?", que se responde con una excepción clara en desarrollo, no con un
186
+ `typeof` chequeado en cada llamada.
187
+
188
+ ### 3.5 Los 3 modos de reload, en detalle
189
+
190
+ **A — Datos (texto/props/CSS).** Idéntico en espíritu a v1 F1: HMR normal
191
+ de webpack (`module.hot.accept("./view.js", cb)`), el callback reapunta un
192
+ binding vivo al módulo recargado y dispara un commit (§3.4). Ya validado
193
+ on-device en v1 — se re-implementa sobre el core nuevo, no se re-descubre.
194
+
195
+ **B — Estructural.** La diferencia real con A es solo "cuánto cambia el
196
+ árbol", no el mecanismo de transporte. Como en v2 la vista SIEMPRE corre en
197
+ background contra un árbol real (§3.1), un cambio estructural (nuevo nodo,
198
+ nodo eliminado, reordenado) produce naturalmente ops
199
+ `CreateElement`/`InsertBefore`/`RemoveChild` en el mismo patch — Mithril ya
200
+ sabe diffear eso, es su trabajo normal. **No hace falta un modo de wire
201
+ protocol separado como el `DEV_ONLY_AddSnapshot` de ReactLynx** (ese existe
202
+ porque ReactLynx precompila templates a nivel de función y necesita mandar
203
+ la función nueva; mithril-lynx interpreta hyperscript en runtime, así que
204
+ el módulo recargado YA contiene la nueva estructura sin nada que serializar
205
+ aparte). El problema real de B no es de protocolo — es de **reconciliación**
206
+ (§3.6): si insertás un nodo hermano de un `<input>` enfocado, ¿el input
207
+ sobrevive?
208
+
209
+ **C — Full reload.** Mecanismo de v1 sin cambios de fondo (CDP
210
+ `Page.reload`, cache-busted) — se dispara cuando `module.hot.check()` es
211
+ rechazado, `module.hot.decline()` fue llamado, o hay un error irrecuperable
212
+ aplicando un patch. Se reescribe sobre el core nuevo por prolijidad, no
213
+ porque el mecanismo esté roto.
214
+
215
+ ### 3.6 Reconciliación en reload estructural — la pieza que v1 no tiene y v2 sí necesita
216
+
217
+ v1 resolvió esto para UN caso (el patrón "stable-host": un componente fijo,
218
+ delegando a un binding vivo) que preserva el `<input>` **solo si la
219
+ estructura no cambia** entre el módulo viejo y el nuevo. Eso no cubre B.
220
+
221
+ ReactLynx lo resuelve con `hydrate(oldRoot, newRoot, {skipUnRef: true})`
222
+ (`LYNX_PAPI_SPEC.md` §5.1): renderiza el árbol nuevo completo desde cero en
223
+ un root nuevo, y después reconcilia por posición/tipo contra el root viejo,
224
+ reutilizando el elemento físico donde el nodo nuevo calza estructuralmente
225
+ y creando/borrando solo donde de verdad cambió — el mismo algoritmo que se
226
+ usa para hidratar SSR, aplicado a un reload.
227
+
228
+ **Decisión para v2:** no reimplementar un hydrate genérico tipo React desde
229
+ cero (es una pieza grande y arriesgada, ver riesgos §6) — en cambio,
230
+ aprovechar que Mithril YA hace esto cuando renderiza dos veces **contra el
231
+ mismo root/vnode tree**: si el hot-update re-ejecuta `render(rootWrapper,
232
+ newVnode)` sobre el **mismo `rootWrapper`** que ya tenía el árbol viejo
233
+ montado (en vez de crear un root nuevo), el diff normal de Mithril (por
234
+ tag + posición + `key`) hace exactamente el trabajo de "reusar donde
235
+ calza, recrear donde no" — sin escribir un hydrate aparte. Esto generaliza
236
+ el patrón stable-host de v1 (que ya hacía esto para un solo componente) a
237
+ cualquier árbol, con una regla de disciplina para el autor de la app: usar
238
+ `key` en listas/nodos que puedan reordenarse, igual que en React. Se marca
239
+ como **hipótesis a verificar en F4**, no como hecho — si el diff normal de
240
+ Mithril no alcanza a preservar el foco en casos no triviales, ahí sí hace
241
+ falta diseñar algo más parecido a `hydrate()`.
242
+
243
+ ### 3.7 Evitar el error "exports is not defined" por diseño, no por monkey-patch
244
+
245
+ v1 resolvió esto parcheando `lynx.requireModuleAsync` en runtime para
246
+ inyectar `module`/`exports` antes de evaluar un chunk `.hot-update.js` de
247
+ webpack. `LYNX_PAPI_SPEC.md` §6 mostró que ReactLynx nunca tiene este
248
+ problema porque **todo chunk se envuelve en build-time** en un module
249
+ system propio (`tt.define(id, function(require, module, exports, ...) {})`)
250
+ vía `RuntimeWrapperWebpackPlugin` — `module`/`exports` son siempre
251
+ parámetros de función reales, nunca variables libres que el motor deba
252
+ proveer.
253
+
254
+ **Decisión para v2 (a validar en F0.2):** escribir un plugin de build
255
+ equivalente para mithril-lynx-v2 que envuelva TODO chunk (incluidos los
256
+ `.hot-update.js`) de la misma forma, en vez de mantener el monkey-patch de
257
+ `requireModuleAsync` en el cliente de dev-reload. Si envolver hot-update
258
+ chunks specifically resulta impracticable con Rspeedy/rspack tal como está
259
+ expuesto hoy, el monkey-patch de v1 queda como plan B documentado (ya
260
+ funciona, es solo menos elegante).
261
+
262
+ ### 3.8 Guard de race entre builds: contador de versión, no un flag de estado
263
+
264
+ v1 tuvo la "race de doble-build" (F1 del plan viejo): un guard
265
+ `hotStatus === "idle"` que, si dos rebuilds llegan casi juntos, degrada a
266
+ full reload aunque cada uno individualmente fuera ligero. ReactLynx evita
267
+ esto con un contador global `reloadVersion` que se incrementa en cada
268
+ reload y descarta silenciosamente cualquier patch en vuelo con una versión
269
+ vieja (`LYNX_PAPI_SPEC.md` §5.1). v2 adopta el contador de versión
270
+ directamente — no es un spike, es una decisión de bajo riesgo con un patrón
271
+ de referencia claro.
272
+
273
+ ---
274
+
275
+ ## 4. Fases
276
+
277
+ | Fase | Contenido | Criterio de salida | Ayuda del usuario |
278
+ |---|---|---|---|
279
+ | **F0** | Spikes de viabilidad (antes de escribir una línea del framework) | Ver F0.1–F0.3 abajo | Sí, dos de tres necesitan device |
280
+ | **F1** | Shim/core reescrito: diff de Mithril + punto de extensión de commit único (§3.4) | Test unitario equivalente al que hoy está roto en v1 (`renderer-integration.test.ts`, reescrito) **pasa desde el primer commit** | No — automatizable con `rstest` |
281
+ | **F2** | Canal de patch (según F0.1) + formato de ops plano (§3.3) + apply en main-thread | Snapshot/replay test: un render produce el mismo árbol físico que hoy, ops verificados por conteo/tipo | No |
282
+ | **F3** | Reload A (datos) + C (full) sobre el core nuevo | Device: editar texto → sin `Page.reload`, foco/texto de `<input>` intactos (mismo criterio que v1 ya alcanzó, ahora con arquitectura limpia) | Sí — device + logcat |
283
+ | **F4** | Reload B (estructural) + verificación de la hipótesis de §3.6 | Device: insertar/quitar un nodo HERMANO de un `<input>` enfocado → foco y texto sobreviven. **v1 nunca llegó a plantear este criterio** | Sí — device + logcat |
284
+ | **F5** | Verificación cruzada con Lynx DevTool | Inspeccionar el árbol de elementos en vivo durante cada uno de los 3 modos; confirmar 0 elementos huérfanos/duplicados tras reload estructural | Sí — DevTool conectado al device |
285
+ | **F6** | Empaquetado: `create-mithril-lynx-v2` (o adaptar `mithril-app-final` como banco de pruebas de v2) | Scaffold fresco compila y corre los 3 modos de reload | No (verificación automatizable una vez F0–F5 cierran) |
286
+
287
+ ### F0 — Detalle de los spikes
288
+
289
+ - **F0.1 — ¿`callLepusMethod`/canal nativo directo disponible sin `@lynx-js/react`?**
290
+ Método: app mínima en `mithril-lynx-v2` (sin ninguna dependencia de
291
+ `@lynx-js/react`) que intente `lynx.getNativeApp().callLepusMethod(...)`
292
+ desde el background y capturar en logcat si el main-thread recibe algo.
293
+ Regla de decisión: ver §3.2.
294
+ - **F0.2 — ¿se puede envolver `.hot-update.js` en build-time con Rspeedy/rspack?**
295
+ Método: plugin mínimo de rspack que intente aplicar el mismo wrapper
296
+ `tt.define` a un chunk de hot-update generado por `HotModuleReplacementPlugin`,
297
+ verificar en el `dist/` compilado si el wrapper quedó aplicado también ahí
298
+ (no solo en los chunks iniciales). Regla de decisión: ver §3.7.
299
+ - **F0.3 — confirmar que el guard de `reloadVersion` alcanza sin el flag `hotStatus`.**
300
+ Método: reproducir la race de doble-build que v1 documentó (dos rebuilds
301
+ en ráfaga, ver `arquitectura-dual-reload.md` "race de doble-build") contra
302
+ una implementación mínima del contador, confirmar que ambos patches se
303
+ aplican en orden correcto (o el viejo se descarta) sin caer a full reload.
304
+
305
+ ---
306
+
307
+ ## 5. Herramientas de verificación ya disponibles en este entorno
308
+
309
+ - **Device Android real** (visto en sesiones anteriores: Galaxy A07 / otro
310
+ dispositivo con Lynx Go instalado) vía `adb` — USB o Wi-Fi.
311
+ - **Lynx DevTool** (skill `lynx-devtool` + bridge ya operativo con el
312
+ device) — inspección de árbol DOM/CSS, screenshots, logs de runtime,
313
+ evaluación de JS en vivo.
314
+ - **`adb logcat`** — script ya existente en v1 (`mithril-app-final/scripts/adb-log.sh`),
315
+ se replica igual en el banco de pruebas de v2.
316
+ - **`rstest`** como test runner — **no `vitest`**, ya confirmado que falla
317
+ con "Rstest API 'describe' is not registered" si se usa por error.
318
+ - **Convención de traza `[mrl-trace]`** (o el nombre que se le dé en v2) —
319
+ logging estructurado y numerado para diagnósticos cruzados
320
+ background/main, ya probado como herramienta de debugging efectiva en v1.
321
+
322
+ ---
323
+
324
+ ## 6. Riesgos
325
+
326
+ | Riesgo | Mitigación |
327
+ |---|---|
328
+ | F0.1 cierra en "no disponible para terceros" | Cae a canal de evento genérico (§3.2) — no bloqueante, ya validado en v1 |
329
+ | F0.2 impracticable con Rspeedy tal como está expuesto | Monkey-patch de v1 queda como plan B documentado, ya funciona |
330
+ | §3.6 (reusar el diff normal de Mithril como reconciliador) no alcanza en casos no triviales | F4 lo marca como hipótesis a verificar, no como hecho — si falla, hace falta una segunda iteración de diseño (hydrate real) antes de cerrar F4 |
331
+ | Sin compilador de templates, el árbol virtual es más caro en runtime que el de ReactLynx | Aceptado como no-objetivo (§2) — no se ataca en esta v2 |
332
+ | Repetir el error de v1 de dejar `console.log("[dbg]...")` de debugging mezclado con código de producción | Regla de higiene explícita: instrumentación de diagnóstico vive detrás de un flag (`__DEV__`-style), nunca como `console.log` suelto en el core |
333
+
334
+ ---
335
+
336
+ ## 7. Referencias
337
+
338
+ - `../../rspeedy-react-analysis/LYNX_PAPI_SPEC.md` — spec de la PAPI y
339
+ mecanismo real de ReactLynx (fase 1 de investigación, ya escrita).
340
+ - `../../mithril-lynx/AGENTS.md` — estado y regresión activa de v1, para no
341
+ repetir los mismos errores de diseño.
342
+ - `../../mithril-lynx/.omo/plans/arquitectura-dual-reload.md` — lo que v1
343
+ intentó, con evidencia on-device real; insumo útil aunque v2 no siga su
344
+ arquitectura exacta en varios puntos (§3.2–§3.8 documentan exactamente
345
+ dónde difiere y por qué).
346
+ - `../../mithril-lynx/CONTRACT.md`, `../../mithril-lynx/DEVICE_VERIFICATION.md` —
347
+ conceptos de Element PAPI wrapper, gestos y listas ya validados en device,
348
+ candidatos a reimplementar (no copiar) en v2 — no forman parte del
349
+ problema de reload, son infraestructura ya resuelta.
350
+
351
+ ---
352
+
353
+ ## 8. Estado de ejecución (actualizado en vivo, no re-escribir el plan de arriba)
354
+
355
+ ### F0 — cerrado, con evidencia real (no experimento en device todavía, pero no hacía falta)
356
+
357
+ - **F0.1** (canal nativo sin `@lynx-js/react`): `callLepusMethod` **no existe**
358
+ en el `lynx_core.js` real instalado (`indicadores-android/.../lynx_core.js`,
359
+ 0 ocurrencias). Sí existe `lynx.triggerLepusGlobalEvent(name, params)` —
360
+ genérico, público, parte del motor base, no de `@lynx-js/react`. Queda
361
+ como candidato de canal para F3; el canal de evento genérico de v1
362
+ (`getCoreContext`/`getJSContext`, también presentes en ese mismo
363
+ `lynx_core.js`) sigue como fallback validado si `triggerLepusGlobalEvent`
364
+ no calza con lo que necesita v2 en la práctica.
365
+ - **F0.2** (wrapping de chunks hot-update): **causa raíz encontrada, no es
366
+ una limitación arquitectónica.** v1 ya depende de
367
+ `@lynx-js/runtime-wrapper-webpack-plugin` (el mismo plugin oficial que usa
368
+ ReactLynx) pero lo configura con
369
+ `test: new RegExp(`${name}/background\\.js$`)` (`mithril-lynx/plugin.js:613`)
370
+ — matchea el asset inicial `main-thread/background.js` (ruta con slash,
371
+ el nombre de ASSET) pero nunca los chunks `.hot-update.js` (que se emiten
372
+ planos, nombrados por el nombre del CHUNK: `main-thread__background.<hash>.hot-update.js`,
373
+ con doble guión bajo, sin slash). Confirmado con un test de regex directo,
374
+ no con una build completa. **F6 solo necesita ampliar ese regex** —
375
+ no hace falta un plugin nuevo ni el monkey-patch de
376
+ `lynx.requireModuleAsync` que v1 tuvo que escribir.
377
+ - **F0.3** (guard de versión): implementado y testeado directamente
378
+ (`src/reload/version.js` + `test/reload-version.test.ts`), sin
379
+ necesidad de reproducir la race real todavía — la lógica es la misma que
380
+ usa ReactLynx, de bajo riesgo.
381
+
382
+ ### F1 — cerrado y verificado
383
+
384
+ Reescrito desde cero (`src/fake-dom.js`, `src/commit.js`, `src/background.js`),
385
+ corriendo el `mithril@2.3.8` real (`render/render.js`, sin fork) contra un
386
+ DOM falso construido para este propósito — no contra una copia modificada
387
+ del shim de v1. El contrato exacto de esa reimplementación está en
388
+ `mithril-lynx/CONTRACT.md` (ya escrito por una sesión anterior, verificado
389
+ por grep contra el `render.js` real) — se usó como checklist, no como
390
+ código a copiar.
391
+
392
+ **Test decisivo, verde desde el primer commit** (`test/end-to-end.test.ts`):
393
+ un `ontap` que muta estado SIN llamar `redraw()`/`m.redraw()` en ningún
394
+ lado produce un segundo patch automáticamente — el mismo escenario que
395
+ estaba en rojo en v1 (`mithril-lynx/test/renderer-integration.test.ts`,
396
+ "device regression"). La diferencia de diseño que lo logra: el callback de
397
+ redraw que Mithril llama solo (`EventDict.handleEvent`, contrato ya
398
+ documentado en CONTRACT.md §e) es una clausura capturada una vez
399
+ (`performRender` en `src/background.js`), nunca un global condicional.
400
+
401
+ ### F2 — núcleo cerrado y verificado; aplicación en main-thread con un TODO explícito
402
+
403
+ `src/patch-protocol.js` (array plano de opcodes, patrón `SnapshotOperation`
404
+ de ReactLynx) + `src/backends/virtual-backend.js` (background, genera ops) +
405
+ `src/apply-patch.js` (main-thread, aplica ops con PAPI real —
406
+ `__CreateView`/`__CreateText`/`__CreateElement`/`__CreateRawText`/
407
+ `__AppendElement`/`__InsertElementBefore`/`__RemoveElement`/`__SetAttribute`/
408
+ `__SetClasses`/`__AddInlineStyle`/`__AddEventListener`/`__FlushElementTree`,
409
+ todas validadas ya en device por v1 — ver `mithril-lynx/src/lynx-mithril-shim.js`
410
+ líneas 503-519 y `CONTRACT.md`).
411
+
412
+ **Test decisivo** (`test/end-to-end.test.ts`, mismo archivo que F1): el
413
+ patch inicial y el patch del auto-redraw se aplican con el PAPI real de
414
+ `@lynx-js/testing-environment` (no un mock) y el handler del tap corre
415
+ sobre el nodo real correcto.
416
+
417
+ **Evidencia adicional para F4** (`test/structural-reload.test.ts`, no
418
+ sustituye la verificación en device pero da una señal fuerte antes de
419
+ llegar ahí): insertar un nodo hermano NUEVO junto a un nodo `input`-like
420
+ existente, ambos con `key`, **no** produce ningún op de
421
+ `CreateElement`/`RemoveChild` sobre el id del input — el diff normal de
422
+ Mithril lo reusa in-place. Confirma la hipótesis del §3.6 en el caso
423
+ keyed; el caso sin `key` NO se probó a propósito (es sabido que ahí
424
+ Mithril recrea desde el punto de la diferencia — disciplina de `key`
425
+ documentada, no bug).
426
+
427
+ **TODO explícito dejado en el código** (`apply-patch.js`, caso
428
+ `RemoveStyleProperty` con nombre `"*"`, o sea `element.style = ""`): lanza
429
+ un error en vez de fallar en silencio, porque no hay todavía una llamada
430
+ PAPI de "limpiar todos los estilos de una vez" validada. Bloqueante solo
431
+ si una app usa ese patrón exacto; no bloquea F3.
432
+
433
+ ### F3 y F4 — cerrados, verificados en device real (2026-09-17, misma sesión)
434
+
435
+ Se construyó `mithril-lynx-v2-app` (repo git propio, hermano de éste),
436
+ esqueleto igual a `mithril-app-final` pero apuntando a las APIs de v2
437
+ (`mithril-lynx-v2/background`, `/main-thread`, `/plugin`). `npm run build`
438
+ compiló sin errores; `npx rspeedy dev` levantó el dev server en
439
+ `10.49.37.154:3000` (misma subred Wi-Fi que el device, `10.49.37.79`,
440
+ confirmado con `adb shell ip -f inet addr show`). Se abrió el bundle en
441
+ Lynx Go vía `adb shell am start -a android.intent.action.VIEW -d
442
+ "lynx://open?url=<bundle-url-encoded>" com.funcs.io.lynx.go` (deep link
443
+ del propio Lynx Go, sin necesidad de escanear el QR a mano).
444
+
445
+ **F1 en device real** (no solo en `rstest`): `adb shell input tap <título>`
446
+ → el título cambió de azul a rojo. `index.ts`'s `ontap` no llama
447
+ `redraw()`/`m.redraw()` en ningún lado — la app entera confía en el
448
+ auto-redraw de Mithril, exactamente el mecanismo que estaba roto en v1.
449
+
450
+ **F3 (reload A — datos), 3 ediciones en vivo de `src/index.ts` (texto del
451
+ título)**: cada guardado produjo en logcat
452
+ `:4 hmr-check hotStatus:"idle"` → `:5 hmr-check-calling` → `:6
453
+ hmr-check-resolved updatedModulesLength:1` — **sin** `:7`/`:8`/`:9`
454
+ (ningún full reload). El input, previamente enfocado con `adb shell input
455
+ tap` + `input text "abc123"`, mantuvo el texto y el teclado abierto en las
456
+ 3 ediciones (confirmado por captura de pantalla en cada paso).
457
+
458
+ **F4 (reload B — estructural), la prueba que v1 nunca llegó a plantear**:
459
+ con el input todavía enfocado y con `abc123` escrito, se agregó un nodo
460
+ `m("text", {key:"extra"}, "NUEVO NODO F4")` HERMANO del input (entre el
461
+ título y el input) y se guardó. Logcat: mismo camino ligero
462
+ (`:4`→`:5`→`:6 updatedModulesLength:1`), sin full reload. **Verificación
463
+ dura, no solo visual**: `adb shell uiautomator dump` después del cambio
464
+ muestra el `EditText` real con
465
+ `text="abc123" ... focused="true"` — el nodo nuevo se insertó, el título
466
+ cambió, y el input ni perdió el foco ni el texto. Esto confirma en
467
+ device real la hipótesis del §3.6 (reusar el diff normal de Mithril con
468
+ `key` estables alcanza para esto), no solo en el test automatizado
469
+ (`test/structural-reload.test.ts`).
470
+
471
+ **F3 (fallback full reload — método C)**: se editó `src/background.ts`
472
+ (un módulo que NINGÚN `module.hot.accept` cubre — ni siquiera un
473
+ self-accept). Logcat: `:4`→`:5`→**`:7 hmr-check-rejected` "Aborted because
474
+ ./src/background.ts is not accepted"** → `:8 reload-called` → `:9
475
+ cdp-page-reload` → reboot limpio (`:0`→`:1`→`:3a`), un solo ciclo, sin
476
+ loop, estado de la app reseteado (el input volvió al placeholder vacío —
477
+ comportamiento correcto y esperado de un full reload).
478
+
479
+ **Los 3 métodos de reload y el auto-redraw de F1 quedan así confirmados
480
+ end-to-end en device real**, con trazas de logcat y un dump de
481
+ `uiautomator` como evidencia dura — no solo con capturas de pantalla ni
482
+ con tests automatizados. Ver el commit `c788572` de
483
+ `mithril-lynx-v2-app` (mensaje del commit incluye el resumen completo de
484
+ esta corrida) y el commit `f449aa6` de este repo (dev-reload-client.js,
485
+ channel.js, main-thread.js, plugin.js con el fix de F0.2 ya aplicado y
486
+ validado — el hot-update chunk se sirvió y evaluó sin el error
487
+ `ReferenceError: exports is not defined` de v1, en ningún momento de la
488
+ sesión, sin ningún monkey-patch de `lynx.requireModuleAsync`).
489
+
490
+ ### F5 — cerrado, verificado con Lynx DevTool (2026-09-17, misma sesión)
491
+
492
+ Usando `agent-lynx` (skill `lynx-devtool`) contra el mismo device/sesión de
493
+ F3/F4. Requirió activar switches que estaban OFF por defecto y necesitan un
494
+ relanzamiento de la app para tomar efecto:
495
+ `enable_dom_tree`, `enable_cdp_domain_dom`, `enable_cdp_domain_css`,
496
+ `enable_cdp_domain_page` (`agent-lynx global-switch set --key <k> --status on`).
497
+ Con eso, `DOM.enable {"useCompression":false}` + `DOM.getDocument
498
+ {"depth":-1}` da el árbol real completo, con `nodeId` estables por
499
+ elemento — la fuente de verdad para "¿se reusó el nodo o se recreó?",
500
+ independiente de `uiautomator` (F4) y de las capturas de pantalla.
501
+
502
+ **Baseline**: 6 nodos — `#document(11) > page(10) > view.Page(13) >
503
+ [text.TitleBlue(14) > raw-text(15), input(16)]`.
504
+
505
+ **Método A (datos)**: tras editar el texto del título, el árbol tiene
506
+ **exactamente los mismos 6 `nodeId`** (10,11,13,14,15,16); el `nodeId 15`
507
+ (el raw-text) pasó a tener el atributo `text` con el string nuevo — mismo
508
+ nodo, atributo actualizado in-place, confirmado por CDP, no por
509
+ inferencia visual.
510
+
511
+ **Método B (estructural)**: tras insertar un nodo hermano nuevo entre el
512
+ título y el input, el árbol pasó a 8 nodos: **los 6 originales
513
+ (10,11,13,14,15,16) sin tocar** — el `input` sigue siendo el `nodeId 16`
514
+ exacto — más dos nodos nuevos (`17`=el `text` nuevo, `18`=su `raw-text`),
515
+ insertados en la posición correcta del árbol. Cero huérfanos, cero
516
+ duplicados — el criterio de aceptación del plan original para F5, cumplido
517
+ con una fuente de verdad distinta de `uiautomator` (que ya lo había
518
+ confirmado por otro lado en F4: mismo resultado, dos instrumentos
519
+ distintos).
520
+
521
+ **Método C (full reload)**: tras editar `background.ts` (dispara el
522
+ fallback, igual que en F3), el árbol se reconstruyó desde cero — mismo
523
+ conteo de nodos que el estado B (8, porque el source todavía tenía el
524
+ nodo "extra"), pero **la numeración de `nodeId` se reinició** (el segundo
525
+ `text`/`raw-text` pasó de 17/18 a 16/17, el `input` de 16 a 18) — es
526
+ decir, la sesión vieja se descartó por completo y se creó una página
527
+ nueva de cero, sin ningún id viejo conviviendo con los nuevos. Exactamente
528
+ el comportamiento esperado de un full reload — no es un caso de
529
+ "huérfanos", es una página nueva reemplazando a la anterior.
530
+
531
+ ### F6 — hecho: `create-mithril-lynx-v2`
532
+
533
+ Ver `create-mithril-lynx-v2/` (repo hermano nuevo). CLI mínimo que copia
534
+ la plantilla (`templates/ts/`, calcada de `mithril-lynx-v2-app` una vez
535
+ limpio de los edits de prueba de F3-F5) a un directorio nuevo, sustituye
536
+ el nombre del paquete, y deja instrucciones de `npm install && npm run
537
+ dev`. Uso: `npx create-mithril-lynx-v2 <nombre-app>`. No incluye un
538
+ prompt interactivo (JS vs TS, blank vs demo) como tiene
539
+ `create-mithril-lynx` (v1) — la plantilla única de F6 es intencionalmente
540
+ mínima; ampliar el CLI queda fuera de este plan si hace falta más
541
+ adelante.
542
+
543
+ **Pendiente, no bloqueante**: `module.hot.decline()` explícito para
544
+ cambios estructurales "duros" no está implementado — el fallback a full
545
+ reload ya sale solo cuando `module.hot.check()` no puede propagar la
546
+ actualización (confirmado en la prueba de método C de F3/F5). Un
547
+ `decline()` explícito solo agregaría un mensaje de log más claro para el
548
+ autor de la app, no cambia el comportamiento observable.