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,179 @@
1
+ // src/apply-patch.js
2
+ //
3
+ // The main-thread half of the patch protocol. Deliberately NOT a DOM — it
4
+ // never runs Mithril's render.js (only the background thread does, see
5
+ // background.js) — it is a direct, low-level interpreter of the flat op
6
+ // array straight onto the real Element PAPI, in the spirit of ReactLynx's
7
+ // own `snapshotPatchApply.js` (see rspeedy-react-analysis/LYNX_PAPI_SPEC.md
8
+ // §4.3): a switch over op codes, one real PAPI call per case, nothing else.
9
+ //
10
+ // The exact `__Create*`/pageId contract below (one `pageId` shared by every
11
+ // element on a page, `__CreateView`/`__CreateText`/generic `__CreateElement`
12
+ // for tags, `__CreateRawText` + `__SetAttribute(id,"text",v)` for raw text
13
+ // content) is not a guess — it's the same contract `mithril-lynx/CONTRACT.md`
14
+ // + `mithril-lynx/src/lynx-mithril-shim.js` already validated on a real
15
+ // device (see mithril-lynx/DEVICE_VERIFICATION.md). Reusing a validated
16
+ // mapping here is exactly the kind of "concept, not code" reuse the v2 plan
17
+ // allows (§2 non-goals) — the bug we're rewriting away lives in the
18
+ // commit/reload layer (commit.js, reload/*.js), never in this mapping.
19
+
20
+ import { Op } from "./patch-protocol.js";
21
+
22
+ /**
23
+ * @param {number} pageId - `__GetElementUniqueID(pageElement)` of the real
24
+ * page this applier is attached to. Every element this applier creates
25
+ * belongs to that one page — see CONTRACT.md / lynx-mithril-shim.js.
26
+ */
27
+ export function createPatchApplier(pageId, { onEvent } = {}) {
28
+ // id (as allocated by the background's virtual backend) -> real PAPI
29
+ // element handle. id 0 is reserved for "the page itself" (see
30
+ // fake-dom.js's LynxDocument) — pre-seeded here so the very first
31
+ // InsertBefore/AppendChild targeting id 0 has somewhere real to land.
32
+ const handles = new Map();
33
+
34
+ function registerPageRoot(pageElementHandle) {
35
+ handles.set(0, pageElementHandle);
36
+ }
37
+
38
+ function createElementHandle(tag) {
39
+ if (tag === "view") return __CreateView(pageId);
40
+ if (tag === "text") return __CreateText(pageId);
41
+ return __CreateElement(tag, pageId, {});
42
+ }
43
+
44
+ /**
45
+ * Applies one commit's worth of ops, then flushes exactly once —
46
+ * `__FlushElementTree` is the real commit; nothing before it is visible.
47
+ * This function itself is the ONLY caller of `__FlushElementTree` on
48
+ * this applier's page — never called conditionally, never looked up
49
+ * through a global (mirrors the fix in commit.js on the background
50
+ * side: one explicit call site, not an implicit one).
51
+ */
52
+ function applyPatch(ops) {
53
+ for (let i = 0; i < ops.length; ) {
54
+ const opcode = ops[i++];
55
+ switch (opcode) {
56
+ case Op.CreateElement: {
57
+ const tag = ops[i++];
58
+ const id = ops[i++];
59
+ handles.set(id, createElementHandle(tag));
60
+ break;
61
+ }
62
+ case Op.CreateElementNS: {
63
+ // Lynx has no XML-namespaced element PAPI distinct from
64
+ // the generic one — CONTRACT.md's createElementNS exists
65
+ // only to satisfy render.js's SVG/MathML path, which
66
+ // mithril-lynx apps don't exercise (no SVG on Lynx).
67
+ const _ns = ops[i++];
68
+ const tag = ops[i++];
69
+ const id = ops[i++];
70
+ handles.set(id, createElementHandle(tag));
71
+ break;
72
+ }
73
+ case Op.CreateText: {
74
+ const value = ops[i++];
75
+ const id = ops[i++];
76
+ handles.set(id, __CreateRawText(String(value)));
77
+ break;
78
+ }
79
+ case Op.InsertBefore: {
80
+ const parentId = ops[i++];
81
+ const childId = ops[i++];
82
+ const refId = ops[i++];
83
+ const parent = handles.get(parentId);
84
+ const child = handles.get(childId);
85
+ if (refId === -1) {
86
+ __AppendElement(parent, child);
87
+ } else {
88
+ __InsertElementBefore(parent, child, handles.get(refId));
89
+ }
90
+ break;
91
+ }
92
+ case Op.RemoveChild: {
93
+ const parentId = ops[i++];
94
+ const childId = ops[i++];
95
+ __RemoveElement(handles.get(parentId), handles.get(childId));
96
+ handles.delete(childId);
97
+ break;
98
+ }
99
+ case Op.SetAttribute: {
100
+ const id = ops[i++];
101
+ const name = ops[i++];
102
+ const value = ops[i++];
103
+ const handle = handles.get(id);
104
+ if (name === "class") __SetClasses(handle, value == null ? "" : value);
105
+ else __SetAttribute(handle, name, value);
106
+ break;
107
+ }
108
+ case Op.RemoveAttribute: {
109
+ const id = ops[i++];
110
+ const name = ops[i++];
111
+ const handle = handles.get(id);
112
+ if (name === "class") __SetClasses(handle, "");
113
+ else __SetAttribute(handle, name, null);
114
+ break;
115
+ }
116
+ case Op.SetAttributeNS: {
117
+ const id = ops[i++];
118
+ const _ns = ops[i++];
119
+ const name = ops[i++];
120
+ const value = ops[i++];
121
+ __SetAttribute(handles.get(id), name, value);
122
+ break;
123
+ }
124
+ case Op.SetStyleProperty: {
125
+ const id = ops[i++];
126
+ const name = ops[i++];
127
+ const value = ops[i++];
128
+ __AddInlineStyle(handles.get(id), name, value);
129
+ break;
130
+ }
131
+ case Op.RemoveStyleProperty: {
132
+ const id = ops[i++];
133
+ const name = ops[i++];
134
+ // `"*"` is fake-dom.js's encoding of `element.style = ""`
135
+ // (clear everything) — there is no bulk-clear PAPI call
136
+ // validated yet, so this case is a documented gap for
137
+ // F3, not a silent no-op: it throws so the gap surfaces
138
+ // as a test failure rather than a mystery on-device.
139
+ if (name === "*") {
140
+ throw new Error(
141
+ "[mithril-lynx-v2] Clearing the whole `style` object at once is not implemented yet (F3 TODO) — set individual properties to \"\" instead.",
142
+ );
143
+ }
144
+ __AddInlineStyle(handles.get(id), name, "");
145
+ break;
146
+ }
147
+ case Op.SetText: {
148
+ const id = ops[i++];
149
+ const value = ops[i++];
150
+ __SetAttribute(handles.get(id), "text", value);
151
+ break;
152
+ }
153
+ case Op.AddEvent: {
154
+ const id = ops[i++];
155
+ const type = ops[i++];
156
+ const handle = handles.get(id);
157
+ __AddEventListener(handle, type, (nativeEvent) => {
158
+ onEvent?.(id, type, nativeEvent);
159
+ }, {});
160
+ break;
161
+ }
162
+ case Op.RemoveEvent: {
163
+ // PAPI has no documented `__RemoveEventListener` in the
164
+ // validated v1 surface (CONTRACT.md never needed it,
165
+ // since mithril-lynx v1 never tore down individual
166
+ // listeners outside of removing the whole element).
167
+ // Left as an explicit no-op + TODO rather than a guess.
168
+ i += 2;
169
+ break;
170
+ }
171
+ default:
172
+ throw new Error(`[mithril-lynx-v2] Unknown patch opcode: ${opcode}`);
173
+ }
174
+ }
175
+ __FlushElementTree();
176
+ }
177
+
178
+ return { registerPageRoot, applyPatch };
179
+ }
@@ -0,0 +1,80 @@
1
+ // src/backends/virtual-backend.js
2
+ //
3
+ // The background-thread half of the patch protocol: `fake-dom.js` calls
4
+ // these methods instead of touching real elements, and every call appends
5
+ // one op to a flat array (see ../patch-protocol.js for the op format and
6
+ // why it's flat). `takeOps()` is called once per commit (see ../commit.js)
7
+ // to drain the array for sending across the thread boundary.
8
+
9
+ import { Op, pushOp } from "../patch-protocol.js";
10
+
11
+ export function createVirtualBackend() {
12
+ let nextId = 1;
13
+ let ops = [];
14
+
15
+ return {
16
+ // Exposed for tests that want to assert on id allocation directly;
17
+ // application code should never need it.
18
+ allocId() {
19
+ return nextId++;
20
+ },
21
+ createElement(tag) {
22
+ const id = nextId++;
23
+ pushOp(ops, Op.CreateElement, tag, id);
24
+ return id;
25
+ },
26
+ createElementNS(ns, tag) {
27
+ const id = nextId++;
28
+ pushOp(ops, Op.CreateElementNS, ns, tag, id);
29
+ return id;
30
+ },
31
+ createText(text) {
32
+ const id = nextId++;
33
+ pushOp(ops, Op.CreateText, text, id);
34
+ return id;
35
+ },
36
+ insertBefore(parentId, childId, refId) {
37
+ pushOp(ops, Op.InsertBefore, parentId, childId, refId ?? -1);
38
+ },
39
+ removeChild(parentId, childId) {
40
+ pushOp(ops, Op.RemoveChild, parentId, childId);
41
+ },
42
+ setAttribute(id, name, value) {
43
+ pushOp(ops, Op.SetAttribute, id, name, value);
44
+ },
45
+ removeAttribute(id, name) {
46
+ pushOp(ops, Op.RemoveAttribute, id, name);
47
+ },
48
+ setAttributeNS(id, ns, name, value) {
49
+ pushOp(ops, Op.SetAttributeNS, id, ns, name, value);
50
+ },
51
+ setClasses(id, value) {
52
+ // Encoded as a SetAttribute on the synthetic "class" name — the
53
+ // patch applier special-cases that name into `__SetClasses`, the
54
+ // same split fake-dom.js already does on the way in.
55
+ pushOp(ops, Op.SetAttribute, id, "class", value);
56
+ },
57
+ setStyleProperty(id, name, value) {
58
+ pushOp(ops, Op.SetStyleProperty, id, name, value);
59
+ },
60
+ removeStyleProperty(id, name) {
61
+ pushOp(ops, Op.RemoveStyleProperty, id, name);
62
+ },
63
+ setText(id, value) {
64
+ pushOp(ops, Op.SetText, id, value);
65
+ },
66
+ addEvent(id, type) {
67
+ pushOp(ops, Op.AddEvent, id, type);
68
+ },
69
+ removeEvent(id, type) {
70
+ pushOp(ops, Op.RemoveEvent, id, type);
71
+ },
72
+ /** Drains and returns the accumulated ops. Called once per commit. */
73
+ takeOps() {
74
+ if (ops.length === 0) return null;
75
+ const out = ops;
76
+ ops = [];
77
+ return out;
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,11 @@
1
+ export interface RenderAppOptions {
2
+ root: () => unknown;
3
+ sendPatch?: (ops: unknown[]) => void;
4
+ }
5
+
6
+ export interface RenderAppHandle {
7
+ redraw: () => void;
8
+ document: unknown;
9
+ }
10
+
11
+ export function renderApp(options: RenderAppOptions): RenderAppHandle;
@@ -0,0 +1,79 @@
1
+ // src/background.js
2
+ //
3
+ // Entry point for the background (JS) thread — the ONLY thread that ever
4
+ // runs a component's view() or Mithril's diff (architecture decision
5
+ // §3.1 of the plan: one thread model, not three like v1 had).
6
+ //
7
+ // The redraw contract, precisely: `render(dom, vnodes, redraw)`'s third
8
+ // argument is what real Mithril calls automatically after any event
9
+ // (CONTRACT.md §e, `EventDict.handleEvent`) — that is the ENTIRE mechanism
10
+ // by which "a tap handler that mutates state repaints the screen" works,
11
+ // with no cooperation from app code. v1's bug was that the function on the
12
+ // other end of that call was sometimes undefined depending on load order;
13
+ // here it is `performRender` — a plain closure, captured once, always the
14
+ // same reference, never looked up through a global.
15
+
16
+ import renderFactory from "mithril-runtime/render/render.js";
17
+ import { createLynxDocument } from "./fake-dom.js";
18
+ import { createVirtualBackend } from "./backends/virtual-backend.js";
19
+ import { createCommitController } from "./commit.js";
20
+ import { onEventFromMainThread, sendPatchToMainThread } from "./channel.js";
21
+ import { register as registerRedraw } from "./mount-redraw.js";
22
+
23
+ /**
24
+ * @param {object} options
25
+ * @param {() => unknown} options.root - Returns the current top-level vnode
26
+ * (a fresh hyperscript tree). Called on every render pass, including the
27
+ * very first — there is no separate "mount" vnode.
28
+ * @param {(ops: unknown[]) => void} [options.sendPatch] - Ships one commit's
29
+ * worth of ops across the thread boundary. Defaults to the real channel
30
+ * (channel.js, F0.1's decision) — tests override it to capture ops
31
+ * in-process instead.
32
+ */
33
+ export function renderApp({ root, sendPatch = sendPatchToMainThread }) {
34
+ const backend = createVirtualBackend();
35
+ const document = createLynxDocument(backend);
36
+ const render = renderFactory();
37
+ const commitController = createCommitController();
38
+
39
+ function flush() {
40
+ const ops = backend.takeOps();
41
+ if (ops) sendPatch(ops);
42
+ }
43
+ commitController.install(flush);
44
+
45
+ // This is Mithril's actual redraw service pattern (the same shape as
46
+ // upstream `mithril/api/mount-redraw.js`'s internal `run()`): the
47
+ // "redraw" callback IS "call render() again", not "send a patch"
48
+ // directly — flushing is a side effect of every render pass completing,
49
+ // whether that pass was the first one, an auto-redraw after an event, a
50
+ // manual `redraw()` call, or a hot-update re-render (see reload/*.js).
51
+ function performRender() {
52
+ render(document, root(), performRender);
53
+ commitController.commit();
54
+ }
55
+
56
+ performRender();
57
+ registerRedraw(performRender);
58
+
59
+ // Wires every forwarded native event straight to the fake-dom node it
60
+ // targets — `dispatchEvent` (fake-dom.js) then invokes Mithril's own
61
+ // EventDict exactly as a real DOM would, and (per the contract at the
62
+ // top of this file) `performRender` auto-fires afterward if the
63
+ // handler doesn't opt out. This is the ONLY consumer of
64
+ // `onEventFromMainThread` — app code never touches the channel directly.
65
+ onEventFromMainThread((event) => {
66
+ const { id, type, payload } = event.data;
67
+ const node = document.getNodeById(id);
68
+ if (!node) return;
69
+ node.dispatchEvent({ type, currentTarget: node, preventDefault() {}, stopPropagation() {}, ...payload });
70
+ });
71
+
72
+ return {
73
+ redraw: performRender,
74
+ /** Exposed for tests and for an app's `module.hot.accept` glue (see
75
+ * the demo app's background.ts) — never needed by the channel
76
+ * wiring above, which is already fully self-contained. */
77
+ document,
78
+ };
79
+ }
package/src/channel.js ADDED
@@ -0,0 +1,41 @@
1
+ // src/channel.js
2
+ //
3
+ // The cross-thread transport (F0.1's decision, plan §3.2). Uses the exact
4
+ // same mechanism mithril-lynx v1's already-on-device-verified renderer mode
5
+ // used (`lynx.getCoreContext()`/`lynx.getJSContext()` event pub/sub with a
6
+ // namespaced event name) rather than the untested `lynx.triggerLepusGlobalEvent`
7
+ // candidate F0.1 turned up — that candidate is real and worth trying in a
8
+ // later iteration (see the plan's §8 F0.1 note), but shipping a first real
9
+ // device build on a validated channel was the priority for F3, not
10
+ // re-verifying the channel choice from scratch.
11
+ //
12
+ // Naming note: `getCoreContext()` (called from the background thread) and
13
+ // `getJSContext()` (called from the main thread) are two different global
14
+ // accessors for the SAME underlying native pub/sub context — confirmed by
15
+ // v1's renderer/background.js and renderer/main-thread.js dispatching to
16
+ // and listening on each other via exactly this pairing.
17
+
18
+ export const patchEventName = "MithrilLynxV2:Patch";
19
+ export const eventFromMainThreadEventName = "MithrilLynxV2:Event";
20
+ export const renderPageEventName = "__RenderPage";
21
+ export const destroyLifetimeEventName = "__DestroyLifetime";
22
+
23
+ /** Background thread: ship one commit's ops to the main thread. */
24
+ export function sendPatchToMainThread(ops) {
25
+ lynx.getCoreContext().dispatchEvent({ type: patchEventName, data: ops });
26
+ }
27
+
28
+ /** Background thread: receive a forwarded native event `{ id, type, payload }`. */
29
+ export function onEventFromMainThread(handler) {
30
+ lynx.getCoreContext().addEventListener(eventFromMainThreadEventName, handler);
31
+ }
32
+
33
+ /** Main thread: receive a patch (an ops array) from the background thread. */
34
+ export function onPatchFromBackground(handler) {
35
+ lynx.getJSContext().addEventListener(patchEventName, handler);
36
+ }
37
+
38
+ /** Main thread: forward a native event on element `id` back to the background thread. */
39
+ export function sendEventToBackground(id, type, payload) {
40
+ lynx.getJSContext().dispatchEvent({ type: eventFromMainThreadEventName, data: { id, type, payload } });
41
+ }
package/src/commit.js ADDED
@@ -0,0 +1,67 @@
1
+ // src/commit.js
2
+ //
3
+ // The single, explicit, non-conditional flush contract — this is the actual
4
+ // fix for the regression that motivated the whole v2 rewrite (see
5
+ // mithril-lynx-v2-desde-cero.md §3.4 and mithril-lynx/AGENTS.md's "Estado
6
+ // actual" section for the v1 postmortem).
7
+ //
8
+ // v1's bug in one sentence: whether a redraw actually reached the main
9
+ // thread depended on `typeof globalThis.__FlushElementTree === "function"`
10
+ // — a question whose answer depended on thread/test/mode ordering. That is
11
+ // a race condition baked into the architecture, not an edge case to patch.
12
+ //
13
+ // v2's rule: there is exactly one commit callback for the lifetime of one
14
+ // `renderApp()` call (see background.js). It is installed explicitly, once,
15
+ // by the code that owns the render — never discovered implicitly by
16
+ // whoever happens to ask first. Asking to commit before installing one is a
17
+ // programmer error and throws immediately and loudly, on the same tick,
18
+ // with a message that says exactly what's missing — never a silently
19
+ // frozen screen (which is what v1 did instead).
20
+
21
+ const NOT_MOUNTED = Symbol("mithril-lynx-v2:not-mounted");
22
+
23
+ export function createCommitController() {
24
+ let commitFn = NOT_MOUNTED;
25
+
26
+ return {
27
+ /**
28
+ * Registers the one and only commit callback. Called exactly once by
29
+ * `renderApp()` (background.js), before Mithril's `render()` is ever
30
+ * invoked — so nothing can observe the "not mounted yet" state from
31
+ * inside a redraw.
32
+ */
33
+ install(fn) {
34
+ if (commitFn !== NOT_MOUNTED) {
35
+ throw new Error(
36
+ "[mithril-lynx-v2] commit callback already installed. " +
37
+ "A shim instance is single-use: one renderApp() call, one " +
38
+ "commit callback, for the lifetime of that background " +
39
+ "context. If you're re-mounting for a reload, create a new " +
40
+ "commit controller instead of reusing this one.",
41
+ );
42
+ }
43
+ commitFn = fn;
44
+ },
45
+ /**
46
+ * The ONLY path anything (an event, `m.redraw()`, an HMR apply) uses
47
+ * to signal "a render pass just happened, push it". Passed as the
48
+ * `redraw` argument to every call of Mithril's real `render()` — see
49
+ * background.js. Mithril's own `EventDict.handleEvent` (render.js,
50
+ * documented in CONTRACT.md §e) already calls this automatically
51
+ * after any event handler runs, with no cooperation required from
52
+ * app code — that automatic call is what makes redraw "just work"
53
+ * like it does in React/Preact.
54
+ */
55
+ commit() {
56
+ if (commitFn === NOT_MOUNTED) {
57
+ throw new Error(
58
+ "[mithril-lynx-v2] commit() called before renderApp() mounted " +
59
+ "the app. This is always a bug in the framework's own " +
60
+ "wiring, never something app code can trigger by accident " +
61
+ "— app code never calls commit() directly.",
62
+ );
63
+ }
64
+ commitFn();
65
+ },
66
+ };
67
+ }