mithril-lynx 0.0.9 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -302
- package/REQUEST.md +71 -0
- package/ROUTE.md +71 -0
- package/package.json +24 -80
- package/plugin.d.ts +4 -33
- package/plugin.js +108 -438
- 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 +171 -187
- 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/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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/mount-redraw.js
|
|
2
|
+
//
|
|
3
|
+
// A minimal version of real Mithril's `api/mount-redraw.js`: a shared
|
|
4
|
+
// singleton so `request.js` can trigger a redraw of whichever app is
|
|
5
|
+
// currently mounted, without needing a direct reference to that specific
|
|
6
|
+
// `renderApp()` call's handle. `background.js` registers its own
|
|
7
|
+
// `performRender` here right after creating it; `route.js` could too, but
|
|
8
|
+
// doesn't need to (it already redraws itself directly on every
|
|
9
|
+
// navigation) — this module exists specifically so `request.js` isn't
|
|
10
|
+
// coupled to "did this app mount via route() or a plain renderApp() call".
|
|
11
|
+
//
|
|
12
|
+
// Deliberately NOT a queue/pubsub of multiple mounted apps (real Mithril's
|
|
13
|
+
// mount-redraw.js supports that because a browser page can `m.mount()`
|
|
14
|
+
// several independent roots) — mithril-lynx-v2 has exactly one `renderApp()`
|
|
15
|
+
// for the app's whole lifetime (plan §3.1), so "the current redraw
|
|
16
|
+
// function" is a single slot, not a list.
|
|
17
|
+
//
|
|
18
|
+
// `redraw()` schedules instead of calling `currentRedraw()` inline — same
|
|
19
|
+
// reason real Mithril's version schedules through the platform's
|
|
20
|
+
// requestAnimationFrame instead of rendering synchronously: `request.js`'s
|
|
21
|
+
// own `promise.then(onSuccess)` calls this BEFORE the caller's own
|
|
22
|
+
// `.then()` runs (that callback is chained onto `request()`'s *returned*
|
|
23
|
+
// promise, one microtask hop further back) — a synchronous redraw here
|
|
24
|
+
// would render the screen one tick too early, before the caller has stored
|
|
25
|
+
// the response in its own state.
|
|
26
|
+
//
|
|
27
|
+
// DEVICE-CONFIRMED LYNX QUIRK (see FETCH_INVESTIGATION.md): unlike a spec
|
|
28
|
+
// browser, where a macrotask (rAF, setTimeout) is guaranteed to run only
|
|
29
|
+
// after every currently-queued microtask (including ones enqueued by other
|
|
30
|
+
// microtasks) has drained, on this Lynx background-thread runtime BOTH
|
|
31
|
+
// `lynx.setTimeout(fn, 0)` and `lynx.requestAnimationFrame(fn)` fire before
|
|
32
|
+
// even the FIRST pending microtask — confirmed with a Promise chain logging
|
|
33
|
+
// three chained `.then()`s against a 0ms/1ms/4ms/16ms timer and against
|
|
34
|
+
// `requestAnimationFrame`: the timer/rAF callback always logged first. A
|
|
35
|
+
// 50ms delay was the first value that reliably let a single `.then()`
|
|
36
|
+
// (the realistic caller shape: `request(url).then(cb)`) run first. There is
|
|
37
|
+
// no known Lynx primitive that defers "until microtasks finish" the way a
|
|
38
|
+
// spec-compliant macrotask does — this delay is an empirical safety margin,
|
|
39
|
+
// not a scheduling guarantee.
|
|
40
|
+
const REDRAW_DELAY_MS = 50;
|
|
41
|
+
|
|
42
|
+
let currentRedraw = null;
|
|
43
|
+
let pending = false;
|
|
44
|
+
|
|
45
|
+
function schedule(fn) {
|
|
46
|
+
const timer = typeof lynx !== "undefined" && typeof lynx.setTimeout === "function" ? lynx.setTimeout.bind(lynx) : setTimeout;
|
|
47
|
+
timer(fn, REDRAW_DELAY_MS);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function register(redraw) {
|
|
51
|
+
currentRedraw = redraw;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What `request.js` calls after a non-background request resolves. A
|
|
55
|
+
* no-op before any app has mounted — a request kicked off before
|
|
56
|
+
* renderApp()/route() ran has nothing to redraw yet, which isn't
|
|
57
|
+
* necessarily a bug the way calling commit() before mounting is. Multiple
|
|
58
|
+
* calls within the delay window collapse into a single scheduled render,
|
|
59
|
+
* same debounce real Mithril's `redraw()` does with its `pending` flag. */
|
|
60
|
+
export function redraw() {
|
|
61
|
+
if (pending) return;
|
|
62
|
+
pending = true;
|
|
63
|
+
schedule(() => {
|
|
64
|
+
pending = false;
|
|
65
|
+
if (currentRedraw != null) currentRedraw();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/patch-protocol.js
|
|
2
|
+
//
|
|
3
|
+
// The wire vocabulary between the background thread (real Mithril diff,
|
|
4
|
+
// running against a virtual tree) and the main thread (applies the patch to
|
|
5
|
+
// real Lynx elements). Adopted from ReactLynx's `SnapshotOperation` pattern
|
|
6
|
+
// (see rspeedy-react-analysis/LYNX_PAPI_SPEC.md §4.3): a FLAT array of
|
|
7
|
+
// numbers/strings/values, not an array of `{op, ...}` objects — cheaper to
|
|
8
|
+
// serialize, and a pattern already proven in production at ReactLynx's scale.
|
|
9
|
+
//
|
|
10
|
+
// Every op is `[opcode, ...args]` concatenated into one flat array. `id`
|
|
11
|
+
// below always refers to the integer handle a node was given by
|
|
12
|
+
// `createVirtualBackend()` — the SAME id space is mirrored 1:1 on the
|
|
13
|
+
// main-thread side by `applyPatch()` (see backends/papi-backend.js), so
|
|
14
|
+
// nodes never need to be looked up by anything other than that integer.
|
|
15
|
+
|
|
16
|
+
export const Op = Object.freeze({
|
|
17
|
+
CreateElement: 0,
|
|
18
|
+
CreateElementNS: 1,
|
|
19
|
+
CreateText: 2,
|
|
20
|
+
CreateFragment: 3,
|
|
21
|
+
InsertBefore: 4, // parentId, childId, refId(-1 = append)
|
|
22
|
+
RemoveChild: 5, // parentId, childId
|
|
23
|
+
SetAttribute: 6, // id, name, value
|
|
24
|
+
RemoveAttribute: 7, // id, name
|
|
25
|
+
SetAttributeNS: 8, // id, ns, name, value
|
|
26
|
+
SetStyleProperty: 9, // id, name, value (dash-case, via setProperty semantics)
|
|
27
|
+
RemoveStyleProperty: 10, // id, name
|
|
28
|
+
SetText: 11, // id, value (nodeValue on a text node)
|
|
29
|
+
AddEvent: 12, // id, type
|
|
30
|
+
RemoveEvent: 13, // id, type
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Encodes one op onto a flat ops array. Kept as a tiny helper (not a class)
|
|
35
|
+
* so the hot path (called on every attribute/child mutation during a real
|
|
36
|
+
* Mithril diff) is just array pushes — no object allocation per op.
|
|
37
|
+
*/
|
|
38
|
+
export function pushOp(ops, opcode, ...args) {
|
|
39
|
+
ops.push(opcode, ...args);
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/reload/version.js
|
|
2
|
+
//
|
|
3
|
+
// Race guard for concurrent hot-updates (method A/B, reload/hot-accept.js).
|
|
4
|
+
// v1 had this bug for real: two rebuilds landing close together made the
|
|
5
|
+
// dev client see a `module.hot.check()` still in flight and degrade to a
|
|
6
|
+
// full reload EVEN THOUGH each edit individually would have been light
|
|
7
|
+
// (mithril-lynx/.omo/plans/arquitectura-dual-reload.md, F1 "race de
|
|
8
|
+
// doble-build"). ReactLynx doesn't avoid the race with a status flag at
|
|
9
|
+
// all — it lets both builds' patches land in whatever order they arrive,
|
|
10
|
+
// and discards any patch whose `reloadVersion` is older than the current
|
|
11
|
+
// one (rspeedy-react-analysis/LYNX_PAPI_SPEC.md §5.1). This is that same
|
|
12
|
+
// counter, adopted directly rather than re-deriving a flag-based guard.
|
|
13
|
+
|
|
14
|
+
let version = 0;
|
|
15
|
+
|
|
16
|
+
export function getReloadVersion() {
|
|
17
|
+
return version;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function increaseReloadVersion() {
|
|
21
|
+
return ++version;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True if a patch stamped with `patchVersion` is stale and must be
|
|
25
|
+
* dropped without being applied — the ENTIRE guard, one comparison. */
|
|
26
|
+
export function isStaleVersion(patchVersion) {
|
|
27
|
+
return typeof patchVersion === "number" && patchVersion < version;
|
|
28
|
+
}
|
package/src/request.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface RequestOptions<T = any> {
|
|
2
|
+
method?: string;
|
|
3
|
+
url?: string;
|
|
4
|
+
params?: Record<string, unknown>;
|
|
5
|
+
body?: unknown;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
timeout?: number;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
responseType?: "json" | "text";
|
|
10
|
+
serialize?: (data: unknown) => string;
|
|
11
|
+
deserialize?: (data: unknown) => unknown;
|
|
12
|
+
extract?: (response: unknown, options: RequestOptions<T>) => unknown;
|
|
13
|
+
type?: new (data: any) => T;
|
|
14
|
+
background?: boolean;
|
|
15
|
+
// Present on the real m.request signature but confirmed unsupported —
|
|
16
|
+
// listed here (rather than omitted) so passing one is a type error at
|
|
17
|
+
// the call site, not a surprise at runtime.
|
|
18
|
+
config?: never;
|
|
19
|
+
async?: never;
|
|
20
|
+
user?: never;
|
|
21
|
+
password?: never;
|
|
22
|
+
withCredentials?: never;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RequestPromise<T> extends Promise<T> {
|
|
26
|
+
/** Not part of real m.request's API — free to add since Lynx's
|
|
27
|
+
* AbortController makes it a real, working cancellation, unlike the
|
|
28
|
+
* `config`-only escape hatch losing `config` takes away. */
|
|
29
|
+
abort(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Request = <T = any>(url: string | RequestOptions<T>, options?: RequestOptions<T>) => RequestPromise<T>;
|
|
33
|
+
|
|
34
|
+
export function createRequestor(fetchImpl?: (url: string, init: RequestInit) => Promise<Response>): Request;
|
|
35
|
+
|
|
36
|
+
declare const request: Request;
|
|
37
|
+
export default request;
|