@evolu/web 3.3.0 → 3.4.1

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 +1 @@
1
- {"version":3,"file":"Sqlite.d.ts","sourceRoot":"","sources":["../../src/Sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAa,MAAM,eAAe,CAAC;AAqCnE,eAAO,MAAM,sBAAsB,EAAE,kBAyLlC,CAAC"}
1
+ {"version":3,"file":"Sqlite.d.ts","sourceRoot":"","sources":["../../src/Sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAa,MAAM,eAAe,CAAC;AAqCnE,eAAO,MAAM,sBAAsB,EAAE,kBA4LlC,CAAC"}
@@ -81,9 +81,11 @@ export const createWasmSqliteDriver = (name, options) => async (run) => {
81
81
  // DbWorker that ended without closing it, because its tab closed,
82
82
  // crashed or navigated away, can hold pool files after the lock has
83
83
  // passed on: WebKit releases a terminated worker's locks and its files
84
- // separately, in no set order. sqlite-wasm cannot set up a pool with a
85
- // held file, and it then deletes the pool directory, which the held file
86
- // usually but not always prevents. Only a worker that has ended can hold
84
+ // separately, in no set order
85
+ // (https://bugs.webkit.org/show_bug.cgi?id=301520). sqlite-wasm cannot
86
+ // set up a pool with a held file, and it then deletes the pool
87
+ // directory, which the held file usually but not always prevents. Only
88
+ // a worker that has ended can hold
87
89
  // the files, and it can only release them, so the pool is set up once
88
90
  // every file opens.
89
91
  const canOpenPoolFiles = async () => {
@@ -101,7 +103,8 @@ export const createWasmSqliteDriver = (name, options) => async (run) => {
101
103
  const accessHandle = await tryAsync(() => handle.createSyncAccessHandle());
102
104
  if (!accessHandle.ok) {
103
105
  // WebKit rejects a held file with InvalidStateError, other
104
- // engines with NoModificationAllowedError, as the spec says.
106
+ // engines with NoModificationAllowedError, as the spec says
107
+ // (https://bugs.webkit.org/show_bug.cgi?id=326135).
105
108
  // WebKit also uses InvalidStateError for a closed or invalid
106
109
  // handle and a stopped context. A retry opens fresh handles from
107
110
  // a new listing, and a stopped context ends the loop with its
@@ -1,27 +1,11 @@
1
1
  import { type ConsoleDep, type ReloadAppDep } from "@evolu/common";
2
- import type { EvoluDeps } from "@evolu/common/local-first";
2
+ import type { EvoluDeps, RequestPersistentStorageDep } from "@evolu/common/local-first";
3
3
  export interface SharedWorkerUnsupported {
4
4
  readonly type: "SharedWorkerUnsupported";
5
5
  }
6
6
  export interface SharedWorkerUnsupportedDep {
7
7
  readonly onSharedWorkerUnsupported: () => void;
8
8
  }
9
- export interface StorageUnavailableDep {
10
- /**
11
- * Called when the browser offers no persistent storage, as in Safari's
12
- * Private Browsing, so every database is kept in memory. See Storage in the
13
- * Shared module of `@evolu/common`.
14
- *
15
- * The app keeps working. Data synced with a relay comes back, as on a new
16
- * device, and nothing stays on the device once the tabs close. That suits
17
- * someone checking their app on a borrowed phone, so a message such as
18
- * "Nothing from this session is kept on this device." tells the user what to
19
- * expect. Data that exists only locally, or has not synced yet, is lost when
20
- * the tab hosting the database closes or navigates away, even while other
21
- * tabs stay open.
22
- */
23
- readonly onStorageUnavailable: () => void;
24
- }
25
9
  /**
26
10
  * Creates Evolu dependencies for the web platform.
27
11
  *
@@ -38,18 +22,30 @@ export interface StorageUnavailableDep {
38
22
  *
39
23
  * When the page enters the browser's back-forward cache, as Safari does on
40
24
  * every navigation away, this tab ends its part as if it closed: it stops the
41
- * database workers it hosts, so another tab takes them over, and it reloads if
42
- * the user comes back to it.
25
+ * database workers it hosts, so another tab takes them over, the shared worker
26
+ * ends this tab's Evolu instances, and the tab reloads if the user comes back
27
+ * to it.
43
28
  *
44
29
  * Where the browser offers no persistent storage, as in Safari's Private
45
- * Browsing, the database is kept in memory, and
46
- * {@link StorageUnavailableDep.onStorageUnavailable} lets the app tell the
47
- * user.
30
+ * Browsing or a Firefox private window, the database is kept in memory, and
31
+ * {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
32
+ * tell the user. Data that exists only locally, or has not synced yet, is lost
33
+ * when the tab hosting the database closes or navigates away, even while other
34
+ * tabs stay open.
35
+ *
36
+ * After the first local mutation of a database the browser stores, this tab
37
+ * asks the browser once with `navigator.storage.persist()` not to delete the
38
+ * site's data when disk space runs low. Chrome and Safari decide silently, and
39
+ * Firefox asks the user, so every tab asks until the user allows it. A custom
40
+ * {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
41
+ * request, for example with `constVoid` to never ask. See [Will my data stay on
42
+ * the
43
+ * device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
48
44
  *
49
45
  * A custom {@link ReloadApp} replaces the default page reload, for example to
50
46
  * save state first. It should end by reloading the page, because the other
51
47
  * build waits until this tab reloads or closes, and a page restored from the
52
48
  * back-forward cache cannot work until it reloads.
53
49
  */
54
- export declare const createEvoluDeps: (deps?: Partial<ConsoleDep> & Partial<ReloadAppDep> & Partial<SharedWorkerUnsupportedDep> & Partial<StorageUnavailableDep>) => EvoluDeps;
50
+ export declare const createEvoluDeps: (deps?: Partial<ConsoleDep> & Partial<ReloadAppDep> & Partial<SharedWorkerUnsupportedDep> & Partial<RequestPersistentStorageDep>) => EvoluDeps;
55
51
  //# sourceMappingURL=Evolu.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Evolu.d.ts","sourceRoot":"","sources":["../../../src/local-first/Evolu.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,UAAU,EAEf,KAAK,YAAY,EAElB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAIV,SAAS,EAIV,MAAM,2BAA2B,CAAC;AAgBnC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;CAC1C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,yBAAyB,EAAE,MAAM,IAAI,CAAC;CAChD;AAED,MAAM,WAAW,qBAAqB;IACpC;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,oBAAoB,EAAE,MAAM,IAAI,CAAC;CAC3C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,eAAe,UACpB,OAAO,CAAC,UAAU,CAAC,GACvB,OAAO,CAAC,YAAY,CAAC,GACrB,OAAO,CAAC,0BAA0B,CAAC,GACnC,OAAO,CAAC,qBAAqB,CAAC,KAC/B,SAiPF,CAAC"}
1
+ {"version":3,"file":"Evolu.d.ts","sourceRoot":"","sources":["../../../src/local-first/Evolu.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,UAAU,EAEf,KAAK,YAAY,EAElB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAKV,SAAS,EACT,2BAA2B,EAI5B,MAAM,2BAA2B,CAAC;AAgBnC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;CAC1C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,yBAAyB,EAAE,MAAM,IAAI,CAAC;CAChD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,eAAe,UACpB,OAAO,CAAC,UAAU,CAAC,GACvB,OAAO,CAAC,YAAY,CAAC,GACrB,OAAO,CAAC,0BAA0B,CAAC,GACnC,OAAO,CAAC,2BAA2B,CAAC,KACrC,SAgSF,CAAC"}
@@ -50,7 +50,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
50
50
  var e = new Error(message);
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
- import { exhaustiveCheck, trySync, } from "@evolu/common";
53
+ import { exhaustiveCheck, tryAsync, trySync, } from "@evolu/common";
54
54
  import { BuildWaiting, BuildWaitingRequest, buildsBroadcastChannelName, createEvoluDeps as createCommonEvoluDeps, } from "@evolu/common/local-first";
55
55
  import { reloadApp } from "../Platform.js";
56
56
  import { createBroadcastChannel, createMessageChannel, createSharedWorker, createWorker, installOneTabSharedWorkerPolyfill, } from "../Worker.js";
@@ -70,13 +70,25 @@ import { createBroadcastChannel, createMessageChannel, createSharedWorker, creat
70
70
  *
71
71
  * When the page enters the browser's back-forward cache, as Safari does on
72
72
  * every navigation away, this tab ends its part as if it closed: it stops the
73
- * database workers it hosts, so another tab takes them over, and it reloads if
74
- * the user comes back to it.
73
+ * database workers it hosts, so another tab takes them over, the shared worker
74
+ * ends this tab's Evolu instances, and the tab reloads if the user comes back
75
+ * to it.
75
76
  *
76
77
  * Where the browser offers no persistent storage, as in Safari's Private
77
- * Browsing, the database is kept in memory, and
78
- * {@link StorageUnavailableDep.onStorageUnavailable} lets the app tell the
79
- * user.
78
+ * Browsing or a Firefox private window, the database is kept in memory, and
79
+ * {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
80
+ * tell the user. Data that exists only locally, or has not synced yet, is lost
81
+ * when the tab hosting the database closes or navigates away, even while other
82
+ * tabs stay open.
83
+ *
84
+ * After the first local mutation of a database the browser stores, this tab
85
+ * asks the browser once with `navigator.storage.persist()` not to delete the
86
+ * site's data when disk space runs low. Chrome and Safari decide silently, and
87
+ * Firefox asks the user, so every tab asks until the user allows it. A custom
88
+ * {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
89
+ * request, for example with `constVoid` to never ask. See [Will my data stay on
90
+ * the
91
+ * device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
80
92
  *
81
93
  * A custom {@link ReloadApp} replaces the default page reload, for example to
82
94
  * save state first. It should end by reloading the page, because the other
@@ -105,6 +117,7 @@ export const createEvoluDeps = (deps = {}) => {
105
117
  const earlyAnnouncedWorkerIds = new Set();
106
118
  // Checks whether the user left this tab while another build waits.
107
119
  let focusCheckId = null;
120
+ let isPersistentStorageRequested = false;
108
121
  const disposer = __addDisposableResource(env_1, new DisposableStack(), false);
109
122
  const buildsBroadcastChannel = disposer.use(createBroadcastChannel(buildsBroadcastChannelName));
110
123
  const stopFocusCheck = () => {
@@ -206,11 +219,6 @@ export const createEvoluDeps = (deps = {}) => {
206
219
  });
207
220
  break;
208
221
  }
209
- case "StorageUnavailable": {
210
- deps.onStorageUnavailable?.();
211
- forward(message);
212
- break;
213
- }
214
222
  case "SharedWorkerUnsupported": {
215
223
  if (deps.onSharedWorkerUnsupported) {
216
224
  deps.onSharedWorkerUnsupported();
@@ -259,20 +267,55 @@ export const createEvoluDeps = (deps = {}) => {
259
267
  },
260
268
  },
261
269
  };
270
+ const requestPersistentStorage = () => {
271
+ if (isPersistentStorageRequested)
272
+ return;
273
+ isPersistentStorageRequested = true;
274
+ void tryAsync(async () => {
275
+ if (await navigator.storage.persisted())
276
+ return;
277
+ await navigator.storage.persist();
278
+ });
279
+ };
280
+ // Disposing the deps releases the locks this page holds through them, as
281
+ // closing the page would, and a lock granted afterwards is released at once.
282
+ // The SharedWorker learns that an Evolu instance ended when it gets the lock
283
+ // the instance holds. Chrome keeps the locks of a page in its back-forward
284
+ // cache, and a request made before the page entered the cache waits until
285
+ // Chrome drops the page (https://issues.chromium.org/issues/567630881), so
286
+ // the SharedWorker would keep syncing the owners of a cached tab's instances
287
+ // and rerunning their queries.
288
+ let areLocksReleased = false;
289
+ const locksReleased = Promise.withResolvers();
290
+ disposer.defer(() => {
291
+ areLocksReleased = true;
292
+ locksReleased.resolve();
293
+ });
294
+ function requestLock(name, ...args) {
295
+ const [options, callback] = args.length === 1 ? [{}, args[0]] : args;
296
+ return navigator.locks.request(name, options, (lock) => areLocksReleased
297
+ ? undefined
298
+ : Promise.race([callback(lock), locksReleased.promise]));
299
+ }
262
300
  const evoluDeps = disposer.use(createCommonEvoluDeps({
301
+ requestPersistentStorage,
263
302
  ...deps,
264
303
  createDbWorker,
265
304
  createBroadcastChannel,
266
305
  createMessageChannel,
267
- lockManager: navigator.locks,
306
+ lockManager: {
307
+ query: () => navigator.locks.query(),
308
+ request: requestLock,
309
+ },
268
310
  reloadApp: reloadThisApp,
269
311
  sharedWorker,
270
312
  }));
271
- // A page entering the back-forward cache is frozen with its DbWorkers, which
272
- // keep their database locks, so the other tabs would stall. WebKit also
273
- // releases the page's own locks, so a restored page would work with state
274
- // the other tabs gave up on. The page therefore ends its part as if it
275
- // closed, and reloads when it is shown again.
313
+ // A page entering the back-forward cache is frozen with its DbWorkers, and
314
+ // WebKit keeps their database locks while the page is cached
315
+ // (https://bugs.webkit.org/show_bug.cgi?id=316904), so the other tabs would
316
+ // stall. WebKit also releases the page's own locks, so a restored page would
317
+ // work with state the other tabs gave up on. The page therefore ends its part
318
+ // as if it closed, and reloads when it is shown again.
276
319
  const reloadRestoredPage = (event) => {
277
320
  if (event.persisted)
278
321
  reloadThisApp();
@@ -66,8 +66,12 @@ addUncaughtErrorListener(self, (error) => {
66
66
  const run = createRun({
67
67
  ...createWorkerDeps(),
68
68
  createWebSocket,
69
- // Safari's Private Browsing offers no OPFS; see Storage in the Shared module.
70
- isPersistentStorageAvailable: async () => (await tryAsync(() => navigator.storage.getDirectory())).ok,
69
+ // Safari's Private Browsing and Firefox's private windows offer no OPFS, and
70
+ // a browser may delete it, as Chrome's incognito does when the session ends;
71
+ // see Storage in the Shared module.
72
+ getDevicePersistence: async () => (await tryAsync(() => navigator.storage.getDirectory())).ok
73
+ ? "Unknown"
74
+ : "NotPersisted",
71
75
  lockManager: navigator.locks,
72
76
  });
73
77
  void run(async (run) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evolu/web",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
4
4
  "description": "Evolu for web",
5
5
  "keywords": [
6
6
  "evolu",
@@ -35,7 +35,7 @@
35
35
  "@evolu/sqlite-wasm": "2.2.4"
36
36
  },
37
37
  "devDependencies": {
38
- "@evolu/common": "8.13.0",
38
+ "@evolu/common": "8.18.0",
39
39
  "@evolu/typescript-config": "0.1.1",
40
40
  "@types/node": "^24.10.9",
41
41
  "@types/sharedworker": "^0.0.229",
@@ -44,7 +44,7 @@
44
44
  "user-agent-data-types": "^0.4.2"
45
45
  },
46
46
  "peerDependencies": {
47
- "@evolu/common": "^8.13.0"
47
+ "@evolu/common": "^8.18.0"
48
48
  },
49
49
  "publishConfig": {
50
50
  "access": "public"
package/src/Sqlite.ts CHANGED
@@ -53,9 +53,11 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
53
53
  // DbWorker that ended without closing it, because its tab closed,
54
54
  // crashed or navigated away, can hold pool files after the lock has
55
55
  // passed on: WebKit releases a terminated worker's locks and its files
56
- // separately, in no set order. sqlite-wasm cannot set up a pool with a
57
- // held file, and it then deletes the pool directory, which the held file
58
- // usually but not always prevents. Only a worker that has ended can hold
56
+ // separately, in no set order
57
+ // (https://bugs.webkit.org/show_bug.cgi?id=301520). sqlite-wasm cannot
58
+ // set up a pool with a held file, and it then deletes the pool
59
+ // directory, which the held file usually but not always prevents. Only
60
+ // a worker that has ended can hold
59
61
  // the files, and it can only release them, so the pool is set up once
60
62
  // every file opens.
61
63
  const canOpenPoolFiles = async (): Promise<boolean> => {
@@ -76,7 +78,8 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
76
78
  );
77
79
  if (!accessHandle.ok) {
78
80
  // WebKit rejects a held file with InvalidStateError, other
79
- // engines with NoModificationAllowedError, as the spec says.
81
+ // engines with NoModificationAllowedError, as the spec says
82
+ // (https://bugs.webkit.org/show_bug.cgi?id=326135).
80
83
  // WebKit also uses InvalidStateError for a closed or invalid
81
84
  // handle and a stopped context. A retry opens fresh handles from
82
85
  // a new listing, and a stopped context ends the loop with its
@@ -1,4 +1,5 @@
1
1
  import {
2
+ acquireLeaderLock,
2
3
  assertEqual,
3
4
  assertFalse,
4
5
  assertNonNullable,
@@ -7,6 +8,7 @@ import {
7
8
  createConsole,
8
9
  createIdFromString,
9
10
  PositiveInt,
11
+ testCreateRun,
10
12
  testName,
11
13
  testStubGlobal,
12
14
  type NativeMessagePort,
@@ -148,26 +150,59 @@ describe("createEvoluDeps", () => {
148
150
  });
149
151
  });
150
152
 
151
- describe("storage unavailable", () => {
152
- const workerId = createIdFromString<"SharedWorker">("worker");
153
+ describe("persistent storage request", () => {
154
+ const setupStorage = (isPersisted: boolean) => ({
155
+ persisted: mock.fn(() => Promise.resolve(isPersisted)),
156
+ persist: mock.fn(() => Promise.resolve(true)),
157
+ });
153
158
 
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);
159
+ it("asks the browser once to keep the site's data", async () => {
160
+ const storage = setupStorage(false);
161
+ using setup = setupWebEvoluDeps({ storage });
158
162
 
159
- setup.post({ type: "StorageUnavailable" });
163
+ setup.deps.requestPersistentStorage?.();
164
+ setup.deps.requestPersistentStorage?.();
165
+ await waitForMacrotask();
160
166
 
161
- assertSame(onStorageUnavailable.mock.callCount(), 1);
167
+ assertSame(storage.persisted.mock.callCount(), 1);
168
+ assertSame(storage.persist.mock.callCount(), 1);
162
169
  });
163
170
 
164
- it("works without a callback", () => {
165
- using setup = setupWebEvoluDeps();
166
- setup.connect(workerId);
171
+ it("does not ask when the site's data is already persistent", async () => {
172
+ const storage = setupStorage(true);
173
+ using setup = setupWebEvoluDeps({ storage });
167
174
 
168
- setup.post({ type: "StorageUnavailable" });
175
+ setup.deps.requestPersistentStorage?.();
176
+ await waitForMacrotask();
169
177
 
170
- assertSame(setup.reloadApp.mock.callCount(), 0);
178
+ assertSame(storage.persist.mock.callCount(), 0);
179
+ });
180
+
181
+ it("ignores a browser that refuses to answer", async () => {
182
+ const storage = {
183
+ persisted: mock.fn(() =>
184
+ Promise.reject(new TypeError("Storage is disabled.")),
185
+ ),
186
+ persist: mock.fn(() => Promise.resolve(true)),
187
+ };
188
+ using setup = setupWebEvoluDeps({ storage });
189
+
190
+ setup.deps.requestPersistentStorage?.();
191
+ await waitForMacrotask();
192
+
193
+ assertSame(storage.persist.mock.callCount(), 0);
194
+ });
195
+
196
+ it("uses the app's request instead", async () => {
197
+ const storage = setupStorage(false);
198
+ const requestPersistentStorage = mock.fn<() => void>();
199
+ using setup = setupWebEvoluDeps({ storage, requestPersistentStorage });
200
+
201
+ setup.deps.requestPersistentStorage?.();
202
+ await waitForMacrotask();
203
+
204
+ assertSame(requestPersistentStorage.mock.callCount(), 1);
205
+ assertSame(storage.persisted.mock.callCount(), 0);
171
206
  });
172
207
  });
173
208
 
@@ -189,6 +224,39 @@ describe("createEvoluDeps", () => {
189
224
  assertSame(setup.reloadApp.mock.callCount(), 0);
190
225
  });
191
226
 
227
+ it("releases the locks of its Evolu instances when the page enters the cache", async () => {
228
+ using setup = setupWebEvoluDeps();
229
+ await using run = testCreateRun({
230
+ lockManager: setup.deps.lockManager,
231
+ });
232
+ await using _instanceLock = await run.ok(
233
+ acquireLeaderLock("cached-instance"),
234
+ );
235
+ assertFalse(await isLockAvailable("evolu-leaderlock-cached-instance"));
236
+
237
+ setup.dispatchPageTransition("pagehide", true);
238
+ await waitForMacrotask();
239
+
240
+ assertTrue(await isLockAvailable("evolu-leaderlock-cached-instance"));
241
+ });
242
+
243
+ it("releases at once a lock granted after the page entered the cache", async () => {
244
+ using setup = setupWebEvoluDeps();
245
+ const name = "evolu-granted-after-cache";
246
+ const held = Promise.withResolvers<void>();
247
+ const holding = navigator.locks.request(name, () => held.promise);
248
+ const callback = mock.fn<LockGrantedCallback<void>>();
249
+ const request = setup.deps.lockManager.request(name, callback);
250
+
251
+ setup.dispatchPageTransition("pagehide", true);
252
+ held.resolve();
253
+ await holding;
254
+ await request;
255
+
256
+ assertSame(callback.mock.callCount(), 0);
257
+ assertTrue(await isLockAvailable(name));
258
+ });
259
+
192
260
  it("reloads once when the page is restored from the cache", () => {
193
261
  using setup = setupWebEvoluDeps();
194
262
  setup.dispatchPageTransition("pageshow", false);
@@ -443,7 +511,8 @@ const setupWebEvoluDeps = ({
443
511
  isSessionStorageAvailable = true,
444
512
  refusalReloads,
445
513
  reloadedFor,
446
- onStorageUnavailable,
514
+ storage,
515
+ requestPersistentStorage,
447
516
  }: {
448
517
  hasFocus?: boolean;
449
518
  isAutomaticReload?: boolean;
@@ -452,7 +521,9 @@ const setupWebEvoluDeps = ({
452
521
  refusalReloads?: string;
453
522
  /** The stored waiting workers, as a previous page load left them. */
454
523
  reloadedFor?: string;
455
- onStorageUnavailable?: () => void;
524
+ /** Stands in for `navigator.storage`. */
525
+ storage?: Pick<StorageManager, "persist" | "persisted">;
526
+ requestPersistentStorage?: () => void;
456
527
  } = {}) => {
457
528
  using disposer = new DisposableStack();
458
529
  const sharedWorkerPort = createClosableNativePort<unknown>();
@@ -539,12 +610,17 @@ const setupWebEvoluDeps = ({
539
610
  );
540
611
  let hasFocus = initialHasFocus;
541
612
  disposer.use(testStubGlobal("document", { hasFocus: () => hasFocus }));
613
+ if (storage) {
614
+ disposer.use(
615
+ testStubGlobal("navigator", { locks: navigator.locks, storage }),
616
+ );
617
+ }
542
618
  const reloadApp = mock.fn<ReloadApp>();
543
619
 
544
620
  const deps = createEvoluDeps({
545
621
  console: createConsole({ level: "silent" }),
546
622
  reloadApp,
547
- ...(onStorageUnavailable && { onStorageUnavailable }),
623
+ ...(requestPersistentStorage && { requestPersistentStorage }),
548
624
  });
549
625
  const builds = channels.find(
550
626
  (channel) => channel.name === buildsBroadcastChannelName,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  exhaustiveCheck,
3
+ tryAsync,
3
4
  trySync,
4
5
  type ConsoleDep,
5
6
  type ReloadApp,
@@ -10,7 +11,9 @@ import type {
10
11
  SharedWorker as CommonSharedWorker,
11
12
  CreateDbWorker,
12
13
  DbWorkerInit,
14
+ Evolu,
13
15
  EvoluDeps,
16
+ RequestPersistentStorageDep,
14
17
  SharedWorkerId,
15
18
  SharedWorkerInput,
16
19
  SharedWorkerOutput,
@@ -38,23 +41,6 @@ export interface SharedWorkerUnsupportedDep {
38
41
  readonly onSharedWorkerUnsupported: () => void;
39
42
  }
40
43
 
41
- export interface StorageUnavailableDep {
42
- /**
43
- * Called when the browser offers no persistent storage, as in Safari's
44
- * Private Browsing, so every database is kept in memory. See Storage in the
45
- * Shared module of `@evolu/common`.
46
- *
47
- * The app keeps working. Data synced with a relay comes back, as on a new
48
- * device, and nothing stays on the device once the tabs close. That suits
49
- * someone checking their app on a borrowed phone, so a message such as
50
- * "Nothing from this session is kept on this device." tells the user what to
51
- * expect. Data that exists only locally, or has not synced yet, is lost when
52
- * the tab hosting the database closes or navigates away, even while other
53
- * tabs stay open.
54
- */
55
- readonly onStorageUnavailable: () => void;
56
- }
57
-
58
44
  /**
59
45
  * Creates Evolu dependencies for the web platform.
60
46
  *
@@ -71,13 +57,25 @@ export interface StorageUnavailableDep {
71
57
  *
72
58
  * When the page enters the browser's back-forward cache, as Safari does on
73
59
  * every navigation away, this tab ends its part as if it closed: it stops the
74
- * database workers it hosts, so another tab takes them over, and it reloads if
75
- * the user comes back to it.
60
+ * database workers it hosts, so another tab takes them over, the shared worker
61
+ * ends this tab's Evolu instances, and the tab reloads if the user comes back
62
+ * to it.
76
63
  *
77
64
  * Where the browser offers no persistent storage, as in Safari's Private
78
- * Browsing, the database is kept in memory, and
79
- * {@link StorageUnavailableDep.onStorageUnavailable} lets the app tell the
80
- * user.
65
+ * Browsing or a Firefox private window, the database is kept in memory, and
66
+ * {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
67
+ * tell the user. Data that exists only locally, or has not synced yet, is lost
68
+ * when the tab hosting the database closes or navigates away, even while other
69
+ * tabs stay open.
70
+ *
71
+ * After the first local mutation of a database the browser stores, this tab
72
+ * asks the browser once with `navigator.storage.persist()` not to delete the
73
+ * site's data when disk space runs low. Chrome and Safari decide silently, and
74
+ * Firefox asks the user, so every tab asks until the user allows it. A custom
75
+ * {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
76
+ * request, for example with `constVoid` to never ask. See [Will my data stay on
77
+ * the
78
+ * device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
81
79
  *
82
80
  * A custom {@link ReloadApp} replaces the default page reload, for example to
83
81
  * save state first. It should end by reloading the page, because the other
@@ -88,7 +86,7 @@ export const createEvoluDeps = (
88
86
  deps: Partial<ConsoleDep> &
89
87
  Partial<ReloadAppDep> &
90
88
  Partial<SharedWorkerUnsupportedDep> &
91
- Partial<StorageUnavailableDep> = {},
89
+ Partial<RequestPersistentStorageDep> = {},
92
90
  ): EvoluDeps => {
93
91
  installOneTabSharedWorkerPolyfill();
94
92
  const reloadThisApp = deps.reloadApp ?? reloadApp;
@@ -110,6 +108,7 @@ export const createEvoluDeps = (
110
108
  const earlyAnnouncedWorkerIds = new Set<SharedWorkerId>();
111
109
  // Checks whether the user left this tab while another build waits.
112
110
  let focusCheckId: ReturnType<typeof setInterval> | null = null;
111
+ let isPersistentStorageRequested = false;
113
112
 
114
113
  using disposer = new DisposableStack();
115
114
  const buildsBroadcastChannel = disposer.use(
@@ -225,12 +224,6 @@ export const createEvoluDeps = (
225
224
  break;
226
225
  }
227
226
 
228
- case "StorageUnavailable": {
229
- deps.onStorageUnavailable?.();
230
- forward(message);
231
- break;
232
- }
233
-
234
227
  case "SharedWorkerUnsupported": {
235
228
  if (deps.onSharedWorkerUnsupported) {
236
229
  deps.onSharedWorkerUnsupported();
@@ -292,23 +285,75 @@ export const createEvoluDeps = (
292
285
  },
293
286
  };
294
287
 
288
+ const requestPersistentStorage = (): void => {
289
+ if (isPersistentStorageRequested) return;
290
+ isPersistentStorageRequested = true;
291
+ void tryAsync(async () => {
292
+ if (await navigator.storage.persisted()) return;
293
+ await navigator.storage.persist();
294
+ });
295
+ };
296
+
297
+ // Disposing the deps releases the locks this page holds through them, as
298
+ // closing the page would, and a lock granted afterwards is released at once.
299
+ // The SharedWorker learns that an Evolu instance ended when it gets the lock
300
+ // the instance holds. Chrome keeps the locks of a page in its back-forward
301
+ // cache, and a request made before the page entered the cache waits until
302
+ // Chrome drops the page (https://issues.chromium.org/issues/567630881), so
303
+ // the SharedWorker would keep syncing the owners of a cached tab's instances
304
+ // and rerunning their queries.
305
+ let areLocksReleased = false;
306
+ const locksReleased = Promise.withResolvers<void>();
307
+ disposer.defer(() => {
308
+ areLocksReleased = true;
309
+ locksReleased.resolve();
310
+ });
311
+
312
+ function requestLock<T>(
313
+ name: string,
314
+ callback: LockGrantedCallback<T>,
315
+ ): Promise<Awaited<T>>;
316
+ function requestLock<T>(
317
+ name: string,
318
+ options: LockOptions,
319
+ callback: LockGrantedCallback<T>,
320
+ ): Promise<Awaited<T>>;
321
+ function requestLock(
322
+ name: string,
323
+ ...args:
324
+ | [LockGrantedCallback<unknown>]
325
+ | [LockOptions, LockGrantedCallback<unknown>]
326
+ ): Promise<unknown> {
327
+ const [options, callback] = args.length === 1 ? [{}, args[0]] : args;
328
+ return navigator.locks.request(name, options, (lock) =>
329
+ areLocksReleased
330
+ ? undefined
331
+ : Promise.race([callback(lock), locksReleased.promise]),
332
+ );
333
+ }
334
+
295
335
  const evoluDeps = disposer.use(
296
336
  createCommonEvoluDeps({
337
+ requestPersistentStorage,
297
338
  ...deps,
298
339
  createDbWorker,
299
340
  createBroadcastChannel,
300
341
  createMessageChannel,
301
- lockManager: navigator.locks,
342
+ lockManager: {
343
+ query: () => navigator.locks.query(),
344
+ request: requestLock,
345
+ },
302
346
  reloadApp: reloadThisApp,
303
347
  sharedWorker,
304
348
  }),
305
349
  );
306
350
 
307
- // A page entering the back-forward cache is frozen with its DbWorkers, which
308
- // keep their database locks, so the other tabs would stall. WebKit also
309
- // releases the page's own locks, so a restored page would work with state
310
- // the other tabs gave up on. The page therefore ends its part as if it
311
- // closed, and reloads when it is shown again.
351
+ // A page entering the back-forward cache is frozen with its DbWorkers, and
352
+ // WebKit keeps their database locks while the page is cached
353
+ // (https://bugs.webkit.org/show_bug.cgi?id=316904), so the other tabs would
354
+ // stall. WebKit also releases the page's own locks, so a restored page would
355
+ // work with state the other tabs gave up on. The page therefore ends its part
356
+ // as if it closed, and reloads when it is shown again.
312
357
  const reloadRestoredPage = (event: PageTransitionEvent): void => {
313
358
  if (event.persisted) reloadThisApp();
314
359
  };
@@ -42,9 +42,13 @@ addUncaughtErrorListener(self, (error) => {
42
42
  const run = createRun({
43
43
  ...createWorkerDeps(),
44
44
  createWebSocket,
45
- // Safari's Private Browsing offers no OPFS; see Storage in the Shared module.
46
- isPersistentStorageAvailable: async () =>
47
- (await tryAsync(() => navigator.storage.getDirectory())).ok,
45
+ // Safari's Private Browsing and Firefox's private windows offer no OPFS, and
46
+ // a browser may delete it, as Chrome's incognito does when the session ends;
47
+ // see Storage in the Shared module.
48
+ getDevicePersistence: async () =>
49
+ (await tryAsync(() => navigator.storage.getDirectory())).ok
50
+ ? "Unknown"
51
+ : "NotPersisted",
48
52
  lockManager: navigator.locks,
49
53
  });
50
54