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.
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 -302
  6. package/REQUEST.md +71 -0
  7. package/ROUTE.md +71 -0
  8. package/package.json +24 -80
  9. package/plugin.d.ts +4 -33
  10. package/plugin.js +108 -438
  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 +171 -187
  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,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
+ }
@@ -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;