mithril-lynx 0.0.9 → 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 -302
  6. package/REQUEST.md +71 -0
  7. package/ROUTE.md +71 -0
  8. package/package.json +24 -80
  9. package/plugin.d.ts +4 -33
  10. package/plugin.js +108 -438
  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 +171 -187
  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
package/plugin.js CHANGED
@@ -1,19 +1,27 @@
1
1
  // plugin.js
2
2
  //
3
- // Generalized version of lynx-examples/examples/vanilla/plugin.ts's
4
- // dual-bundle build pattern, published as a reusable Rsbuild/Rspeedy plugin
5
- // instead of being copy-pasted per app.
3
+ // Rspeedy/Rsbuild plugin wiring the two-bundle build (main-thread/Lepus +
4
+ // background/JS) for mithril-lynx-v2 apps. Adapted from mithril-lynx v1's
5
+ // plugin.js — this file is build TOOLING, not the redraw/reload mechanism
6
+ // that motivated the v2 rewrite (see mithril-lynx-v2-desde-cero.md §2: v1's
7
+ // bugs lived in the shim/commit/reload layer, never here), so it is reused
8
+ // with fixes rather than rewritten from nothing. Two real changes from v1:
6
9
  //
7
- // Convention: for each configured entry, the entry's import path names a
8
- // "main-thread" file. If a sibling "background.ts"/"background.js" exists
9
- // next to it, it is picked up automatically and compiled as a second Lynx
10
- // bundle chunk (the background/JS-thread bundle), while the main-thread file
11
- // is always compiled and encoded as lepus (main-thread/Lepus VM chunk).
10
+ // 1. (F0.2 fix, the actual point of this file's existence in the plan)
11
+ // `RuntimeWrapperWebpackPlugin`'s `test` regex now also matches
12
+ // `.hot-update.js` chunks. v1's regex (`${name}/background\.js$`)
13
+ // matched the initial background ASSET path (nested under
14
+ // `.rspeedy/<name>/`, with a slash) but never the flat, double-
15
+ // underscore-named hot-update chunk (`<name>__background.<hash>.hot-
16
+ // update.js`) — confirmed with a plain regex test against both real
17
+ // filenames, not a guess. That gap is the entire reason v1 needed a
18
+ // runtime monkey-patch of `lynx.requireModuleAsync` in its dev-reload
19
+ // client; v2's client has no such patch (see src/dev-reload-client.js).
12
20
  //
13
- // This is pure multi-entry bundling, no AST transform of user code: apps
14
- // author two plain files (main-thread.ts + optional background.ts) and this
15
- // plugin wires them into the two Lynx bundle slots. See mithril-lynx's
16
- // project plan, Phase 2.
21
+ // 2. Only ONE rendering mode exists (v2 plan §2 non-goals: no main-thread-
22
+ // owned/data-channel modes) — so there is no mode-detection logic here,
23
+ // `dev.hmr` is unconditionally on in dev, and every entry always gets a
24
+ // background chunk.
17
25
 
18
26
  import fs from "node:fs";
19
27
  import path from "node:path";
@@ -23,95 +31,34 @@ import { fileURLToPath } from "node:url";
23
31
  import { RuntimeWrapperWebpackPlugin } from "@lynx-js/runtime-wrapper-webpack-plugin";
24
32
  import { LynxEncodePlugin, LynxTemplatePlugin } from "@lynx-js/template-webpack-plugin";
25
33
 
26
- const PLUGIN_NAME = "mithril-lynx-template-webpack";
27
-
28
- const BACKGROUND_CANDIDATES = ["background.ts", "background.js"];
34
+ const PLUGIN_NAME = "mithril-lynx-v2-template-webpack";
29
35
  const STYLE_CANDIDATES = ["style.css"];
30
36
 
