@evolu/web 3.1.2 → 3.2.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.
@@ -1,10 +1,29 @@
1
1
  import {
2
2
  assertEqual,
3
+ assertFalse,
3
4
  assertNonNullable,
5
+ assertSame,
6
+ assertTrue,
7
+ createConsole,
8
+ createIdFromString,
9
+ PositiveInt,
10
+ testName,
4
11
  testStubGlobal,
5
12
  type NativeMessagePort,
13
+ type ReloadApp,
14
+ type UnsupportedDbVersionError,
6
15
  } from "@evolu/common";
7
- import { describe, it, mock } from "node:test";
16
+ import {
17
+ buildsBroadcastChannelName,
18
+ testAppOwner,
19
+ type BuildWaiting,
20
+ type BuildWaitingRequest,
21
+ type DbWorkerInput,
22
+ type DbWorkerOutput,
23
+ type SharedWorkerId,
24
+ type SharedWorkerOutput,
25
+ } from "@evolu/common/local-first";
26
+ import { describe, it, mock, type Mock } from "node:test";
8
27
  import { createEvoluDeps } from "./Evolu.ts";
9
28
 
10
29
  describe("createEvoluDeps", () => {
@@ -26,6 +45,11 @@ describe("createEvoluDeps", () => {
26
45
  return nativeDbWorker;
27
46
  });
28
47
  using _worker = testStubGlobal("Worker", Worker);
48
+ using _addEventListener = testStubGlobal("addEventListener", () => {});
49
+ using _removeEventListener = testStubGlobal(
50
+ "removeEventListener",
51
+ () => {},
52
+ );
29
53
 
30
54
  using deps = createEvoluDeps({
31
55
  onSharedWorkerUnsupported,
@@ -41,10 +65,584 @@ describe("createEvoluDeps", () => {
41
65
  assertEqual(Worker.mock.callCount(), 0);
42
66
  assertNonNullable(deps);
43
67
  });
68
+
69
+ describe("database refusals", () => {
70
+ const createRefusal = (
71
+ storedVersion: number,
72
+ ): UnsupportedDbVersionError => ({
73
+ type: "UnsupportedDbVersionError",
74
+ storedVersion: PositiveInt.orThrow(storedVersion),
75
+ supportedVersion: PositiveInt.orThrow(2),
76
+ });
77
+
78
+ it("reloads once for a refusal, even with focus, instead of reporting it", () => {
79
+ using setup = setupWebEvoluDeps({ hasFocus: true });
80
+
81
+ setup.post({ type: "Error", error: createRefusal(3) });
82
+
83
+ assertSame(setup.reloadApp.mock.callCount(), 1);
84
+ assertSame(setup.deps.evoluError.get(), null);
85
+ assertSame(setup.getRefusalReloads(), "3");
86
+ assertFalse(setup.isAutomaticReloadMarked());
87
+ });
88
+
89
+ it("reloads for each refusal before the page unloads", () => {
90
+ using setup = setupWebEvoluDeps();
91
+
92
+ setup.post({ type: "Error", error: createRefusal(3) });
93
+ setup.post({ type: "Error", error: createRefusal(3) });
94
+
95
+ assertSame(setup.reloadApp.mock.callCount(), 2);
96
+ assertSame(setup.deps.evoluError.get(), null);
97
+ });
98
+
99
+ it("reports a refusal that recurs after its reload without creating a DbWorker", () => {
100
+ using setup = setupWebEvoluDeps({ refusalReloads: "3" });
101
+ const error = createRefusal(3);
102
+
103
+ setup.post({ type: "Error", error });
104
+
105
+ assertSame(setup.reloadApp.mock.callCount(), 0);
106
+ assertEqual(setup.deps.evoluError.get(), error);
107
+ assertSame(setup.getDbWorkerCount(), 0);
108
+ });
109
+
110
+ it("reloads again for a newer stored version", () => {
111
+ using setup = setupWebEvoluDeps({ refusalReloads: "3" });
112
+
113
+ setup.post({ type: "Error", error: createRefusal(4) });
114
+
115
+ assertSame(setup.reloadApp.mock.callCount(), 1);
116
+ assertSame(setup.deps.evoluError.get(), null);
117
+ assertSame(setup.getRefusalReloads(), "3,4");
118
+ });
119
+
120
+ it("reloads once for refusals of several databases, then reports them", () => {
121
+ let refusalReloads: string | null;
122
+ {
123
+ using firstPage = setupWebEvoluDeps();
124
+ firstPage.post({ type: "Error", error: createRefusal(3) });
125
+ firstPage.post({ type: "Error", error: createRefusal(4) });
126
+ assertSame(firstPage.reloadApp.mock.callCount(), 2);
127
+ refusalReloads = firstPage.getRefusalReloads();
128
+ }
129
+ assertNonNullable(refusalReloads);
130
+ using secondPage = setupWebEvoluDeps({ refusalReloads });
131
+ const error = createRefusal(4);
132
+
133
+ secondPage.post({ type: "Error", error });
134
+ secondPage.post({ type: "Error", error: createRefusal(3) });
135
+
136
+ assertSame(secondPage.reloadApp.mock.callCount(), 0);
137
+ assertEqual(secondPage.deps.evoluError.get(), error);
138
+ });
139
+
140
+ it("reports a refusal without session storage", () => {
141
+ using setup = setupWebEvoluDeps({ isSessionStorageAvailable: false });
142
+ const error = createRefusal(3);
143
+
144
+ setup.post({ type: "Error", error });
145
+
146
+ assertSame(setup.reloadApp.mock.callCount(), 0);
147
+ assertEqual(setup.deps.evoluError.get(), error);
148
+ });
149
+ });
150
+
151
+ describe("storage unavailable", () => {
152
+ const workerId = createIdFromString<"SharedWorker">("worker");
153
+
154
+ it("tells the app when its worker keeps databases in memory", () => {
155
+ const onStorageUnavailable = mock.fn<() => void>();
156
+ using setup = setupWebEvoluDeps({ onStorageUnavailable });
157
+ setup.connect(workerId);
158
+
159
+ setup.post({ type: "StorageUnavailable" });
160
+
161
+ assertSame(onStorageUnavailable.mock.callCount(), 1);
162
+ });
163
+
164
+ it("works without a callback", () => {
165
+ using setup = setupWebEvoluDeps();
166
+ setup.connect(workerId);
167
+
168
+ setup.post({ type: "StorageUnavailable" });
169
+
170
+ assertSame(setup.reloadApp.mock.callCount(), 0);
171
+ });
172
+ });
173
+
174
+ describe("back-forward cache", () => {
175
+ const workerId = createIdFromString<"SharedWorker">("cached");
176
+
177
+ it("ends its part as if it closed when the page enters the cache", async () => {
178
+ using setup = setupWebEvoluDeps();
179
+ setup.connect(workerId);
180
+ await setup.waitForTabLeadership();
181
+ setup.startDbWorker();
182
+
183
+ setup.dispatchPageTransition("pagehide", true);
184
+ await waitForMacrotask();
185
+
186
+ assertSame(setup.dbWorkers[0]?.terminate.mock.callCount(), 1);
187
+ assertSame(setup.sharedWorkerPort.close.mock.callCount(), 1);
188
+ assertTrue(await isLockAvailable(`evolu-leaderlock-tab-${workerId}`));
189
+ assertSame(setup.reloadApp.mock.callCount(), 0);
190
+ });
191
+
192
+ it("reloads once when the page is restored from the cache", () => {
193
+ using setup = setupWebEvoluDeps();
194
+ setup.dispatchPageTransition("pageshow", false);
195
+
196
+ setup.dispatchPageTransition("pagehide", true);
197
+ setup.dispatchPageTransition("pageshow", true);
198
+ setup.dispatchPageTransition("pageshow", true);
199
+
200
+ assertSame(setup.reloadApp.mock.callCount(), 1);
201
+ assertFalse(setup.isAutomaticReloadMarked());
202
+ });
203
+
204
+ it("ignores a pagehide that does not enter the cache", () => {
205
+ using setup = setupWebEvoluDeps();
206
+ setup.startDbWorker();
207
+
208
+ setup.dispatchPageTransition("pagehide", false);
209
+ setup.dispatchPageTransition("pageshow", true);
210
+
211
+ assertSame(setup.dbWorkers[0]?.terminate.mock.callCount(), 0);
212
+ assertSame(setup.sharedWorkerPort.close.mock.callCount(), 0);
213
+ assertSame(setup.reloadApp.mock.callCount(), 0);
214
+ });
215
+
216
+ it("ends its DbWorkers when disposed, and then ignores the cache", () => {
217
+ using setup = setupWebEvoluDeps();
218
+ setup.startDbWorker();
219
+
220
+ setup.disposeDeps();
221
+ setup.dispatchPageTransition("pagehide", true);
222
+ setup.dispatchPageTransition("pageshow", true);
223
+
224
+ assertSame(setup.dbWorkers[0]?.terminate.mock.callCount(), 1);
225
+ assertSame(setup.reloadApp.mock.callCount(), 0);
226
+ });
227
+ });
228
+
229
+ describe("worker relaunch", () => {
230
+ const firstWorkerId = createIdFromString<"SharedWorker">("first");
231
+ const relaunchedWorkerId = createIdFromString<"SharedWorker">("relaunched");
232
+
233
+ it("reloads at once when its worker connects again", () => {
234
+ using setup = setupWebEvoluDeps({ hasFocus: true });
235
+ setup.connect(firstWorkerId);
236
+
237
+ setup.connect(relaunchedWorkerId);
238
+
239
+ assertSame(setup.reloadApp.mock.callCount(), 1);
240
+ assertFalse(setup.isAutomaticReloadMarked());
241
+ });
242
+
243
+ it("reloads at once when its worker reports waiting again", () => {
244
+ using setup = setupWebEvoluDeps({ hasFocus: true });
245
+ setup.connect(firstWorkerId);
246
+
247
+ setup.post({ type: "Waiting", workerId: relaunchedWorkerId });
248
+
249
+ assertSame(setup.reloadApp.mock.callCount(), 1);
250
+ assertEqual(setup.builds.posted, [{ type: "BuildWaitingRequest" }]);
251
+ });
252
+
253
+ it("reloads at once when a waiting tab's worker connects as another worker", () => {
254
+ using setup = setupWebEvoluDeps({ hasFocus: true });
255
+ setup.post({ type: "Waiting", workerId: firstWorkerId });
256
+ setup.post({ type: "Error", error: { type: "OtherBuildRunningError" } });
257
+
258
+ setup.connect(relaunchedWorkerId);
259
+
260
+ assertSame(setup.reloadApp.mock.callCount(), 1);
261
+ assertFalse(setup.isAutomaticReloadMarked());
262
+ // The relaunched worker's Connected is not forwarded, so the tab neither
263
+ // clears the wait nor elects a leader for a worker without its databases.
264
+ assertEqual(setup.deps.evoluError.get(), {
265
+ type: "OtherBuildRunningError",
266
+ });
267
+ assertEqual(setup.builds.posted, [
268
+ { type: "BuildWaiting", workerId: firstWorkerId },
269
+ ]);
270
+ });
271
+
272
+ it("reloads at once when a waiting tab's worker waits as another worker", () => {
273
+ using setup = setupWebEvoluDeps({ hasFocus: true });
274
+ setup.post({ type: "Waiting", workerId: firstWorkerId });
275
+
276
+ setup.post({ type: "Waiting", workerId: relaunchedWorkerId });
277
+
278
+ assertSame(setup.reloadApp.mock.callCount(), 1);
279
+ assertEqual(setup.builds.posted, [
280
+ { type: "BuildWaiting", workerId: firstWorkerId },
281
+ ]);
282
+ });
283
+
284
+ it("does not reload when its waiting worker connects", () => {
285
+ using setup = setupWebEvoluDeps({ hasFocus: true });
286
+ setup.post({ type: "Waiting", workerId: firstWorkerId });
287
+
288
+ setup.connect(firstWorkerId);
289
+
290
+ assertSame(setup.reloadApp.mock.callCount(), 0);
291
+ assertEqual(setup.builds.posted, [
292
+ { type: "BuildWaiting", workerId: firstWorkerId },
293
+ { type: "BuildWaitingRequest" },
294
+ ]);
295
+ });
296
+ });
297
+
298
+ describe("builds", () => {
299
+ const createWorkerId = (name: string): SharedWorkerId =>
300
+ createIdFromString<"SharedWorker">(name);
301
+ const ownWorkerId = createWorkerId("own");
302
+ const otherWorkerId = createWorkerId("other");
303
+ const thirdWorkerId = createWorkerId("third");
304
+ const otherBuildWaiting: BuildWaiting = {
305
+ type: "BuildWaiting",
306
+ workerId: otherWorkerId,
307
+ };
308
+ const buildWaitingRequest: BuildWaitingRequest = {
309
+ type: "BuildWaitingRequest",
310
+ };
311
+
312
+ it("announces its build when its worker waits and when asked", () => {
313
+ using setup = setupWebEvoluDeps();
314
+
315
+ setup.post({ type: "Waiting", workerId: ownWorkerId });
316
+ setup.receive(buildWaitingRequest);
317
+
318
+ assertEqual(setup.builds.posted, [
319
+ { type: "BuildWaiting", workerId: ownWorkerId },
320
+ { type: "BuildWaiting", workerId: ownWorkerId },
321
+ ]);
322
+ });
323
+
324
+ it("asks waiting tabs to announce once it connects, and stops announcing", () => {
325
+ using setup = setupWebEvoluDeps();
326
+ setup.post({ type: "Waiting", workerId: ownWorkerId });
327
+
328
+ setup.connect(ownWorkerId);
329
+ setup.receive(buildWaitingRequest);
330
+
331
+ assertEqual(setup.builds.posted, [
332
+ { type: "BuildWaiting", workerId: ownWorkerId },
333
+ buildWaitingRequest,
334
+ ]);
335
+ });
336
+
337
+ it("does not announce its build after an automatic reload", () => {
338
+ using setup = setupWebEvoluDeps({ isAutomaticReload: true });
339
+
340
+ setup.post({ type: "Waiting", workerId: ownWorkerId });
341
+ setup.receive(buildWaitingRequest);
342
+
343
+ assertEqual(setup.builds.posted, []);
344
+ // Only the page the reload loaded skips announcing.
345
+ assertFalse(setup.isAutomaticReloadMarked());
346
+ });
347
+
348
+ it("reloads at once when another build waits and the user is not in the tab", () => {
349
+ using setup = setupWebEvoluDeps();
350
+ setup.connect(ownWorkerId);
351
+
352
+ setup.receive(otherBuildWaiting);
353
+
354
+ assertSame(setup.reloadApp.mock.callCount(), 1);
355
+ assertTrue(setup.isAutomaticReloadMarked());
356
+ assertSame(setup.getReloadedFor(), otherWorkerId);
357
+ });
358
+
359
+ it("reloads the tab the user is in once it loses focus", (t) => {
360
+ t.mock.timers.enable({ apis: ["setInterval"] });
361
+ using setup = setupWebEvoluDeps({ hasFocus: true });
362
+ setup.connect(ownWorkerId);
363
+
364
+ setup.receive(otherBuildWaiting);
365
+ t.mock.timers.tick(1000);
366
+ assertSame(setup.reloadApp.mock.callCount(), 0);
367
+
368
+ // However focus left, for example from a frame of the page.
369
+ setup.setFocus(false);
370
+ t.mock.timers.tick(1000);
371
+ assertSame(setup.reloadApp.mock.callCount(), 1);
372
+
373
+ t.mock.timers.tick(3000);
374
+ assertSame(setup.reloadApp.mock.callCount(), 1);
375
+ });
376
+
377
+ it("reloads once it connects for another build announced while it waited", () => {
378
+ using setup = setupWebEvoluDeps();
379
+ setup.post({ type: "Waiting", workerId: ownWorkerId });
380
+
381
+ setup.receive(otherBuildWaiting);
382
+ assertSame(setup.reloadApp.mock.callCount(), 0);
383
+
384
+ setup.connect(ownWorkerId);
385
+ assertSame(setup.reloadApp.mock.callCount(), 1);
386
+ });
387
+
388
+ it("ignores its own worker and invalid messages", () => {
389
+ using setup = setupWebEvoluDeps();
390
+
391
+ setup.receive({ type: "BuildWaiting", workerId: ownWorkerId });
392
+ setup.connect(ownWorkerId);
393
+ setup.receive({ type: "BuildWaiting", workerId: ownWorkerId });
394
+ setup.receive({ type: "BuildWaiting", workerId: "not an id" });
395
+
396
+ assertSame(setup.reloadApp.mock.callCount(), 0);
397
+ });
398
+
399
+ it("reloads at most once for each waiting worker", () => {
400
+ using setup = setupWebEvoluDeps({ reloadedFor: otherWorkerId });
401
+ setup.connect(ownWorkerId);
402
+
403
+ setup.receive(otherBuildWaiting);
404
+ assertSame(setup.reloadApp.mock.callCount(), 0);
405
+
406
+ setup.receive({ type: "BuildWaiting", workerId: thirdWorkerId });
407
+ assertSame(setup.reloadApp.mock.callCount(), 1);
408
+ assertSame(setup.getReloadedFor(), `${otherWorkerId},${thirdWorkerId}`);
409
+ });
410
+
411
+ it("does not reload without session storage", () => {
412
+ using setup = setupWebEvoluDeps({ isSessionStorageAvailable: false });
413
+ setup.connect(ownWorkerId);
414
+
415
+ setup.receive(otherBuildWaiting);
416
+
417
+ assertSame(setup.reloadApp.mock.callCount(), 0);
418
+ });
419
+
420
+ it("does not reload once disposed", (t) => {
421
+ t.mock.timers.enable({ apis: ["setInterval"] });
422
+ using setup = setupWebEvoluDeps({ hasFocus: true });
423
+ setup.connect(ownWorkerId);
424
+ setup.receive(otherBuildWaiting);
425
+
426
+ setup.disposeDeps();
427
+ setup.setFocus(false);
428
+ t.mock.timers.tick(1000);
429
+
430
+ assertSame(setup.reloadApp.mock.callCount(), 0);
431
+ });
432
+ });
44
433
  });
45
434
 
435
+ /**
436
+ * Creates web deps with stubbed workers, session storage, and a focus the test
437
+ * controls, and broadcast channels that record posts and deliver
438
+ * synchronously.
439
+ */
440
+ const setupWebEvoluDeps = ({
441
+ hasFocus: initialHasFocus = false,
442
+ isAutomaticReload = false,
443
+ isSessionStorageAvailable = true,
444
+ refusalReloads,
445
+ reloadedFor,
446
+ onStorageUnavailable,
447
+ }: {
448
+ hasFocus?: boolean;
449
+ isAutomaticReload?: boolean;
450
+ isSessionStorageAvailable?: boolean;
451
+ /** The stored refusal reload versions, as a previous page load left them. */
452
+ refusalReloads?: string;
453
+ /** The stored waiting workers, as a previous page load left them. */
454
+ reloadedFor?: string;
455
+ onStorageUnavailable?: () => void;
456
+ } = {}) => {
457
+ using disposer = new DisposableStack();
458
+ const sharedWorkerPort = createClosableNativePort<unknown>();
459
+ disposer.use(
460
+ testStubGlobal(
461
+ "SharedWorker",
462
+ class {
463
+ readonly port = sharedWorkerPort as unknown as NativeMessagePort<
464
+ never,
465
+ unknown
466
+ >;
467
+ },
468
+ ),
469
+ );
470
+ const dbWorkers: Array<{ readonly terminate: Mock<() => void> }> = [];
471
+ const Worker = mock.fn(function () {
472
+ const dbWorker = {
473
+ ...createClosableNativePort(),
474
+ terminate: mock.fn<() => void>(),
475
+ };
476
+ dbWorkers.push(dbWorker);
477
+ return dbWorker;
478
+ });
479
+ disposer.use(testStubGlobal("Worker", Worker));
480
+ // Page lifecycle events such as pagehide and pageshow.
481
+ const page = new EventTarget();
482
+ disposer.use(
483
+ testStubGlobal("addEventListener", page.addEventListener.bind(page)),
484
+ );
485
+ disposer.use(
486
+ testStubGlobal("removeEventListener", page.removeEventListener.bind(page)),
487
+ );
488
+ const channels: Array<{
489
+ readonly name: string;
490
+ readonly posted: Array<unknown>;
491
+ onmessage: ((event: MessageEvent) => void) | null;
492
+ }> = [];
493
+ disposer.use(
494
+ testStubGlobal(
495
+ "BroadcastChannel",
496
+ class {
497
+ readonly name: string;
498
+ readonly posted: Array<unknown> = [];
499
+ onmessage: ((event: MessageEvent) => void) | null = null;
500
+ constructor(name: string) {
501
+ this.name = name;
502
+ channels.push(this);
503
+ }
504
+ postMessage(message: unknown): void {
505
+ this.posted.push(message);
506
+ }
507
+ close(): void {}
508
+ },
509
+ ),
510
+ );
511
+ const sessionItems = new Map<string, string>();
512
+ if (isAutomaticReload) sessionItems.set(automaticReloadKey, "1");
513
+ if (refusalReloads !== undefined) {
514
+ sessionItems.set(refusalReloadKey, refusalReloads);
515
+ }
516
+ if (reloadedFor !== undefined) sessionItems.set(reloadedForKey, reloadedFor);
517
+ const throwSecurityError = (): never => {
518
+ throw new Error("The session storage is not available.");
519
+ };
520
+ disposer.use(
521
+ testStubGlobal(
522
+ "sessionStorage",
523
+ isSessionStorageAvailable
524
+ ? {
525
+ getItem: (key: string) => sessionItems.get(key) ?? null,
526
+ setItem: (key: string, value: string) => {
527
+ sessionItems.set(key, value);
528
+ },
529
+ removeItem: (key: string) => {
530
+ sessionItems.delete(key);
531
+ },
532
+ }
533
+ : {
534
+ getItem: throwSecurityError,
535
+ setItem: throwSecurityError,
536
+ removeItem: throwSecurityError,
537
+ },
538
+ ),
539
+ );
540
+ let hasFocus = initialHasFocus;
541
+ disposer.use(testStubGlobal("document", { hasFocus: () => hasFocus }));
542
+ const reloadApp = mock.fn<ReloadApp>();
543
+
544
+ const deps = createEvoluDeps({
545
+ console: createConsole({ level: "silent" }),
546
+ reloadApp,
547
+ ...(onStorageUnavailable && { onStorageUnavailable }),
548
+ });
549
+ const builds = channels.find(
550
+ (channel) => channel.name === buildsBroadcastChannelName,
551
+ );
552
+ assertNonNullable(builds);
553
+ const post = (output: SharedWorkerOutput): void => {
554
+ sharedWorkerPort.onmessage?.(new MessageEvent("message", { data: output }));
555
+ };
556
+ const disposables = disposer.move();
557
+
558
+ return {
559
+ deps,
560
+ builds,
561
+ reloadApp,
562
+ sharedWorkerPort,
563
+ post,
564
+ dbWorkers,
565
+ getDbWorkerCount: (): number => Worker.mock.callCount(),
566
+ /** Starts a DbWorker in this tab, as the SharedWorker asks its leader. */
567
+ startDbWorker: (): void => {
568
+ post({
569
+ type: "DbWorkerInit",
570
+ name: testName,
571
+ consoleLevel: "silent",
572
+ sqliteSchema: { tables: {}, indexes: [] },
573
+ encryptionKey: testAppOwner.encryptionKey,
574
+ memoryOnly: false,
575
+ port: createClosableNativePort() as unknown as NativeMessagePort<
576
+ DbWorkerOutput,
577
+ DbWorkerInput
578
+ >,
579
+ });
580
+ },
581
+ /** Waits until this tab wins the election and announces itself. */
582
+ waitForTabLeadership: async (): Promise<void> => {
583
+ const isAnnounced = (): boolean =>
584
+ sharedWorkerPort.postMessage.mock.calls.some(
585
+ ({ arguments: [message] }) =>
586
+ (message as { readonly type?: unknown }).type ===
587
+ "AnnounceTabLeader",
588
+ );
589
+ for (let turn = 0; turn < 100 && !isAnnounced(); turn += 1) {
590
+ await waitForMacrotask();
591
+ }
592
+ assertTrue(isAnnounced());
593
+ },
594
+ dispatchPageTransition: (
595
+ type: "pagehide" | "pageshow",
596
+ persisted: boolean,
597
+ ): void => {
598
+ page.dispatchEvent(Object.assign(new Event(type), { persisted }));
599
+ },
600
+ isAutomaticReloadMarked: (): boolean =>
601
+ sessionItems.has(automaticReloadKey),
602
+ getReloadedFor: (): string | null =>
603
+ sessionItems.get(reloadedForKey) ?? null,
604
+ getRefusalReloads: (): string | null =>
605
+ sessionItems.get(refusalReloadKey) ?? null,
606
+ connect: (workerId: SharedWorkerId): void => {
607
+ post({
608
+ type: "Connected",
609
+ workerId,
610
+ syncStateChannelName: `evolu:sync-state:${workerId}`,
611
+ });
612
+ },
613
+ setFocus: (value: boolean): void => {
614
+ hasFocus = value;
615
+ },
616
+ /** Delivers a message another tab posted on the builds channel. */
617
+ receive: (message: unknown): void => {
618
+ builds.onmessage?.(new MessageEvent("message", { data: message }));
619
+ },
620
+ disposeDeps: (): void => {
621
+ deps[Symbol.dispose]();
622
+ },
623
+ [Symbol.dispose]: () => {
624
+ deps[Symbol.dispose]();
625
+ disposables.dispose();
626
+ },
627
+ };
628
+ };
629
+
630
+ // The session storage keys the web adapter uses for its reload markers.
631
+ const automaticReloadKey = "evolu:automatic-reload";
632
+ const reloadedForKey = "evolu:reloaded-for";
633
+ const refusalReloadKey = "evolu:refusal-reloads";
634
+
635
+ const waitForMacrotask = (): Promise<void> =>
636
+ new Promise((resolve) => {
637
+ setImmediate(resolve);
638
+ });
639
+
640
+ const isLockAvailable = (name: string): Promise<boolean> =>
641
+ navigator.locks.request(name, { ifAvailable: true }, (lock) => lock !== null);
642
+
46
643
  const createClosableNativePort = <Output = never>() => ({
47
644
  close: mock.fn(),
48
645
  onmessage: null as ((event: MessageEvent<Output>) => void) | null,
49
- postMessage: mock.fn(),
646
+ postMessage:
647
+ mock.fn<(message: unknown, transfer?: ReadonlyArray<unknown>) => void>(),
50
648
  });