mithril-lynx 0.0.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.omo/plans/m-request-fetch-lynx.md +306 -0
  2. package/.omo/plans/m-route-en-memoria.md +397 -0
  3. package/.omo/plans/mithril-lynx-v2-desde-cero.md +548 -0
  4. package/FETCH_INVESTIGATION.md +307 -0
  5. package/README.md +32 -284
  6. package/REQUEST.md +71 -0
  7. package/ROUTE.md +71 -0
  8. package/package.json +24 -80
  9. package/plugin.d.ts +4 -27
  10. package/plugin.js +142 -359
  11. package/rstest.config.ts +27 -0
  12. package/src/apply-patch.js +179 -0
  13. package/src/backends/virtual-backend.js +80 -0
  14. package/src/background.d.ts +11 -0
  15. package/src/background.js +79 -0
  16. package/src/channel.js +41 -0
  17. package/src/commit.js +67 -0
  18. package/src/dev-reload-client.js +245 -0
  19. package/src/dev-transport-noop.js +10 -0
  20. package/src/fake-dom.js +374 -0
  21. package/src/main-thread.d.ts +1 -0
  22. package/src/main-thread.js +68 -0
  23. package/src/mount-redraw.js +67 -0
  24. package/src/patch-protocol.js +40 -0
  25. package/src/reload/version.js +28 -0
  26. package/src/request.d.ts +37 -0
  27. package/src/request.js +181 -0
  28. package/src/route.d.ts +33 -0
  29. package/src/route.js +207 -0
  30. package/test/end-to-end.test.ts +86 -0
  31. package/test/reload-version.test.ts +17 -0
  32. package/test/request.test.ts +182 -0
  33. package/test/route-hot-reload.test.ts +40 -0
  34. package/test/route.test.ts +152 -0
  35. package/test/setup.ts +25 -0
  36. package/test/structural-reload.test.ts +95 -0
  37. package/CONTRACT.md +0 -151
  38. package/LICENSE +0 -21
  39. package/background.d.ts +0 -54
  40. package/background.js +0 -169
  41. package/element.d.ts +0 -34
  42. package/element.js +0 -83
  43. package/gesture.d.ts +0 -40
  44. package/gesture.js +0 -117
  45. package/internal/constants.js +0 -26
  46. package/internal/virtual-node.js +0 -388
  47. package/list.d.ts +0 -31
  48. package/list.js +0 -185
  49. package/main-thread.d.ts +0 -43
  50. package/main-thread.js +0 -165
  51. package/navigation.d.ts +0 -35
  52. package/navigation.js +0 -76
  53. package/renderer/background.d.ts +0 -21
  54. package/renderer/background.js +0 -84
  55. package/renderer/main-thread.d.ts +0 -12
  56. package/renderer/main-thread.js +0 -175
  57. package/src/lynx-mithril-shim.d.ts +0 -16
  58. package/src/lynx-mithril-shim.js +0 -1505
  59. package/src/worklet-runtime.js +0 -82
  60. package/testing.d.ts +0 -10
  61. package/testing.js +0 -91
@@ -0,0 +1,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 {}
@@ -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
+ }