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.
- package/.omo/plans/m-request-fetch-lynx.md +306 -0
- package/.omo/plans/m-route-en-memoria.md +397 -0
- package/.omo/plans/mithril-lynx-v2-desde-cero.md +548 -0
- package/FETCH_INVESTIGATION.md +307 -0
- package/README.md +32 -284
- package/REQUEST.md +71 -0
- package/ROUTE.md +71 -0
- package/package.json +24 -80
- package/plugin.d.ts +4 -27
- package/plugin.js +142 -359
- package/rstest.config.ts +27 -0
- package/src/apply-patch.js +179 -0
- package/src/backends/virtual-backend.js +80 -0
- package/src/background.d.ts +11 -0
- package/src/background.js +79 -0
- package/src/channel.js +41 -0
- package/src/commit.js +67 -0
- package/src/dev-reload-client.js +245 -0
- package/src/dev-transport-noop.js +10 -0
- package/src/fake-dom.js +374 -0
- package/src/main-thread.d.ts +1 -0
- package/src/main-thread.js +68 -0
- package/src/mount-redraw.js +67 -0
- package/src/patch-protocol.js +40 -0
- package/src/reload/version.js +28 -0
- package/src/request.d.ts +37 -0
- package/src/request.js +181 -0
- package/src/route.d.ts +33 -0
- package/src/route.js +207 -0
- package/test/end-to-end.test.ts +86 -0
- package/test/reload-version.test.ts +17 -0
- package/test/request.test.ts +182 -0
- package/test/route-hot-reload.test.ts +40 -0
- package/test/route.test.ts +152 -0
- package/test/setup.ts +25 -0
- package/test/structural-reload.test.ts +95 -0
- package/CONTRACT.md +0 -151
- package/LICENSE +0 -21
- package/background.d.ts +0 -54
- package/background.js +0 -169
- package/element.d.ts +0 -34
- package/element.js +0 -83
- package/gesture.d.ts +0 -40
- package/gesture.js +0 -117
- package/internal/constants.js +0 -26
- package/internal/virtual-node.js +0 -388
- package/list.d.ts +0 -31
- package/list.js +0 -185
- package/main-thread.d.ts +0 -43
- package/main-thread.js +0 -165
- package/navigation.d.ts +0 -35
- package/navigation.js +0 -76
- package/renderer/background.d.ts +0 -21
- package/renderer/background.js +0 -84
- package/renderer/main-thread.d.ts +0 -12
- package/renderer/main-thread.js +0 -175
- package/src/lynx-mithril-shim.d.ts +0 -16
- package/src/lynx-mithril-shim.js +0 -1505
- package/src/worklet-runtime.js +0 -82
- package/testing.d.ts +0 -10
- package/testing.js +0 -91
package/plugin.js
CHANGED
|
@@ -1,226 +1,84 @@
|
|
|
1
1
|
// plugin.js
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
*
|
|
191
|
-
*
|
|
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
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 ===
|
|
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
|
|
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
|
-
//
|
|
273
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
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
|
|
362
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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:
|
|
202
|
+
chunks: [bgEntry, mtEntry],
|
|
415
203
|
dsl: "react_nodiff",
|
|
416
204
|
targetSdkVersion,
|
|
417
205
|
cssPlugins: [],
|
|
418
206
|
},
|
|
419
207
|
]);
|
|
420
208
|
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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,
|
package/rstest.config.ts
ADDED
|
@@ -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
|
+
});
|