31
- // ---------------------------------------------------------------------------
32
- // Live reload (dev only)
33
- //
34
- // Two strategies, both triggered by a successful rebuild, picked by the
35
- // `liveReload` option:
36
- //
37
- // - `true` (default): the "in-bundle" client. A small WebSocket client
38
- // (src/dev-reload-client.js) is injected into a synthetic background chunk
39
- // and runs on the device; on an `ok` message it sends a CDP `Page.reload`
40
- // through `NativeModules.LynxDevToolSetModule.invokeCdp` — the same
41
- // mechanism Rspeedy's own live-reload client uses
42
- // (@lynx-js/webpack-dev-transport/lib/client/reloadApp.js) — with a
43
- // cache-busted URL. Page.reload reloads the CURRENT page in place without
44
- // starting a new Activity (verified in 0.0.8). This works over Wi-Fi alone
45
- // (the WebSocket is the only network hop): no adb, no cable. Requires the
46
- // viewer SDK to expose the DevTool CDP bridge native module (Lynx Go does).
47
- //
48
- // - `"devtool"`: the 0.0.8 strategy. Runs from the Node dev-server process
49
- // via @lynx-js/devtool-connector (adb on Android) and sends CDP
50
- // `Page.reload` to the session serving this app's bundle. Also reloads in
51
- // place (no back-stack corruption), but needs the device on adb — so it is
52
- // the fallback for viewers/SDKs where the in-bundle CDP bridge is missing.
53
- //
54
- // Why not real module HMR: mithril view code lives in the main-thread/Lepus
55
- // chunk, and rspack's hot runtime can only patch modules in the registry it
56
- // runs from — the background/JS thread. That is an architectural mismatch,
57
- // not a bug to fix. dev.hmr is forced off regardless of strategy.
58
- //
59
- // Why not lynx.reload() (0.0.9's original mechanism): on the device's engine
60
- // (reported 3.5) it re-executes the main-thread bundle against the live
61
- // session but does not re-inject the runtime helpers
62
- // removeComponents/updatePage, so the render crashes with two TypeErrors and
63
- // the background chunk never re-runs (socket dies; later builds ignored). Kept
64
- // only as an in-client fallback for viewers whose reload path is intact.
65
- //
66
- // Why not ExplorerModule.openSchema(url) (0.0.7's mechanism): it works over
67
- // Wi-Fi, but it is a real navigation — Lynx Go starts a NEW
68
- // LynxViewShellActivity every time and never finishes the one it replaces, so
69
- // Back steps through one frozen snapshot per reload. Kept only as an
70
- // in-client fallback for viewers lacking `lynx.reload`.
71
- //
72
- // Why not lynx.fetchBundle + lynx.loadScript: in lynx-core these are the
73
- // lazy-bundle path (LazyBundleLoader / LoadCustomSectionScript) — they load a
74
- // customSection of a SEPARATE lazily-fetched bundle, not the page's own
75
- // main-thread/Lepus template, so they cannot re-render the current page.
76
- // ---------------------------------------------------------------------------
77
-
78
- // The in-bundle client only runs where a full JS engine with WebSocket + the
79
- // `lynx` global exists: the background/JS thread. The main-thread/Lepus VM
80
- // cannot use it. An app without background.ts therefore gets a synthetic
81
- // background entry in development; see `includeBackground` below. Imported by
82
- // its resolved absolute path (plus the query string the client reads its
83
- // config from) rather than through the bare "mithril-lynx/dev-reload-client"
84
- // specifier + an alias: the package-wide "mithril-lynx" prefix alias set
85
- // below matches that specifier first regardless of registration order
86
- // (webpack/rspack's resolve.alias picks the first matching entry, not the
87
- // most specific one), which broke the import entirely in 0.0.7.
88
37
  const DEV_RELOAD_CLIENT_PATH = path.join(
89
38
  path.dirname(fileURLToPath(import.meta.url)),
90
39
  "src",
91
40
  "dev-reload-client.js",
92
41
  );
93
42
 
43
+ const DEV_TRANSPORT_NOOP_PATH = path.join(
44
+ path.dirname(fileURLToPath(import.meta.url)),
45
+ "src",
46
+ "dev-transport-noop.js",
47
+ );
48
+
94
49
  /**
95
50
  * Builds the query string the in-bundle dev-reload client reads its config
96
- * from: the WebSocket endpoint to connect to and the bundle URL it would
97
- * fall back to (ExplorerModule.openSchema). Mirrors what
98
- * `@lynx-js/rsbuild-plugin` bakes into `@lynx-js/webpack-dev-transport/client`.
51
+ * from — unchanged from v1's version (build-tooling glue, not part of the
52
+ * bug this rewrite is about).
99
53
  */
