mithril-lynx 2.6.0 → 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.
Files changed (55) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/LICENSE.txt +21 -0
  5. package/PLAN_REVIEW.md +134 -0
  6. package/README.md +47 -7
  7. package/REQUEST.md +5 -2
  8. package/ROUTE.md +111 -16
  9. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  10. package/UPDATE_ANIMATION_GAP.md +133 -0
  11. package/informe-contrato-mithril-lynx.md +206 -0
  12. package/package.json +1 -9
  13. package/plugin.js +33 -0
  14. package/prompts-analisis-contrato-mithril-lynx.md +141 -0
  15. package/src/apply-patch.js +408 -68
  16. package/src/backends/virtual-backend.js +120 -22
  17. package/src/background.d.ts +40 -1
  18. package/src/background.js +116 -7
  19. package/src/channel.js +32 -7
  20. package/src/commit.js +9 -0
  21. package/src/dev-reload-client.js +46 -0
  22. package/src/dev-transport-noop.js +3 -0
  23. package/src/fake-dom.js +559 -61
  24. package/src/list-attributes.js +69 -0
  25. package/src/list-runtime.js +425 -0
  26. package/src/main-thread.js +33 -3
  27. package/src/mount-redraw.d.ts +10 -0
  28. package/src/mount-redraw.js +69 -3
  29. package/src/patch-protocol.js +52 -13
  30. package/src/reload/version.js +10 -1
  31. package/src/request.d.ts +2 -0
  32. package/src/request.js +59 -3
  33. package/src/route.d.ts +52 -5
  34. package/src/route.js +236 -59
  35. package/src/testing.d.ts +14 -1
  36. package/src/testing.js +79 -11
  37. package/test/event-render-coalescing.test.ts +117 -0
  38. package/test/gesture.test.ts +75 -0
  39. package/test/input.test.ts +388 -0
  40. package/test/list.test.ts +422 -100
  41. package/test/patch-protocol.test.ts +41 -0
  42. package/test/remove-event.test.ts +26 -0
  43. package/test/remove-subtree-cleanup.test.ts +75 -0
  44. package/test/route-hot-reload.test.ts +4 -2
  45. package/test/route.test.ts +206 -6
  46. package/test/setup.ts +10 -0
  47. package/test/style-clear.test.ts +97 -0
  48. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  49. package/.omo/plans/m-route-en-memoria.md +0 -397
  50. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  51. package/.omo/run-continuation/ses_f48265d07ffenKG0GAz9ZRFvY6.json +0 -10
  52. package/src/list-cell.d.ts +0 -20
  53. package/src/list-cell.js +0 -73
  54. package/src/list-support.d.ts +0 -13
  55. package/src/list-support.js +0 -171
