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
package/plugin.js CHANGED
@@ -1,226 +1,84 @@
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";
20
28
  import { createRequire } from "node:module";
29
+ import { fileURLToPath } from "node:url";
21
30
 
22
31
  import { RuntimeWrapperWebpackPlugin } from "@lynx-js/runtime-wrapper-webpack-plugin";
23
32
  import { LynxEncodePlugin, LynxTemplatePlugin } from "@lynx-js/template-webpack-plugin";
24
33
 
25
- const PLUGIN_NAME = "mithril-lynx-template-webpack";
26
-
27
- const BACKGROUND_CANDIDATES = ["background.ts", "background.js"];
34
+ const PLUGIN_NAME = "mithril-lynx-v2-template-webpack";
28
35
  const STYLE_CANDIDATES = ["style.css"];
29
36
 
30
- // ---------------------------------------------------------------------------
31
- // Live reload (dev only)
32
- //
33
- // How a rebuild reaches the device, and why it works this way:
34
- //
35
- // - Real module HMR cannot help here at all. Mithril view code lives in the
36
- // main-thread/Lepus chunk, and rspack's hot runtime can only patch modules in
37
- // the registry it runs from — the background/JS thread. That is an
38
- // architectural mismatch, not a bug to fix.
39
- // - A CDP command the bundle sends to *itself*
40
- // (NativeModules.LynxDevToolSetModule.invokeCdp) is a silent no-op: there is
41
- // no external DevTool session behind it.
42
- // - ExplorerModule.openSchema(url) — what 0.0.7 shipped — works, but it is a
43
- // real navigation: Lynx Go starts a NEW LynxViewShellActivity every time and
44
- // never finishes the one it replaces, so Back then steps through one frozen
45
- // snapshot per reload.
46
- // - Page.reload from an *external* DevTool session reloads the existing page in
47
- // place (the DevTools reference notes the session URL is unchanged after it),
48
- // so nothing new is pushed onto the back stack.
49
- //
50
- // So this runs from the Node dev-server process rather than from a chunk
51
- // bundled into the app, and no synthetic background entry is needed.
52
- //
53
- // The trade-off: the DevTool connector reaches the device through adb, so live
54
- // reload needs the device connected over adb. openSchema could work over Wi-Fi
55
- // alone, but only by corrupting the back stack.
56
- // ---------------------------------------------------------------------------
57
-
58
- /** Lynx Go's own shell page is a Lynx session too — never a reload target. */
59
- const VIEWER_SHELL_BUNDLE = "homepage.lynx.bundle";
60
-
61
- let connectorPromise;
62
- let devtoolTransport;
63
-
64
- /**
65
- * Lazily loads the DevTool connector. Kept lazy so `@lynx-js/devtool-connector`
66
- * is only ever loaded by a dev rebuild, never by a production build.
67
- */
68
- function getDevtoolConnector() {
69
- if (!connectorPromise) {
70
- connectorPromise = Promise.all([
71
- import("@lynx-js/devtool-connector"),
72
- import("@lynx-js/devtool-connector/transport"),
73
- ])
74
- .then(([{ Connector }, { AndroidTransport }]) => {
75
- devtoolTransport = new AndroidTransport();
76
- return new Connector([devtoolTransport]);
77
- })
78
- .catch((error) => {
79
- // Don't cache a rejection: the usual cause is the package not
80
- // being installed yet, and the dev server outlives an
81
- // `npm install`.
82
- connectorPromise = undefined;
83
- throw error;
84
- });
85
- }
86
- return connectorPromise;
87
- }
88
-
89
- /** Last path segment of a URL, ignoring any query string or fragment. */
90
- function bundleBasename(url) {
91
- if (typeof url !== "string") return "";
92
- const withoutQuery = url.split("?")[0].split("#")[0];
93
- const segments = withoutQuery.split("/");
94
- return segments[segments.length - 1] || withoutQuery;
95
- }
96
-
97
- function isViewerShell(url) {
98
- return bundleBasename(url) === VIEWER_SHELL_BUNDLE;
99
- }
100
-
101
- /**
102
- * Adds a unique query parameter to a bundle URL.
103
- *
104
- * `Page.reload` on its own is not enough to pick up a rebuild: measured
105
- * on-device, a reload without this re-fetched and re-ran the PREVIOUS bundle
106
- * (the loaded template stayed byte-for-byte the old one, and the old text
107
- * stayed on screen) even with `ignoreCache: true`. Both the HTTP layer and
108
- * Lynx's own bytecode cache are keyed by URL, so changing the URL is what
109
- * actually invalidates them. The session's own URL is unaffected — the DevTools
110
- * reference notes it does not change after a reload, and that was confirmed
111
- * here too.
112
- *
113
- * Returns undefined for anything that isn't an http(s) URL, in which case the
114
- * caller lets Page.reload use the URL it already has (it rejects anything else).
115
- */
116
- export function cacheBustedUrl(url, now = Date.now()) {
117
- if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return undefined;
118
- const [base, query = ""] = url.split("?");
119
- const params = new URLSearchParams(query);
120
- params.set("t", String(now));
121
- return `${base}?${params.toString()}`;
122
- }
123
-
124
- function matchesHint(url, hints) {
125
- if (typeof url !== "string" || hints.length === 0) return false;
126
- return hints.some((hint) => url.includes(hint));
127
- }
128
-
129
- /**
130
- * Chooses which client/session to reload: `targets` is `[{ client, sessions }]`,
131
- * returns `{ clientId, sessionId, url }` or null.
132
- *
133
- * Pure and exported so it can be tested without a device attached.
134
- */
135
- export function pickReloadTarget(targets, { bundleHints = [] } = {}) {
136
- const candidates = [];
137
- for (const { client, sessions } of targets) {
138
- for (const session of sessions ?? []) {
139
- if (session?.type !== "lynx") continue;
140
- if (isViewerShell(session.url)) continue;
141
- candidates.push({ clientId: client.id, session });
142
- }
143
- }
144
- if (candidates.length === 0) return null;
145
-
146
- // Prefer the session actually serving one of this app's bundles over
147
- // "whatever was opened most recently": with a second Lynx app, or a second
148
- // attached device, the newest session need not be ours.
149
- const preferred = candidates.filter((candidate) => matchesHint(candidate.session.url, bundleHints));
150
- const pool = preferred.length > 0 ? preferred : candidates;
151
-
152
- const latest = pool.reduce((a, b) => (b.session.session_id > a.session.session_id ? b : a));
153
- return {
154
- clientId: latest.clientId,
155
- sessionId: latest.session.session_id,
156
- url: latest.session.url,
157
- };
158
- }
159
-
160
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
161
-
162
- async function listClientSessions(connector) {
163
- const clients = await connector.listClients();
164
- const targets = [];
165
- for (const client of clients) {
166
- try {
167
- targets.push({ client, sessions: await connector.sendListSessionMessage(client.id) });
168
- } catch {
169
- // This client doesn't support session listing, or isn't ready yet.
170
- }
171
- }
172
- return targets;
173
- }
37
+ const DEV_RELOAD_CLIENT_PATH = path.join(
38
+ path.dirname(fileURLToPath(import.meta.url)),
39
+ "src",
40
+ "dev-reload-client.js",
41
+ );
174
42
 
