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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Development-only client for Lynx Go — background thread only.
|
|
2
|
+
//
|
|
3
|
+
// Adapted from mithril-lynx v1's src/dev-reload-client.js, with the F0
|
|
4
|
+
// monkey-patch of `lynx.requireModuleAsync` REMOVED: that patch existed
|
|
5
|
+
// only because v1's `RuntimeWrapperWebpackPlugin` config never matched
|
|
6
|
+
// `.hot-update.js` chunks, so the native eval context had no `module`/
|
|
7
|
+
// `exports` bindings for them (see mithril-lynx-v2-desde-cero.md §F0.2 —
|
|
8
|
+
// the real fix is the widened `test` regex in this package's plugin.js,
|
|
9
|
+
// applied at build time to every chunk including hot-update ones, not a
|
|
10
|
+
// runtime patch of the module loader).
|
|
11
|
+
//
|
|
12
|
+
// Also REMOVED: the `__mithril_redraw` global v1 called after a successful
|
|
13
|
+
// check(). It isn't needed — the app's own background.ts calls
|
|
14
|
+
// `module.hot.accept("./index.js", ...)` and re-renders from ITS OWN
|
|
15
|
+
// closure when that fires (see the demo app's background.ts for the
|
|
16
|
+
// pattern). This client's only job is: decide whether a rebuild can be
|
|
17
|
+
// applied via HMR at all, and fall back to a full CDP `Page.reload` when
|
|
18
|
+
// it can't (structural changes, `module.hot.decline()`, or a check()
|
|
19
|
+
// rejection).
|
|
20
|
+
//
|
|
21
|
+
// One real fix over v1 that IS new here: the "race de doble-build" (two
|
|
22
|
+
// rebuilds landing close together made v1 see `hotStatus !== "idle"` and
|
|
23
|
+
// give up to a full reload even though each edit alone was light) is
|
|
24
|
+
// fixed by coalescing instead of by a version counter — see
|
|
25
|
+
// `pendingRecheckHash` below. `module.hot.check()` itself already refuses
|
|
26
|
+
// to run when not idle (real webpack behavior), so the fix has to live on
|
|
27
|
+
// this side of that call, not inside it.
|
|
28
|
+
|
|
29
|
+
var MRL = "[mrl-trace]";
|
|
30
|
+
|
|
31
|
+
function parseResourceQuery(query) {
|
|
32
|
+
var values = {};
|
|
33
|
+
if (typeof query !== "string" || !query.startsWith("?")) return values;
|
|
34
|
+
for (var i = 0, pairs = query.slice(1).split("&"); i < pairs.length; i++) {
|
|
35
|
+
var pair = pairs[i];
|
|
36
|
+
var index = pair.indexOf("=");
|
|
37
|
+
var key = index === -1 ? pair : pair.slice(0, index);
|
|
38
|
+
var value = index === -1 ? "" : pair.slice(index + 1);
|
|
39
|
+
values[key] = decodeURIComponent(value);
|
|
40
|
+
}
|
|
41
|
+
return values;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function socketURL(options) {
|
|
45
|
+
var hostname = options.hostname || "";
|
|
46
|
+
var port = options.port ? ":" + options.port : "";
|
|
47
|
+
var pathname = options.pathname || "/rsbuild-hmr";
|
|
48
|
+
var token = options.token ? "?token=" + encodeURIComponent(options.token) : "";
|
|
49
|
+
return (options.protocol || "ws") + "://" + hostname + port + pathname + token;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Lynx keys both the HTTP layer and its bytecode cache by URL — a
|
|
54
|
+
* `Page.reload` with the SAME url re-runs the previous bundle byte-for-byte
|
|
55
|
+
* even with `ignoreCache: true` (measured on-device against v1, 0.0.8).
|
|
56
|
+
*/
|
|
57
|
+
function cacheBustedUrl(url, now) {
|
|
58
|
+
if (typeof url !== "string" || !/^https?:\/\//i.test(url)) return undefined;
|
|
59
|
+
if (now === undefined) now = Date.now();
|
|
60
|
+
var parts = url.split("?");
|
|
61
|
+
var base = parts[0];
|
|
62
|
+
var query = parts[1] || "";
|
|
63
|
+
var params = new URLSearchParams(query);
|
|
64
|
+
params.set("t", String(now));
|
|
65
|
+
return base + "?" + params.toString();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
var options = parseResourceQuery(__resourceQuery);
|
|
69
|
+
var bundleUrl = options["bundle-url"];
|
|
70
|
+
|
|
71
|
+
console.info(MRL + ":0 client-boot", JSON.stringify({
|
|
72
|
+
hostname: options.hostname,
|
|
73
|
+
port: options.port,
|
|
74
|
+
protocol: options.protocol,
|
|
75
|
+
pathname: options.pathname,
|
|
76
|
+
hasToken: !!options.token,
|
|
77
|
+
bundleUrl: bundleUrl,
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
var currentHash;
|
|
81
|
+
var initialBuild = true;
|
|
82
|
+
// Anti-loop guard: without comparing against the hash the running bundle
|
|
83
|
+
// was actually built with, every boot would trigger check() for the SAME
|
|
84
|
+
// hash it already carries -> manifest 404 -> full reload -> boot -> loop.
|
|
85
|
+
var runtimeHash = typeof __webpack_require__ !== "undefined"
|
|
86
|
+
&& typeof __webpack_require__.h === "function"
|
|
87
|
+
? __webpack_require__.h()
|
|
88
|
+
: null;
|
|
89
|
+
var socket;
|
|
90
|
+
var reloading = false;
|
|
91
|
+
// Set when an "ok" for a NEW hash arrives while a check() from a PREVIOUS
|
|
92
|
+
// hash is still in flight. Instead of giving up to a full reload (v1's
|
|
93
|
+
// race), the in-flight check's `.then`/`.catch` re-drives the check logic
|
|
94
|
+
// against this hash once it settles.
|
|
95
|
+
var pendingRecheckHash;
|
|
96
|
+
|
|
97
|
+
function invokeCdpReload() {
|
|
98
|
+
var upperCase = typeof NativeModules !== "undefined" ? NativeModules.LynxDevToolSetModule : undefined;
|
|
99
|
+
var lowerCase = typeof NativeModules !== "undefined" ? NativeModules.LynxDevtoolSetModule : undefined;
|
|
100
|
+
var invokeCdp =
|
|
101
|
+
(upperCase && upperCase.invokeCdp ? upperCase.invokeCdp.bind(upperCase) : undefined) ??
|
|
102
|
+
(typeof (lowerCase && lowerCase.invokeCdp) === "function" ? lowerCase.invokeCdp.bind(lowerCase, "Page.reload") : undefined);
|
|
103
|
+
if (typeof invokeCdp !== "function") return false;
|
|
104
|
+
|
|
105
|
+
var params = { ignoreCache: true };
|
|
106
|
+
var url = cacheBustedUrl(bundleUrl);
|
|
107
|
+
if (url != null) params.url = url;
|
|
108
|
+
|
|
109
|
+
console.info(MRL + ":9 cdp-page-reload", JSON.stringify({ url: url, bundleUrl: bundleUrl }));
|
|
110
|
+
|
|
111
|
+
invokeCdp(
|
|
112
|
+
JSON.stringify({ method: "Page.reload", params }),
|
|
113
|
+
function (data) {
|
|
114
|
+
if (!data) return;
|
|
115
|
+
try {
|
|
116
|
+
var parsed = JSON.parse(data);
|
|
117
|
+
if (parsed.error) console.error("[mithril-lynx-v2] Page.reload failed:", parsed.error.message);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
// response is not JSON — ignore
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function reload(reason) {
|
|
127
|
+
console.info(MRL + ":8 reload-called", JSON.stringify({ reason: reason || "unspecified" }));
|
|
128
|
+
reloading = true;
|
|
129
|
+
if (socket) socket.close();
|
|
130
|
+
if (!invokeCdpReload()) {
|
|
131
|
+
console.warn(
|
|
132
|
+
"[mithril-lynx-v2] Live reload unavailable: NativeModules.LynxDevToolSetModule.invokeCdp was not found.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function runCheck(hash) {
|
|
138
|
+
console.info(MRL + ":5 hmr-check-calling", "module.hot.check(true)");
|
|
139
|
+
module.hot.check(true).then(function (updatedModules) {
|
|
140
|
+
console.info(MRL + ":6 hmr-check-resolved", JSON.stringify({
|
|
141
|
+
updatedModulesLength: updatedModules ? updatedModules.length : 0,
|
|
142
|
+
}));
|
|
143
|
+
var recheck = pendingRecheckHash;
|
|
144
|
+
pendingRecheckHash = undefined;
|
|
145
|
+
if (recheck && recheck !== hash) {
|
|
146
|
+
console.info(MRL + ":6d coalesced-recheck", JSON.stringify({ hash: recheck }));
|
|
147
|
+
maybeCheck(recheck);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!updatedModules || updatedModules.length === 0) {
|
|
151
|
+
console.info(MRL + ":6c hmr-no-modules", "falling back to full reload");
|
|
152
|
+
reload("hmr-check-returned-no-modules");
|
|
153
|
+
}
|
|
154
|
+
}).catch(function (err) {
|
|
155
|
+
console.warn(MRL + ":7 hmr-check-rejected", JSON.stringify({
|
|
156
|
+
message: err && err.message,
|
|
157
|
+
}));
|
|
158
|
+
var recheck = pendingRecheckHash;
|
|
159
|
+
pendingRecheckHash = undefined;
|
|
160
|
+
if (recheck) {
|
|
161
|
+
maybeCheck(recheck);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
reload("hmr-check-rejected:" + (err && err.message ? err.message : String(err)));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function maybeCheck(hash) {
|
|
169
|
+
var hasHot = typeof module !== "undefined" && module.hot;
|
|
170
|
+
var hotStatus = hasHot ? module.hot.status() : "n/a";
|
|
171
|
+
|
|
172
|
+
console.info(MRL + ":4 hmr-check", JSON.stringify({ hasModuleHot: hasHot, hotStatus: hotStatus }));
|
|
173
|
+
|
|
174
|
+
if (!hasHot) {
|
|
175
|
+
console.info(MRL + ":4a hmr-unavailable", JSON.stringify({ hasHot: hasHot }));
|
|
176
|
+
reload("hmr-unavailable:no-module-hot");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (hotStatus !== "idle") {
|
|
180
|
+
// A check for an OLDER hash is still in flight — coalesce instead of
|
|
181
|
+
// bailing to a full reload (the v1 "race de doble-build" fix).
|
|
182
|
+
pendingRecheckHash = hash;
|
|
183
|
+
console.info(MRL + ":4b hmr-check-in-flight", JSON.stringify({ queuedHash: hash }));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
runCheck(hash);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function handleMessage(rawMessage) {
|
|
190
|
+
var message;
|
|
191
|
+
try {
|
|
192
|
+
message = JSON.parse(rawMessage);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.warn("[mithril-lynx-v2] Ignoring an invalid dev-server message.");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
switch (message.type) {
|
|
199
|
+
case "hash":
|
|
200
|
+
currentHash = message.data;
|
|
201
|
+
console.info(MRL + ":2a hash-received", JSON.stringify({ hash: message.data }));
|
|
202
|
+
break;
|
|
203
|
+
case "ok":
|
|
204
|
+
console.info(MRL + ":3 ok-received", JSON.stringify({ initialBuild: initialBuild }));
|
|
205
|
+
if (initialBuild) {
|
|
206
|
+
initialBuild = false;
|
|
207
|
+
console.info(MRL + ":3a initial-build-skipped");
|
|
208
|
+
} else if (currentHash) {
|
|
209
|
+
if (runtimeHash && currentHash === runtimeHash) {
|
|
210
|
+
console.info(MRL + ":3b hash-equal-same-build");
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
maybeCheck(currentHash);
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
case "warnings":
|
|
217
|
+
if (!message.params || !message.params.preventReloading) {
|
|
218
|
+
if (!initialBuild) reload("build-warnings");
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
case "errors":
|
|
222
|
+
console.warn("[mithril-lynx-v2] Build failed; waiting for the next successful build.", message.data);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function connect(retries) {
|
|
228
|
+
if (retries === undefined) retries = 0;
|
|
229
|
+
console.info(MRL + ":1 ws-connecting", JSON.stringify({ url: socketURL(options), retry: retries }));
|
|
230
|
+
|
|
231
|
+
socket = new WebSocket(socketURL(options));
|
|
232
|
+
socket.onmessage = function (event) { handleMessage(event.data); };
|
|
233
|
+
socket.onerror = function (error) { console.error("[mithril-lynx-v2] Dev server connection error:", error); };
|
|
234
|
+
socket.onclose = function () {
|
|
235
|
+
if (reloading) return;
|
|
236
|
+
if (retries >= 10) {
|
|
237
|
+
console.error("[mithril-lynx-v2] Unable to reconnect to the dev server.");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
var delay = 1000 * Math.pow(2, retries) + Math.random() * 100;
|
|
241
|
+
setTimeout(function () { connect(retries + 1); }, delay);
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
connect();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// No-op stand-in for @lynx-js/webpack-dev-transport/client.
|
|
2
|
+
//
|
|
3
|
+
// `dev.hmr: true` makes Rsbuild/Rspeedy inject the official transport
|
|
4
|
+
// client, which opens its OWN WebSocket to /rsbuild-hmr and full-reloads
|
|
5
|
+
// via reloadApp.js — a second reload channel on top of this package's
|
|
6
|
+
// in-bundle dev-reload-client.js. plugin.js re-aliases the module here
|
|
7
|
+
// (order "post", so it wins over the alias @lynx-js/rsbuild-plugin
|
|
8
|
+
// registers) so `module.hot` stays live without a competing channel. Same
|
|
9
|
+
// fix as mithril-lynx v1's F2 (src/dev-transport-noop.js).
|
|
10
|
+
export default class DevTransportClientNoop {}
|
package/src/fake-dom.js
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// src/fake-dom.js
|
|
2
|
+
//
|
|
3
|
+
// A DOM implementation good enough for the REAL `render/render.js` (from
|
|
4
|
+
// `mithril-runtime`, https://github.com/carlos-sweb/mithril-runtime — a
|
|
5
|
+
// distribution of Mithril 2.3.8 that drops the browser-only route/trust/
|
|
6
|
+
// request APIs, with render/render.js itself otherwise unmodified from
|
|
7
|
+
// upstream, see CONTRACT.md §g) to run against — nothing more. The exact
|
|
8
|
+
// surface required is documented in `mithril-lynx/CONTRACT.md` (a prior,
|
|
9
|
+
// verified-by-grep extraction of what render.js actually touches on its
|
|
10
|
+
// `dom` parameter): createElement(NS)/createTextNode/createDocumentFragment,
|
|
11
|
+
// insertBefore/appendChild/removeChild, nodeValue, value/checked/
|
|
12
|
+
// selectedIndex, className, setAttribute/removeAttribute/setAttributeNS,
|
|
13
|
+
// style, innerHTML, textContent, firstChild, parentNode, ownerDocument,
|
|
14
|
+
// namespaceURI, contains, focus, nextSibling. render.js never calls
|
|
15
|
+
// `getAttribute` and never checks `nodeType` — so neither is implemented
|
|
16
|
+
// here.
|
|
17
|
+
//
|
|
18
|
+
// This file only runs on the BACKGROUND thread, against a `backend` that
|
|
19
|
+
// records patch ops instead of touching real elements (see
|
|
20
|
+
// backends/virtual-backend.js). The main thread never runs this file, or
|
|
21
|
+
// Mithril's render.js at all — it only replays the recorded ops through
|
|
22
|
+
// `apply-patch.js`, which calls the real Element PAPI directly. That split
|
|
23
|
+
// is the point of the whole architecture (see
|
|
24
|
+
// mithril-lynx-v2/.omo/plans/mithril-lynx-v2-desde-cero.md §3.1): only ONE
|
|
25
|
+
// side needs to be "a DOM", the other side only needs to be "a PAPI patch
|
|
26
|
+
// applier".
|
|
27
|
+
|
|
28
|
+
const DASH_CASE = /-/;
|
|
29
|
+
|
|
30
|
+
function camelToDash(name) {
|
|
31
|
+
return name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class LynxNode {
|
|
35
|
+
constructor(ownerDocument) {
|
|
36
|
+
this.ownerDocument = ownerDocument;
|
|
37
|
+
this._parent = null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get parentNode() {
|
|
41
|
+
return this._parent;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get nextSibling() {
|
|
45
|
+
if (!this._parent) return null;
|
|
46
|
+
const siblings = this._parent._children;
|
|
47
|
+
const index = siblings.indexOf(this);
|
|
48
|
+
return index === -1 ? null : (siblings[index + 1] ?? null);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Shared child-list bookkeeping for anything that can contain other nodes:
|
|
53
|
+
// real elements, fragments, and the document/root itself. `insertBefore`
|
|
54
|
+
// handles the one piece of real-DOM behavior render.js actually depends on
|
|
55
|
+
// for fragments (CONTRACT.md §c, `createDocumentFragment`): inserting a
|
|
56
|
+
// fragment moves ITS children into the target and leaves the fragment
|
|
57
|
+
// empty, rather than inserting the fragment node itself.
|
|
58
|
+
class LynxContainerNode extends LynxNode {
|
|
59
|
+
constructor(ownerDocument) {
|
|
60
|
+
super(ownerDocument);
|
|
61
|
+
this._children = [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get firstChild() {
|
|
65
|
+
return this._children[0] ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
contains(other) {
|
|
69
|
+
let node = other;
|
|
70
|
+
while (node) {
|
|
71
|
+
if (node === this) return true;
|
|
72
|
+
node = node._parent;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
appendChild(child) {
|
|
78
|
+
this.insertBefore(child, null);
|
|
79
|
+
return child;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
insertBefore(child, refChild) {
|
|
83
|
+
if (child instanceof LynxFragment) {
|
|
84
|
+
// Real DOM semantics: the fragment itself is never attached —
|
|
85
|
+
// only its (already backend-created) children are moved in, in
|
|
86
|
+
// order, then the fragment is left empty.
|
|
87
|
+
const grandchildren = child._children.slice();
|
|
88
|
+
child._children.length = 0;
|
|
89
|
+
for (const gc of grandchildren) this.insertBefore(gc, refChild);
|
|
90
|
+
return child;
|
|
91
|
+
}
|
|
92
|
+
if (child._parent) child._parent._removeChildBookkeeping(child);
|
|
93
|
+
const index = refChild ? this._children.indexOf(refChild) : -1;
|
|
94
|
+
if (index === -1) {
|
|
95
|
+
this._children.push(child);
|
|
96
|
+
} else {
|
|
97
|
+
this._children.splice(index, 0, child);
|
|
98
|
+
}
|
|
99
|
+
child._parent = this;
|
|
100
|
+
if (this._id != null && child._id != null) {
|
|
101
|
+
this._backend.insertBefore(this._id, child._id, refChild ? refChild._id : -1);
|
|
102
|
+
}
|
|
103
|
+
return child;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
removeChild(child) {
|
|
107
|
+
this._removeChildBookkeeping(child);
|
|
108
|
+
if (this._id != null && child._id != null) {
|
|
109
|
+
this._backend.removeChild(this._id, child._id);
|
|
110
|
+
}
|
|
111
|
+
return child;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_removeChildBookkeeping(child) {
|
|
115
|
+
const index = this._children.indexOf(child);
|
|
116
|
+
if (index !== -1) this._children.splice(index, 1);
|
|
117
|
+
child._parent = null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function createStyleProxy(element) {
|
|
122
|
+
const methods = {
|
|
123
|
+
setProperty(name, value) {
|
|
124
|
+
element._backend.setStyleProperty(element._id, name, String(value));
|
|
125
|
+
},
|
|
126
|
+
removeProperty(name) {
|
|
127
|
+
element._backend.removeStyleProperty(element._id, name);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
return new Proxy(methods, {
|
|
131
|
+
get(target, prop) {
|
|
132
|
+
return target[prop];
|
|
133
|
+
},
|
|
134
|
+
set(_target, prop, value) {
|
|
135
|
+
if (typeof prop !== "string") return true;
|
|
136
|
+
// Direct camelCase assignment path (CONTRACT.md §f, line 764/781).
|
|
137
|
+
// Normalized to dash-case so the backend/PAPI only ever sees one
|
|
138
|
+
// key shape regardless of which of Mithril's two style paths ran.
|
|
139
|
+
const name = DASH_CASE.test(prop) ? prop : camelToDash(prop);
|
|
140
|
+
if (value === "" || value == null) {
|
|
141
|
+
element._backend.removeStyleProperty(element._id, name);
|
|
142
|
+
} else {
|
|
143
|
+
element._backend.setStyleProperty(element._id, name, String(value));
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export class LynxElement extends LynxContainerNode {
|
|
151
|
+
constructor(ownerDocument, backend, tag, ns) {
|
|
152
|
+
super(ownerDocument);
|
|
153
|
+
this._backend = backend;
|
|
154
|
+
this.tag = tag;
|
|
155
|
+
this.namespaceURI = ns;
|
|
156
|
+
this._id = ns ? backend.createElementNS(ns, tag) : backend.createElement(tag);
|
|
157
|
+
ownerDocument._nodesById.set(this._id, this);
|
|
158
|
+
this._style = null;
|
|
159
|
+
this._listeners = Object.create(null);
|
|
160
|
+
// `hasPropertyKey` (CONTRACT.md §e) requires `"value" in vnode.dom` etc.
|
|
161
|
+
// to be true for the property-write fast path to apply to form
|
|
162
|
+
// elements — plain own properties satisfy the `in` check.
|
|
163
|
+
this.value = undefined;
|
|
164
|
+
this.checked = undefined;
|
|
165
|
+
this.selectedIndex = undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
get style() {
|
|
169
|
+
if (!this._style) this._style = createStyleProxy(this);
|
|
170
|
+
return this._style;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
set style(value) {
|
|
174
|
+
if (value == null || value === "") {
|
|
175
|
+
// `element.style = ""` (CONTRACT.md §f, lines 750-752): clear.
|
|
176
|
+
// We don't track which properties were set, so this relies on the
|
|
177
|
+
// backend/native side treating a style-reset op as "clear all" —
|
|
178
|
+
// see backends/virtual-backend.js `Op.SetStyleProperty` with a
|
|
179
|
+
// name of `*`.
|
|
180
|
+
this._backend.removeStyleProperty(this._id, "*");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (typeof value !== "object") {
|
|
184
|
+
// `element.style = "color: red"` (string passthrough, §f lines
|
|
185
|
+
// 753-755) — not supported: Lynx's style PAPI is key/value, not a
|
|
186
|
+
// CSS-text parser. Documented limitation, not a silent bug.
|
|
187
|
+
if (typeof console !== "undefined") {
|
|
188
|
+
console.warn(
|
|
189
|
+
"[mithril-lynx-v2] Assigning a CSS text string to `style` is not supported; use a style object.",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Mithril itself never assigns a plain object to `.style` directly —
|
|
195
|
+
// `updateStyle` always goes through `.setProperty`/property
|
|
196
|
+
// assignment for object styles (§f). This branch exists only for
|
|
197
|
+
// completeness against the DOM contract.
|
|
198
|
+
for (const key of Object.keys(value)) {
|
|
199
|
+
this.style[key] = value[key];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
get className() {
|
|
204
|
+
return this._className ?? "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
set className(value) {
|
|
208
|
+
// Mithril's `setAttr`/`removeAttr` map `className` -> the `"class"`
|
|
209
|
+
// attribute (CONTRACT.md §e); routed here directly since `className`
|
|
210
|
+
// is also a real property on this class (`hasPropertyKey` would
|
|
211
|
+
// otherwise be tempted to use the property path instead).
|
|
212
|
+
this._className = value;
|
|
213
|
+
this._backend.setClasses(this._id, value == null ? "" : String(value));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
setAttribute(name, value) {
|
|
217
|
+
if (name === "class") {
|
|
218
|
+
this.className = value;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this._backend.setAttribute(this._id, name, value == null ? null : String(value));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
removeAttribute(name) {
|
|
225
|
+
if (name === "class") {
|
|
226
|
+
this.className = "";
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
this._backend.removeAttribute(this._id, name);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
setAttributeNS(ns, name, value) {
|
|
233
|
+
this._backend.setAttributeNS(this._id, ns, name, value == null ? null : String(value));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
addEventListener(type, listener) {
|
|
237
|
+
const isNew = !(type in this._listeners);
|
|
238
|
+
this._listeners[type] = listener;
|
|
239
|
+
if (isNew) this._backend.addEvent(this._id, type);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
removeEventListener(type) {
|
|
243
|
+
if (!(type in this._listeners)) return;
|
|
244
|
+
delete this._listeners[type];
|
|
245
|
+
this._backend.removeEvent(this._id, type);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Invoked by the background-side event router when a forwarded native
|
|
249
|
+
* event for this element's id arrives — see background.js. Mirrors what
|
|
250
|
+
* a real DOM does automatically for an EventListener OBJECT (as opposed
|
|
251
|
+
* to a plain function) registered via addEventListener: it calls
|
|
252
|
+
* `.handleEvent(ev)` on it. Mithril's own `EventDict` (render.js) relies
|
|
253
|
+
* on exactly this. */
|
|
254
|
+
dispatchEvent(event) {
|
|
255
|
+
const listener = this._listeners[event.type];
|
|
256
|
+
if (!listener) return;
|
|
257
|
+
if (typeof listener === "function") listener.call(event.currentTarget, event);
|
|
258
|
+
else if (typeof listener.handleEvent === "function") listener.handleEvent(event);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
set textContent(value) {
|
|
262
|
+
// render.js only ever does `dom.textContent = ""` (first-render
|
|
263
|
+
// clear, CONTRACT.md §b line 898) — implemented as "remove every
|
|
264
|
+
// child", which is exactly what that assignment means for an
|
|
265
|
+
// already-empty-or-not container.
|
|
266
|
+
if (value !== "") {
|
|
267
|
+
if (typeof console !== "undefined") {
|
|
268
|
+
console.warn("[mithril-lynx-v2] Non-empty `textContent` assignment is not supported.");
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
for (const child of this._children.slice()) this.removeChild(child);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
set innerHTML(_value) {
|
|
276
|
+
// `m.trust()`/contenteditable sync (CONTRACT.md §c) — Lynx elements
|
|
277
|
+
// have no HTML-string target to parse into. Documented as
|
|
278
|
+
// unsupported, matching this project's existing stance on other
|
|
279
|
+
// browser-only Mithril features (e.g. `m.request`, see
|
|
280
|
+
// mithril-lynx/AGENTS.md history) rather than silently doing nothing
|
|
281
|
+
// with no signal.
|
|
282
|
+
if (typeof console !== "undefined") {
|
|
283
|
+
console.warn("[mithril-lynx-v2] `m.trust()` / innerHTML is not supported on Lynx elements.");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
focus() {
|
|
288
|
+
// Native `<input>` focus on Lynx is managed by the platform, not by
|
|
289
|
+
// a JS `.focus()` call reaching into the render pipeline — calling
|
|
290
|
+
// into the backend here would mean patch application could disturb
|
|
291
|
+
// focus mid-keystroke, which is the exact failure mode
|
|
292
|
+
// mithril-lynx v1 was designed around (its `<input>` deliberately
|
|
293
|
+
// has no bound `value` for the same reason). No-op by design.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export class LynxText extends LynxNode {
|
|
298
|
+
constructor(ownerDocument, backend, text) {
|
|
299
|
+
super(ownerDocument);
|
|
300
|
+
this._backend = backend;
|
|
301
|
+
this._id = backend.createText(text);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
get nodeValue() {
|
|
305
|
+
return this._text;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
set nodeValue(value) {
|
|
309
|
+
this._text = value;
|
|
310
|
+
this._backend.setText(this._id, value);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Fragments never get a backend id — see LynxContainerNode#insertBefore,
|
|
315
|
+
// which special-cases them by moving their children instead of attaching
|
|
316
|
+
// the fragment itself. `_id` stays `undefined` on purpose: the `if
|
|
317
|
+
// (this._id != null && child._id != null)` guards in insertBefore/
|
|
318
|
+
// removeChild are what keep a fragment-as-parent from ever trying to call
|
|
319
|
+
// the backend for itself.
|
|
320
|
+
export class LynxFragment extends LynxContainerNode {}
|
|
321
|
+
|
|
322
|
+
export class LynxDocument extends LynxContainerNode {
|
|
323
|
+
constructor(backend) {
|
|
324
|
+
super(null);
|
|
325
|
+
this._backend = backend;
|
|
326
|
+
this.ownerDocument = this;
|
|
327
|
+
// id 0 is reserved for "the real page container" — pre-registered by
|
|
328
|
+
// the main-thread patch applier before any ops are replayed (see
|
|
329
|
+
// apply-patch.js). Explicit and inspectable, unlike an implicit
|
|
330
|
+
// "whatever the first created element happens to be" convention.
|
|
331
|
+
this._id = 0;
|
|
332
|
+
this.namespaceURI = undefined;
|
|
333
|
+
/** id -> node, for dispatching a forwarded native event (which only
|
|
334
|
+
* carries an id + type) to the right fake-dom element. Populated by
|
|
335
|
+
* every LynxElement/LynxText constructor; never by fragments, which
|
|
336
|
+
* have no id and are never event targets. */
|
|
337
|
+
this._nodesById = new Map();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
getNodeById(id) {
|
|
341
|
+
return this._nodesById.get(id) ?? null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
focus() {
|
|
345
|
+
// Never meaningfully called on the document root itself; present so
|
|
346
|
+
// render.js's post-render focus-restoration check (CONTRACT.md §b)
|
|
347
|
+
// never throws if `activeElement` happens to resolve to the root.
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
set textContent(value) {
|
|
351
|
+
if (value !== "") return;
|
|
352
|
+
for (const child of this._children.slice()) this.removeChild(child);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
createElement(tag) {
|
|
356
|
+
return new LynxElement(this, this._backend, tag, undefined);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
createElementNS(ns, tag) {
|
|
360
|
+
return new LynxElement(this, this._backend, tag, ns);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
createTextNode(text) {
|
|
364
|
+
return new LynxText(this, this._backend, text);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
createDocumentFragment() {
|
|
368
|
+
return new LynxFragment(this);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function createLynxDocument(backend) {
|
|
373
|
+
return new LynxDocument(backend);
|
|
374
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function setupRenderer(): void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/main-thread.js
|
|
2
|
+
//
|
|
3
|
+
// Entry point for the main thread (Lepus VM). Never runs Mithril or any app
|
|
4
|
+
// view code (see background.js's header, plan §3.1) — only replays patches
|
|
5
|
+
// from the background thread onto real Element PAPI, and forwards native
|
|
6
|
+
// events back. Structure ported from mithril-lynx v1's
|
|
7
|
+
// renderer/main-thread.js (setupRenderer()), which already validated this
|
|
8
|
+
// exact __RenderPage/__DestroyLifetime timing and patch-buffering behavior
|
|
9
|
+
// on a real device (mithril-lynx/DEVICE_VERIFICATION.md) — that plumbing
|
|
10
|
+
// was never part of the bug this rewrite exists to fix.
|
|
11
|
+
|
|
12
|
+
import { createPatchApplier } from "./apply-patch.js";
|
|
13
|
+
import {
|
|
14
|
+
destroyLifetimeEventName,
|
|
15
|
+
onPatchFromBackground,
|
|
16
|
+
renderPageEventName,
|
|
17
|
+
sendEventToBackground,
|
|
18
|
+
} from "./channel.js";
|
|
19
|
+
|
|
20
|
+
// The native engine unconditionally invokes a global `processData(initData)`
|
|
21
|
+
// hook on every __RenderPage — found missing here via real-device testing
|
|
22
|
+
// in mithril-lynx v1 (its main-thread.js already had this fix; its
|
|
23
|
+
// renderer/main-thread.js needed it too). Required regardless of framework.
|
|
24
|
+
Object.assign(globalThis, {
|
|
25
|
+
processData: (data) => data,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Call once, at main-thread.ts's top level. Waits for `__RenderPage` to
|
|
30
|
+
* create the real page (the background thread's own initial render may
|
|
31
|
+
* finish before or after that fires — patches arriving early are buffered
|
|
32
|
+
* and replayed in order once the page exists), then wires the patch/event
|
|
33
|
+
* channel for the lifetime of the page.
|
|
34
|
+
*/
|
|
35
|
+
export function setupRenderer() {
|
|
36
|
+
const engine = lynx.getEngine();
|
|
37
|
+
let applier = null;
|
|
38
|
+
let pageReady = false;
|
|
39
|
+
let pendingPatches = [];
|
|
40
|
+
|
|
41
|
+
const onPatch = (event) => {
|
|
42
|
+
if (!pageReady) {
|
|
43
|
+
pendingPatches.push(event.data);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
applier.applyPatch(event.data);
|
|
47
|
+
};
|
|
48
|
+
onPatchFromBackground(onPatch);
|
|
49
|
+
|
|
50
|
+
const onRenderPage = () => {
|
|
51
|
+
const page = __CreatePage("0", 0);
|
|
52
|
+
const pageId = __GetElementUniqueID(page);
|
|
53
|
+
applier = createPatchApplier(pageId, {
|
|
54
|
+
onEvent: (id, type, nativeEvent) => sendEventToBackground(id, type, nativeEvent),
|
|
55
|
+
});
|
|
56
|
+
applier.registerPageRoot(page);
|
|
57
|
+
pageReady = true;
|
|
58
|
+
for (const ops of pendingPatches) applier.applyPatch(ops);
|
|
59
|
+
pendingPatches = [];
|
|
60
|
+
};
|
|
61
|
+
engine.addEventListener(renderPageEventName, onRenderPage);
|
|
62
|
+
|
|
63
|
+
const onDestroyLifetime = () => {
|
|
64
|
+
engine.removeEventListener(renderPageEventName, onRenderPage);
|
|
65
|
+
engine.removeEventListener(destroyLifetimeEventName, onDestroyLifetime);
|
|
66
|
+
};
|
|
67
|
+
engine.addEventListener(destroyLifetimeEventName, onDestroyLifetime);
|
|
68
|
+
}
|