mithril-lynx 2.6.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 +1 -1
- package/prompts-analisis-contrato-mithril-lynx.md +141 -0
- package/src/apply-patch.js +41 -10
- package/src/backends/virtual-backend.js +1 -1
- package/src/background.d.ts +14 -1
- package/src/channel.js +10 -2
- package/src/list-support.js +29 -3
- 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 +16 -2
- 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 +19 -1
- 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/.omo/run-continuation/ses_f48265d07ffenKG0GAz9ZRFvY6.json +0 -10
package/src/apply-patch.js
CHANGED
|
@@ -216,6 +216,12 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
216
216
|
// device), only `handles` (a plain Map) does.
|
|
217
217
|
const listSetters = new Map();
|
|
218
218
|
|
|
219
|
+
// id -> Map<event type, listener callback>. `__AddEventListener` needs the
|
|
220
|
+
// exact callback reference back when `__RemoveEventListener` runs, so the
|
|
221
|
+
// listener cannot be an inline closure re-created per Op.AddEvent — it is
|
|
222
|
+
// stored here and reused by the Op.RemoveEvent case.
|
|
223
|
+
const eventListeners = new Map();
|
|
224
|
+
|
|
219
225
|
/** General form: seed the mapping for any id, not just the page root —
|
|
220
226
|
* list-support.js uses this to alias a list-cell.js `containerId` (an
|
|
221
227
|
* off-tree id from the background thread's OWN document) to the real
|
|
@@ -359,18 +365,43 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
359
365
|
const id = ops[i++];
|
|
360
366
|
const type = ops[i++];
|
|
361
367
|
const handle = handles.get(id);
|
|
362
|
-
|
|
368
|
+
const listener = (nativeEvent) => {
|
|
363
369
|
onEvent?.(id, type, nativeEvent);
|
|
364
|
-
}
|
|
370
|
+
};
|
|
371
|
+
let byType = eventListeners.get(id);
|
|
372
|
+
if (byType == null) eventListeners.set(id, (byType = new Map()));
|
|
373
|
+
byType.set(type, listener);
|
|
374
|
+
__AddEventListener(handle, type, listener, {});
|
|
365
375
|
break;
|
|
366
376
|
}
|
|
367
377
|
case Op.RemoveEvent: {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
378
|
+
const id = ops[i++];
|
|
379
|
+
const type = ops[i++];
|
|
380
|
+
const handle = handles.get(id);
|
|
381
|
+
const byType = eventListeners.get(id);
|
|
382
|
+
const listener = byType ? byType.get(type) : undefined;
|
|
383
|
+
if (typeof __RemoveEventListener === "function") {
|
|
384
|
+
// Native Fiber requires the options argument (>= 4 params) and
|
|
385
|
+
// derives the binding slot from it — pass the same `{}` the
|
|
386
|
+
// Op.AddEvent case uses so add/remove target the same slot.
|
|
387
|
+
if (listener != null) __RemoveEventListener(handle, type, listener, {});
|
|
388
|
+
} else if (listener != null) {
|
|
389
|
+
// Failing loudly is deliberate: without __RemoveEventListener a
|
|
390
|
+
// remove→re-add cycle on a kept element would accumulate
|
|
391
|
+
// duplicate native listeners and fire the handler N times per
|
|
392
|
+
// event. A silent no-op here is exactly the class of bug the
|
|
393
|
+
// whole project refuses to ship.
|
|
394
|
+
throw new Error(
|
|
395
|
+
"[mithril-lynx] __RemoveEventListener is not available on this runtime, " +
|
|
396
|
+
"so Op.RemoveEvent cannot be applied — a conditional event handler " +
|
|
397
|
+
"would leak duplicate listeners. Keep the handler constant or " +
|
|
398
|
+
"remove the whole element instead.",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (byType != null) {
|
|
402
|
+
byType.delete(type);
|
|
403
|
+
if (byType.size === 0) eventListeners.delete(id);
|
|
404
|
+
}
|
|
374
405
|
break;
|
|
375
406
|
}
|
|
376
407
|
case Op.SetGestureDetector: {
|
|
@@ -401,8 +432,8 @@ export function createPatchApplier(pageId, { onEvent, flush = true } = {}) {
|
|
|
401
432
|
}
|
|
402
433
|
case Op.SetListItems: {
|
|
403
434
|
const id = ops[i++];
|
|
404
|
-
const
|
|
405
|
-
listSetters.get(id)(
|
|
435
|
+
const cells = ops[i++];
|
|
436
|
+
listSetters.get(id)(cells);
|
|
406
437
|
break;
|
|
407
438
|
}
|
|
408
439
|
default:
|
|
@@ -81,7 +81,7 @@ export function createVirtualBackend() {
|
|
|
81
81
|
return id;
|
|
82
82
|
},
|
|
83
83
|
setListItems(id, cells) {
|
|
84
|
-
pushOp(ops, Op.SetListItems, id,
|
|
84
|
+
pushOp(ops, Op.SetListItems, id, cells);
|
|
85
85
|
},
|
|
86
86
|
/** Drains and returns the accumulated ops. Called once per commit. */
|
|
87
87
|
takeOps() {
|
package/src/background.d.ts
CHANGED
|
@@ -3,9 +3,22 @@ export interface RenderAppOptions {
|
|
|
3
3
|
sendPatch?: (ops: unknown[]) => void;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
/** Minimal shape of a fake-DOM node exposed for tests and HMR glue — enough
|
|
7
|
+
* to dispatch a synthetic event (e.g. `node.dispatchEvent({ type: "tap" })`)
|
|
8
|
+
* and correlate with a background-side id. */
|
|
9
|
+
export interface LynxNode {
|
|
10
|
+
dispatchEvent(event: unknown): unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The subset of the fake-DOM document exposed on the render handle — used
|
|
14
|
+
* by tests and by an app's own HMR glue, never by the channel wiring. */
|
|
15
|
+
export interface LynxDocument {
|
|
16
|
+
getNodeById(id: number): LynxNode | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
6
19
|
export interface RenderAppHandle {
|
|
7
20
|
redraw: () => void;
|
|
8
|
-
document:
|
|
21
|
+
document: LynxDocument;
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
export function renderApp(options: RenderAppOptions): RenderAppHandle;
|
package/src/channel.js
CHANGED
|
@@ -20,9 +20,17 @@ export const eventFromMainThreadEventName = "MithrilLynx:Event";
|
|
|
20
20
|
export const renderPageEventName = "__RenderPage";
|
|
21
21
|
export const destroyLifetimeEventName = "__DestroyLifetime";
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
import { PROTOCOL_VERSION } from "./patch-protocol.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Background thread: ship one commit's ops to the main thread.
|
|
27
|
+
*
|
|
28
|
+
* The patch is prefixed with `PROTOCOL_VERSION` so the main thread can
|
|
29
|
+
* detect a stale/desynced bundle (partial HMR, cache) and fail loudly
|
|
30
|
+
* instead of re-interpreting reordered opcodes against the shared id space.
|
|
31
|
+
*/
|
|
24
32
|
export function sendPatchToMainThread(ops) {
|
|
25
|
-
lynx.getCoreContext().dispatchEvent({ type: patchEventName, data: ops });
|
|
33
|
+
lynx.getCoreContext().dispatchEvent({ type: patchEventName, data: [PROTOCOL_VERSION, ...ops] });
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
/** Background thread: receive a forwarded native event `{ id, type, payload }`. */
|
package/src/list-support.js
CHANGED
|
@@ -46,10 +46,35 @@ export function createNativeList(pageId, scrollOrientation, listType, spanCount,
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
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;
|
|
66
|
+
}
|
|
67
|
+
|
|
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
|
+
}
|
|
73
|
+
|
|
49
74
|
function bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush) {
|
|
50
75
|
const wrapperHandle = __CreateElement("list-item", pageId, {});
|
|
51
76
|
__SetAttribute(wrapperHandle, "item-key", String(cellIndex));
|
|
52
|
-
|
|
77
|
+
attachWrapper(listHandle, wrapperHandle, cellIndex);
|
|
53
78
|
|
|
54
79
|
const applier = replayCell(wrapperHandle, cell);
|
|
55
80
|
const sign = __GetElementUniqueID(wrapperHandle);
|
|
@@ -58,11 +83,12 @@ export function createNativeList(pageId, scrollOrientation, listType, spanCount,
|
|
|
58
83
|
return sign;
|
|
59
84
|
}
|
|
60
85
|
|
|
61
|
-
function bindRecycledItem(listId, cellIndex, opId, cell, pool, flush) {
|
|
86
|
+
function bindRecycledItem(listHandle, listId, cellIndex, opId, cell, pool, flush) {
|
|
62
87
|
const [sign, entry] = pool.entries().next().value;
|
|
63
88
|
pool.delete(sign);
|
|
64
89
|
clearWrapperChildren(entry);
|
|
65
90
|
__SetAttribute(entry.wrapperHandle, "item-key", String(cellIndex));
|
|
91
|
+
attachWrapper(listHandle, entry.wrapperHandle, cellIndex);
|
|
66
92
|
const applier = replayCell(entry.wrapperHandle, cell);
|
|
67
93
|
entry.applier = applier;
|
|
68
94
|
entry.rootChildIds = cell.rootChildIds;
|
|
@@ -78,7 +104,7 @@ export function createNativeList(pageId, scrollOrientation, listType, spanCount,
|
|
|
78
104
|
}
|
|
79
105
|
const cell = cells[cellIndex];
|
|
80
106
|
const pool = recycleMap.get(cell.typeKey);
|
|
81
|
-
if (pool && pool.size > 0) return bindRecycledItem(listId, cellIndex, opId, cell, pool, flush);
|
|
107
|
+
if (pool && pool.size > 0) return bindRecycledItem(listHandle, listId, cellIndex, opId, cell, pool, flush);
|
|
82
108
|
return bindFreshItem(listHandle, listId, cellIndex, opId, cell, flush);
|
|
83
109
|
}
|
|
84
110
|
|
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,
|
|
@@ -49,7 +63,7 @@ export const Op = Object.freeze({
|
|
|
49
63
|
// never runs renderItem() itself, only replays the ops that rendering
|
|
50
64
|
// already produced (apply-patch.js's Op.CreateList case + list-support.js).
|
|
51
65
|
CreateList: 16, // id, scrollOrientation, listType, spanCount
|
|
52
|
-
SetListItems: 17, // id,
|
|
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.
|
|
53
67
|
});
|
|
54
68
|
|
|
55
69
|
/**
|
|
@@ -85,7 +99,7 @@ export const OP_ARITY = Object.freeze({
|
|
|
85
99
|
[Op.SetGestureDetector]: 4, // id, gestureId, gestureType, arenaPolicy
|
|
86
100
|
[Op.RemoveGestureDetector]: 2, // id, gestureId
|
|
87
101
|
[Op.CreateList]: 4, // id, scrollOrientation, listType, spanCount
|
|
88
|
-
[Op.SetListItems]: 2, // id,
|
|
102
|
+
[Op.SetListItems]: 2, // id, cells
|
|
89
103
|
});
|
|
90
104
|
|
|
91
105
|
/** Walks a flat ops array, calling `visit(opcode, args)` once per op — args
|
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>;
|
package/src/route.js
CHANGED
|
@@ -116,15 +116,32 @@ export function createRoute() {
|
|
|
116
116
|
if (!compiled.some((entry) => entry.check(defaultData))) {
|
|
117
117
|
throw new ReferenceError("Default route doesn't match any known routes.");
|
|
118
118
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
if (!ready) {
|
|
120
|
+
history = [defaultRoute];
|
|
121
|
+
historyIndex = 0;
|
|
122
|
+
ready = true;
|
|
123
|
+
resolveRoute(defaultRoute, null);
|
|
124
|
+
} else {
|
|
125
|
+
// Re-registration (e.g. HMR of the route module): keep the history
|
|
126
|
+
// stack and re-resolve the CURRENT path against the new table —
|
|
127
|
+
// the same thing the documented HMR pattern does by hand with
|
|
128
|
+
// route.set(route.get(), null, {replace: true}). Resolving
|
|
129
|
+
// defaultRoute here instead would silently jump the screen back to
|
|
130
|
+
// the initial route and discard the user's back/forward stack.
|
|
131
|
+
resolveRoute(currentPath != null ? currentPath : defaultRoute, null);
|
|
132
|
+
}
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
route.SKIP = {};
|
|
126
136
|
|
|
127
137
|
route.set = function (path, data, options) {
|
|
138
|
+
if (!ready) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
"[mithril-lynx] route.set() called before route(defaultRoute, routes) — " +
|
|
141
|
+
"the route table does not exist yet, so this navigation would be " +
|
|
142
|
+
"silently dropped. Call route() first.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
128
145
|
if (lastUpdate != null) {
|
|
129
146
|
options = options || {};
|
|
130
147
|
options.replace = true;
|
|
@@ -161,14 +178,16 @@ export function createRoute() {
|
|
|
161
178
|
* is F4/F5 of the plan, not done yet.
|
|
162
179
|
*/
|
|
163
180
|
route.back = function () {
|
|
164
|
-
if (historyIndex <= 0) return;
|
|
181
|
+
if (historyIndex <= 0) return false;
|
|
165
182
|
historyIndex--;
|
|
166
183
|
resolveRoute(history[historyIndex], null);
|
|
184
|
+
return true;
|
|
167
185
|
};
|
|
168
186
|
route.forward = function () {
|
|
169
|
-
if (historyIndex >= history.length - 1) return;
|
|
187
|
+
if (historyIndex >= history.length - 1) return false;
|
|
170
188
|
historyIndex++;
|
|
171
189
|
resolveRoute(history[historyIndex], null);
|
|
190
|
+
return true;
|
|
172
191
|
};
|
|
173
192
|
|
|
174
193
|
// Lynx has no `<a>`/`onclick` — this renders a tap-driven element
|
package/src/testing.js
CHANGED
|
@@ -24,25 +24,38 @@ export { createPatchApplier } from "./apply-patch.js";
|
|
|
24
24
|
/**
|
|
25
25
|
* Installs the polyfill on the main-thread globals object
|
|
26
26
|
* @lynx-js/testing-environment hands to onInjectMainThreadGlobals. Scoped
|
|
27
|
-
* to
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
27
|
+
* to the one real gap: @lynx-js/testing-environment already implements
|
|
28
|
+
* __CreateView/__CreateText/__CreateElement/__CreateRawText/__AppendElement/
|
|
29
|
+
* __InsertElementBefore/__RemoveElement/__SetAttribute/__SetClasses/
|
|
30
|
+
* __AddInlineStyle/__FlushElementTree/__GetElementUniqueID — the missing
|
|
31
|
+
* piece is __AddEventListener/__RemoveEventListener (the testing environment
|
|
32
|
+
* only implements the string/worklet-event __AddEvent family that ReactLynx
|
|
33
|
+
* uses; mithril-lynx binds real JS function listeners directly). Gesture and
|
|
34
|
+
* list ops (Op.SetGestureDetector/Op.CreateList) are covered by the testing
|
|
35
|
+
* environment itself, not by this polyfill — see test/gesture.test.ts and
|
|
36
|
+
* test/list.test.ts.
|
|
36
37
|
*/
|
|
37
38
|
export function installTestingPolyfills(target) {
|
|
38
39
|
target.lynx.getEngine = target.lynx.getNative;
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
// Faithful stand-ins for the native `__AddEventListener` /
|
|
42
|
+
// `__RemoveEventListener` PAPIs (element, name, callback, options). The
|
|
43
|
+
// options object is accepted and ignored here — the real native side
|
|
44
|
+
// requires it (FiberRemoveEventListener throws "param size should >= 4"
|
|
45
|
+
// without it), so __RemoveEventListener mirrors that arity requirement to
|
|
46
|
+
// keep the test environment honest about the wire contract.
|
|
47
|
+
target.__AddEventListener = (node, name, handler, _options) => {
|
|
41
48
|
node.__vanillaListeners ??= {};
|
|
42
49
|
(node.__vanillaListeners[name] ??= new Set()).add(handler);
|
|
43
50
|
};
|
|
44
51
|
|
|
45
|
-
target.__RemoveEventListener = (node, name, handler)
|
|
52
|
+
target.__RemoveEventListener = function (node, name, handler, _options) {
|
|
53
|
+
if (arguments.length < 4) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"[mithril-lynx/testing] __RemoveEventListener requires 4 params " +
|
|
56
|
+
"(element, name, callback, options) — matching FiberRemoveEventListener.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
46
59
|
node.__vanillaListeners?.[name]?.delete(handler);
|
|
47
60
|
};
|
|
48
61
|
}
|
package/test/list.test.ts
CHANGED
|
@@ -59,7 +59,7 @@ function setupList() {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function setCells(applier: ReturnType<typeof createPatchApplier>, cells: unknown[]) {
|
|
62
|
-
applier.applyPatch([Op.SetListItems, 1,
|
|
62
|
+
applier.applyPatch([Op.SetListItems, 1, cells]);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
describe("Op.CreateList (native virtualized list support)", () => {
|
|
@@ -116,6 +116,24 @@ describe("Op.CreateList (native virtualized list support)", () => {
|
|
|
116
116
|
expect(textOf(wrapper)).toBe("0:z");
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
it("a recycled cell is repositioned in the real tree to match its new index, not left where it was created", () => {
|
|
120
|
+
const { buildCells } = makeCellSource((item: string, index: number) => m("text", {}, `${index}:${item}`));
|
|
121
|
+
const { applier, listHandle } = setupList();
|
|
122
|
+
setCells(applier, buildCells(["a", "b", "c", "d"]));
|
|
123
|
+
|
|
124
|
+
requestCell(listHandle, 0);
|
|
125
|
+
const signB = requestCell(listHandle, 1);
|
|
126
|
+
requestCell(listHandle, 2);
|
|
127
|
+
// Real tree order matches request order so far: a, b, c.
|
|
128
|
+
expect([listHandle.children[0], listHandle.children[1], listHandle.children[2]].map(textOf)).toEqual(["0:a", "1:b", "2:c"]);
|
|
129
|
+
|
|
130
|
+
releaseCell(listHandle, signB); // b's wrapper goes to the recycle pool, still sitting in the middle of the tree
|
|
131
|
+
requestCell(listHandle, 3); // recycled for "d" — index 3 is after every other attached cell, so it belongs at the END
|
|
132
|
+
|
|
133
|
+
const children = [listHandle.children[0], listHandle.children[1], listHandle.children[2]];
|
|
134
|
+
expect(children.map(textOf)).toEqual(["0:a", "2:c", "3:d"]); // not ["0:a", "3:d", "2:c"] — the bug this test guards against
|
|
135
|
+
});
|
|
136
|
+
|
|
119
137
|
it("a tap inside a cell dispatches through the background thread's own fake-dom node", () => {
|
|
120
138
|
const { document, buildCells } = makeCellSource(() =>
|
|
121
139
|
m("text", { ontap: () => { taps += 1; } }, "tap me"),
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "@rstest/core";
|
|
2
|
+
import { Op, OP_ARITY, PROTOCOL_VERSION, forEachOp, pushOp } from "../src/patch-protocol.js";
|
|
3
|
+
|
|
4
|
+
// M8: OP_ARITY is the "single source of truth" for walking a flat op array,
|
|
5
|
+
// but apply-patch.js re-implements the arity inline in its own switch. These
|
|
6
|
+
// tests lock the two to each other: every opcode must have an arity entry
|
|
7
|
+
// (no gap → no silent desync in forEachOp/list-cell), and the version prefix
|
|
8
|
+
// must never collide with a real opcode.
|
|
9
|
+
describe("patch-protocol OP_ARITY completeness", () => {
|
|
10
|
+
it("has an arity entry for every opcode in Op", () => {
|
|
11
|
+
const opcodes = Object.values(Op) as number[];
|
|
12
|
+
expect(opcodes.length).toBeGreaterThan(0);
|
|
13
|
+
for (const opcode of opcodes) {
|
|
14
|
+
expect(OP_ARITY[opcode], `OP_ARITY is missing opcode ${opcode}`).toBeDefined();
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("PROTOCOL_VERSION does not collide with any opcode value", () => {
|
|
19
|
+
for (const opcode of Object.values(Op) as number[]) {
|
|
20
|
+
expect(opcode).not.toBe(PROTOCOL_VERSION);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("forEachOp walks a flat array built by pushOp without desyncing", () => {
|
|
25
|
+
const ops: unknown[] = [];
|
|
26
|
+
pushOp(ops, Op.CreateElement, "view", 1);
|
|
27
|
+
pushOp(ops, Op.SetText, 1, "hi");
|
|
28
|
+
pushOp(ops, Op.InsertBefore, 0, 1, -1);
|
|
29
|
+
|
|
30
|
+
const seen: number[] = [];
|
|
31
|
+
forEachOp(ops, (opcode, args) => {
|
|
32
|
+
seen.push(opcode);
|
|
33
|
+
if (opcode === Op.InsertBefore) expect(args).toEqual([0, 1, -1]);
|
|
34
|
+
});
|
|
35
|
+
expect(seen).toEqual([Op.CreateElement, Op.SetText, Op.InsertBefore]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("forEachOp throws on an unknown opcode instead of silently desyncing", () => {
|
|
39
|
+
expect(() => forEachOp([999], () => {})).toThrow(/Unknown patch opcode/);
|
|
40
|
+
});
|
|
41
|
+
});
|