175
- /**
176
- * Finds a session worth reloading, retrying briefly: on the very first rebuild
177
- * the DevTool client may not have registered with the device yet.
178
- */
179
- async function findReloadTarget(bundleHints, { attempts = 3, delayMs = 400 } = {}) {
180
- const connector = await getDevtoolConnector();
181
- for (let attempt = 0; attempt < attempts; attempt++) {
182
- const target = pickReloadTarget(await listClientSessions(connector), { bundleHints });
183
- if (target) return { connector, target };
184
- if (attempt < attempts - 1) await sleep(delayMs);
185
- }
186
- return { connector, target: null };
187
- }
43
+ const DEV_TRANSPORT_NOOP_PATH = path.join(
44
+ path.dirname(fileURLToPath(import.meta.url)),
45
+ "src",
46
+ "dev-transport-noop.js",
47
+ );
188
48
 
189
49
  /**
190
- * Reloads the running page in place. Returns true when a session was reloaded,
191
- * false when none was found (the caller logs that).
50
+ * Builds the query string the in-bundle dev-reload client reads its config
51
+ * from — unchanged from v1's version (build-tooling glue, not part of the
52
+ * bug this rewrite is about).
192
53
  */
193
- async function reloadViaDevtool(bundleHints) {
194
- const { connector, target } = await findReloadTarget(bundleHints);
195
- if (!target) return false;
196
-
197
- const params = { ignoreCache: true };
198
- // Without a changed URL the device replays its cached copy of the previous
199
- // bundle — see cacheBustedUrl(). The session URL itself does not change.
200
- const url = cacheBustedUrl(target.url);
201
- if (url != null) params.url = url;
202
-
203
- await connector.sendCDPMessage(target.clientId, target.sessionId, "Page.reload", params);
204
- console.info(
205
- `[mithril-lynx] Reloaded ${target.url || "(url unknown)"} (session ${target.sessionId}${url != null ? "" : ", no cache-busting: non-http url"}).`,
54
+ function createDevReloadClientQuery(api, environment, entryName) {
55
+ const config = environment.config ?? {};
56
+ const dev = config.dev ?? {};
57
+ const server = config.server ?? {};
58
+ const devServer = api.context.devServer ?? {};
59
+ const hostname = dev.client?.host || devServer.hostname || server.host || "";
60
+ const port = devServer.port ?? server.port ?? "";
61
+ const protocol = devServer.https ? "https" : "http";
62
+ const assetPrefix = (typeof dev.assetPrefix === "string" ? dev.assetPrefix : "/").replaceAll(
63
+ "<port>",
64
+ String(port),
206
65
  );
207
- return true;
208
- }
209
-
210
- /** Releases the adb connection when the dev server goes away. */
211
- async function closeDevtoolTransport() {
212
- const transport = devtoolTransport;
213
- devtoolTransport = undefined;
214
- connectorPromise = undefined;
215
- await transport?.close?.();
216
- }
217
-
218
- /** Turns a connector failure into an actionable one-liner. */
219
- function describeReloadFailure(error) {
220
- if (error?.code === "ERR_MODULE_NOT_FOUND" || /devtool-connector/.test(error?.message ?? "")) {
221
- return "Live reload is off: @lynx-js/devtool-connector is not installed. Run `npm install @lynx-js/devtool-connector`, then restart the dev server.";
222
- }
223
- return `Live reload unavailable: ${error instanceof Error ? error.message : String(error)}`;
66
+ const base = /^https?:\/\//.test(assetPrefix)
67
+ ? assetPrefix
68
+ : `${protocol}://${hostname}${port ? `:${port}` : ""}${assetPrefix}`;
69
+ const clientBundleUrl = new URL(
70
+ `${entryName}.bundle`,
71
+ base.endsWith("/") ? base : `${base}/`,
72
+ ).toString();
73
+ const params = new URLSearchParams({
74
+ hostname,
75
+ port: String(port),
76
+ pathname: "/rsbuild-hmr",
77
+ protocol: devServer.https ? "wss" : "ws",
78
+ "bundle-url": clientBundleUrl,
79
+ });
80
+ if (environment.webSocketToken) params.set("token", environment.webSocketToken);
81
+ return params.toString();
224
82
  }
225
83
 
226
84
  function findSibling(dir, candidates) {
@@ -231,18 +89,12 @@ function findSibling(dir, candidates) {
231
89
  return null;
232
90
  }
233
91
 
234
- /**
235
- * Walks up from a resolved file to the root of the package that owns it,
236
- * verified by name rather than assumed from the layout — this package's
237
- * exports map deliberately doesn't expose ./package.json, so the usual
238
- * require.resolve("<pkg>/package.json") trick isn't available here.
239
- */
240
- function packageRootOf(resolvedFile) {
92
+ function packageRootOf(resolvedFile, expectedName) {
241
93
  let dir = path.dirname(resolvedFile);
242
94
  for (let i = 0; i < 10; i++) {
243
95
  try {
244
96
  const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
245
- if (pkg.name === "mithril-lynx") return dir;
97
+ if (pkg.name === expectedName) return dir;
246
98
  } catch {
247
99
  // keep walking
248
100
  }
@@ -253,125 +105,60 @@ function packageRootOf(resolvedFile) {
253
105
  return null;
254
106
  }
255
107
 
256
- export function pluginMithrilLynx(options = {}) {
108
+ export function pluginMithrilLynxV2(options = {}) {
257
109
  const targetSdkVersion = options.targetSdkVersion ?? "3.5";
258
- const hmr = options.hmr ?? false;
259
110
  const liveReload = options.liveReload ?? true;
260
111
 
261
- // Filled in by modifyBundlerChain below with "<entry>.bundle" for every
262
- // configured entry, so a reload prefers the session actually serving this
263
- // app over whichever Lynx session happens to be newest.
264
- const bundleHints = new Set();
265
-
266
112
  return {
267
113
  name: PLUGIN_NAME,
268
114
  setup(api) {
269
- // Keep the template plugin discoverable by Rspeedy's Lynx internals.
270
115
  api.expose(Symbol.for("LynxTemplatePlugin"), { LynxTemplatePlugin });
271
116
 
272
- // setupApp()'s render model has no per-module "accept and patch"
273
- // story (rendering is driven by native __RenderPage/__UpdatePage
274
- // events, not by re-executing a hot-swapped module) -- module-level
275
- // HMR's eval'd *.hot-update.js chunks also aren't runtime-wrapped
276
- // the way the real background.js bundle is, and fail native-side
277
- // with "ReferenceError: exports is not defined" if hot is left on.
278
- // Force dev.hmr off (unless the app explicitly set it) -- real HMR
279
- // can't reach this framework's app code regardless of how reload
280
- // itself is triggered (see reloadViaDevtool() above), so leaving it
281
- // on only adds that error noise 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").
282
119
  api.modifyRsbuildConfig({
283
- // Not a plain default: Rsbuild has already stamped dev.hmr:true onto
284
- // the config by the time ANY hook sees it (even api.getRsbuildConfig
285
- // ("original")), so there's no reliable way to tell "the app asked for
286
- // hot module replacement" apart from "Rsbuild defaulted it" -- this
287
- // always wins, with an explicit opt-out via pluginMithrilLynx({ hmr })
288
- // for anyone who's fixed up their own app-level accept() story and the
289
- // RuntimeWrapperWebpackPlugin gap noted below.
290
- handler: (config, { mergeRsbuildConfig }) => mergeRsbuildConfig(config, { dev: { hmr } }),
120
+ handler: (config, { mergeRsbuildConfig }) => mergeRsbuildConfig(config, { dev: { hmr: true } }),
291
121
  order: "post",
292
122
  });
293
123
 
294
- // Live reload itself: on every successful dev rebuild (skipping the
295
- // first, which is the initial build rather than an edit), find the
296
- // running Lynx session and CDP-reload it in place. See the block
297
- // above reloadViaDevtool() for why this runs from here (the Node
298
- // dev-server process) instead of from a chunk bundled into the app.
299
- if (liveReload) {
300
- api.onAfterDevCompile(async ({ isFirstCompile, stats }) => {
301
- if (isFirstCompile || stats.hasErrors()) return;
302
- let reloaded = false;
303
- try {
304
- reloaded = await reloadViaDevtool([...bundleHints]);
305
- } catch (error) {
306
- console.warn(`[mithril-lynx] ${describeReloadFailure(error)}`);
307
- return;
308
- }
309
- if (!reloaded) {
310
- console.warn(
311
- "[mithril-lynx] Live reload unavailable: no Lynx session found for this app. " +
312
- "Is the device connected over adb with the page open in Lynx Go? Reload manually.",
313
- );
314
- }
315
- });
316
-
317
- // The transport owns adb port-forwards; don't let them outlive
318
- // the dev server.
319
- api.onCloseDevServer?.(closeDevtoolTransport);
320
- }
321
-
322
- api.modifyBundlerChain((chain) => {
323
- // mithril-lynx's own src/lynx-mithril-shim.js deep-imports mithril's
324
- // internal render/cachedAttrsIsStaticMap.js (and its emptyAttrs
325
- // singleton). If the app's own `require("mithril")` resolves to a
326
- // DIFFERENT physical copy of the package than the one mithril-lynx
327
- // itself was installed/linked with — the norm for a `file:`-linked
328
- // local package, whose own node_modules (built for ITS OWN tests)
329
- // shadows Node's normal directory-walk resolution once linked — the
330
- // two copies' emptyAttrs singletons differ. The shim then can't
331
- // recognize the app's legitimately-reused empty-attrs object as
332
- // such, and Mithril's own updateAttrs() misfires its "Don't reuse
333
- // attrs object" warning on every plain `m(tag, null, ...)` element,
334
- // every redraw. Force a single resolution by aliasing "mithril" to
335
- // whatever copy the app itself resolves from its own project root.
336
- try {
337
- const appRequire = createRequire(path.join(process.cwd(), "package.json"));
338
- // Resolve the PACKAGE DIRECTORY (not mithril's own main entry
339
- // file) — a prefix alias needs "mithril/render/x" to rewrite to
340
- // "<dir>/render/x", which only works aliased to a directory.
341
- const mithrilDir = path.dirname(appRequire.resolve("mithril/package.json"));
342
- chain.resolve.alias.set("mithril", mithrilDir);
343
- } catch {
344
- // App has no local "mithril" resolvable from its own root —
345
- // leave resolution as-is rather than guessing.
346
- }
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
+ });
347
135
 
348
- // Same class of problem, worse symptom: mithril-lynx itself keeps
349
- // per-app state in module-level variables — the shim's rootWrapper/
350
- // redraw/runRender, main-thread.js's latestData and its cross-thread
351
- // handler maps, background.js's mirror of those. Two physical copies
352
- // means two disconnected renderers: the app renders through one, and
353
- // any LIBRARY that depends on mithril-lynx (a component library, say,
354
- // resolving its own nested copy once linked) calls shim.redraw() on
355
- // the other — whose `redraw` is still null. That's a silent no-op:
356
- // no error, nothing logged, components simply never update. Confirmed
357
- // on real hardware 2026-09-11 while building mithril-lynx-ui, where
358
- // it read as "the animation just doesn't run".
136
+ api.modifyBundlerChain((chain, { isDev, environment }) => {
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).
359
144
  try {
360
145
  const appRequire = createRequire(path.join(process.cwd(), "package.json"));
361
- const selfDir = packageRootOf(appRequire.resolve("mithril-lynx"));
362
- if (selfDir != null) {
363
- // The bare specifier has to point at the entry FILE: aliasing it
364
- // to the directory would bypass this package's own exports map
365
- // (which has no "main" to fall back on) and fail to resolve.
366
- // The prefix alias then keeps subpaths — "mithril-lynx/main-thread"
367
- // and friends, which hold state of their own — on that same copy.
368
- chain.resolve.alias.set("mithril-lynx$", path.join(selfDir, "src", "lynx-mithril-shim.js"));
369
- chain.resolve.alias.set("mithril-lynx", selfDir);
370
- }
146
+ const mithrilDir = path.dirname(appRequire.resolve("mithril-runtime/package.json"));
147
+ chain.resolve.alias.set("mithril-runtime", mithrilDir);
371
148
  } catch {
372
- // App doesn't resolve mithril-lynx from its own root (it's being
373
- // consumed some other way) — leave resolution alone.
149
+ // App has no local "mithril-runtime" resolvable from its own root.
374
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.
375
162
 
376
163
  const rawEntries = Object.entries(chain.entryPoints.entries() ?? {});
377
164
  chain.entryPoints.clear();
@@ -383,24 +170,25 @@ export function pluginMithrilLynx(options = {}) {
383
170
  if (typeof mtSource !== "string") continue;
384
171
 
385
172
  const dir = path.dirname(mtSource);
386
- const bgSource = findSibling(dir, BACKGROUND_CANDIDATES);
173
+ const bgSource = findSibling(dir, ["background.ts", "background.js"]);
387
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
+ }
388
181
 
389
182
  const bgEntry = `${name}__background`;
390
183
  const mtEntry = `${name}__main-thread`;
391
184
  const bgAsset = `.rspeedy/${name}/background.js`;
392
185
  const mtAsset = `.rspeedy/${name}/main-thread.js`;
393
- const hasBackground = bgSource != null;
394
186
 
395
- // Each entry always has main-thread code and may opt into a
396
- // background thread by adding a sibling background.ts file.
397
- if (hasBackground) {
398
- chain.entry(bgEntry).add({
399
- import: bgSource,
400
- filename: bgAsset,
401
- });
402
- }
187
+ const bgImports = isDev && liveReload
188
+ ? [`${DEV_RELOAD_CLIENT_PATH}?${createDevReloadClientQuery(api, environment, name)}`, bgSource]
189
+ : bgSource;
403
190
 
191
+ chain.entry(bgEntry).add({ import: bgImports, filename: bgAsset });
404
192
  chain.entry(mtEntry).add({
405
193
  import: cssSource != null ? [mtSource, cssSource] : [mtSource],
406
194
  filename: mtAsset,
@@ -411,31 +199,41 @@ export function pluginMithrilLynx(options = {}) {
411
199
  ...LynxTemplatePlugin.defaultOptions,
412
200
  filename: `${name}.bundle`,
413
201
  intermediate: `.rspeedy/${name}`,
414
- chunks: hasBackground ? [bgEntry, mtEntry] : [mtEntry],
202
+ chunks: [bgEntry, mtEntry],
415
203
  dsl: "react_nodiff",
416
204
  targetSdkVersion,
417
205
  cssPlugins: [],
418
206
  },
419
207
  ]);
420
208
 
421
- // The bundle this entry produces is always "<name>.bundle"
422
- // (the filename above), and a loaded session's URL ends with
423
- // it — that is what lets a reload pick out this app's session.
424
- bundleHints.add(`${name}.bundle`);
425
-
426
- if (hasBackground) {
427
- // Background chunks run in the JavaScript thread and need the
428
- // Lynx runtime wrapper; main-thread chunks are encoded as lepus.
429
- chain.plugin(`runtime-wrapper-${name}`).use(
430
- RuntimeWrapperWebpackPlugin,
431
- [
432
- {
433
- targetSdkVersion,
434
- test: new RegExp(`${name}/background\\.js$`),
435
- },
436
- ],
437
- );
438
- }
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
+ );
439
237
  }
440
238
 
441
239
  chain.plugin("encode").use(LynxEncodePlugin, []);
@@ -443,11 +241,6 @@ export function pluginMithrilLynx(options = {}) {
443
241
  chain.plugin("before-encode").use({
444
242
  apply(compiler) {
445
243
  compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
446
- // The default grouping only routes a chunk to lepus (main thread)
447
- // when its asset carries `lynx:main-thread`. These hand-built
448
- // entries don't, so main-thread JS lands in `manifest` and lepus
449
- // stays empty. Re-map it here: background JS to manifest, the
450
- // main-thread chunk to lepus. CSS is already grouped correctly.
451
244
  const hooks = LynxTemplatePlugin.getLynxTemplatePluginHooks(compilation);
452
245
  hooks.beforeEncode.tap(PLUGIN_NAME, (args) => {
453
246
  const pageName = args.intermediate ? path.basename(args.intermediate) : "";
@@ -458,24 +251,14 @@ export function pluginMithrilLynx(options = {}) {
458
251
 
459
252
  const backgroundAsset = compilation.getAsset(bgAsset);
460
253
  const mainThreadAsset = compilation.getAsset(mtAsset);
461
-
462
254
  if (!mainThreadAsset) return args;
463
255
 
464
256
  args.encodeData.compilerOptions.targetSdkVersion = targetSdkVersion;
465
257
  args.encodeData.compilerOptions.enableEventRefactor = true;
466
-
467
- // Route tap/gesture events through the refactored main-thread
468
- // path so `__AddEventListener` handlers fire. This page-config
469
- // flag was dropped from `@lynx-js/config-rsbuild-plugin` 0.2.0's
470
- // schema, so set it on the page config directly.
471
258
  args.encodeData.sourceContent.config.enableEventHandleRefactor = true;
472
259
 
473
260
  args.encodeData.manifest = backgroundAsset
474
- ? {
475
- [backgroundAsset.name]: backgroundAsset.source
476
- .source()
477
- .toString(),
478
- }
261
+ ? { [backgroundAsset.name]: backgroundAsset.source.source().toString() }
479
262
  : {};
480
263
  args.encodeData.lepusCode = {
481
264
  root: mainThreadAsset,
@@ -0,0 +1,27 @@
1
+ import { defineConfig } from "@rstest/core";
2
+
3
+ export default defineConfig({
4
+ testEnvironment: "jsdom",
5
+ setupFiles: [
6
+ "./test/setup.ts",
7
+ "@lynx-js/testing-environment/env/rstest",
8
+ ],
9
+ globals: true,
10
+ include: ["test/**/*.test.ts"],
11
+ tools: {
12
+ rspack: {
13
+ module: {
14
+ rules: [
15
+ // src/ is CommonJS-shaped in a "type": "module" package in a
16
+ // couple of places (none yet as of F1, kept for parity with
17
+ // v1's config in case a future file needs it) — see
18
+ // mithril-lynx/rstest.config.ts for the original rationale.
19
+ {
20
+ test: /\/src\/.*\.cjs$/,
21
+ type: "javascript/dynamic",
22
+ },
23
+ ],
24
+ },
25
+ },
26
+ },
27
+ });