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
|
@@ -11,7 +11,7 @@ import { Op } from "../src/patch-protocol.js";
|
|
|
11
11
|
// else. This test locks that in.
|
|
12
12
|
|
|
13
13
|
describe("route.js + stable-host: a same-path re-resolve patches in place, never recreates", () => {
|
|
14
|
-
it("swapping the live-bound view (module.hot.accept's job) produces only a SetText, no Create/Remove", () => {
|
|
14
|
+
it("swapping the live-bound view (module.hot.accept's job) produces only a SetText, no Create/Remove", async () => {
|
|
15
15
|
lynxTestingEnv.switchToMainThread();
|
|
16
16
|
const capturedOps: unknown[][] = [];
|
|
17
17
|
lynx.getJSContext().addEventListener("MithrilLynx:Patch", (event: any) => {
|
|
@@ -32,6 +32,7 @@ describe("route.js + stable-host: a same-path re-resolve patches in place, never
|
|
|
32
32
|
|
|
33
33
|
currentDetail = { view: () => m("text", { key: "title" }, "second") };
|
|
34
34
|
route.set(route.get() as string, null, { replace: true });
|
|
35
|
+
await Promise.resolve(); // navigation resolves on the next microtask
|
|
35
36
|
|
|
36
37
|
const flat = capturedOps.flat();
|
|
37
38
|
expect(flat).not.toContain(Op.CreateElement);
|
package/test/route.test.ts
CHANGED
|
@@ -37,8 +37,11 @@ function setupRealTree() {
|
|
|
37
37
|
// isn't exported from a stable path used elsewhere yet in tests.
|
|
38
38
|
import { createPatchApplier } from "../src/apply-patch.js";
|
|
39
39
|
|
|
40
|
+
/** Navigation resolves on the next microtask (like upstream m.route). */
|
|
41
|
+
const settle = () => Promise.resolve();
|
|
42
|
+
|
|
40
43
|
describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
41
|
-
it("mounts the default route and exposes route.get()/route.param()", () => {
|
|
44
|
+
it("mounts the default route and exposes route.get()/route.param()", async () => {
|
|
42
45
|
setupRealTree();
|
|
43
46
|
lynxTestingEnv.switchToBackgroundThread();
|
|
44
47
|
|
|
@@ -51,11 +54,12 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
51
54
|
expect(route.get()).toBe("/");
|
|
52
55
|
|
|
53
56
|
route.set("/users/42");
|
|
57
|
+
await settle();
|
|
54
58
|
expect(route.get()).toBe("/users/42");
|
|
55
59
|
expect(route.param("id")).toBe("42");
|
|
56
60
|
});
|
|
57
61
|
|
|
58
|
-
it("navigating replaces the screen with no orphaned nodes left behind", () => {
|
|
62
|
+
it("navigating replaces the screen with no orphaned nodes left behind", async () => {
|
|
59
63
|
const { capturedOps } = setupRealTree();
|
|
60
64
|
lynxTestingEnv.switchToBackgroundThread();
|
|
61
65
|
|
|
@@ -67,6 +71,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
67
71
|
capturedOps.length = 0; // only care about the ops from the NAVIGATION below
|
|
68
72
|
|
|
69
73
|
route.set("/b");
|
|
74
|
+
await settle();
|
|
70
75
|
|
|
71
76
|
// Same spirit as test/structural-reload.test.ts, but here the whole
|
|
72
77
|
// screen changes (not a sibling insert) — the decisive check is
|
|
@@ -78,7 +83,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
78
83
|
expect(navOps).toContain(Op.CreateElement);
|
|
79
84
|
});
|
|
80
85
|
|
|
81
|
-
it("back()/forward() walk the in-memory history stack", () => {
|
|
86
|
+
it("back()/forward() walk the in-memory history stack", async () => {
|
|
82
87
|
setupRealTree();
|
|
83
88
|
lynxTestingEnv.switchToBackgroundThread();
|
|
84
89
|
|
|
@@ -90,15 +95,19 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
90
95
|
route("/a", { "/a": A, "/b": B, "/c": C });
|
|
91
96
|
route.set("/b");
|
|
92
97
|
route.set("/c");
|
|
98
|
+
await settle();
|
|
93
99
|
expect(route.get()).toBe("/c");
|
|
94
100
|
|
|
95
101
|
route.back();
|
|
102
|
+
await settle();
|
|
96
103
|
expect(route.get()).toBe("/b");
|
|
97
104
|
|
|
98
105
|
route.back();
|
|
106
|
+
await settle();
|
|
99
107
|
expect(route.get()).toBe("/a");
|
|
100
108
|
|
|
101
109
|
route.forward();
|
|
110
|
+
await settle();
|
|
102
111
|
expect(route.get()).toBe("/b");
|
|
103
112
|
});
|
|
104
113
|
|
|
@@ -129,7 +138,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
129
138
|
});
|
|
130
139
|
});
|
|
131
140
|
|
|
132
|
-
it("route.Link navigates on tap, and disabled Links don't wire ontap", () => {
|
|
141
|
+
it("route.Link navigates on tap, and disabled Links don't wire ontap", async () => {
|
|
133
142
|
setupRealTree();
|
|
134
143
|
lynxTestingEnv.switchToBackgroundThread();
|
|
135
144
|
|
|
@@ -144,6 +153,7 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
144
153
|
});
|
|
145
154
|
expect(typeof link.attrs.ontap).toBe("function");
|
|
146
155
|
link.attrs.ontap({ currentTarget: null, redraw: true });
|
|
156
|
+
await settle();
|
|
147
157
|
expect(route.get()).toBe("/b");
|
|
148
158
|
|
|
149
159
|
const disabledLink = (route as any).Link.view({
|
|
@@ -153,3 +163,190 @@ describe("route.js: in-memory router (plan m-route-en-memoria.md)", () => {
|
|
|
153
163
|
expect(disabledLink.attrs.ontap).toBeUndefined();
|
|
154
164
|
});
|
|
155
165
|
});
|
|
166
|
+
|
|
167
|
+
// Parity fixes found by ROUTE_CONTRACT_ANALYSIS.md — each one was first
|
|
168
|
+
// reproduced against the previous route.js, then fixed.
|
|
169
|
+
describe("route.js: parity with Mithril 2.3.8's m.route", () => {
|
|
170
|
+
function mountRoute(defaultRoute: string, routes: Record<string, unknown>) {
|
|
171
|
+
setupRealTree();
|
|
172
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
173
|
+
const route = createRoute();
|
|
174
|
+
route(defaultRoute, routes);
|
|
175
|
+
return route;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it("a redirect from a page's oninit (a guard) works instead of throwing mid-render", async () => {
|
|
179
|
+
let route: ReturnType<typeof createRoute> | null = null;
|
|
180
|
+
const Guarded = { oninit: () => route!.set("/login"), view: () => m("text", null, "secret") };
|
|
181
|
+
route = mountRoute("/", { "/": { view: () => m("text", null, "home") }, "/secret": Guarded, "/login": { view: () => m("text", null, "login") } });
|
|
182
|
+
|
|
183
|
+
route!.set("/secret");
|
|
184
|
+
await settle();
|
|
185
|
+
await settle();
|
|
186
|
+
expect(route!.get()).toBe("/login");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("several navigations in one tick resolve once, to the last one", async () => {
|
|
190
|
+
const views: string[] = [];
|
|
191
|
+
const page = (name: string) => ({ view: () => (views.push(name), m("text", null, name)) });
|
|
192
|
+
const route = mountRoute("/a", { "/a": page("a"), "/b": page("b"), "/c": page("c") });
|
|
193
|
+
views.length = 0;
|
|
194
|
+
|
|
195
|
+
route.set("/b");
|
|
196
|
+
route.set("/c");
|
|
197
|
+
expect(route.get()).toBe("/a"); // not resolved yet, like upstream
|
|
198
|
+
await settle();
|
|
199
|
+
expect(route.get()).toBe("/c");
|
|
200
|
+
expect(views).toEqual(["c"]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("a changed :key param remounts the page (fresh state)", async () => {
|
|
204
|
+
const inits: string[] = [];
|
|
205
|
+
const Page = { oninit: (v: any) => inits.push(v.attrs.key), view: () => m("text", null, "p") };
|
|
206
|
+
const route = mountRoute("/p/a", { "/p/:key": Page });
|
|
207
|
+
|
|
208
|
+
route.set("/p/:key", { key: "b" });
|
|
209
|
+
await settle();
|
|
210
|
+
expect(inits).toEqual(["a", "b"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("options.state is merged into params and restored by back()/forward()", async () => {
|
|
214
|
+
const route = mountRoute("/", { "/": { view: () => m("text", null, "h") }, "/d": { view: () => m("text", null, "d") } });
|
|
215
|
+
|
|
216
|
+
route.set("/d", null, { state: { from: "home" } } as any);
|
|
217
|
+
await settle();
|
|
218
|
+
expect(route.param("from")).toBe("home");
|
|
219
|
+
|
|
220
|
+
route.back();
|
|
221
|
+
await settle();
|
|
222
|
+
expect(route.param("from")).toBeUndefined();
|
|
223
|
+
|
|
224
|
+
route.forward();
|
|
225
|
+
await settle();
|
|
226
|
+
expect(route.param("from")).toBe("home");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("route.get() returns the decoded path, params stay decoded", async () => {
|
|
230
|
+
const route = mountRoute("/", { "/": { view: () => m("text", null, "h") }, "/d/:id": { view: () => m("text", null, "d") } });
|
|
231
|
+
route.set("/d/:id", { id: "a b/c" });
|
|
232
|
+
await settle();
|
|
233
|
+
expect(route.get()).toBe("/d/a b/c");
|
|
234
|
+
expect(route.param("id")).toBe("a b/c");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("route.Link runs its lifecycle hooks once (not copied onto the rendered element)", () => {
|
|
238
|
+
let oncreates = 0;
|
|
239
|
+
setupRealTree();
|
|
240
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
241
|
+
const route = createRoute();
|
|
242
|
+
route("/", { "/": { view: () => m((route as any).Link, { href: "/", oncreate: () => oncreates++, key: "l" }, m("text", null, "l")) } });
|
|
243
|
+
expect(oncreates).toBe(1);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("route.Link: preventDefault() cancels navigation, and an EventListener object works as ontap", async () => {
|
|
247
|
+
const route = mountRoute("/a", { "/a": { view: () => m("text", null, "a") }, "/b": { view: () => m("text", null, "b") } });
|
|
248
|
+
const tap = (ontap: unknown) => {
|
|
249
|
+
const link = (route as any).Link.view({ attrs: { href: "/b", ontap }, children: [] });
|
|
250
|
+
const e: any = { currentTarget: null, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
|
|
251
|
+
link.attrs.ontap(e);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
tap((e: any) => e.preventDefault());
|
|
255
|
+
await settle();
|
|
256
|
+
expect(route.get()).toBe("/a");
|
|
257
|
+
|
|
258
|
+
let handled = false;
|
|
259
|
+
tap({ handleEvent: () => { handled = true; } });
|
|
260
|
+
await settle();
|
|
261
|
+
expect(handled).toBe(true);
|
|
262
|
+
expect(route.get()).toBe("/b");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("rejects param names not separated by '/', '.' or '-', and a missing default route", () => {
|
|
266
|
+
setupRealTree();
|
|
267
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
268
|
+
expect(() => createRoute()("/", { "/": { view: () => m("text", null, "x") }, "/:a:b": { view: () => m("text", null, "y") } })).toThrow(SyntaxError);
|
|
269
|
+
expect(() => createRoute()(null as any, { "/": { view: () => m("text", null, "x") } })).toThrow(/defaultRoute is required/);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Opt-in Android back button bridge: the host sends a global event when back
|
|
274
|
+
// is pressed (only while its OnBackPressedCallback is enabled), and learns
|
|
275
|
+
// through onCanGoBackChange when there is history to go back to.
|
|
276
|
+
describe("route.listenBackButton()", () => {
|
|
277
|
+
function withFakeEmitter() {
|
|
278
|
+
const listeners = new Map<string, Set<Function>>();
|
|
279
|
+
const emitter = {
|
|
280
|
+
addListener: (name: string, fn: Function) => (listeners.get(name) ?? listeners.set(name, new Set()).get(name)!).add(fn),
|
|
281
|
+
removeListener: (name: string, fn: Function) => listeners.get(name)?.delete(fn),
|
|
282
|
+
emit: (name: string) => listeners.get(name)?.forEach((fn) => fn()),
|
|
283
|
+
count: (name: string) => listeners.get(name)?.size ?? 0,
|
|
284
|
+
};
|
|
285
|
+
const original = (lynx as any).getJSModule;
|
|
286
|
+
(lynx as any).getJSModule = (name: string) => (name === "GlobalEventEmitter" ? emitter : original?.call(lynx, name));
|
|
287
|
+
return { emitter, restore: () => ((lynx as any).getJSModule = original) };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function mountTwoPages() {
|
|
291
|
+
setupRealTree();
|
|
292
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
293
|
+
const route = createRoute();
|
|
294
|
+
route("/", { "/": { view: () => m("text", null, "home") }, "/detail": { view: () => m("text", null, "detail") } });
|
|
295
|
+
return route;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
it("the back event goes back, and onCanGoBackChange reports each change once", async () => {
|
|
299
|
+
const route = mountTwoPages();
|
|
300
|
+
const { emitter, restore } = withFakeEmitter();
|
|
301
|
+
const changes: boolean[] = [];
|
|
302
|
+
const stop = route.listenBackButton({ onCanGoBackChange: (can: boolean) => changes.push(can) });
|
|
303
|
+
|
|
304
|
+
route.set("/detail");
|
|
305
|
+
route.set("/detail", null, { replace: true }); // no change in canGoBack
|
|
306
|
+
await settle();
|
|
307
|
+
emitter.emit("mithrilLynx:back");
|
|
308
|
+
await settle();
|
|
309
|
+
|
|
310
|
+
expect(route.get()).toBe("/");
|
|
311
|
+
expect(changes).toEqual([false, true, false]);
|
|
312
|
+
stop();
|
|
313
|
+
restore();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("stop() unsubscribes; a custom event name is honored", () => {
|
|
317
|
+
const route = mountTwoPages();
|
|
318
|
+
const { emitter, restore } = withFakeEmitter();
|
|
319
|
+
const stop = route.listenBackButton({ eventName: "hostBack" });
|
|
320
|
+
expect(emitter.count("hostBack")).toBe(1);
|
|
321
|
+
stop();
|
|
322
|
+
expect(emitter.count("hostBack")).toBe(0);
|
|
323
|
+
restore();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("a throwing onCanGoBackChange (e.g. a missing native module) never breaks navigation", async () => {
|
|
327
|
+
const route = mountTwoPages();
|
|
328
|
+
const { restore } = withFakeEmitter();
|
|
329
|
+
const error = console.error;
|
|
330
|
+
console.error = () => {};
|
|
331
|
+
route.listenBackButton({ onCanGoBackChange: () => { throw new Error("no module"); } });
|
|
332
|
+
route.set("/detail");
|
|
333
|
+
await settle();
|
|
334
|
+
console.error = error;
|
|
335
|
+
expect(route.get()).toBe("/detail");
|
|
336
|
+
restore();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("without GlobalEventEmitter it only warns and does nothing", () => {
|
|
340
|
+
const route = mountTwoPages();
|
|
341
|
+
const original = (lynx as any).getJSModule;
|
|
342
|
+
(lynx as any).getJSModule = () => undefined;
|
|
343
|
+
const warn = console.warn;
|
|
344
|
+
const warnings: string[] = [];
|
|
345
|
+
console.warn = (msg: string) => warnings.push(msg);
|
|
346
|
+
const stop = route.listenBackButton();
|
|
347
|
+
console.warn = warn;
|
|
348
|
+
(lynx as any).getJSModule = original;
|
|
349
|
+
expect(warnings.some((w) => w.includes("GlobalEventEmitter"))).toBe(true);
|
|
350
|
+
expect(() => stop()).not.toThrow();
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "@rstest/core";
|
|
2
|
+
import m from "mithril-runtime";
|
|
3
|
+
import { createPatchApplier } from "../src/apply-patch.js";
|
|
4
|
+
import { renderApp } from "../src/background.js";
|
|
5
|
+
import { unregister } from "../src/mount-redraw.js";
|
|
6
|
+
import { forEachOp, Op } from "../src/patch-protocol.js";
|
|
7
|
+
|
|
8
|
+
// Mithril clears a whole style with `element.style = ""` (a style prop that
|
|
9
|
+
// disappears, or unconditionally before a first object style). There is no
|
|
10
|
+
// device-validated bulk-clear PAPI, so fake-dom.js must expand the clear into
|
|
11
|
+
// one RemoveStyleProperty per property actually applied — and emit nothing
|
|
12
|
+
// when there is nothing to clear.
|
|
13
|
+
|
|
14
|
+
function mount(styleOf: () => Record<string, string> | undefined) {
|
|
15
|
+
lynxTestingEnv.switchToMainThread();
|
|
16
|
+
const pageId = __GetElementUniqueID(__CreatePage());
|
|
17
|
+
const applier = createPatchApplier(pageId);
|
|
18
|
+
applier.registerPageRoot(__CreateView(pageId));
|
|
19
|
+
|
|
20
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
21
|
+
unregister();
|
|
22
|
+
const patches: unknown[][] = [];
|
|
23
|
+
const app = renderApp({
|
|
24
|
+
root: () => {
|
|
25
|
+
const style = styleOf();
|
|
26
|
+
return m("view", style ? { style } : {});
|
|
27
|
+
},
|
|
28
|
+
sendPatch: (ops) => patches.push(ops),
|
|
29
|
+
subscribeEvents: () => {},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Applies every patch produced since the last call on the main thread
|
|
33
|
+
* (which must not throw) and returns the style ops they carried. */
|
|
34
|
+
let applied = 0;
|
|
35
|
+
const flushToMainThread = () => {
|
|
36
|
+
const fresh = patches.slice(applied);
|
|
37
|
+
applied = patches.length;
|
|
38
|
+
lynxTestingEnv.switchToMainThread();
|
|
39
|
+
for (const ops of fresh) applier.applyPatch(ops);
|
|
40
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
41
|
+
const removed: string[] = [];
|
|
42
|
+
const set: string[] = [];
|
|
43
|
+
for (const ops of fresh) {
|
|
44
|
+
forEachOp(ops, (opcode, args) => {
|
|
45
|
+
if (opcode === Op.RemoveStyleProperty) removed.push(args[1] as string);
|
|
46
|
+
if (opcode === Op.SetStyleProperty) set.push(args[1] as string);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return { removed, set };
|
|
50
|
+
};
|
|
51
|
+
return { app, flushToMainThread };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("clearing a whole style expands into per-property removals", () => {
|
|
55
|
+
it("removing the style prop of a styled element no longer throws", () => {
|
|
56
|
+
let style: Record<string, string> | undefined = { color: "red" };
|
|
57
|
+
const { app, flushToMainThread } = mount(() => style);
|
|
58
|
+
flushToMainThread();
|
|
59
|
+
|
|
60
|
+
style = undefined;
|
|
61
|
+
app.redraw();
|
|
62
|
+
expect(flushToMainThread().removed).toEqual(["color"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("clearing {color, width} removes both properties", () => {
|
|
66
|
+
let style: Record<string, string> | undefined = { color: "red", width: "10px" };
|
|
67
|
+
const { app, flushToMainThread } = mount(() => style);
|
|
68
|
+
flushToMainThread();
|
|
69
|
+
|
|
70
|
+
style = undefined;
|
|
71
|
+
app.redraw();
|
|
72
|
+
expect(flushToMainThread().removed.sort()).toEqual(["color", "width"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("clearing a style whose properties were already removed one by one emits nothing", () => {
|
|
76
|
+
let style: Record<string, string> | undefined = { color: "red", width: "10px" };
|
|
77
|
+
const { app, flushToMainThread } = mount(() => style);
|
|
78
|
+
flushToMainThread();
|
|
79
|
+
|
|
80
|
+
// Object -> object: Mithril removes the missing keys individually.
|
|
81
|
+
style = {};
|
|
82
|
+
app.redraw();
|
|
83
|
+
expect(flushToMainThread().removed.sort()).toEqual(["color", "width"]);
|
|
84
|
+
|
|
85
|
+
// Now the whole style goes away: `style = ""` with nothing left to clear.
|
|
86
|
+
style = undefined;
|
|
87
|
+
app.redraw();
|
|
88
|
+
expect(flushToMainThread().removed).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("an element that never had a style gets no clear op on its first styled render", () => {
|
|
92
|
+
const { flushToMainThread } = mount(() => ({ color: "red" }));
|
|
93
|
+
const { removed, set } = flushToMainThread();
|
|
94
|
+
expect(removed).toEqual([]);
|
|
95
|
+
expect(set).toEqual(["color"]);
|
|
96
|
+
});
|
|
97
|
+
});
|