100
54
  function createDevReloadClientQuery(api, environment, entryName) {
101
55
  const config = environment.config ?? {};
102
56
  const dev = config.dev ?? {};
103
57
  const server = config.server ?? {};
104
58
  const devServer = api.context.devServer ?? {};
105
- // Rsbuild resolves this to the LAN address it advertises when server.host
106
- // is 0.0.0.0, which is the address a physical Lynx Go device can use.
107
59
  const hostname = dev.client?.host || devServer.hostname || server.host || "";
108
60
  const port = devServer.port ?? server.port ?? "";
109
61
  const protocol = devServer.https ? "https" : "http";
110
- // At this point in the pipeline Rsbuild hasn't started the dev server yet,
111
- // so `dev.assetPrefix` (when it's the default, host-derived one) still
112
- // contains the literal "<port>" placeholder it's only resolved to a real
113
- // port number later. `hostname`/`port` above are already the real values,
114
- // so resolve the placeholder the same way rather than trusting assetPrefix.
115
62
  const assetPrefix = (typeof dev.assetPrefix === "string" ? dev.assetPrefix : "/").replaceAll(
116
63
  "<port>",
117
64
  String(port),
@@ -134,180 +81,6 @@ function createDevReloadClientQuery(api, environment, entryName) {
134
81
  return params.toString();
135
82
  }
136
83
 
137
- // --- Devtool-connector strategy (liveReload: "devtool") --------------------
138
- // Everything below through closeDevtoolTransport()/describeReloadFailure() is
139
- // the 0.0.8 adb/CDP path, used only when the in-bundle client can't run (e.g.
140
- // a viewer whose SDK predates the DevTool CDP bridge native module). It is
141
- // otherwise dormant.
142
-
143
- /** Lynx Go's own shell page is a Lynx session too — never a reload target. */
144
- const VIEWER_SHELL_BUNDLE = "homepage.lynx.bundle";
145
-
146
- let connectorPromise;
147
- let devtoolTransport;
148
-
149
- /**
150
- * Lazily loads the DevTool connector. Kept lazy so `@lynx-js/devtool-connector`
151
- * is only ever loaded by a dev rebuild, never by a production build.
152
- */
153
- function getDevtoolConnector() {
154
- if (!connectorPromise) {
155
- connectorPromise = Promise.all([
156
- import("@lynx-js/devtool-connector"),
157
- import("@lynx-js/devtool-connector/transport"),
158
- ])
159
- .then(([{ Connector }, { AndroidTransport }]) => {
160
- devtoolTransport = new AndroidTransport();
161
- return new Connector([devtoolTransport]);
162
- })
163
- .catch((error) => {
164
- // Don't cache a rejection: the usual cause is the package not
165
- // being installed yet, and the dev server outlives an
166
- // `npm install`.
167
- connectorPromise = undefined;
168
- throw error;
169
- });
170
- }
171
- return connectorPromise;
172
- }
173
-
174
- /** Last path segment of a URL, ignoring any query string or fragment. */
175
- function bundleBasename(url) {
176
- if (typeof url !== "string") return "";
177
- const withoutQuery = url.split("?")[0].split("#")[0];
178
- const segments = withoutQuery.split("/");
179
- return segments[segments.length - 1] || withoutQuery;
180
- }
181
-
182
- function isViewerShell(url) {
183
- return bundleBasename(url) === VIEWER_SHELL_BUNDLE;
184
- }
185
-
186
- /**
187
- * Adds a unique query parameter to a bundle URL.
188
- *
189
- * `Page.reload` on its own is not enough to pick up a rebuild: measured
190
- * on-device, a reload without this re-fetched and re-ran the PREVIOUS bundle
191
- * (the loaded template stayed byte-for-byte the old one, and the old text
192
- * stayed on screen) even with `ignoreCache: true`. Both the HTTP layer and
193
- * Lynx's own bytecode cache are keyed by URL, so changing the URL is what
194
- * actually invalidates them. The session's own URL is unaffected — the DevTools
195
- * reference notes it does not change after a reload, and that was confirmed
196
- * here too.
197
- *
198
- * Returns undefined for anything that isn't an http(s) URL, in which case the
199
- * caller lets Page.reload use the URL it already has (it rejects anything else).
200
- */
201
- export function cacheBustedUrl(url, now = Date.now()) {
202
- if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return undefined;
203
- const [base, query = ""] = url.split("?");
204
- const params = new URLSearchParams(query);
205
- params.set("t", String(now));
206
- return `${base}?${params.toString()}`;
207
- }
208
-
209
- function matchesHint(url, hints) {
210
- if (typeof url !== "string" || hints.length === 0) return false;
211
- return hints.some((hint) => url.includes(hint));
212
- }
213
-
214
- /**
215
- * Chooses which client/session to reload: `targets` is `[{ client, sessions }]`,
216
- * returns `{ clientId, sessionId, url }` or null.
217
- *
218
- * Pure and exported so it can be tested without a device attached.
219
- */
220
- export function pickReloadTarget(targets, { bundleHints = [] } = {}) {
221
- const candidates = [];
222
- for (const { client, sessions } of targets) {
223
- for (const session of sessions ?? []) {
224
- if (session?.type !== "lynx") continue;
225
- if (isViewerShell(session.url)) continue;
226
- candidates.push({ clientId: client.id, session });
227
- }
228
- }
229
- if (candidates.length === 0) return null;
230
-
231
- // Prefer the session actually serving one of this app's bundles over
232
- // "whatever was opened most recently": with a second Lynx app, or a second
233
- // attached device, the newest session need not be ours.
234
- const preferred = candidates.filter((candidate) => matchesHint(candidate.session.url, bundleHints));
235
- const pool = preferred.length > 0 ? preferred : candidates;
236
-
237
- const latest = pool.reduce((a, b) => (b.session.session_id > a.session.session_id ? b : a));
238
- return {
239
- clientId: latest.clientId,
240
- sessionId: latest.session.session_id,
241
- url: latest.session.url,
242
- };
243
- }
244
-
245
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
246
-
247
- async function listClientSessions(connector) {
248
- const clients = await connector.listClients();
249
- const targets = [];
250
- for (const client of clients) {
251
- try {
252
- targets.push({ client, sessions: await connector.sendListSessionMessage(client.id) });
253
- } catch {
254
- // This client doesn't support session listing, or isn't ready yet.
255
- }
256
- }
257
- return targets;
258
- }
259
-
260
- /**
261
- * Finds a session worth reloading, retrying briefly: on the very first rebuild
262
- * the DevTool client may not have registered with the device yet.
263
- */
264
- async function findReloadTarget(bundleHints, { attempts = 3, delayMs = 400 } = {}) {
265
- const connector = await getDevtoolConnector();
266
- for (let attempt = 0; attempt < attempts; attempt++) {
267
- const target = pickReloadTarget(await listClientSessions(connector), { bundleHints });
268
- if (target) return { connector, target };
269
- if (attempt < attempts - 1) await sleep(delayMs);
270
- }
271
- return { connector, target: null };
272
- }
273
-
274
- /**
275
- * Reloads the running page in place. Returns true when a session was reloaded,
276
- * false when none was found (the caller logs that).
277
- */
278
- async function reloadViaDevtool(bundleHints) {
279
- const { connector, target } = await findReloadTarget(bundleHints);
280
- if (!target) return false;
281
-
282
- const params = { ignoreCache: true };
283
- // Without a changed URL the device replays its cached copy of the previous
284
- // bundle — see cacheBustedUrl(). The session URL itself does not change.
285
- const url = cacheBustedUrl(target.url);
286
- if (url != null) params.url = url;
287
-
288
- await connector.sendCDPMessage(target.clientId, target.sessionId, "Page.reload", params);
289
- console.info(
290
- `[mithril-lynx] Reloaded ${target.url || "(url unknown)"} (session ${target.sessionId}${url != null ? "" : ", no cache-busting: non-http url"}).`,
291
- );
292
- return true;
293
- }
294
-
295
- /** Releases the adb connection when the dev server goes away. */
296
- async function closeDevtoolTransport() {
297
- const transport = devtoolTransport;
298
- devtoolTransport = undefined;
299
- connectorPromise = undefined;
300
- await transport?.close?.();
301
- }
302
-
303
- /** Turns a connector failure into an actionable one-liner. */
304
- function describeReloadFailure(error) {
305
- if (error?.code === "ERR_MODULE_NOT_FOUND" || /devtool-connector/.test(error?.message ?? "")) {
306
- return "Live reload is off: @lynx-js/devtool-connector is not installed. Run `npm install @lynx-js/devtool-connector`, then restart the dev server.";
307
- }
308
- return `Live reload unavailable: ${error instanceof Error ? error.message : String(error)}`;
309
- }
310
-
311
84
  function findSibling(dir, candidates) {
312
85
  for (const name of candidates) {
313
86
  const candidate = path.join(dir, name);
@@ -316,18 +89,12 @@ function findSibling(dir, candidates) {
316
89
  return null;
317
90
  }
318
91
 
319
- /**
320
- * Walks up from a resolved file to the root of the package that owns it,
321
- * verified by name rather than assumed from the layout — this package's
322
- * exports map deliberately doesn't expose ./package.json, so the usual
323
- * require.resolve("<pkg>/package.json") trick isn't available here.
324
- */
325
- function packageRootOf(resolvedFile) {
92
+ function packageRootOf(resolvedFile, expectedName) {
326
93
  let dir = path.dirname(resolvedFile);
327
94
  for (let i = 0; i < 10; i++) {
328
95
  try {
329
96
  const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
330
- if (pkg.name === "mithril-lynx") return dir;
97
+ if (pkg.name === expectedName) return dir;
331
98
  } catch {
332
99
  // keep walking
333
100
  }
@@ -338,131 +105,60 @@ function packageRootOf(resolvedFile) {
338
105
  return null;
339
106
  }
340
107
 
341
- export function pluginMithrilLynx(options = {}) {
108
+ export function pluginMithrilLynxV2(options = {}) {
342
109
  const targetSdkVersion = options.targetSdkVersion ?? "3.5";
343
- const hmr = options.hmr ?? false;
344
- // `true` (default): in-bundle client over Wi-Fi via CDP Page.reload (see
345
- // src/dev-reload-client.js) — no adb. `"devtool"`: 0.0.8's adb/CDP
346
- // Page.reload path (fallback for viewers without the in-bundle CDP bridge).
347
- // `false`: off.
348
110
  const liveReload = options.liveReload ?? true;
349
- const useDevtoolReload = liveReload === "devtool";
350
-
351
- // Filled in by modifyBundlerChain below with "<entry>.bundle" for every
352
- // configured entry, so a reload prefers the session actually serving this
353
- // app over whichever Lynx session happens to be newest (devtool mode only).
354
- const bundleHints = new Set();
355
111
 
356
112
  return {
357
113
  name: PLUGIN_NAME,
358
114
  setup(api) {
359
- // Keep the template plugin discoverable by Rspeedy's Lynx internals.
360
115
  api.expose(Symbol.for("LynxTemplatePlugin"), { LynxTemplatePlugin });
361
116
 
362
- // setupApp()'s render model has no per-module "accept and patch"
363
- // story (rendering is driven by native __RenderPage/__UpdatePage
364
- // events, not by re-executing a hot-swapped module) -- module-level
365
- // HMR's eval'd *.hot-update.js chunks also aren't runtime-wrapped
366
- // the way the real background.js bundle is, and fail native-side
367
- // with "ReferenceError: exports is not defined" if hot is left on.
368
- // Force dev.hmr off (unless the app explicitly set it) -- real HMR
369
- // can't reach this framework's app code regardless of whether the
370
- // reload strategy is in-bundle (CDP Page.reload) or devtool (Node
371
- // process, CDP Page.reload), so leaving it on only adds error noise
372
- // for no benefit.
117
+ // One mode only -> dev.hmr is unconditionally on in dev (v1 had to
118
+ // detect renderer-mode-vs-not here; v2 has no "not").
373
119
  api.modifyRsbuildConfig({
374
- // Not a plain default: Rsbuild has already stamped dev.hmr:true onto
375
- // the config by the time ANY hook sees it (even api.getRsbuildConfig
376
- // ("original")), so there's no reliable way to tell "the app asked for
377
- // hot module replacement" apart from "Rsbuild defaulted it" -- this
378
- // always wins, with an explicit opt-out via pluginMithrilLynx({ hmr })
379
- // for anyone who's fixed up their own app-level accept() story and the
380
- // RuntimeWrapperWebpackPlugin gap noted below.
381
- handler: (config, { mergeRsbuildConfig }) => mergeRsbuildConfig(config, { dev: { hmr } }),
120
+ handler: (config, { mergeRsbuildConfig }) => mergeRsbuildConfig(config, { dev: { hmr: true } }),
382
121
  order: "post",
383
122
  });
384
123
 
385
- // Live reload (devtool strategy only): on every successful dev rebuild
386
- // (skipping the first), find the running Lynx session over adb and
387
- // CDP-reload it in place. The default in-bundle strategy needs none of
388
- // this — its client (injected below into a synthetic background chunk)
389
- // drives the reload itself via CDP Page.reload.
390
- if (useDevtoolReload) {
391
- api.onAfterDevCompile(async ({ isFirstCompile, stats }) => {
392
- if (isFirstCompile || stats.hasErrors()) return;
393
- let reloaded = false;
394
- try {
395
- reloaded = await reloadViaDevtool([...bundleHints]);
396
- } catch (error) {
397
- console.warn(`[mithril-lynx] ${describeReloadFailure(error)}`);
398
- return;
399
- }
400
- if (!reloaded) {
401
- console.warn(
402
- "[mithril-lynx] Live reload unavailable: no Lynx session found for this app. " +
403
- "Is the device connected over adb with the page open in Lynx Go? Reload manually.",
404
- );
405
- }
406
- });
407
-
408
- // The transport owns adb port-forwards; don't let them outlive
409
- // the dev server.
410
- api.onCloseDevServer?.(closeDevtoolTransport);
411
- }
124
+ // dev.hmr:true makes Rsbuild/Rspeedy inject its own HMR transport
125
+ // client (a second WebSocket to /rsbuild-hmr) alongside the
126
+ // in-bundle client above — re-alias it to a no-op so `module.hot`
127
+ // stays live without a competing channel (same fix v1 F2 made).
128
+ api.modifyBundlerChain({
129
+ handler: (chain, { isDev }) => {
130
+ if (!isDev) return;
131
+ chain.resolve.alias.set("@lynx-js/webpack-dev-transport/client", DEV_TRANSPORT_NOOP_PATH);
132
+ },
133
+ order: "post",
134
+ });
412
135
 
413
136
  api.modifyBundlerChain((chain, { isDev, environment }) => {
414
- // mithril-lynx's own src/lynx-mithril-shim.js deep-imports mithril's
415
- // internal render/cachedAttrsIsStaticMap.js (and its emptyAttrs
416
- // singleton). If the app's own `require("mithril")` resolves to a
417
- // DIFFERENT physical copy of the package than the one mithril-lynx
418
- // itself was installed/linked with — the norm for a `file:`-linked
419
- // local package, whose own node_modules (built for ITS OWN tests)
420
- // shadows Node's normal directory-walk resolution once linked — the
421
- // two copies' emptyAttrs singletons differ. The shim then can't
422
- // recognize the app's legitimately-reused empty-attrs object as
423
- // such, and Mithril's own updateAttrs() misfires its "Don't reuse
424
- // attrs object" warning on every plain `m(tag, null, ...)` element,
425
- // every redraw. Force a single resolution by aliasing "mithril" to
426
- // whatever copy the app itself resolves from its own project root.
427
- try {
428
- const appRequire = createRequire(path.join(process.cwd(), "package.json"));
429
- // Resolve the PACKAGE DIRECTORY (not mithril's own main entry
430
- // file) — a prefix alias needs "mithril/render/x" to rewrite to
431
- // "<dir>/render/x", which only works aliased to a directory.
432
- const mithrilDir = path.dirname(appRequire.resolve("mithril/package.json"));
433
- chain.resolve.alias.set("mithril", mithrilDir);
434
- } catch {
435
- // App has no local "mithril" resolvable from its own root —
436
- // leave resolution as-is rather than guessing.
437
- }
438
-
439
- // Same class of problem, worse symptom: mithril-lynx itself keeps
440
- // per-app state in module-level variables — the shim's rootWrapper/
441
- // redraw/runRender, main-thread.js's latestData and its cross-thread
442
- // handler maps, background.js's mirror of those. Two physical copies
443
- // means two disconnected renderers: the app renders through one, and
444
- // any LIBRARY that depends on mithril-lynx (a component library, say,
445
- // resolving its own nested copy once linked) calls shim.redraw() on
446
- // the other — whose `redraw` is still null. That's a silent no-op:
447
- // no error, nothing logged, components simply never update. Confirmed
448
- // on real hardware 2026-09-11 while building mithril-lynx-ui, where
449
- // it read as "the animation just doesn't run".
137
+ // Force a single resolved copy of "mithril-runtime" and
138
+ // "mithril-lynx-v2" — a `file:`-linked local package can
139
+ // otherwise resolve a second physical copy with its own
140
+ // module-level state (this exact class of bug bit v1 twice:
141
+ // mithril's emptyAttrs singleton, and mithril-lynx's own
142
+ // per-app render state — see mithril-lynx/plugin.js's
143
+ // comments for the on-device symptom).
450
144
  try {
451
145
  const appRequire = createRequire(path.join(process.cwd(), "package.json"));
452
- const selfDir = packageRootOf(appRequire.resolve("mithril-lynx"));
453
- if (selfDir != null) {
454
- // The bare specifier has to point at the entry FILE: aliasing it
455
- // to the directory would bypass this package's own exports map
456
- // (which has no "main" to fall back on) and fail to resolve.
457
- // The prefix alias then keeps subpaths — "mithril-lynx/main-thread"
458
- // and friends, which hold state of their own — on that same copy.
459
- chain.resolve.alias.set("mithril-lynx$", path.join(selfDir, "src", "lynx-mithril-shim.js"));
460
- chain.resolve.alias.set("mithril-lynx", selfDir);
461
- }
146
+ const mithrilDir = path.dirname(appRequire.resolve("mithril-runtime/package.json"));
147
+ chain.resolve.alias.set("mithril-runtime", mithrilDir);
462
148
  } catch {
463
- // App doesn't resolve mithril-lynx from its own root (it's being
464
- // consumed some other way) — leave resolution alone.
149
+ // App has no local "mithril-runtime" resolvable from its own root.
465
150
  }
151
+ // Note: v1 also force-aliased its OWN package name here (a
152
+ // second copy of mithril-lynx would mean two disconnected
153
+ // renderers with separate module-level state — see
154
+ // mithril-lynx/plugin.js's comment for the on-device
155
+ // symptom). v2's per-app state lives inside closures created
156
+ // by `renderApp()`/`setupRenderer()` calls, not module-level
157
+ // variables — same class of bug can't reappear the same way,
158
+ // so this dedup isn't reproduced here. Revisit if a
159
+ // multi-copy scenario (npm link, a component library
160
+ // nesting its own copy) turns up the same symptom in
161
+ // practice.
466
162
 
467
163
  const rawEntries = Object.entries(chain.entryPoints.entries() ?? {});
468
164
  chain.entryPoints.clear();
@@ -474,45 +170,25 @@ export function pluginMithrilLynx(options = {}) {
474
170
  if (typeof mtSource !== "string") continue;
475
171
 
476
172
  const dir = path.dirname(mtSource);
477
- const bgSource = findSibling(dir, BACKGROUND_CANDIDATES);
173
+ const bgSource = findSibling(dir, ["background.ts", "background.js"]);
478
174
  const cssSource = findSibling(dir, STYLE_CANDIDATES);
175
+ if (bgSource == null) {
176
+ throw new Error(
177
+ `[mithril-lynx-v2] entry "${name}": no sibling background.ts/background.js found next to ${mtSource}. ` +
178
+ "mithril-lynx-v2 has exactly one rendering mode and it always needs a background entry — see the plan's §2 non-goals.",
179
+ );
180
+ }
479
181
 
480
182
  const bgEntry = `${name}__background`;
481
183
  const mtEntry = `${name}__main-thread`;
482
184
  const bgAsset = `.rspeedy/${name}/background.js`;
483
185
  const mtAsset = `.rspeedy/${name}/main-thread.js`;
484
- const hasBackground = bgSource != null;
485
- // In dev with the in-bundle reload client, always materialize a
486
- // background chunk — even for an app with no background.ts of its
487
- // own — so the dev-reload client has somewhere to run. In
488
- // production, or when the reload is driven from the Node process
489
- // (devtool mode), keep the original behavior exactly (no
490
- // background chunk at all when the app doesn't use one).
491
- const includeBackground = hasBackground || (isDev && liveReload === true);
492
186
 
493
- // Each entry always has main-thread code and may opt into a
494
- // background thread by adding a sibling background.ts file.
495
- if (includeBackground) {
496
- // The dev-reload client is imported by its resolved absolute path
497
- // (plus the query string it reads its config from) rather than
498
- // through the bare "mithril-lynx/dev-reload-client" specifier + an
499
- // alias: the package-wide "mithril-lynx" prefix alias set above
500
- // matches that specifier first regardless of registration order
501
- // (webpack/rspack's resolve.alias picks the first matching entry,
502
- // not the most specific one), which broke the import entirely in
503
- // 0.0.7.
504
- const bgImports = isDev && liveReload === true
505
- ? [
506
- `${DEV_RELOAD_CLIENT_PATH}?${createDevReloadClientQuery(api, environment, name)}`,
507
- ...(hasBackground ? [bgSource] : []),
508
- ]
509
- : bgSource;
510
- chain.entry(bgEntry).add({
511
- import: bgImports,
512
- filename: bgAsset,
513
- });
514
- }
187
+ const bgImports = isDev && liveReload
188
+ ? [`${DEV_RELOAD_CLIENT_PATH}?${createDevReloadClientQuery(api, environment, name)}`, bgSource]
189
+ : bgSource;
515
190
 
191
+ chain.entry(bgEntry).add({ import: bgImports, filename: bgAsset });
516
192
  chain.entry(mtEntry).add({
517
193
  import: cssSource != null ? [mtSource, cssSource] : [mtSource],
518
194
  filename: mtAsset,
@@ -523,32 +199,41 @@ export function pluginMithrilLynx(options = {}) {
523
199
  ...LynxTemplatePlugin.defaultOptions,
524
200
  filename: `${name}.bundle`,
525
201
  intermediate: `.rspeedy/${name}`,
526
- chunks: includeBackground ? [bgEntry, mtEntry] : [mtEntry],
202
+ chunks: [bgEntry, mtEntry],
527
203
  dsl: "react_nodiff",
528
204
  targetSdkVersion,
529
205
  cssPlugins: [],
530
206
  },
531
207
  ]);
532
208
 
533
- // The bundle this entry produces is always "<name>.bundle"
534
- // (the filename above), and a loaded session's URL ends with
535
- // it — that is what lets a devtool-mode reload pick out this
536
- // app's session.
537
- bundleHints.add(`${name}.bundle`);
538
-
539
- if (includeBackground) {
540
- // Background chunks run in the JavaScript thread and need the
541
- // Lynx runtime wrapper; main-thread chunks are encoded as lepus.
542
- chain.plugin(`runtime-wrapper-${name}`).use(
543
- RuntimeWrapperWebpackPlugin,
544
- [
545
- {
546
- targetSdkVersion,
547
- test: new RegExp(`${name}/background\\.js$`),
548
- },
549
- ],
550
- );
551
- }
209
+ // --- F0.2 fix: the actual change this plugin exists to make ---
210
+ // v1: `test: new RegExp(`${name}/background\\.js$`)` matches
211
+ // the initial asset (`.rspeedy/<name>/background.js`, a
212
+ // nested PATH using a slash) but never a hot-update chunk
213
+ // (`<bgEntry>.<hash>.hot-update.js` — a FLAT file named
214
+ // after the webpack chunk NAME, with a double underscore,
215
+ // no slash). Verified against both real filename shapes
216
+ // with a plain regex test, not a live build, before
217
+ // writing this — see the plan's §8 F0.2 entry. Requiring
218
+ // `${name}[_/]` scopes the match to just this entry's own
219
+ // chunks (so a multi-entry app's OTHER pages' background
220
+ // chunks aren't double-wrapped by this instance) while
221
+ // still excluding the main-thread/lepus asset, which
222
+ // never contains "background".
223
+ chain.plugin(`runtime-wrapper-${name}`).use(
224
+ RuntimeWrapperWebpackPlugin,
225
+ [
226
+ {
227
+ targetSdkVersion,
228
+ test: new RegExp(`(^|/)${name}[_/].*background.*\\.js$`),
229
+ },
230
+ ],
231
+ );
232
+
233
+ console.info(
234
+ `[mithril-lynx-v2:build] entry="${name}" hmr=true liveReload=${liveReload} ` +
235
+ `bgEntry=${bgEntry} mtEntry=${mtEntry} targetSdk=${targetSdkVersion}`,
236
+ );
552
237
  }
553
238
 
554
239
  chain.plugin("encode").use(LynxEncodePlugin, []);
@@ -556,11 +241,6 @@ export function pluginMithrilLynx(options = {}) {
556
241
  chain.plugin("before-encode").use({
557
242
  apply(compiler) {
558
243
  compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
559
- // The default grouping only routes a chunk to lepus (main thread)
560
- // when its asset carries `lynx:main-thread`. These hand-built
561
- // entries don't, so main-thread JS lands in `manifest` and lepus
562
- // stays empty. Re-map it here: background JS to manifest, the
563
- // main-thread chunk to lepus. CSS is already grouped correctly.
564
244
  const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation);
565
245
  hooks.beforeEncode.tap(PLUGIN_NAME, (args) => {
566
246
  const pageName = args.intermediate ? path.basename(args.intermediate) : "";
@@ -571,24 +251,14 @@ export function pluginMithrilLynx(options = {}) {
571
251
 
572
252
  const backgroundAsset = compilation.getAsset(bgAsset);
573
253
  const mainThreadAsset = compilation.getAsset(mtAsset);
574
-
575
254
  if (!mainThreadAsset) return args;
576
255
 
577
256
  args.encodeData.compilerOptions.targetSdkVersion = targetSdkVersion;
578
257
  args.encodeData.compilerOptions.enableEventRefactor = true;
579
-
580
- // Route tap/gesture events through the refactored main-thread
581
- // path so `__AddEventListener` handlers fire. This page-config
582
- // flag was dropped from `@lynx-js/config-rsbuild-plugin` 0.2.0's
583
- // schema, so set it on the page config directly.
584
258
  args.encodeData.sourceContent.config.enableEventHandleRefactor = true;
585
259
 
586
260
  args.encodeData.manifest = backgroundAsset
587
- ? {
588
- [backgroundAsset.name]: backgroundAsset.source
589
- .source()
590
- .toString(),
591
- }
261
+ ? { [backgroundAsset.name]: backgroundAsset.source.source().toString() }
592
262
  : {};
593
263
  args.encodeData.lepusCode = {
594
264
  root: mainThreadAsset,