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
@@ -2,6 +2,7 @@ import { describe, expect, it } from "@rstest/core";
2
2
  import m from "mithril";
3
3
  import { renderApp } from "../src/background.js";
4
4
  import { createPatchApplier } from "../src/apply-patch.js";
5
+ import { Op } from "../src/patch-protocol.js";
5
6
 
6
7
  // Op.SetGestureDetector end-to-end: a component calls vnode.dom's own
7
8
  // setGestureDetector() (fake-dom.js) in oncreate, the resulting op gets
@@ -173,3 +174,77 @@ describe("Op.SetGestureDetector (native gesture support)", () => {
173
174
  expect(moves).toEqual([42]);
174
175
  });
175
176
  });
177
+
178
+ // Every gesture worklet closure holds its element's native handle, so the
179
+ // global registry must drop a detector's worklets when the detector — or its
180
+ // element, or any ancestor — goes away; otherwise it keeps the handle alive.
181
+ describe("gesture worklets are unregistered when their detector goes away", () => {
182
+ const registrySize = () => Object.keys((globalThis as any).lynxWorkletImpl?._workletMap ?? {}).length;
183
+
184
+ function mountApplier(receivedEvents: string[] = []) {
185
+ lynxTestingEnv.switchToMainThread();
186
+ const pageId = __GetElementUniqueID(__CreatePage());
187
+ const applier = createPatchApplier(pageId, { onEvent: (_id, type) => receivedEvents.push(type) });
188
+ applier.registerPageRoot(__CreateView(pageId));
189
+ return applier;
190
+ }
191
+
192
+ it("Op.RemoveGestureDetector unregisters the detector's worklets", () => {
193
+ const received: string[] = [];
194
+ const applier = mountApplier(received);
195
+ applier.applyPatch([Op.CreateElement, "view", 1, Op.InsertBefore, 0, 1, -1]);
196
+ const before = registrySize();
197
+
198
+ applier.applyPatch([Op.SetGestureDetector, 1, 7, "native", { mode: "claim" }]);
199
+ expect(registrySize()).toBe(before + 3);
200
+ const callbacks = gestureCallbacksOf(applier.getHandle(1));
201
+
202
+ applier.applyPatch([Op.RemoveGestureDetector, 1, 7]);
203
+ expect(registrySize()).toBe(before);
204
+
205
+ // A late native callback for an unregistered worklet is a no-op.
206
+ callbacks.onTouchesMove(touchEvent(1, 1), makeController());
207
+ expect(received).toEqual([]);
208
+ });
209
+
210
+ it("removing an ancestor unregisters the worklets of detectors in its subtree", () => {
211
+ const applier = mountApplier();
212
+ applier.applyPatch([
213
+ Op.CreateElement, "view", 1,
214
+ Op.CreateElement, "view", 2,
215
+ Op.InsertBefore, 0, 1, -1,
216
+ Op.InsertBefore, 1, 2, -1,
217
+ ]);
218
+ const before = registrySize();
219
+
220
+ applier.applyPatch([Op.SetGestureDetector, 2, 8, "native", { mode: "claim" }]);
221
+ expect(registrySize()).toBe(before + 3);
222
+
223
+ applier.applyPatch([Op.RemoveChild, 0, 1]);
224
+ expect(registrySize()).toBe(before);
225
+ });
226
+
227
+ it("removing one detector keeps another element's worklets working", () => {
228
+ const received: string[] = [];
229
+ const applier = mountApplier(received);
230
+ applier.applyPatch([
231
+ Op.CreateElement, "view", 1,
232
+ Op.CreateElement, "view", 2,
233
+ Op.InsertBefore, 0, 1, -1,
234
+ Op.InsertBefore, 0, 2, -1,
235
+ ]);
236
+ const before = registrySize();
237
+
238
+ applier.applyPatch([
239
+ Op.SetGestureDetector, 1, 9, "native", { mode: "claim" },
240
+ Op.SetGestureDetector, 2, 10, "native", { mode: "claim" },
241
+ ]);
242
+ const survivor = gestureCallbacksOf(applier.getHandle(2));
243
+
244
+ applier.applyPatch([Op.RemoveChild, 0, 1]);
245
+ expect(registrySize()).toBe(before + 3);
246
+
247
+ survivor.onTouchesMove(touchEvent(1, 1), makeController());
248
+ expect(received).toEqual(["gesturemove"]);
249
+ });
250
+ });
@@ -0,0 +1,388 @@
1
+ import { afterEach, 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
+ import { setUIMethodResponder, uiMethodCalls } from "../src/testing.js";
8
+
9
+ // <input>/<textarea> have no `value` attribute natively, only UI methods
10
+ // (setValue, focus, …). fake-dom.js gives them a real `value` property that
11
+ // sends Op.SetInputValue, synced back from every forwarded event, and
12
+ // apply-patch.js runs UI methods right after the patch's flush — so
13
+ // `m("input", { value, autofocus })` works like Mithril on the web, with no
14
+ // id, timer or selector query.
15
+
16
+ type Forwarded = { id: number; type: string; payload: unknown; seq?: number };
17
+
18
+ function mount(view: () => unknown) {
19
+ lynxTestingEnv.switchToMainThread();
20
+ const pageId = __GetElementUniqueID(__CreatePage());
21
+ const toBackground: Forwarded[] = [];
22
+ const applier = createPatchApplier(pageId, {
23
+ onEvent: (id: number, type: string, payload: unknown, seq?: number) =>
24
+ toBackground.push(seq === undefined ? { id, type, payload } : { id, type, payload, seq }),
25
+ });
26
+ applier.registerPageRoot(__CreateView(pageId));
27
+
28
+ lynxTestingEnv.switchToBackgroundThread();
29
+ unregister();
30
+ const patches: unknown[][] = [];
31
+ let router: (event: { data: Forwarded }) => void = () => {};
32
+ const app = renderApp({
33
+ root: view,
34
+ sendPatch: (ops) => patches.push(ops),
35
+ subscribeEvents: (handler: any) => {
36
+ router = handler;
37
+ },
38
+ });
39
+
40
+ let applied = 0;
41
+ /** Delivers what the main thread forwarded (events, invoke results). */
42
+ const deliver = () => {
43
+ while (toBackground.length > 0) router({ data: toBackground.shift()! });
44
+ };
45
+ /** Applies every new patch on the main thread, then delivers what it forwarded. */
46
+ const sync = () => {
47
+ const fresh = patches.slice(applied);
48
+ applied = patches.length;
49
+ lynxTestingEnv.switchToMainThread();
50
+ for (const ops of fresh) applier.applyPatch(ops);
51
+ lynxTestingEnv.switchToBackgroundThread();
52
+ deliver();
53
+ return fresh;
54
+ };
55
+ /** Fires a native event on the main thread, as native would. */
56
+ const fireNative = (node: any, type: string, detail: Record<string, unknown>) => {
57
+ lynxTestingEnv.switchToMainThread();
58
+ const handle: any = applier.getHandle(node._id);
59
+ for (const listener of handle.__vanillaListeners?.[type] ?? []) listener({ type, detail });
60
+ lynxTestingEnv.switchToBackgroundThread();
61
+ deliver();
62
+ };
63
+ const handleOf = (node: any) => applier.getHandle(node._id);
64
+ return { app, patches, sync, fireNative, handleOf, applier, deliver };
65
+ }
66
+
67
+ function uiCalls(handle: unknown) {
68
+ return uiMethodCalls.filter((c) => c.element === handle).map((c) => [c.method, c.params]);
69
+ }
70
+
71
+ function opsOf(patches: unknown[][], opcode: number) {
72
+ const out: unknown[][] = [];
73
+ for (const ops of patches) forEachOp(ops, (code, args) => code === opcode && out.push(args));
74
+ return out;
75
+ }
76
+
77
+ afterEach(() => {
78
+ uiMethodCalls.length = 0;
79
+ setUIMethodResponder(null);
80
+ });
81
+
82
+ describe("<input> value", () => {
83
+ it("an initial value is one setValue, with no id, run once the element is in the tree", () => {
84
+ // The UI method runs after the whole patch — including the insert of
85
+ // the element that the same patch created — has been applied.
86
+ const parentsAtInvoke: unknown[] = [];
87
+ setUIMethodResponder((element: any) => {
88
+ parentsAtInvoke.push(element.parentNode);
89
+ return { code: 0 };
90
+ });
91
+ let field: any;
92
+ const { sync, handleOf } = mount(() => m("view", m("input", { value: "hola", oncreate: (v: any) => (field = v.dom) })));
93
+ sync();
94
+ expect(uiCalls(handleOf(field))).toEqual([["setValue", { value: "hola" }]]);
95
+ expect(parentsAtInvoke).toHaveLength(1);
96
+ expect(parentsAtInvoke[0]).toBeTruthy();
97
+ expect(field.value).toBe("hola");
98
+ });
99
+
100
+ it("an empty or absent value sends nothing", () => {
101
+ const { sync } = mount(() => m("view", [m("input", { value: "" }), m("input", {}), m("textarea", {})]));
102
+ sync();
103
+ expect(uiMethodCalls).toEqual([]);
104
+ });
105
+
106
+ it("text the user typed is not sent back (Mithril sees dom.value already equal)", () => {
107
+ let text = "";
108
+ let field: any;
109
+ const { sync, fireNative, handleOf } = mount(() =>
110
+ m("input", { value: text, oncreate: (v: any) => (field = v.dom), oninput: (e: any) => (text = e.detail.value) }),
111
+ );
112
+ sync();
113
+ fireNative(field, "input", { value: "a", selectionStart: 1, selectionEnd: 1 });
114
+ fireNative(field, "input", { value: "ab", selectionStart: 2, selectionEnd: 2 });
115
+ sync();
116
+ expect(text).toBe("ab");
117
+ expect(field.value).toBe("ab");
118
+ expect(field.selectionStart).toBe(2);
119
+ expect(uiCalls(handleOf(field))).toEqual([]);
120
+ });
121
+
122
+ it("a value the app transforms is sent once", () => {
123
+ let text = "";
124
+ let field: any;
125
+ const { sync, fireNative, handleOf } = mount(() =>
126
+ m("input", { value: text, oncreate: (v: any) => (field = v.dom), oninput: (e: any) => (text = e.detail.value.toUpperCase()) }),
127
+ );
128
+ sync();
129
+ fireNative(field, "input", { value: "ab" });
130
+ sync();
131
+ expect(uiCalls(handleOf(field))).toEqual([["setValue", { value: "AB" }]]);
132
+ // The native echo of that setValue changes nothing.
133
+ fireNative(field, "input", { value: "AB" });
134
+ sync();
135
+ expect(uiCalls(handleOf(field))).toHaveLength(1);
136
+ });
137
+
138
+ it("a clear from another event (a button) is sent", () => {
139
+ let text = "";
140
+ let field: any;
141
+ let button: any;
142
+ const { sync, fireNative, handleOf } = mount(() =>
143
+ m("view", [
144
+ m("input", { value: text, oncreate: (v: any) => (field = v.dom), oninput: (e: any) => (text = e.detail.value) }),
145
+ m("view", { oncreate: (v: any) => (button = v.dom), ontap: () => (text = "") }),
146
+ ]),
147
+ );
148
+ sync();
149
+ fireNative(field, "input", { value: "hola" });
150
+ sync();
151
+ fireNative(button, "tap", {});
152
+ sync();
153
+ expect(uiCalls(handleOf(field))).toEqual([["setValue", { value: "" }]]);
154
+ });
155
+
156
+ it("removing the value attribute clears the field", () => {
157
+ let attrs: Record<string, unknown> = { value: "x" };
158
+ let field: any;
159
+ const { app, sync, handleOf } = mount(() => m("input", { ...attrs, oncreate: (v: any) => (field = v.dom) }));
160
+ sync();
161
+ attrs = {};
162
+ app.redraw();
163
+ sync();
164
+ expect(uiCalls(handleOf(field))).toEqual([
165
+ ["setValue", { value: "x" }],
166
+ ["setValue", { value: "" }],
167
+ ]);
168
+ });
169
+
170
+ it("a value computed before a newer keystroke is dropped on the main thread", () => {
171
+ let text = "";
172
+ let field: any;
173
+ const { sync, fireNative, handleOf } = mount(() =>
174
+ m("input", { value: text, oncreate: (v: any) => (field = v.dom), oninput: (e: any) => (text = e.detail.value.toUpperCase()) }),
175
+ );
176
+ sync();
177
+ fireNative(field, "input", { value: "a" }); // background renders "A" (seq 1)
178
+ // Before that patch is applied, the user types again: native counts seq 2.
179
+ lynxTestingEnv.switchToMainThread();
180
+ const handle: any = handleOf(field);
181
+ for (const listener of handle.__vanillaListeners.input) listener({ type: "input", detail: { value: "ab" } });
182
+ lynxTestingEnv.switchToBackgroundThread();
183
+ sync(); // applies "A" with seq 1 < 2: dropped; then delivers "ab"
184
+ expect(uiCalls(handle)).toEqual([]);
185
+ sync(); // the "ab" event's render sends "AB" with seq 2
186
+ expect(uiCalls(handle)).toEqual([["setValue", { value: "AB" }]]);
187
+ expect(text).toBe("AB");
188
+ });
189
+
190
+ it("a native input event that doesn't change the text is not forwarded (creation, setValue echo)", () => {
191
+ // Seen on device: a <textarea> fires `input ""` by itself while it is
192
+ // first flushed, and native echoes every setValue as an `input`.
193
+ // Forwarded, the first would hand the app a spurious "" (wiping a
194
+ // controlled field's state) and count as a keystroke, dropping the
195
+ // field's initial setValue.
196
+ const seen: string[] = [];
197
+ let text = "inicial";
198
+ let field: any;
199
+ const { patches, fireNative, handleOf, applier, deliver } = mount(() =>
200
+ m("textarea", {
201
+ value: text,
202
+ oncreate: (v: any) => (field = v.dom),
203
+ oninput: (e: any) => {
204
+ seen.push(e.detail.value);
205
+ text = e.detail.value;
206
+ },
207
+ }),
208
+ );
209
+ // Native's spurious `input ""`, fired from inside the patch's flush —
210
+ // before the patch's setValue runs. Applied by hand: switching
211
+ // threads re-injects the globals, which would drop the override.
212
+ lynxTestingEnv.switchToMainThread();
213
+ const realFlush = (globalThis as any).__FlushElementTree;
214
+ let spurious = 0;
215
+ (globalThis as any).__FlushElementTree = (...args: unknown[]) => {
216
+ const handle: any = applier.getHandle(field._id);
217
+ if (spurious++ === 0) for (const listener of handle.__vanillaListeners.input) listener({ type: "input", detail: { value: "" } });
218
+ return realFlush(...args);
219
+ };
220
+ for (const ops of patches) applier.applyPatch(ops);
221
+ (globalThis as any).__FlushElementTree = realFlush;
222
+ lynxTestingEnv.switchToBackgroundThread();
223
+ deliver();
224
+ expect(spurious).toBeGreaterThan(0);
225
+ fireNative(field, "input", { value: "inicial" }); // the setValue echo
226
+ expect(seen).toEqual([]);
227
+ expect(text).toBe("inicial");
228
+ expect(uiCalls(handleOf(field))).toEqual([["setValue", { value: "inicial" }]]);
229
+
230
+ fireNative(field, "input", { value: "inicial!" });
231
+ expect(seen).toEqual(["inicial!"]);
232
+ });
233
+
234
+ it("an unchanged text still forwards a change of composition state", () => {
235
+ const seen: unknown[] = [];
236
+ let field: any;
237
+ const { sync, fireNative } = mount(() =>
238
+ m("input", { oncreate: (v: any) => (field = v.dom), oninput: (e: any) => seen.push([e.detail.value, e.detail.isComposing]) }),
239
+ );
240
+ sync();
241
+ fireNative(field, "input", { value: "ni", isComposing: true });
242
+ fireNative(field, "input", { value: "ni", isComposing: false });
243
+ fireNative(field, "input", { value: "ni", isComposing: false });
244
+ expect(seen).toEqual([
245
+ ["ni", true],
246
+ ["ni", false],
247
+ ]);
248
+ });
249
+
250
+ it("textarea behaves the same", () => {
251
+ let text = "uno";
252
+ let field: any;
253
+ const { sync, fireNative, handleOf } = mount(() =>
254
+ m("textarea", { value: text, oncreate: (v: any) => (field = v.dom), oninput: (e: any) => (text = e.detail.value) }),
255
+ );
256
+ sync();
257
+ fireNative(field, "input", { value: "uno dos" });
258
+ sync();
259
+ expect(uiCalls(handleOf(field))).toEqual([["setValue", { value: "uno" }]]);
260
+ expect(field.value).toBe("uno dos");
261
+ });
262
+
263
+ it("focus/blur/confirm events sync dom.value too", () => {
264
+ let field: any;
265
+ const { sync, fireNative } = mount(() => m("input", { oncreate: (v: any) => (field = v.dom), onblur: () => {} }));
266
+ sync();
267
+ fireNative(field, "blur", { value: "typed" });
268
+ expect(field.value).toBe("typed");
269
+ });
270
+ });
271
+
272
+ describe("focus", () => {
273
+ it("autofocus focuses once, on creation, and never again on redraw", () => {
274
+ let field: any;
275
+ const { app, sync, handleOf } = mount(() => m("input", { autofocus: true, oncreate: (v: any) => (field = v.dom) }));
276
+ sync();
277
+ app.redraw();
278
+ sync();
279
+ expect(uiCalls(handleOf(field))).toEqual([["focus", {}]]);
280
+ });
281
+
282
+ it("dom.focus() and dom.blur() from oncreate", () => {
283
+ let field: any;
284
+ const { sync, handleOf } = mount(() =>
285
+ m("input", {
286
+ oncreate: (v: any) => {
287
+ field = v.dom;
288
+ v.dom.focus();
289
+ },
290
+ }),
291
+ );
292
+ sync();
293
+ field.blur();
294
+ return Promise.resolve().then(() => {
295
+ sync();
296
+ expect(uiCalls(handleOf(field))).toEqual([
297
+ ["focus", {}],
298
+ ["blur", {}],
299
+ ]);
300
+ });
301
+ });
302
+
303
+ it("dom.focus() outside a render is sent on its own at the end of the task", async () => {
304
+ let field: any;
305
+ const { patches, sync, handleOf } = mount(() => m("input", { oncreate: (v: any) => (field = v.dom) }));
306
+ sync();
307
+ const before = patches.length;
308
+ field.focus();
309
+ expect(patches.length).toBe(before);
310
+ await Promise.resolve();
311
+ expect(patches.length).toBe(before + 1);
312
+ expect(opsOf(patches.slice(before), Op.InvokeUIMethod)).toEqual([[field._id, "focus", {}, 0]]);
313
+ sync();
314
+ expect(uiCalls(handleOf(field))).toEqual([["focus", {}]]);
315
+ });
316
+
317
+ it("Mithril's post-render focus restoration never calls focus()", () => {
318
+ let show = true;
319
+ const { app, sync } = mount(() => m("view", show ? [m("input", { key: "a" }), m("input", { key: "b" })] : [m("input", { key: "b" })]));
320
+ sync();
321
+ show = false;
322
+ app.redraw();
323
+ sync();
324
+ expect(uiMethodCalls).toEqual([]);
325
+ });
326
+ });
327
+
328
+ describe("dom.invoke()", () => {
329
+ it("resolves with the method's data", async () => {
330
+ setUIMethodResponder((_e, method) => (method === "getValue" ? { code: 0, data: { value: "hola", selectionStart: 4, selectionEnd: 4 } } : { code: 0 }));
331
+ let field: any;
332
+ const { sync } = mount(() => m("input", { oncreate: (v: any) => (field = v.dom) }));
333
+ sync();
334
+ const result = field.invoke("getValue");
335
+ await Promise.resolve();
336
+ sync();
337
+ await expect(result).resolves.toEqual({ value: "hola", selectionStart: 4, selectionEnd: 4 });
338
+ });
339
+
340
+ it("works on any element (e.g. a scroll-view method) and passes params", async () => {
341
+ let scroller: any;
342
+ const { sync, handleOf } = mount(() => m("scroll-view", { oncreate: (v: any) => (scroller = v.dom) }));
343
+ sync();
344
+ const done = scroller.invoke("scrollTo", { offset: 120, smooth: true });
345
+ await Promise.resolve();
346
+ sync();
347
+ await expect(done).resolves.toBeUndefined();
348
+ expect(uiCalls(handleOf(scroller))).toEqual([["scrollTo", { offset: 120, smooth: true }]]);
349
+ });
350
+
351
+ it("rejects with the native code and data", async () => {
352
+ setUIMethodResponder(() => ({ code: 4, data: "no such method" }));
353
+ let field: any;
354
+ const { sync } = mount(() => m("input", { oncreate: (v: any) => (field = v.dom) }));
355
+ sync();
356
+ const result = field.invoke("nope");
357
+ await Promise.resolve();
358
+ sync();
359
+ await expect(result).rejects.toMatchObject({ code: 4, data: "no such method" });
360
+ });
361
+
362
+ it("rejects when the element is removed by the same patch", async () => {
363
+ let show = true;
364
+ let field: any;
365
+ let pending: Promise<unknown> | undefined;
366
+ const { app, sync } = mount(() =>
367
+ m("view", show ? m("input", { oncreate: (v: any) => (field = v.dom) }) : null),
368
+ );
369
+ sync();
370
+ pending = field.invoke("getValue");
371
+ show = false;
372
+ app.redraw();
373
+ sync();
374
+ await expect(pending).rejects.toThrow(/removed/);
375
+ expect(uiMethodCalls).toEqual([]);
376
+ });
377
+
378
+ it("setSelectionRange is a UI method call", async () => {
379
+ let field: any;
380
+ const { sync, handleOf } = mount(() => m("input", { oncreate: (v: any) => (field = v.dom) }));
381
+ sync();
382
+ const done = field.setSelectionRange(1, 3);
383
+ await Promise.resolve();
384
+ sync();
385
+ await done;
386
+ expect(uiCalls(handleOf(field))).toEqual([["setSelectionRange", { selectionStart: 1, selectionEnd: 3 }]]);
387
+ });
388
+ });