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.
Files changed (46) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/FETCH_INVESTIGATION.md +3 -2
  3. package/INPUT.md +115 -0
  4. package/README.md +47 -8
  5. package/ROUTE.md +111 -16
  6. package/ROUTE_CONTRACT_ANALYSIS.md +224 -0
  7. package/UPDATE_ANIMATION_GAP.md +133 -0
  8. package/package.json +1 -9
  9. package/plugin.js +33 -0
  10. package/src/apply-patch.js +369 -60
  11. package/src/backends/virtual-backend.js +120 -22
  12. package/src/background.d.ts +26 -0
  13. package/src/background.js +116 -7
  14. package/src/channel.js +22 -5
  15. package/src/commit.js +9 -0
  16. package/src/dev-reload-client.js +46 -0
  17. package/src/dev-transport-noop.js +3 -0
  18. package/src/fake-dom.js +559 -61
  19. package/src/list-attributes.js +69 -0
  20. package/src/list-runtime.js +425 -0
  21. package/src/main-thread.js +18 -1
  22. package/src/mount-redraw.js +14 -1
  23. package/src/patch-protocol.js +41 -16
  24. package/src/reload/version.js +10 -1
  25. package/src/request.js +38 -0
  26. package/src/route.d.ts +30 -2
  27. package/src/route.js +214 -56
  28. package/src/testing.d.ts +14 -1
  29. package/src/testing.js +56 -1
  30. package/test/event-render-coalescing.test.ts +117 -0
  31. package/test/gesture.test.ts +75 -0
  32. package/test/input.test.ts +388 -0
  33. package/test/list.test.ts +417 -113
  34. package/test/remove-subtree-cleanup.test.ts +75 -0
  35. package/test/route-hot-reload.test.ts +2 -1
  36. package/test/route.test.ts +201 -4
  37. package/test/style-clear.test.ts +97 -0
  38. package/.omo/plans/m-request-fetch-lynx.md +0 -306
  39. package/.omo/plans/m-route-en-memoria.md +0 -397
  40. package/.omo/plans/mithril-lynx-v2-desde-cero.md +0 -548
  41. package/.omo/run-continuation/ses_f2f02336cffeO0ssvuxqZ05iZq.json +0 -10
  42. package/.omo/run-continuation/ses_f2fd5cb5fffeN1tjdUyPqdKG6B.json +0 -10
  43. package/src/list-cell.d.ts +0 -20
  44. package/src/list-cell.js +0 -73
  45. package/src/list-support.d.ts +0 -13
  46. package/src/list-support.js +0 -197
@@ -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 0..17 opcode range (0x4d4c = "ML"
25
- * in ASCII) so a versioned array can never be misread as an op sequence if
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 = 0x4d4c;
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
- // A native virtualized list — see docs/native-papi/papi-06-virtualized-lists.md
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.
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, e.g. list-cell.js's
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.CreateList]: 4, // id, scrollOrientation, listType, spanCount
102
- [Op.SetListItems]: 2, // id, cells
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++];
@@ -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
- 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. */
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;