mithril-lynx 2.5.0 → 2.6.2
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/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +10 -0
- package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +10 -0
- package/LICENSE.txt +21 -0
- package/PLAN_REVIEW.md +134 -0
- package/README.md +2 -1
- package/REQUEST.md +5 -2
- package/ROUTE.md +1 -1
- package/informe-contrato-mithril-lynx.md +206 -0
- package/package.json +5 -1
- package/prompts-analisis-contrato-mithril-lynx.md +141 -0
- package/src/apply-patch.js +87 -27
- package/src/backends/virtual-backend.js +18 -4
- package/src/background.d.ts +14 -1
- package/src/channel.js +10 -2
- package/src/fake-dom.js +13 -10
- package/src/list-cell.d.ts +20 -0
- package/src/list-cell.js +73 -0
- package/src/list-support.d.ts +12 -9
- package/src/list-support.js +144 -114
- package/src/main-thread.js +15 -2
- package/src/mount-redraw.d.ts +10 -0
- package/src/mount-redraw.js +55 -2
- package/src/patch-protocol.js +61 -6
- package/src/request.d.ts +2 -0
- package/src/request.js +21 -3
- package/src/route.d.ts +22 -3
- package/src/route.js +25 -6
- package/src/testing.js +24 -11
- package/test/list.test.ts +86 -40
- package/test/patch-protocol.test.ts +41 -0
- package/test/remove-event.test.ts +26 -0
- package/test/route-hot-reload.test.ts +2 -1
- package/test/route.test.ts +5 -2
- package/test/setup.ts +10 -0
package/src/list-support.js
CHANGED
|
@@ -1,153 +1,175 @@
|
|
|
1
1
|
// src/list-support.js
|
|
2
2
|
//
|
|
3
|
-
// Runs ONLY on the main thread —
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// are separate JS engine instances — no shared closures, only serializable
|
|
9
|
-
// messages), so an app registers it here by a string key instead, and
|
|
10
|
-
// mithril-lynx-ui's List component references that same key from
|
|
11
|
-
// background.ts.
|
|
3
|
+
// Runs ONLY on the main thread — the counterpart to list-cell.js's
|
|
4
|
+
// background-thread half. Wires up the native list contract
|
|
5
|
+
// (__CreateList/__UpdateListCallbacks, the synchronous componentAtIndex/
|
|
6
|
+
// enqueueComponent pair, recycling cells by a type key) — unchanged from
|
|
7
|
+
// mithril-lynx v1's own device-verified list.js.
|
|
12
8
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// trip could never satisfy that, so this is the only architecture that can.
|
|
9
|
+
// What's different from v1, and from this file's own first version: a
|
|
10
|
+
// cell's content is never rendered here. `componentAtIndex` only replays
|
|
11
|
+
// ops the background thread already computed (list-cell.js), the same way
|
|
12
|
+
// apply-patch.js's own top-level applyPatch replays the app's own tree —
|
|
13
|
+
// this file has no dependency on mithril-runtime, fake-dom.js, or a virtual
|
|
14
|
+
// backend at all anymore, because it never runs a render pass of its own.
|
|
15
|
+
// componentAtIndex(itemCount) cost is therefore the cost of creating/
|
|
16
|
+
// updating real elements from an already-known op list, same as any other
|
|
17
|
+
// patch — not a render.
|
|
23
18
|
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return typeof vnode.tag === "string" ? vnode.tag : (vnode.tag && vnode.tag.name) || "default";
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function makeCell(pageId, wrapperHandle, createPatchApplier) {
|
|
50
|
-
const backend = createVirtualBackend();
|
|
51
|
-
const document = createLynxDocument(backend);
|
|
52
|
-
const render = renderFactory();
|
|
53
|
-
const applier = createPatchApplier(pageId);
|
|
54
|
-
applier.registerPageRoot(wrapperHandle);
|
|
55
|
-
return { wrapper: wrapperHandle, backend, document, render, applier, typeKey: null };
|
|
56
|
-
}
|
|
19
|
+
// Recycling a cell for a different item clears its real children (removed
|
|
20
|
+
// by the handle ids list-cell.js recorded — see clearWrapperChildren below)
|
|
21
|
+
// and replays the new item's ops fresh; this is a real teardown-and-rebuild
|
|
22
|
+
// of that cell's content, not a diff against what was there before. A real
|
|
23
|
+
// Mithril diff on recycle would need a persistent per-slot fake-dom
|
|
24
|
+
// document kept in sync with which native cell native actually chose to
|
|
25
|
+
// reuse — information only native has, on the main thread, which the
|
|
26
|
+
// background thread (where the diff would have to run) can't see without a
|
|
27
|
+
// round trip. Not implemented.
|
|
28
|
+
|
|
29
|
+
export function createNativeList(pageId, scrollOrientation, listType, spanCount, createPatchApplier, onEvent) {
|
|
30
|
+
let cells = []; // current cells, indexed by cellIndex — see patch-protocol.js's Op.SetListItems
|
|
31
|
+
let count = 0;
|
|
32
|
+
const signMap = new Map(); // sign -> { wrapperHandle, applier, rootChildIds, cellIndex, typeKey }
|
|
33
|
+
const recycleMap = new Map(); // typeKey -> Map<sign, entry> (entries currently off-screen, available to reuse)
|
|
34
|
+
|
|
35
|
+
function replayCell(wrapperHandle, cell) {
|
|
36
|
+
const applier = createPatchApplier(pageId, { onEvent, flush: false });
|
|
37
|
+
applier.registerRoot(cell.containerId, wrapperHandle);
|
|
38
|
+
applier.applyPatch(cell.ops);
|
|
39
|
+
return applier;
|
|
40
|
+
}
|
|
57
41
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
cell.render(cell.document, [vnode], () => {});
|
|
65
|
-
const ops = cell.backend.takeOps();
|
|
66
|
-
if (ops) cell.applier.applyPatch(ops);
|
|
67
|
-
}
|
|
42
|
+
function clearWrapperChildren(entry) {
|
|
43
|
+
for (const id of entry.rootChildIds) {
|
|
44
|
+
const handle = entry.applier.getHandle(id);
|
|
45
|
+
if (handle != null) __RemoveElement(entry.wrapperHandle, handle);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
68
48
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
49
|
+
/** The wrapper of the lowest currently-attached cellIndex greater than
|
|
50
|
+
* `cellIndex`, or null if none — the DOM position a cell at `cellIndex`
|
|
51
|
+
* needs to be inserted before to land in the right place. Native lays
|
|
52
|
+
* cells out in document-tree order, not by `item-key`, so a recycled
|
|
53
|
+
* wrapper that keeps its OLD tree position (this file's own first
|
|
54
|
+
* version never repositioned it at all) shows up out of order the
|
|
55
|
+
* moment it's reused for a different index — confirmed on real
|
|
56
|
+
* hardware via a scrambled child order after scrolling (item-keys
|
|
57
|
+
* 7,8,9,3,4,5,6). Same problem `@lynx-js/react`'s own
|
|
58
|
+
* `attachListItemAtIndex`/`findNextAttachedItem` solves for its
|
|
59
|
+
* Element Template list. */
|
|
60
|
+
function findNextAttachedWrapper(cellIndex) {
|
|
61
|
+
let best = null;
|
|
62
|
+
for (const entry of signMap.values()) {
|
|
63
|
+
if (entry.cellIndex > cellIndex && (best == null || entry.cellIndex < best.cellIndex)) best = entry;
|
|
64
|
+
}
|
|
65
|
+
return best ? best.wrapperHandle : null;
|
|
85
66
|
}
|
|
86
67
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
68
|
+
function attachWrapper(listHandle, wrapperHandle, cellIndex) {
|
|
69
|
+
const refHandle = findNextAttachedWrapper(cellIndex);
|
|
70
|
+
if (refHandle != null) __InsertElementBefore(listHandle, wrapperHandle, refHandle);
|
|
71
|
+
else __AppendElement(listHandle, wrapperHandle);
|
|
72
|
+
}
|
|
91
73
|
|
|
92
|
-
function bindFreshItem(listHandle, listId, cellIndex, opId,
|
|
74
|
+
function bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush) {
|
|
93
75
|
const wrapperHandle = __CreateElement("list-item", pageId, {});
|
|
94
76
|
__SetAttribute(wrapperHandle, "item-key", String(cellIndex));
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const cell = makeCell(pageId, wrapperHandle, (pid) => createPatchApplier(pid, { onEvent }));
|
|
98
|
-
cell.typeKey = typeKey;
|
|
99
|
-
renderCellVnode(cell, vnode);
|
|
77
|
+
attachWrapper(listHandle, wrapperHandle, cellIndex);
|
|
100
78
|
|
|
79
|
+
const applier = replayCell(wrapperHandle, cell);
|
|
101
80
|
const sign = __GetElementUniqueID(wrapperHandle);
|
|
102
|
-
signMap.set(sign, cell);
|
|
103
|
-
__FlushElementTree(wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
|
|
81
|
+
signMap.set(sign, { wrapperHandle, applier, rootChildIds: cell.rootChildIds, cellIndex, typeKey: cell.typeKey });
|
|
82
|
+
if (flush) __FlushElementTree(wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
|
|
104
83
|
return sign;
|
|
105
84
|
}
|
|
106
85
|
|
|
107
|
-
function bindRecycledItem(listId, cellIndex, opId,
|
|
108
|
-
const [sign,
|
|
86
|
+
function bindRecycledItem(listHandle, listId, cellIndex, opId, cell, pool, flush) {
|
|
87
|
+
const [sign, entry] = pool.entries().next().value;
|
|
109
88
|
pool.delete(sign);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
89
|
+
clearWrapperChildren(entry);
|
|
90
|
+
__SetAttribute(entry.wrapperHandle, "item-key", String(cellIndex));
|
|
91
|
+
attachWrapper(listHandle, entry.wrapperHandle, cellIndex);
|
|
92
|
+
const applier = replayCell(entry.wrapperHandle, cell);
|
|
93
|
+
entry.applier = applier;
|
|
94
|
+
entry.rootChildIds = cell.rootChildIds;
|
|
95
|
+
entry.cellIndex = cellIndex;
|
|
96
|
+
signMap.set(sign, entry);
|
|
97
|
+
if (flush) __FlushElementTree(entry.wrapperHandle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
|
|
115
98
|
return sign;
|
|
116
99
|
}
|
|
117
100
|
|
|
118
|
-
function
|
|
101
|
+
function bindCell(listHandle, listId, cellIndex, opId, flush) {
|
|
119
102
|
if (cellIndex < 0 || cellIndex >= count) {
|
|
120
103
|
throw new Error(`[mithril-lynx] list: cellIndex ${cellIndex} out of range (itemCount=${count})`);
|
|
121
104
|
}
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
105
|
+
const cell = cells[cellIndex];
|
|
106
|
+
const pool = recycleMap.get(cell.typeKey);
|
|
107
|
+
if (pool && pool.size > 0) return bindRecycledItem(listHandle, listId, cellIndex, opId, cell, pool, flush);
|
|
108
|
+
return bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function componentAtIndex(listHandle, listId, cellIndex, opId) {
|
|
112
|
+
return bindCell(listHandle, listId, cellIndex, opId, true);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The batched form of componentAtIndex — real native calls this (not a
|
|
117
|
+
* fallback: confirmed against `@lynx-js/react`'s own
|
|
118
|
+
* componentAtIndexFactory/attachListItemAtIndex, which always registers
|
|
119
|
+
* BOTH forms) for several cells at once, expecting exactly ONE
|
|
120
|
+
* `__FlushElementTree` covering all of them (`operationIDs`/`elementIDs`
|
|
121
|
+
* arrays), not one per cell — the real reason a native list's initial,
|
|
122
|
+
* simultaneously-visible cells need this at all.
|
|
123
|
+
*/
|
|
124
|
+
function componentAtIndexes(listHandle, listId, cellIndexes, operationIDs) {
|
|
125
|
+
const elementIDs = cellIndexes.map((cellIndex, i) => bindCell(listHandle, listId, cellIndex, operationIDs[i], false));
|
|
126
|
+
__FlushElementTree(listHandle, { triggerLayout: true, operationIDs, elementIDs, listID: listId });
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
function enqueueComponent(_listHandle, _listId, sign) {
|
|
130
|
-
const
|
|
131
|
-
if (
|
|
130
|
+
const entry = signMap.get(sign);
|
|
131
|
+
if (entry == null) return;
|
|
132
132
|
signMap.delete(sign);
|
|
133
|
-
if (!recycleMap.has(
|
|
134
|
-
recycleMap.get(
|
|
133
|
+
if (!recycleMap.has(entry.typeKey)) recycleMap.set(entry.typeKey, new Map());
|
|
134
|
+
recycleMap.get(entry.typeKey).set(sign, entry);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
function sendListInfo(listHandle, listId, insertAction, removeAction, updateAction) {
|
|
138
138
|
__SetAttribute(listHandle, "update-list-info", { insertAction, removeAction, updateAction });
|
|
139
|
-
__UpdateListCallbacks(listHandle, componentAtIndex, enqueueComponent);
|
|
139
|
+
__UpdateListCallbacks(listHandle, componentAtIndex, enqueueComponent, componentAtIndexes);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
const listHandle = __CreateList(pageId, componentAtIndex, enqueueComponent, {});
|
|
142
|
+
const listHandle = __CreateList(pageId, componentAtIndex, enqueueComponent, {}, componentAtIndexes);
|
|
143
143
|
const listId = __GetElementUniqueID(listHandle);
|
|
144
144
|
__SetAttribute(listHandle, "scroll-orientation", scrollOrientation);
|
|
145
145
|
__SetAttribute(listHandle, "list-type", listType);
|
|
146
146
|
__SetAttribute(listHandle, "span-count", String(spanCount));
|
|
147
147
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
// Re-flushes every currently-attached (on-screen) cell with its newest
|
|
149
|
+
// ops — e.g. after a redraw triggered by a tap inside a cell, or any
|
|
150
|
+
// other app state change that reaches this list's items. Unconditional,
|
|
151
|
+
// same as List's own onupdate already unconditionally resends the whole
|
|
152
|
+
// `items` array on every relevant redraw (see mithril-lynx-ui's
|
|
153
|
+
// list/list.js) — a real "did this cell's content actually change"
|
|
154
|
+
// check would need to compare `ops` arrays, not implemented yet. Cells
|
|
155
|
+
// that aren't attached right now just pick up their new content next
|
|
156
|
+
// time componentAtIndex asks for that index.
|
|
157
|
+
function refreshAttachedCells(nextCells) {
|
|
158
|
+
for (const [sign, entry] of signMap) {
|
|
159
|
+
const nextCell = nextCells[entry.cellIndex];
|
|
160
|
+
clearWrapperChildren(entry);
|
|
161
|
+
entry.applier = replayCell(entry.wrapperHandle, nextCell);
|
|
162
|
+
entry.rootChildIds = nextCell.rootChildIds;
|
|
163
|
+
entry.typeKey = nextCell.typeKey;
|
|
164
|
+
signMap.set(sign, entry);
|
|
165
|
+
__FlushElementTree(entry.wrapperHandle, { triggerLayout: false });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function setCells(nextCells) {
|
|
170
|
+
const nextCount = nextCells.length;
|
|
171
|
+
refreshAttachedCells(nextCells);
|
|
172
|
+
cells = nextCells;
|
|
151
173
|
if (nextCount === count) return;
|
|
152
174
|
if (nextCount > count) {
|
|
153
175
|
sendListInfo(
|
|
@@ -161,7 +183,15 @@ export function createNativeList(pageId, rendererKey, scrollOrientation, listTyp
|
|
|
161
183
|
sendListInfo(listHandle, listId, [], Array.from({ length: count - nextCount }, (_, i) => nextCount + i), []);
|
|
162
184
|
}
|
|
163
185
|
count = nextCount;
|
|
164
|
-
}
|
|
186
|
+
}
|
|
165
187
|
|
|
166
|
-
|
|
188
|
+
// Returned separately, not as a property stuck onto `listHandle` itself:
|
|
189
|
+
// confirmed on real hardware (unlike @lynx-js/testing-environment's
|
|
190
|
+
// simulated PAPI) that a native list's own returned handle does not
|
|
191
|
+
// reliably hold a custom property across calls — assigning one there
|
|
192
|
+
// and reading it back later from apply-patch.js's Op.SetListItems case
|
|
193
|
+
// threw "TypeError: not a function" on the very first SetListItems, even
|
|
194
|
+
// with zero items. apply-patch.js keeps `setCells` in its own map
|
|
195
|
+
// instead, keyed the same way `handles` already is.
|
|
196
|
+
return { handle: listHandle, setCells };
|
|
167
197
|
}
|
package/src/main-thread.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// was never part of the bug this rewrite exists to fix.
|
|
11
11
|
|
|
12
12
|
import { createPatchApplier } from "./apply-patch.js";
|
|
13
|
+
import { PROTOCOL_VERSION } from "./patch-protocol.js";
|
|
13
14
|
import {
|
|
14
15
|
destroyLifetimeEventName,
|
|
15
16
|
onPatchFromBackground,
|
|
@@ -39,11 +40,23 @@ export function setupRenderer() {
|
|
|
39
40
|
let pendingPatches = [];
|
|
40
41
|
|
|
41
42
|
const onPatch = (event) => {
|
|
43
|
+
const data = event.data;
|
|
44
|
+
if (!Array.isArray(data) || data[0] !== PROTOCOL_VERSION) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`[mithril-lynx] patch protocol mismatch: expected version ${PROTOCOL_VERSION}, ` +
|
|
47
|
+
`got ${Array.isArray(data) ? String(data[0]) : typeof data}. ` +
|
|
48
|
+
"This is always a stale/desynced bundle (partial HMR or a cached main-thread chunk) — " +
|
|
49
|
+
"rebuild both bundles together.",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
// Strip the version prefix so applyPatch() still receives the bare
|
|
53
|
+
// flat op array the rest of the protocol documents.
|
|
54
|
+
const ops = data.slice(1);
|
|
42
55
|
if (!pageReady) {
|
|
43
|
-
pendingPatches.push(
|
|
56
|
+
pendingPatches.push(ops);
|
|
44
57
|
return;
|
|
45
58
|
}
|
|
46
|
-
applier.applyPatch(
|
|
59
|
+
applier.applyPatch(ops);
|
|
47
60
|
};
|
|
48
61
|
onPatchFromBackground(onPatch);
|
|
49
62
|
|
package/src/mount-redraw.d.ts
CHANGED
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
* auto-redraw-after-event contract doesn't cover. */
|
|
7
7
|
export function register(redraw: () => void): void;
|
|
8
8
|
|
|
9
|
+
/** Clears the current redraw registration (and the pending debounce flag).
|
|
10
|
+
* Fail-fast counterpart to {@link register}: a second `register()` throws,
|
|
11
|
+
* so call this on teardown or full reload before mounting a new app. */
|
|
12
|
+
export function unregister(): void;
|
|
13
|
+
|
|
14
|
+
/** Overrides the debounce delay `redraw()` uses (default 50ms — see the
|
|
15
|
+
* `mount-redraw.js` header for why it is an empirical margin, not a
|
|
16
|
+
* scheduling guarantee). */
|
|
17
|
+
export function configure(options?: { redrawDelayMs?: number }): void;
|
|
18
|
+
|
|
9
19
|
/** No-op before any app has mounted. Scheduled, not synchronous — see
|
|
10
20
|
* mount-redraw.js's own header for why a synchronous call here would race
|
|
11
21
|
* a caller's own pending `.then()`/callback that hasn't stored its result
|
package/src/mount-redraw.js
CHANGED
|
@@ -48,16 +48,68 @@ const REDRAW_DELAY_MS = 50;
|
|
|
48
48
|
|
|
49
49
|
let currentRedraw = null;
|
|
50
50
|
let pending = false;
|
|
51
|
+
/** Handle of the currently-scheduled redraw timer, so `unregister()` can
|
|
52
|
+
* cancel it instead of leaving a stray callback that fires into a later
|
|
53
|
+
* mount. */
|
|
54
|
+
let pendingTimer = null;
|
|
55
|
+
/** Overridable via {@link configure}; defaults to REDRAW_DELAY_MS. */
|
|
56
|
+
let redrawDelayMs = REDRAW_DELAY_MS;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Overrides the debounce delay `redraw()` uses. Defaults to `REDRAW_DELAY_MS`
|
|
60
|
+
* (50ms) — the empirically-chosen margin documented at the top of this file,
|
|
61
|
+
* which is a safety margin rather than a scheduling guarantee. A device whose
|
|
62
|
+
* timer behaves differently (see FETCH_INVESTIGATION.md §4.6) can raise or
|
|
63
|
+
* lower it here.
|
|
64
|
+
*/
|
|
65
|
+
export function configure(options) {
|
|
66
|
+
if (options && options.redrawDelayMs != null) {
|
|
67
|
+
redrawDelayMs = options.redrawDelayMs;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
51
70
|
|
|
52
71
|
function schedule(fn) {
|
|
53
72
|
const timer = typeof lynx !== "undefined" && typeof lynx.setTimeout === "function" ? lynx.setTimeout.bind(lynx) : setTimeout;
|
|
54
|
-
timer(fn,
|
|
73
|
+
return timer(fn, redrawDelayMs);
|
|
55
74
|
}
|
|
56
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Registers the current app's redraw. Fail-fast: throws if a redraw is
|
|
78
|
+
* already registered, because a second live `renderApp()` in the same
|
|
79
|
+
* background context would silently overwrite the slot and let two
|
|
80
|
+
* documents corrupt the shared id space (see R2 in
|
|
81
|
+
* informe-contrato-mithril-lynx.md). Call {@link unregister} on teardown
|
|
82
|
+
* or full reload before registering a new one.
|
|
83
|
+
*/
|
|
57
84
|
export function register(redraw) {
|
|
85
|
+
if (currentRedraw != null) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"[mithril-lynx] redraw already registered — a shim instance is single-use: " +
|
|
88
|
+
"one renderApp() per background context, one redraw slot, for the " +
|
|
89
|
+
"lifetime of that context. Call unregister() (or do a full reload) " +
|
|
90
|
+
"before mounting a new app.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
58
93
|
currentRedraw = redraw;
|
|
59
94
|
}
|
|
60
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Clears the current redraw registration (and the pending debounce flag) —
|
|
98
|
+
* used by a full reload and by the test suite between mounts. After this,
|
|
99
|
+
* `redraw()` is a no-op again until the next `register()`.
|
|
100
|
+
*/
|
|
101
|
+
export function unregister() {
|
|
102
|
+
currentRedraw = null;
|
|
103
|
+
pending = false;
|
|
104
|
+
if (pendingTimer != null) {
|
|
105
|
+
const clear = typeof lynx !== "undefined" && typeof lynx.clearTimeout === "function"
|
|
106
|
+
? lynx.clearTimeout.bind(lynx)
|
|
107
|
+
: clearTimeout;
|
|
108
|
+
clear(pendingTimer);
|
|
109
|
+
pendingTimer = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
61
113
|
/** What `request.js` calls after a non-background request resolves. A
|
|
62
114
|
* no-op before any app has mounted — a request kicked off before
|
|
63
115
|
* renderApp()/route() ran has nothing to redraw yet, which isn't
|
|
@@ -67,7 +119,8 @@ export function register(redraw) {
|
|
|
67
119
|
export function redraw() {
|
|
68
120
|
if (pending) return;
|
|
69
121
|
pending = true;
|
|
70
|
-
schedule(() => {
|
|
122
|
+
pendingTimer = schedule(() => {
|
|
123
|
+
pendingTimer = null;
|
|
71
124
|
pending = false;
|
|
72
125
|
if (currentRedraw != null) currentRedraw();
|
|
73
126
|
});
|
package/src/patch-protocol.js
CHANGED
|
@@ -13,6 +13,20 @@
|
|
|
13
13
|
// main-thread side by `applyPatch()` (see backends/papi-backend.js), so
|
|
14
14
|
// nodes never need to be looked up by anything other than that integer.
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The wire protocol version, prepended to every patch by
|
|
18
|
+
* `sendPatchToMainThread()` and validated (then stripped) by the main
|
|
19
|
+
* thread before any op is interpreted. The two bundles are normally built
|
|
20
|
+
* from the same source, but a partial HMR or a cached main-thread bundle
|
|
21
|
+
* could otherwise re-interpret a reordered opcode silently — a version
|
|
22
|
+
* mismatch throws instead of corrupting the mirrored id space.
|
|
23
|
+
*
|
|
24
|
+
* The value is deliberately outside the 0..17 opcode range (0x4d4c = "ML"
|
|
25
|
+
* in ASCII) so a versioned array can never be misread as an op sequence if
|
|
26
|
+
* it is ever fed to `applyPatch()` without the strip.
|
|
27
|
+
*/
|
|
28
|
+
export const PROTOCOL_VERSION = 0x4d4c;
|
|
29
|
+
|
|
16
30
|
export const Op = Object.freeze({
|
|
17
31
|
CreateElement: 0,
|
|
18
32
|
CreateElementNS: 1,
|
|
@@ -43,12 +57,13 @@ export const Op = Object.freeze({
|
|
|
43
57
|
SetGestureDetector: 14, // id, gestureId, gestureType, arenaPolicy
|
|
44
58
|
RemoveGestureDetector: 15, // id, gestureId
|
|
45
59
|
// A native virtualized list — see docs/native-papi/papi-06-virtualized-lists.md
|
|
46
|
-
// in mithril-lynx-ui for the full design.
|
|
47
|
-
//
|
|
48
|
-
// list-
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
// in mithril-lynx-ui for the full design. Each list item is rendered on
|
|
61
|
+
// the BACKGROUND thread, through the app's own real render pass
|
|
62
|
+
// (list-cell.js), same as everything else in the tree — the main thread
|
|
63
|
+
// never runs renderItem() itself, only replays the ops that rendering
|
|
64
|
+
// already produced (apply-patch.js's Op.CreateList case + list-support.js).
|
|
65
|
+
CreateList: 16, // id, scrollOrientation, listType, spanCount
|
|
66
|
+
SetListItems: 17, // id, cells — cells: Array<{ typeKey, containerId, ops, rootChildIds }>, one entry per current item, computed by list-cell.js's renderListCell(). `ops` is a flat op array in this SAME encoding, scoped to `containerId` as its root parent id.
|
|
52
67
|
});
|
|
53
68
|
|
|
54
69
|
/**
|
|
@@ -59,3 +74,43 @@ export const Op = Object.freeze({
|
|
|
59
74
|
export function pushOp(ops, opcode, ...args) {
|
|
60
75
|
ops.push(opcode, ...args);
|
|
61
76
|
}
|
|
77
|
+
|
|
78
|
+
// How many argument slots follow each opcode — the single source of truth
|
|
79
|
+
// for walking a flat ops array without re-interpreting it (apply-patch.js's
|
|
80
|
+
// own switch increments `i` inline instead of using this table, since it
|
|
81
|
+
// also needs to look at individual arg values as it goes; this exists for
|
|
82
|
+
// callers that only need to skip/scan, e.g. list-cell.js's
|
|
83
|
+
// findTopLevelChildIds(), which never applies the ops itself).
|
|
84
|
+
export const OP_ARITY = Object.freeze({
|
|
85
|
+
[Op.CreateElement]: 2, // tag, id
|
|
86
|
+
[Op.CreateElementNS]: 3, // ns, tag, id
|
|
87
|
+
[Op.CreateText]: 2, // value, id
|
|
88
|
+
[Op.CreateFragment]: 1, // id (never actually emitted — see fake-dom.js's LynxFragment)
|
|
89
|
+
[Op.InsertBefore]: 3, // parentId, childId, refId
|
|
90
|
+
[Op.RemoveChild]: 2, // parentId, childId
|
|
91
|
+
[Op.SetAttribute]: 3, // id, name, value
|
|
92
|
+
[Op.RemoveAttribute]: 2, // id, name
|
|
93
|
+
[Op.SetAttributeNS]: 4, // id, ns, name, value
|
|
94
|
+
[Op.SetStyleProperty]: 3, // id, name, value
|
|
95
|
+
[Op.RemoveStyleProperty]: 2, // id, name
|
|
96
|
+
[Op.SetText]: 2, // id, value
|
|
97
|
+
[Op.AddEvent]: 2, // id, type
|
|
98
|
+
[Op.RemoveEvent]: 2, // id, type
|
|
99
|
+
[Op.SetGestureDetector]: 4, // id, gestureId, gestureType, arenaPolicy
|
|
100
|
+
[Op.RemoveGestureDetector]: 2, // id, gestureId
|
|
101
|
+
[Op.CreateList]: 4, // id, scrollOrientation, listType, spanCount
|
|
102
|
+
[Op.SetListItems]: 2, // id, cells
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/** Walks a flat ops array, calling `visit(opcode, args)` once per op — args
|
|
106
|
+
* is the plain slice of that op's own arguments (not including the opcode
|
|
107
|
+
* itself). Throws on an unknown opcode rather than silently desyncing. */
|
|
108
|
+
export function forEachOp(ops, visit) {
|
|
109
|
+
for (let i = 0; i < ops.length; ) {
|
|
110
|
+
const opcode = ops[i++];
|
|
111
|
+
const arity = OP_ARITY[opcode];
|
|
112
|
+
if (arity === undefined) throw new Error(`[mithril-lynx] Unknown patch opcode: ${opcode}`);
|
|
113
|
+
visit(opcode, ops.slice(i, i + arity));
|
|
114
|
+
i += arity;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/request.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface RequestOptions<T = any> {
|
|
|
10
10
|
serialize?: (data: unknown) => string;
|
|
11
11
|
deserialize?: (data: unknown) => unknown;
|
|
12
12
|
extract?: (response: unknown, options: RequestOptions<T>) => unknown;
|
|
13
|
+
/** A constructor applied to the response: per element when the response
|
|
14
|
+
* is an array (matching real `m.request`), otherwise to the whole result. */
|
|
13
15
|
type?: new (data: any) => T;
|
|
14
16
|
background?: boolean;
|
|
15
17
|
// Present on the real m.request signature but confirmed unsupported —
|
package/src/request.js
CHANGED
|
@@ -112,9 +112,13 @@ export function createRequestor(fetchImpl) {
|
|
|
112
112
|
// controller so a caller-provided signal and our timeout can both
|
|
113
113
|
// trigger the same abort.
|
|
114
114
|
const ctrl = new AbortController();
|
|
115
|
+
// Stored so the listener can be detached on settle (see detachSignal
|
|
116
|
+
// below) — a long-lived shared AbortSignal would otherwise accumulate
|
|
117
|
+
// one dead closure per request.
|
|
118
|
+
const abortHandler = () => ctrl.abort();
|
|
115
119
|
if (options.signal) {
|
|
116
120
|
if (options.signal.aborted) ctrl.abort();
|
|
117
|
-
else options.signal.addEventListener("abort",
|
|
121
|
+
else options.signal.addEventListener("abort", abortHandler);
|
|
118
122
|
}
|
|
119
123
|
let timeoutId;
|
|
120
124
|
if (options.timeout) {
|
|
@@ -138,8 +142,10 @@ export function createRequestor(fetchImpl) {
|
|
|
138
142
|
|
|
139
143
|
if (typeof options.extract === "function") {
|
|
140
144
|
// Matches real m.request: extract() bypasses the status check
|
|
141
|
-
// entirely — it decides success/failure itself.
|
|
142
|
-
|
|
145
|
+
// entirely — it decides success/failure itself. `type` is still
|
|
146
|
+
// applied afterwards, exactly as upstream does (extract does NOT
|
|
147
|
+
// skip the type constructor).
|
|
148
|
+
return applyType(options.extract(response, options), options.type);
|
|
143
149
|
}
|
|
144
150
|
|
|
145
151
|
const ok = response.ok || response.status === 304;
|
|
@@ -156,12 +162,24 @@ export function createRequestor(fetchImpl) {
|
|
|
156
162
|
});
|
|
157
163
|
});
|
|
158
164
|
|
|
165
|
+
// Detach the caller's signal listener once this request settles, so a
|
|
166
|
+
// long-lived shared AbortSignal doesn't accumulate a dead closure per
|
|
167
|
+
// request. Done inside the settled handlers (rather than a separate
|
|
168
|
+
// `.then`) so an ignored rejection still surfaces as unhandled.
|
|
169
|
+
const detachSignal = () => {
|
|
170
|
+
if (options.signal && !options.signal.aborted) {
|
|
171
|
+
options.signal.removeEventListener("abort", abortHandler);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
159
175
|
const result = promise.then(
|
|
160
176
|
(value) => {
|
|
177
|
+
detachSignal();
|
|
161
178
|
if (options.background !== true) sharedRedraw();
|
|
162
179
|
return value;
|
|
163
180
|
},
|
|
164
181
|
(error) => {
|
|
182
|
+
detachSignal();
|
|
165
183
|
clearRequestTimeout();
|
|
166
184
|
if (options.background !== true) sharedRedraw();
|
|
167
185
|
throw error;
|
package/src/route.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Mithril-style component type. Declared locally because this
|
|
3
|
+
* package's runtime peer is `mithril-runtime`, which ships no TypeScript
|
|
4
|
+
* types of its own; the previous `import type { Component } from "mithril"`
|
|
5
|
+
* depended on an undeclared module. `view`'s return is typed `any` so
|
|
6
|
+
* `route.Link` stays assignable to a caller's own `m()` typed against
|
|
7
|
+
* `@types/mithril`.
|
|
8
|
+
*/
|
|
9
|
+
export interface Component<Attrs = unknown> {
|
|
10
|
+
view(vnode: { attrs: Attrs; children?: unknown }): any;
|
|
11
|
+
}
|
|
2
12
|
|
|
3
13
|
export interface RouteResolver {
|
|
4
14
|
onmatch?(args: Record<string, string>, requestedPath: string, route: string): unknown;
|
|
@@ -19,9 +29,18 @@ export interface Route {
|
|
|
19
29
|
(defaultRoute: string, routes: Record<string, unknown | RouteResolver>): void;
|
|
20
30
|
set(path: string, data?: unknown, options?: { replace?: boolean }): void;
|
|
21
31
|
get(): string | undefined;
|
|
32
|
+
/** Returns the named route parameter (with `key`), or the whole params
|
|
33
|
+
* object (without). Typed `unknown` because the value can be a string
|
|
34
|
+
* (path params), a string|boolean (query params — `"true"`/`"false"` are
|
|
35
|
+
* coerced), or anything passed as `data` to `set(path, data)`; the common
|
|
36
|
+
* path-param case is always a string. */
|
|
22
37
|
param(key?: string): unknown;
|
|
23
|
-
back()
|
|
24
|
-
|
|
38
|
+
/** Walks back one entry; returns `false` (without navigating) at the top
|
|
39
|
+
* of the history stack so a back affordance can disable itself. */
|
|
40
|
+
back(): boolean;
|
|
41
|
+
/** Walks forward one entry; returns `false` (without navigating) at the
|
|
42
|
+
* end of the history stack so a forward affordance can disable itself. */
|
|
43
|
+
forward(): boolean;
|
|
25
44
|
prefix: string;
|
|
26
45
|
SKIP: unknown;
|
|
27
46
|
Link: Component<RouteLinkAttrs>;
|