mithril-lynx 2.6.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/FETCH_INVESTIGATION.md +3 -2
- package/INPUT.md +115 -0
- package/README.md +47 -8
- package/ROUTE.md +111 -16
- package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
- package/UPDATE_ANIMATION_GAP.md +133 -0
- package/package.json +1 -9
- package/plugin.js +33 -0
- package/src/apply-patch.js +369 -60
- package/src/backends/virtual-backend.js +120 -22
- package/src/background.d.ts +26 -0
- package/src/background.js +116 -7
- package/src/channel.js +22 -5
- package/src/commit.js +9 -0
- package/src/dev-reload-client.js +46 -0
- package/src/dev-transport-noop.js +3 -0
- package/src/fake-dom.js +559 -61
- package/src/list-attributes.js +69 -0
- package/src/list-runtime.js +425 -0
- package/src/main-thread.js +18 -1
- package/src/mount-redraw.js +14 -1
- package/src/patch-protocol.js +41 -16
- package/src/reload/version.js +10 -1
- package/src/request.js +38 -0
- package/src/route.d.ts +30 -2
- package/src/route.js +214 -56
- package/src/testing.d.ts +14 -1
- package/src/testing.js +56 -1
- package/test/event-render-coalescing.test.ts +117 -0
- package/test/gesture.test.ts +75 -0
- package/test/input.test.ts +388 -0
- package/test/list.test.ts +417 -113
- package/test/remove-subtree-cleanup.test.ts +75 -0
- package/test/route-hot-reload.test.ts +2 -1
- package/test/route.test.ts +201 -4
- package/test/style-clear.test.ts +97 -0
- package/.omo/plans/m-request-fetch-lynx.md +0 -306
- package/.omo/plans/m-route-en-memoria.md +0 -397
- package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
- package/.omo/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
- package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +0 -10
- package/src/list-cell.d.ts +0 -20
- package/src/list-cell.js +0 -73
- package/src/list-support.d.ts +0 -13
- package/src/list-support.js +0 -197
package/src/patch-protocol.js
CHANGED
|
@@ -21,11 +21,14 @@
|
|
|
21
21
|
* could otherwise re-interpret a reordered opcode silently — a version
|
|
22
22
|
* mismatch throws instead of corrupting the mirrored id space.
|
|
23
23
|
*
|
|
24
|
-
* The value is deliberately outside the
|
|
25
|
-
* in ASCII
|
|
24
|
+
* The value is deliberately outside the opcode range (0x4d4c was "ML"
|
|
25
|
+
* in ASCII; bumped to 0x4d4d in 3.0.0, when the Op.CreateList /
|
|
26
|
+
* Op.SetListItems list protocol was replaced by plain `list`/`list-item`
|
|
27
|
+
* elements, and to 0x4d4e, also in 3.0.0, when Op.InvokeUIMethod /
|
|
28
|
+
* Op.SetInputValue were added) so a versioned array can never be misread as an op sequence if
|
|
26
29
|
* it is ever fed to `applyPatch()` without the strip.
|
|
27
30
|
*/
|
|
28
|
-
export const PROTOCOL_VERSION =
|
|
31
|
+
export const PROTOCOL_VERSION = 0x4d4e;
|
|
29
32
|
|
|
30
33
|
export const Op = Object.freeze({
|
|
31
34
|
CreateElement: 0,
|
|
@@ -56,20 +59,38 @@ export const Op = Object.freeze({
|
|
|
56
59
|
// background-thread side.
|
|
57
60
|
SetGestureDetector: 14, // id, gestureId, gestureType, arenaPolicy
|
|
58
61
|
RemoveGestureDetector: 15, // id, gestureId
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// (
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
// Calls a native UI method (`focus`, `getValue`, `scrollTo`, …) through
|
|
63
|
+
// the main-thread `__InvokeUIMethod` PAPI — the path ReactLynx's
|
|
64
|
+
// main-thread `Element.invoke()` uses. Deferred until after the patch's
|
|
65
|
+
// `__FlushElementTree()`, so an element created by the same patch
|
|
66
|
+
// already exists natively. callbackId 0 = no result wanted; otherwise
|
|
67
|
+
// the result comes back as an INVOKE_RESULT_EVENT forwarded event.
|
|
68
|
+
InvokeUIMethod: 16, // id, method, params, callbackId
|
|
69
|
+
// `value` of an <input>/<textarea>: a `setValue` UI method call guarded
|
|
70
|
+
// by `seq`, the number of native `input` events the background had seen
|
|
71
|
+
// for this field when it computed the value. The main thread drops it
|
|
72
|
+
// when the user has typed since (a newer `input` event is in flight and
|
|
73
|
+
// will re-render) — see apply-patch.js.
|
|
74
|
+
SetInputValue: 17, // id, value, seq
|
|
67
75
|
});
|
|
68
76
|
|
|
77
|
+
/** The reserved forwarded-event type that carries an Op.InvokeUIMethod
|
|
78
|
+
* result (`{ callbackId, code, data }`) back to the background thread, over
|
|
79
|
+
* the same channel as native events. Never an element's own event type. */
|
|
80
|
+
export const INVOKE_RESULT_EVENT = "mithrilLynx:invokeResult";
|
|
81
|
+
|
|
82
|
+
/** Tags whose `value` is a native UI method call (Op.SetInputValue) rather
|
|
83
|
+
* than an attribute. */
|
|
84
|
+
export const FORM_FIELD_TAGS = Object.freeze(["input", "textarea"]);
|
|
85
|
+
|
|
69
86
|
/**
|
|
70
87
|
* Encodes one op onto a flat ops array. Kept as a tiny helper (not a class)
|
|
71
88
|
* so the hot path (called on every attribute/child mutation during a real
|
|
72
89
|
* Mithril diff) is just array pushes — no object allocation per op.
|
|
90
|
+
* @param {unknown[]} ops - The flat ops array to append to.
|
|
91
|
+
* @param {number} opcode - One of the `Op` codes.
|
|
92
|
+
* @param {...unknown} args - The op's arguments (see `OP_ARITY`).
|
|
93
|
+
* @returns {void}
|
|
73
94
|
*/
|
|
74
95
|
export function pushOp(ops, opcode, ...args) {
|
|
75
96
|
ops.push(opcode, ...args);
|
|
@@ -79,8 +100,7 @@ export function pushOp(ops, opcode, ...args) {
|
|
|
79
100
|
// for walking a flat ops array without re-interpreting it (apply-patch.js's
|
|
80
101
|
// own switch increments `i` inline instead of using this table, since it
|
|
81
102
|
// also needs to look at individual arg values as it goes; this exists for
|
|
82
|
-
// callers that only need to skip/scan
|
|
83
|
-
// findTopLevelChildIds(), which never applies the ops itself).
|
|
103
|
+
// callers that only need to skip/scan an ops array without applying it).
|
|
84
104
|
export const OP_ARITY = Object.freeze({
|
|
85
105
|
[Op.CreateElement]: 2, // tag, id
|
|
86
106
|
[Op.CreateElementNS]: 3, // ns, tag, id
|
|
@@ -98,13 +118,18 @@ export const OP_ARITY = Object.freeze({
|
|
|
98
118
|
[Op.RemoveEvent]: 2, // id, type
|
|
99
119
|
[Op.SetGestureDetector]: 4, // id, gestureId, gestureType, arenaPolicy
|
|
100
120
|
[Op.RemoveGestureDetector]: 2, // id, gestureId
|
|
101
|
-
[Op.
|
|
102
|
-
[Op.
|
|
121
|
+
[Op.InvokeUIMethod]: 4, // id, method, params, callbackId
|
|
122
|
+
[Op.SetInputValue]: 3, // id, value, seq
|
|
103
123
|
});
|
|
104
124
|
|
|
105
125
|
/** Walks a flat ops array, calling `visit(opcode, args)` once per op — args
|
|
106
126
|
* 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.
|
|
127
|
+
* itself). Throws on an unknown opcode rather than silently desyncing.
|
|
128
|
+
* @param {unknown[]} ops - A flat ops array.
|
|
129
|
+
* @param {(opcode: number, args: unknown[]) => void} visit - Called once per op with its arguments.
|
|
130
|
+
* @returns {void}
|
|
131
|
+
* @throws {Error} On an unknown opcode.
|
|
132
|
+
*/
|
|
108
133
|
export function forEachOp(ops, visit) {
|
|
109
134
|
for (let i = 0; i < ops.length; ) {
|
|
110
135
|
const opcode = ops[i++];
|
package/src/reload/version.js
CHANGED
|
@@ -13,16 +13,25 @@
|
|
|
13
13
|
|
|
14
14
|
let version = 0;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* @returns {number} The current reload version.
|
|
18
|
+
*/
|
|
16
19
|
export function getReloadVersion() {
|
|
17
20
|
return version;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
/**
|
|
24
|
+
* @returns {number} The new reload version, after incrementing.
|
|
25
|
+
*/
|
|
20
26
|
export function increaseReloadVersion() {
|
|
21
27
|
return ++version;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/** True if a patch stamped with `patchVersion` is stale and must be
|
|
25
|
-
* dropped without being applied — the ENTIRE guard, one comparison.
|
|
31
|
+
* dropped without being applied — the ENTIRE guard, one comparison.
|
|
32
|
+
* @param {*} patchVersion - The version stamped on a patch.
|
|
33
|
+
* @returns {boolean} `true` when `patchVersion` is a number older than the current version.
|
|
34
|
+
*/
|
|
26
35
|
export function isStaleVersion(patchVersion) {
|
|
27
36
|
return typeof patchVersion === "number" && patchVersion < version;
|
|
28
37
|
}
|
package/src/request.js
CHANGED
|
@@ -32,6 +32,12 @@ const UNSUPPORTED = [
|
|
|
32
32
|
["withCredentials", (o) => o.withCredentials === true],
|
|
33
33
|
];
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Throws if `options` uses an option Lynx's fetch cannot honor.
|
|
37
|
+
* @param {Object} options - The request options.
|
|
38
|
+
* @returns {void}
|
|
39
|
+
* @throws {Error} If an unsupported option (`config`, `async: false`, `user`, `password`, `withCredentials`) is present.
|
|
40
|
+
*/
|
|
35
41
|
function checkUnsupported(options) {
|
|
36
42
|
for (const [name, matches] of UNSUPPORTED) {
|
|
37
43
|
if (matches(options)) {
|
|
@@ -44,6 +50,12 @@ function checkUnsupported(options) {
|
|
|
44
50
|
}
|
|
45
51
|
}
|
|
46
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Case-insensitive own-property check for a header name.
|
|
55
|
+
* @param {Object<string, string>} headers - The headers object.
|
|
56
|
+
* @param {string} name - The lowercase header name to look for.
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
47
59
|
function hasHeader(headers, name) {
|
|
48
60
|
for (const key in headers) {
|
|
49
61
|
if (Object.prototype.hasOwnProperty.call(headers, key) && key.toLowerCase() === name) return true;
|
|
@@ -51,6 +63,12 @@ function hasHeader(headers, name) {
|
|
|
51
63
|
return false;
|
|
52
64
|
}
|
|
53
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Wraps response data in `Type` instances, per item for arrays.
|
|
68
|
+
* @param {*} data - The response data.
|
|
69
|
+
* @param {Function} [Type] - A constructor; when not a function, `data` is returned unchanged.
|
|
70
|
+
* @returns {*} The wrapped data.
|
|
71
|
+
*/
|
|
54
72
|
function applyType(data, Type) {
|
|
55
73
|
if (typeof Type !== "function") return data;
|
|
56
74
|
if (Array.isArray(data)) return data.map((item) => new Type(item));
|
|
@@ -63,10 +81,18 @@ function applyType(data, Type) {
|
|
|
63
81
|
* so importing this file never requires `lynx` to already exist) —
|
|
64
82
|
* same pattern route.js uses for `renderApp`. Tests inject a fake here
|
|
65
83
|
* instead of hitting a real network.
|
|
84
|
+
* @returns {(url: string|Object, options?: Object) => Promise<*>} A `request` function shaped like `m.request`; its promise has an extra `abort()` method.
|
|
66
85
|
*/
|
|
67
86
|
export function createRequestor(fetchImpl) {
|
|
68
87
|
const doFetch = fetchImpl || ((url, init) => lynx.fetch(url, init));
|
|
69
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Performs a request and redraws when it settles (unless `options.background` is `true`).
|
|
91
|
+
* @param {string|Object} url - The URL, or an options object containing `url`.
|
|
92
|
+
* @param {Object} [options] - `m.request`-style options (`method`, `params`, `body`, `headers`, `serialize`, `deserialize`, `extract`, `type`, `responseType`, `timeout`, `signal`, `background`).
|
|
93
|
+
* @returns {Promise<*> & {abort: () => void}} Resolves with the (typed) response data; rejects with an `Error` carrying `code` and `response` for non-OK statuses.
|
|
94
|
+
* @throws {Error} Synchronously, for unsupported options or a `FormData` body.
|
|
95
|
+
*/
|
|
70
96
|
return function request(url, options) {
|
|
71
97
|
if (typeof url !== "string") {
|
|
72
98
|
options = url;
|
|
@@ -115,6 +141,10 @@ export function createRequestor(fetchImpl) {
|
|
|
115
141
|
// Stored so the listener can be detached on settle (see detachSignal
|
|
116
142
|
// below) — a long-lived shared AbortSignal would otherwise accumulate
|
|
117
143
|
// one dead closure per request.
|
|
144
|
+
/**
|
|
145
|
+
* Aborts the internal controller when the caller's signal aborts.
|
|
146
|
+
* @returns {void}
|
|
147
|
+
*/
|
|
118
148
|
const abortHandler = () => ctrl.abort();
|
|
119
149
|
if (options.signal) {
|
|
120
150
|
if (options.signal.aborted) ctrl.abort();
|
|
@@ -127,6 +157,10 @@ export function createRequestor(fetchImpl) {
|
|
|
127
157
|
: setTimeout;
|
|
128
158
|
timeoutId = schedule(() => ctrl.abort(), options.timeout);
|
|
129
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Cancels the pending timeout, if any.
|
|
162
|
+
* @returns {void}
|
|
163
|
+
*/
|
|
130
164
|
function clearRequestTimeout() {
|
|
131
165
|
if (timeoutId == null) return;
|
|
132
166
|
const clear = typeof lynx !== "undefined" && typeof lynx.clearTimeout === "function"
|
|
@@ -166,6 +200,10 @@ export function createRequestor(fetchImpl) {
|
|
|
166
200
|
// long-lived shared AbortSignal doesn't accumulate a dead closure per
|
|
167
201
|
// request. Done inside the settled handlers (rather than a separate
|
|
168
202
|
// `.then`) so an ignored rejection still surfaces as unhandled.
|
|
203
|
+
/**
|
|
204
|
+
* Detaches the abort listener from the caller's signal.
|
|
205
|
+
* @returns {void}
|
|
206
|
+
*/
|
|
169
207
|
const detachSignal = () => {
|
|
170
208
|
if (options.signal && !options.signal.aborted) {
|
|
171
209
|
options.signal.removeEventListener("abort", abortHandler);
|
package/src/route.d.ts
CHANGED
|
@@ -21,13 +21,34 @@ export interface RouteLinkAttrs {
|
|
|
21
21
|
params?: Record<string, unknown>;
|
|
22
22
|
options?: { replace?: boolean };
|
|
23
23
|
disabled?: boolean;
|
|
24
|
-
ontap?: (e: unknown) => unknown;
|
|
24
|
+
ontap?: ((e: unknown) => unknown) | { handleEvent(e: unknown): void };
|
|
25
25
|
[key: string]: unknown;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface RouteSetOptions {
|
|
29
|
+
/** Overwrite the current history entry instead of pushing a new one. */
|
|
30
|
+
replace?: boolean;
|
|
31
|
+
/** Merged into the route's params; restored by back()/forward(). */
|
|
32
|
+
state?: Record<string, unknown>;
|
|
33
|
+
/** Accepted for m.route compatibility; ignored (no title bar). */
|
|
34
|
+
title?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ListenBackButtonOptions {
|
|
38
|
+
/** Global event the host sends when back is pressed. @defaultValue "mithrilLynx:back" */
|
|
39
|
+
eventName?: string;
|
|
40
|
+
/** Called with canGoBack() right away and whenever it changes — e.g. to
|
|
41
|
+
* enable/disable the host's OnBackPressedCallback through a native module. */
|
|
42
|
+
onCanGoBackChange?: (canGoBack: boolean) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
export interface Route {
|
|
46
|
+
/** `defaultRoute` is required: Lynx has no URL to start from. */
|
|
29
47
|
(defaultRoute: string, routes: Record<string, unknown | RouteResolver>): void;
|
|
30
|
-
|
|
48
|
+
/** Updates the history now; the new screen resolves on the next microtask
|
|
49
|
+
* (like m.route), so get() returns the previous path until then. */
|
|
50
|
+
set(path: string, data?: unknown, options?: RouteSetOptions): void;
|
|
51
|
+
/** The current path, decoded. */
|
|
31
52
|
get(): string | undefined;
|
|
32
53
|
/** Returns the named route parameter (with `key`), or the whole params
|
|
33
54
|
* object (without). Typed `unknown` because the value can be a string
|
|
@@ -41,6 +62,10 @@ export interface Route {
|
|
|
41
62
|
/** Walks forward one entry; returns `false` (without navigating) at the
|
|
42
63
|
* end of the history stack so a forward affordance can disable itself. */
|
|
43
64
|
forward(): boolean;
|
|
65
|
+
/** Whether back() would navigate. */
|
|
66
|
+
canGoBack(): boolean;
|
|
67
|
+
/** Opt-in Android back button bridge (see ROUTE.md). Returns a function that stops listening. */
|
|
68
|
+
listenBackButton(options?: ListenBackButtonOptions): () => void;
|
|
44
69
|
prefix: string;
|
|
45
70
|
SKIP: unknown;
|
|
46
71
|
Link: Component<RouteLinkAttrs>;
|
|
@@ -48,5 +73,8 @@ export interface Route {
|
|
|
48
73
|
|
|
49
74
|
export function createRoute(): Route;
|
|
50
75
|
|
|
76
|
+
/** Default event name listened to by `route.listenBackButton()`. */
|
|
77
|
+
export declare const BACK_EVENT_NAME: string;
|
|
78
|
+
|
|
51
79
|
declare const route: Route;
|
|
52
80
|
export default route;
|