@@ -13,6 +13,23 @@
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 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
29
+ * it is ever fed to `applyPatch()` without the strip.
30
+ */
31
+ export const PROTOCOL_VERSION = 0x4d4e;
32
+
16
33
  export const Op = Object.freeze({
17
34
  CreateElement: 0,
18
35
  CreateElementNS: 1,
@@ -42,20 +59,38 @@ export const Op = Object.freeze({
42
59
  // background-thread side.
43
60
  SetGestureDetector: 14, // id, gestureId, gestureType, arenaPolicy
44
61
  RemoveGestureDetector: 15, // id, gestureId
45
- // A native virtualized list — see docs/native-papi/papi-06-virtualized-lists.md
46
- // in mithril-lynx-ui for the full design. Each list item is rendered on
47
- // the BACKGROUND thread, through the app's own real render pass
48
- // (list-cell.js), same as everything else in the tree — the main thread
49
- // never runs renderItem() itself, only replays the ops that rendering
50
- // already produced (apply-patch.js's Op.CreateList case + list-support.js).
51
- CreateList: 16, // id, scrollOrientation, listType, spanCount
52
- SetListItems: 17, // id, cellsJSON — cellsJSON = JSON.stringify(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.
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
53
75
  });
54
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
+
55
86
  /**
56
87
  * Encodes one op onto a flat ops array. Kept as a tiny helper (not a class)
57
88
  * so the hot path (called on every attribute/child mutation during a real
58
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}
59
94
  */
60
95
  export function pushOp(ops, opcode, ...args) {
61
96
  ops.push(opcode, ...args);
@@ -65,8 +100,7 @@ export function pushOp(ops, opcode, ...args) {
65
100
  // for walking a flat ops array without re-interpreting it (apply-patch.js's
66
101
  // own switch increments `i` inline instead of using this table, since it
67
102
  // also needs to look at individual arg values as it goes; this exists for
68
- // callers that only need to skip/scan, e.g. list-cell.js's
69
- // findTopLevelChildIds(), which never applies the ops itself).
103
+ // callers that only need to skip/scan an ops array without applying it).
70
104
  export const OP_ARITY = Object.freeze({
71
105
  [Op.CreateElement]: 2, // tag, id
72
106
  [Op.CreateElementNS]: 3, // ns, tag, id
@@ -84,13 +118,18 @@ export const OP_ARITY = Object.freeze({
84
118
  [Op.RemoveEvent]: 2, // id, type
85
119
  [Op.SetGestureDetector]: 4, // id, gestureId, gestureType, arenaPolicy
86
120
  [Op.RemoveGestureDetector]: 2, // id, gestureId
87
- [Op.CreateList]: 4, // id, scrollOrientation, listType, spanCount
88
- [Op.SetListItems]: 2, // id, cellsJSON
121
+ [Op.InvokeUIMethod]: 4, // id, method, params, callbackId
122
+ [Op.SetInputValue]: 3, // id, value, seq
89
123
  });
90
124
 
91
125
  /** Walks a flat ops array, calling `visit(opcode, args)` once per op — args
92
126
  * is the plain slice of that op's own arguments (not including the opcode
93
- * 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
+ */
94
133
  export function forEachOp(ops, visit) {
95
134
  for (let i = 0; i < ops.length; ) {
96
135
  const opcode = ops[i++];
@@ -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.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
@@ -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;
@@ -112,9 +138,17 @@ export function createRequestor(fetchImpl) {
112
138
  // controller so a caller-provided signal and our timeout can both
113
139
  // trigger the same abort.
114
140
  const ctrl = new AbortController();
141
+ // Stored so the listener can be detached on settle (see detachSignal
142
+ // below) — a long-lived shared AbortSignal would otherwise accumulate
143
+ // one dead closure per request.
144
+ /**
145
+ * Aborts the internal controller when the caller's signal aborts.
146
+ * @returns {void}
147
+ */
148
+ const abortHandler = () => ctrl.abort();
115
149
  if (options.signal) {
116
150
  if (options.signal.aborted) ctrl.abort();
117
- else options.signal.addEventListener("abort", () => ctrl.abort());
151
+ else options.signal.addEventListener("abort", abortHandler);
118
152
  }
119
153
  let timeoutId;
120
154
  if (options.timeout) {
@@ -123,6 +157,10 @@ export function createRequestor(fetchImpl) {
123
157
  : setTimeout;
124
158
  timeoutId = schedule(() => ctrl.abort(), options.timeout);
125
159
  }
160
+ /**
161
+ * Cancels the pending timeout, if any.
162
+ * @returns {void}
163
+ */
126
164
  function clearRequestTimeout() {
127
165
  if (timeoutId == null) return;
128
166
  const clear = typeof lynx !== "undefined" && typeof lynx.clearTimeout === "function"
@@ -138,8 +176,10 @@ export function createRequestor(fetchImpl) {
138
176
 
139
177
  if (typeof options.extract === "function") {
140
178
  // Matches real m.request: extract() bypasses the status check
141
- // entirely — it decides success/failure itself.
142
- return options.extract(response, options);
179
+ // entirely — it decides success/failure itself. `type` is still
180
+ // applied afterwards, exactly as upstream does (extract does NOT
181
+ // skip the type constructor).
182
+ return applyType(options.extract(response, options), options.type);
143
183
  }
144
184
 
145
185
  const ok = response.ok || response.status === 304;
@@ -156,12 +196,28 @@ export function createRequestor(fetchImpl) {
156
196
  });
157
197
  });
158
198
 
199
+ // Detach the caller's signal listener once this request settles, so a
200
+ // long-lived shared AbortSignal doesn't accumulate a dead closure per
201
+ // request. Done inside the settled handlers (rather than a separate
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
+ */
207
+ const detachSignal = () => {
208
+ if (options.signal && !options.signal.aborted) {
209
+ options.signal.removeEventListener("abort", abortHandler);
210
+ }
211
+ };
212
+
159
213
  const result = promise.then(
160
214
  (value) => {
215
+ detachSignal();
161
216
  if (options.background !== true) sharedRedraw();
162
217
  return value;
163
218
  },
164
219
  (error) => {
220
+ detachSignal();
165
221
  clearRequestTimeout();
166
222
  if (options.background !== true) sharedRedraw();
167
223
  throw error;
package/src/route.d.ts CHANGED
@@ -1,4 +1,14 @@
1
- import type { Component } from "mithril";
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;
@@ -11,17 +21,51 @@ export interface RouteLinkAttrs {
11
21
  params?: Record<string, unknown>;
12
22
  options?: { replace?: boolean };
13
23
  disabled?: boolean;
14
- ontap?: (e: unknown) => unknown;
24
+ ontap?: ((e: unknown) => unknown) | { handleEvent(e: unknown): void };
15
25
  [key: string]: unknown;
16
26
  }
17
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
+
18
45
  export interface Route {
46
+ /** `defaultRoute` is required: Lynx has no URL to start from. */
19
47
  (defaultRoute: string, routes: Record<string, unknown | RouteResolver>): void;
20
- set(path: string, data?: unknown, options?: { replace?: boolean }): void;
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. */
21
52
  get(): string | undefined;
53
+ /** Returns the named route parameter (with `key`), or the whole params
54
+ * object (without). Typed `unknown` because the value can be a string
55
+ * (path params), a string|boolean (query params — `"true"`/`"false"` are
56
+ * coerced), or anything passed as `data` to `set(path, data)`; the common
57
+ * path-param case is always a string. */
22
58
  param(key?: string): unknown;
23
- back(): void;
24
- forward(): void;
59
+ /** Walks back one entry; returns `false` (without navigating) at the top
60
+ * of the history stack so a back affordance can disable itself. */
61
+ back(): boolean;
62
+ /** Walks forward one entry; returns `false` (without navigating) at the
63
+ * end of the history stack so a forward affordance can disable itself. */
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;
25
69
  prefix: string;
26
70
  SKIP: unknown;
27
71
  Link: Component<RouteLinkAttrs>;
@@ -29,5 +73,8 @@ export interface Route {
29
73
 
30
74
  export function createRoute(): Route;
31
75
 
76
+ /** Default event name listened to by `route.listenBackButton()`. */
77
+ export declare const BACK_EVENT_NAME: string;
78
+
32
79
  declare const route: Route;
33
80
  export default route;