mithril-lynx 0.0.9 → 2.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 (61) hide show
  1. package/.omo/plans/m-request-fetch-lynx.md +306 -0
  2. package/.omo/plans/m-route-en-memoria.md +397 -0
  3. package/.omo/plans/mithril-lynx-v2-desde-cero.md +548 -0
  4. package/FETCH_INVESTIGATION.md +307 -0
  5. package/README.md +32 -302
  6. package/REQUEST.md +71 -0
  7. package/ROUTE.md +71 -0
  8. package/package.json +24 -80
  9. package/plugin.d.ts +4 -33
  10. package/plugin.js +108 -438
  11. package/rstest.config.ts +27 -0
  12. package/src/apply-patch.js +179 -0
  13. package/src/backends/virtual-backend.js +80 -0
  14. package/src/background.d.ts +11 -0
  15. package/src/background.js +79 -0
  16. package/src/channel.js +41 -0
  17. package/src/commit.js +67 -0
  18. package/src/dev-reload-client.js +171 -187
  19. package/src/dev-transport-noop.js +10 -0
  20. package/src/fake-dom.js +374 -0
  21. package/src/main-thread.d.ts +1 -0
  22. package/src/main-thread.js +68 -0
  23. package/src/mount-redraw.js +67 -0
  24. package/src/patch-protocol.js +40 -0
  25. package/src/reload/version.js +28 -0
  26. package/src/request.d.ts +37 -0
  27. package/src/request.js +181 -0
  28. package/src/route.d.ts +33 -0
  29. package/src/route.js +207 -0
  30. package/test/end-to-end.test.ts +86 -0
  31. package/test/reload-version.test.ts +17 -0
  32. package/test/request.test.ts +182 -0
  33. package/test/route-hot-reload.test.ts +40 -0
  34. package/test/route.test.ts +152 -0
  35. package/test/setup.ts +25 -0
  36. package/test/structural-reload.test.ts +95 -0
  37. package/CONTRACT.md +0 -151
  38. package/LICENSE +0 -21
  39. package/background.d.ts +0 -54
  40. package/background.js +0 -169
  41. package/element.d.ts +0 -34
  42. package/element.js +0 -83
  43. package/gesture.d.ts +0 -40
  44. package/gesture.js +0 -117
  45. package/internal/constants.js +0 -26
  46. package/internal/virtual-node.js +0 -388
  47. package/list.d.ts +0 -31
  48. package/list.js +0 -185
  49. package/main-thread.d.ts +0 -43
  50. package/main-thread.js +0 -165
  51. package/navigation.d.ts +0 -35
  52. package/navigation.js +0 -76
  53. package/renderer/background.d.ts +0 -21
  54. package/renderer/background.js +0 -84
  55. package/renderer/main-thread.d.ts +0 -12
  56. package/renderer/main-thread.js +0 -175
  57. package/src/lynx-mithril-shim.d.ts +0 -16
  58. package/src/lynx-mithril-shim.js +0 -1505
  59. package/src/worklet-runtime.js +0 -82
  60. package/testing.d.ts +0 -10
  61. package/testing.js +0 -91
package/src/request.js ADDED
@@ -0,0 +1,181 @@
1
+ // src/request.js
2
+ //
3
+ // `m.request`-shaped wrapper over `lynx.fetch` — the subset confirmed
4
+ // faithful in FETCH_INVESTIGATION.md / .omo/plans/m-request-fetch-lynx.md.
5
+ // Every option below is either "works the same as real m.request" or
6
+ // throws immediately with a clear message pointing at the doc — never a
7
+ // silent behavior difference. See FETCH_INVESTIGATION.md §3 for the full
8
+ // option-by-option table this file implements.
9
+ //
10
+ // Explicitly NOT supported, by design, confirmed unfixable on Lynx:
11
+ // - `config(xhr)`: fetch has no live object to hand back.
12
+ // - `body` as `FormData`: confirmed absent at runtime (typeof FormData
13
+ // === "undefined"). `URLSearchParams` bodies DO work — confirmed on
14
+ // device — and need no special-casing here beyond not JSON-stringifying
15
+ // them.
16
+ // - `responseType: "blob"`/`"document"`: no `.blob()` on Lynx's `Body`;
17
+ // `"document"` has no meaning outside a browser DOM.
18
+ // - `user`/`password` (inline Basic Auth), `withCredentials`,
19
+ // `async: false`: XMLHttpRequest/browser-only concepts with no `fetch`
20
+ // equivalent, confirmed (withCredentials: no CORS model on Lynx;
21
+ // user/password: no `btoa` to build the Authorization header even if
22
+ // we wanted to guess one).
23
+
24
+ import buildPathname from "mithril-runtime/pathname/build.js";
25
+ import { redraw as sharedRedraw } from "./mount-redraw.js";
26
+
27
+ const UNSUPPORTED = [
28
+ ["config", (o) => o.config != null],
29
+ ["async: false", (o) => o.async === false],
30
+ ["user", (o) => typeof o.user === "string"],
31
+ ["password", (o) => typeof o.password === "string"],
32
+ ["withCredentials", (o) => o.withCredentials === true],
33
+ ];
34
+
35
+ function checkUnsupported(options) {
36
+ for (const [name, matches] of UNSUPPORTED) {
37
+ if (matches(options)) {
38
+ throw new Error(
39
+ `[mithril-lynx-v2/request] "${name}" is not supported — Lynx's fetch has no equivalent ` +
40
+ "(see FETCH_INVESTIGATION.md for exactly why). This throws instead of silently " +
41
+ "behaving differently from what you asked for.",
42
+ );
43
+ }
44
+ }
45
+ }
46
+
47
+ function hasHeader(headers, name) {
48
+ for (const key in headers) {
49
+ if (Object.prototype.hasOwnProperty.call(headers, key) && key.toLowerCase() === name) return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ function applyType(data, Type) {
55
+ if (typeof Type !== "function") return data;
56
+ if (Array.isArray(data)) return data.map((item) => new Type(item));
57
+ return new Type(data);
58
+ }
59
+
60
+ /**
61
+ * @param {(url: string, init: object) => Promise<Response>} [fetchImpl] -
62
+ * Defaults to `lynx.fetch`, looked up lazily (not at module load time,
63
+ * so importing this file never requires `lynx` to already exist) —
64
+ * same pattern route.js uses for `renderApp`. Tests inject a fake here
65
+ * instead of hitting a real network.
66
+ */
67
+ export function createRequestor(fetchImpl) {
68
+ const doFetch = fetchImpl || ((url, init) => lynx.fetch(url, init));
69
+
70
+ return function request(url, options) {
71
+ if (typeof url !== "string") {
72
+ options = url;
73
+ url = url.url;
74
+ } else if (options == null) {
75
+ options = {};
76
+ }
77
+ checkUnsupported(options);
78
+
79
+ if (typeof FormData !== "undefined" && options.body instanceof FormData) {
80
+ throw new Error(
81
+ "[mithril-lynx-v2/request] FormData bodies are not supported — Lynx has no FormData " +
82
+ "at runtime (confirmed absent, see FETCH_INVESTIGATION.md). Use lynx.fetch directly " +
83
+ "if you have another way to send this data, or restructure it as plain JSON.",
84
+ );
85
+ }
86
+
87
+ const method = options.method != null ? options.method.toUpperCase() : "GET";
88
+ const path = buildPathname(url, options.params);
89
+ const headers = Object.assign({}, options.headers);
90
+
91
+ let body;
92
+ if (options.body != null) {
93
+ if (typeof URLSearchParams !== "undefined" && options.body instanceof URLSearchParams) {
94
+ // Confirmed on device: fetch sets Content-Type automatically for
95
+ // this body type, exactly like a real browser.
96
+ body = options.body;
97
+ } else if (typeof options.serialize === "function") {
98
+ body = options.serialize(options.body);
99
+ } else {
100
+ body = JSON.stringify(options.body);
101
+ if (!hasHeader(headers, "content-type")) headers["Content-Type"] = "application/json; charset=utf-8";
102
+ }
103
+ }
104
+ if (typeof options.deserialize !== "function" && !hasHeader(headers, "accept")) {
105
+ headers["Accept"] = "application/json, text/*";
106
+ }
107
+
108
+ // Real cancellation/timeout — confirmed working on device (unlike
109
+ // what an earlier version of the investigation assumed): aborting
110
+ // actually tears down the in-flight connection, not just abandons
111
+ // the wait. `options.signal` (if given) is linked into our own
112
+ // controller so a caller-provided signal and our timeout can both
113
+ // trigger the same abort.
114
+ const ctrl = new AbortController();
115
+ if (options.signal) {
116
+ if (options.signal.aborted) ctrl.abort();
117
+ else options.signal.addEventListener("abort", () => ctrl.abort());
118
+ }
119
+ let timeoutId;
120
+ if (options.timeout) {
121
+ const schedule = typeof lynx !== "undefined" && typeof lynx.setTimeout === "function"
122
+ ? lynx.setTimeout.bind(lynx)
123
+ : setTimeout;
124
+ timeoutId = schedule(() => ctrl.abort(), options.timeout);
125
+ }
126
+ function clearRequestTimeout() {
127
+ if (timeoutId == null) return;
128
+ const clear = typeof lynx !== "undefined" && typeof lynx.clearTimeout === "function"
129
+ ? lynx.clearTimeout.bind(lynx)
130
+ : clearTimeout;
131
+ clear(timeoutId);
132
+ }
133
+
134
+ const responseType = options.responseType || (typeof options.extract === "function" ? "" : "json");
135
+
136
+ const promise = doFetch(path, { method, headers, body, signal: ctrl.signal }).then((response) => {
137
+ clearRequestTimeout();
138
+
139
+ if (typeof options.extract === "function") {
140
+ // Matches real m.request: extract() bypasses the status check
141
+ // entirely — it decides success/failure itself.
142
+ return options.extract(response, options);
143
+ }
144
+
145
+ const ok = response.ok || response.status === 304;
146
+ const bodyPromise = responseType === "text" ? response.text() : response.json();
147
+ return bodyPromise.then((data) => {
148
+ if (typeof options.deserialize === "function") data = options.deserialize(data);
149
+ if (!ok) {
150
+ const error = new Error(typeof data === "string" ? data : response.statusText);
151
+ error.code = response.status;
152
+ error.response = data;
153
+ throw error;
154
+ }
155
+ return applyType(data, options.type);
156
+ });
157
+ });
158
+
159
+ const result = promise.then(
160
+ (value) => {
161
+ if (options.background !== true) sharedRedraw();
162
+ return value;
163
+ },
164
+ (error) => {
165
+ clearRequestTimeout();
166
+ if (options.background !== true) sharedRedraw();
167
+ throw error;
168
+ },
169
+ );
170
+
171
+ // Not part of real m.request's API (there, you can only reach
172
+ // xhr.abort() through `config`) — free to add since we already have
173
+ // the controller, and it's exactly the escape hatch losing `config`
174
+ // takes away. Documented in FETCH_INVESTIGATION.md, not hidden.
175
+ result.abort = () => ctrl.abort();
176
+ return result;
177
+ };
178
+ }
179
+
180
+ const request = createRequestor();
181
+ export default request;
package/src/route.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { Component } from "mithril";
2
+
3
+ export interface RouteResolver {
4
+ onmatch?(args: Record<string, string>, requestedPath: string, route: string): unknown;
5
+ render?(vnode: unknown): unknown;
6
+ }
7
+
8
+ export interface RouteLinkAttrs {
9
+ href: string;
10
+ selector?: string;
11
+ params?: Record<string, unknown>;
12
+ options?: { replace?: boolean };
13
+ disabled?: boolean;
14
+ ontap?: (e: unknown) => unknown;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ export interface Route {
19
+ (defaultRoute: string, routes: Record<string, unknown | RouteResolver>): void;
20
+ set(path: string, data?: unknown, options?: { replace?: boolean }): void;
21
+ get(): string | undefined;
22
+ param(key?: string): unknown;
23
+ back(): void;
24
+ forward(): void;
25
+ prefix: string;
26
+ SKIP: unknown;
27
+ Link: Component<RouteLinkAttrs>;
28
+ }
29
+
30
+ export function createRoute(): Route;
31
+
32
+ declare const route: Route;
33
+ export default route;
package/src/route.js ADDED
@@ -0,0 +1,207 @@
1
+ // src/route.js
2
+ //
3
+ // In-memory `m.route`, replacing `window.history`/`popstate` with a plain
4
+ // array — the exact same pattern React Router's `MemoryRouter` and Vue
5
+ // Router's `createMemoryHistory()` use for Lynx (see
6
+ // .omo/plans/m-route-en-memoria.md §1–§3): both official framework
7
+ // integrations converge on "history lives in a JS array, not the browser",
8
+ // for the same reason we're doing it here — Lynx has no
9
+ // `window.location`/History API at all.
10
+ //
11
+ // Structured to mirror real Mithril's `api/router.js` as closely as
12
+ // possible (same variable names/flow for `resolveRoute`/`route.set`'s
13
+ // `hasBeenResolved` gate) so porting route-using app code only requires
14
+ // swapping the import, not relearning the control flow. The one
15
+ // unavoidable signature change: `m.route(root, defaultRoute, routes)`
16
+ // loses `root` — there is no DOM node to point it at in v2's architecture
17
+ // (a single `renderApp()` for the app's whole lifetime, plan §3.1) — see
18
+ // the plan §5.2 for why that's a deliberate, documented deviation rather
19
+ // than a fake DOM node just to keep the arg count.
20
+
21
+ import m from "mithril-runtime";
22
+ import buildPathname from "mithril-runtime/pathname/build.js";
23
+ import parsePathname from "mithril-runtime/pathname/parse.js";
24
+ import compileTemplate from "mithril-runtime/pathname/compileTemplate.js";
25
+ import { renderApp } from "./background.js";
26
+
27
+ export function createRoute() {
28
+ var compiled, fallbackRoute;
29
+ var component, attrs, currentPath, currentResolver;
30
+ var lastUpdate = null;
31
+ var ready = false;
32
+ var hasBeenResolved = false;
33
+ var app = null;
34
+
35
+ // The in-memory equivalent of the browser's session history: a plain
36
+ // stack of resolved paths. `route.set(path, data, {replace: true})`
37
+ // overwrites the top entry instead of pushing — same semantics as
38
+ // `history.replaceState` vs `history.pushState`, just without a
39
+ // browser underneath it.
40
+ var history = [];
41
+ var historyIndex = -1;
42
+
43
+ var RouterRoot = {
44
+ view() {
45
+ var vnode = component != null ? m(component, attrs) : null;
46
+ return currentResolver ? currentResolver.render(vnode) : vnode;
47
+ },
48
+ };
49
+
50
+ function resolveRoute(path, data) {
51
+ var parsed = parsePathname(path);
52
+ if (data) Object.assign(parsed.params, data);
53
+
54
+ function reject(e) {
55
+ if (typeof console !== "undefined") console.error(e);
56
+ route.set(fallbackRoute, null, { replace: true });
57
+ }
58
+
59
+ loop(0);
60
+ function loop(i) {
61
+ for (; i < compiled.length; i++) {
62
+ if (compiled[i].check(parsed)) {
63
+ var payload = compiled[i].payload;
64
+ var update = (lastUpdate = function (comp) {
65
+ if (update !== lastUpdate) return;
66
+ if (comp === route.SKIP) return loop(i + 1);
67
+ component = comp != null && (typeof comp.view === "function" || typeof comp === "function")
68
+ ? comp
69
+ : "view";
70
+ attrs = parsed.params;
71
+ currentPath = path;
72
+ lastUpdate = null;
73
+ currentResolver = payload.render ? payload : null;
74
+ if (hasBeenResolved) {
75
+ app.redraw();
76
+ } else {
77
+ hasBeenResolved = true;
78
+ app = renderApp({ root: () => m(RouterRoot) });
79
+ }
80
+ });
81
+ if (payload.view || typeof payload === "function") {
82
+ update(payload);
83
+ } else if (payload.onmatch) {
84
+ Promise.resolve()
85
+ .then(() => payload.onmatch(parsed.params, path, compiled[i].route))
86
+ .then(update, path === fallbackRoute ? undefined : reject);
87
+ } else {
88
+ update("view");
89
+ }
90
+ return;
91
+ }
92
+ }
93
+ if (path === fallbackRoute) {
94
+ throw new Error("Could not resolve default route " + fallbackRoute + ".");
95
+ }
96
+ route.set(fallbackRoute, null, { replace: true });
97
+ }
98
+ }
99
+
100
+ /**
101
+ * @param {string} defaultRoute - Both the fallback for an unmatched path
102
+ * AND the screen the app starts on — there is no browser URL to read
103
+ * an initial path from, so this is the one path v2 always starts at
104
+ * (the closest in-memory equivalent of React Router's
105
+ * `initialEntries={["/"]}`).
106
+ * @param {Record<string, unknown>} routes - Same shape as real
107
+ * `m.route`: `{ "/path/:param": Component | { onmatch, render } }`.
108
+ */
109
+ function route(defaultRoute, routes) {
110
+ compiled = Object.keys(routes).map((r) => {
111
+ if (r[0] !== "/") throw new SyntaxError("Routes must start with a '/'.");
112
+ return { route: r, payload: routes[r], check: compileTemplate(r) };
113
+ });
114
+ fallbackRoute = defaultRoute;
115
+ var defaultData = parsePathname(defaultRoute);
116
+ if (!compiled.some((entry) => entry.check(defaultData))) {
117
+ throw new ReferenceError("Default route doesn't match any known routes.");
118
+ }
119
+ history = [defaultRoute];
120
+ historyIndex = 0;
121
+ ready = true;
122
+ resolveRoute(defaultRoute, null);
123
+ }
124
+
125
+ route.SKIP = {};
126
+
127
+ route.set = function (path, data, options) {
128
+ if (lastUpdate != null) {
129
+ options = options || {};
130
+ options.replace = true;
131
+ }
132
+ lastUpdate = null;
133
+ path = buildPathname(path, data);
134
+ if (options && options.replace) {
135
+ history[Math.max(historyIndex, 0)] = path;
136
+ } else {
137
+ history = history.slice(0, historyIndex + 1);
138
+ history.push(path);
139
+ historyIndex = history.length - 1;
140
+ }
141
+ if (ready) resolveRoute(path, null);
142
+ };
143
+
144
+ route.get = () => currentPath;
145
+
146
+ route.param = (key) => (attrs && key != null ? attrs[key] : attrs);
147
+
148
+ // No URL bar in Lynx — kept as an assignable no-op so app code ported
149
+ // from a real Mithril app that defensively sets `m.route.prefix = ""`
150
+ // doesn't throw. It never affects anything here.
151
+ route.prefix = "";
152
+
153
+ /**
154
+ * `back()`/`forward()` walk the SAME in-memory stack `route.set` writes
155
+ * to — this is the "no native back button" answer from plan §5.7/F0:
156
+ * static analysis of the installed Lynx runtime found no hardware/
157
+ * gesture "back" event exposed to JS (only `onAppEnterBackground`,
158
+ * which is app-lifecycle, not navigation) — so an app's own explicit
159
+ * back affordance (a `route.Link`/button calling this) is the only
160
+ * way back navigation happens. Confirming this holds on a real device
161
+ * is F4/F5 of the plan, not done yet.
162
+ */
163
+ route.back = function () {
164
+ if (historyIndex <= 0) return;
165
+ historyIndex--;
166
+ resolveRoute(history[historyIndex], null);
167
+ };
168
+ route.forward = function () {
169
+ if (historyIndex >= history.length - 1) return;
170
+ historyIndex++;
171
+ resolveRoute(history[historyIndex], null);
172
+ };
173
+
174
+ // Lynx has no `<a>`/`onclick` — this renders a tap-driven element
175
+ // instead, the same shape ReactLynx's `useNavigate()+ontap` and Vue
176
+ // Lynx's custom `RouterLink` slot use (plan §1–§2, §5.4).
177
+ route.Link = {
178
+ view(vnode) {
179
+ var a = vnode.attrs;
180
+ var selector = a.selector || "view";
181
+ var rest = {};
182
+ for (var key in a) {
183
+ if (key !== "selector" && key !== "options" && key !== "params" && key !== "href" && key !== "ontap") {
184
+ rest[key] = a[key];
185
+ }
186
+ }
187
+ var disabled = Boolean(a.disabled);
188
+ rest.disabled = disabled;
189
+ if (!disabled) {
190
+ rest.ontap = function (e) {
191
+ var result;
192
+ if (typeof a.ontap === "function") result = a.ontap.call(e.currentTarget, e);
193
+ if (result !== false) {
194
+ e.redraw = false;
195
+ route.set(buildPathname(a.href, a.params), null, a.options);
196
+ }
197
+ };
198
+ }
199
+ return m(selector, rest, vnode.children);
200
+ },
201
+ };
202
+
203
+ return route;
204
+ }
205
+
206
+ const route = createRoute();
207
+ export default route;
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import m from "mithril";
3
+ import { renderApp } from "../src/background.js";
4
+ import { createPatchApplier } from "../src/apply-patch.js";
5
+
6
+ // Full round trip: background renders real Mithril against the virtual
7
+ // backend, the resulting ops are replayed onto REAL Lynx PAPI elements
8
+ // (via @lynx-js/testing-environment, not a mock), a simulated tap fires on
9
+ // the real element, is forwarded back to the background thread's fake-dom
10
+ // node, and Mithril's OWN automatic redraw-on-event (no explicit redraw()
11
+ // call anywhere in the view below) produces a second patch that updates
12
+ // the real tree again.
13
+ //
14
+ // This is the direct replacement for mithril-lynx v1's
15
+ // `test/renderer-integration.test.ts` — same intent, rewritten for the v2
16
+ // architecture (see mithril-lynx-v2-desde-cero.md §F1's acceptance
17
+ // criterion: this must pass from the first commit, never be fixed later).
18
+
19
+ function storedListeners(node: any, type: string): Set<(...args: any[]) => unknown> {
20
+ return node?.__vanillaListeners?.[type] ?? new Set();
21
+ }
22
+
23
+ describe("background -> apply-patch end-to-end (real PAPI, via @lynx-js/testing-environment)", () => {
24
+ it("auto-redraws after a tap with NO explicit redraw() call anywhere in the view", () => {
25
+ lynxTestingEnv.switchToMainThread();
26
+
27
+ const pageId = __GetElementUniqueID(__CreatePage());
28
+ const applier = createPatchApplier(pageId);
29
+ // id 0 (the fake-dom document root) maps to a real container the
30
+ // applier creates itself, exactly like the page's own root view.
31
+ applier.registerPageRoot(__CreateView(pageId));
32
+
33
+ let count = 0;
34
+ function root() {
35
+ return m("view", { class: "page" }, [
36
+ m(
37
+ "view",
38
+ {
39
+ class: "button",
40
+ ontap: () => {
41
+ // No redraw()/m.redraw() call here on purpose — the
42
+ // whole point of this test is that Mithril's own
43
+ // EventDict auto-redraw (CONTRACT.md §e) is what
44
+ // makes this repaint, with zero cooperation from
45
+ // app code.
46
+ count += 1;
47
+ },
48
+ },
49
+ [m("text", null, "Tap")],
50
+ ),
51
+ m("text", { class: "counter" }, String(count)),
52
+ ]);
53
+ }
54
+
55
+ lynxTestingEnv.switchToBackgroundThread();
56
+ let lastOps: unknown[] | null = null;
57
+ const app = renderApp({
58
+ root,
59
+ sendPatch: (ops) => {
60
+ lastOps = ops;
61
+ },
62
+ });
63
+ expect(lastOps).not.toBeNull();
64
+
65
+ lynxTestingEnv.switchToMainThread();
66
+ applier.applyPatch(lastOps as unknown[]);
67
+
68
+ // The decisive assertion for "auto-redraw fired": a second
69
+ // `sendPatch` call happens purely from the tap (dispatched below),
70
+ // with no explicit redraw() anywhere in `root()`'s view code.
71
+ lastOps = null;
72
+
73
+ lynxTestingEnv.switchToBackgroundThread();
74
+ // Forward the tap the same way main-thread -> background forwarding
75
+ // will in the real channel (F2/F3): find the fake-dom node for the
76
+ // button and dispatch a synthetic tap on it directly. The id is
77
+ // deterministic here (root=0 is the document; the button is the
78
+ // second element created — first is the page `view`, second the
79
+ // button `view`), matching virtual-backend.js's sequential id
80
+ // allocation starting at 1.
81
+ app.document.getNodeById(2)!.dispatchEvent({ type: "tap", currentTarget: app.document.getNodeById(2) });
82
+
83
+ expect(count).toBe(1);
84
+ expect(lastOps).not.toBeNull();
85
+ });
86
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "@rstest/core";
2
+ import { getReloadVersion, increaseReloadVersion, isStaleVersion } from "../src/reload/version.js";
3
+
4
+ describe("reload version guard (replaces v1's hotStatus race-prone flag)", () => {
5
+ it("discards a patch stamped with an older version than the current one", () => {
6
+ const v1 = getReloadVersion();
7
+ expect(isStaleVersion(v1)).toBe(false); // not stale yet, nothing bumped
8
+
9
+ increaseReloadVersion();
10
+ // A patch built before this reload (stamped with the old version)
11
+ // must now be treated as stale — this is the exact scenario of the
12
+ // v1 "race de doble-build": two builds in flight, the older one's
13
+ // patch must lose without needing a status flag at all.
14
+ expect(isStaleVersion(v1)).toBe(true);
15
+ expect(isStaleVersion(getReloadVersion())).toBe(false);
16
+ });
17
+ });