@evolu/web 3.3.0 → 3.4.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,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
  *
@@ -42,14 +26,25 @@ export interface StorageUnavailableDep {
42
26
  * the user comes back to it.
43
27
  *
44
28
  * 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.
29
+ * Browsing or a Firefox private window, the database is kept in memory, and
30
+ * {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
31
+ * tell the user. Data that exists only locally, or has not synced yet, is lost
32
+ * when the tab hosting the database closes or navigates away, even while other
33
+ * tabs stay open.
34
+ *
35
+ * After the first local mutation of a database the browser stores, this tab
36
+ * asks the browser once with `navigator.storage.persist()` not to delete the
37
+ * site's data when disk space runs low. Chrome and Safari decide silently, and
38
+ * Firefox asks the user, so every tab asks until the user allows it. A custom
39
+ * {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
40
+ * request, for example with `constVoid` to never ask. See [Will my data stay on
41
+ * the
42
+ * device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
48
43
  *
49
44
  * A custom {@link ReloadApp} replaces the default page reload, for example to
50
45
  * save state first. It should end by reloading the page, because the other
51
46
  * build waits until this tab reloads or closes, and a page restored from the
52
47
  * back-forward cache cannot work until it reloads.
53
48
  */
54
- export declare const createEvoluDeps: (deps?: Partial<ConsoleDep> & Partial<ReloadAppDep> & Partial<SharedWorkerUnsupportedDep> & Partial<StorageUnavailableDep>) => EvoluDeps;
49
+ export declare const createEvoluDeps: (deps?: Partial<ConsoleDep> & Partial<ReloadAppDep> & Partial<SharedWorkerUnsupportedDep> & Partial<RequestPersistentStorageDep>) => EvoluDeps;
55
50
  //# 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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;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,SAsPF,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";
@@ -74,9 +74,20 @@ import { createBroadcastChannel, createMessageChannel, createSharedWorker, creat
74
74
  * the user comes back to it.
75
75
  *
76
76
  * 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.
77
+ * Browsing or a Firefox private window, the database is kept in memory, and
78
+ * {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
79
+ * tell the user. Data that exists only locally, or has not synced yet, is lost
80
+ * when the tab hosting the database closes or navigates away, even while other
81
+ * tabs stay open.
82
+ *
83
+ * After the first local mutation of a database the browser stores, this tab
84
+ * asks the browser once with `navigator.storage.persist()` not to delete the
85
+ * site's data when disk space runs low. Chrome and Safari decide silently, and
86
+ * Firefox asks the user, so every tab asks until the user allows it. A custom
87
+ * {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
88
+ * request, for example with `constVoid` to never ask. See [Will my data stay on
89
+ * the
90
+ * device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
80
91
  *
81
92
  * A custom {@link ReloadApp} replaces the default page reload, for example to
82
93
  * save state first. It should end by reloading the page, because the other
@@ -105,6 +116,7 @@ export const createEvoluDeps = (deps = {}) => {
105
116
  const earlyAnnouncedWorkerIds = new Set();
106
117
  // Checks whether the user left this tab while another build waits.
107
118
  let focusCheckId = null;
119
+ let isPersistentStorageRequested = false;
108
120
  const disposer = __addDisposableResource(env_1, new DisposableStack(), false);
109
121
  const buildsBroadcastChannel = disposer.use(createBroadcastChannel(buildsBroadcastChannelName));
110
122
  const stopFocusCheck = () => {
@@ -206,11 +218,6 @@ export const createEvoluDeps = (deps = {}) => {
206
218
  });
207
219
  break;
208
220
  }
209
- case "StorageUnavailable": {
210
- deps.onStorageUnavailable?.();
211
- forward(message);
212
- break;
213
- }
214
221
  case "SharedWorkerUnsupported": {
215
222
  if (deps.onSharedWorkerUnsupported) {
216
223
  deps.onSharedWorkerUnsupported();
@@ -259,7 +266,18 @@ export const createEvoluDeps = (deps = {}) => {
259
266
  },
260
267
  },
261
268
  };
269
+ const requestPersistentStorage = () => {
270
+ if (isPersistentStorageRequested)
271
+ return;
272
+ isPersistentStorageRequested = true;
273
+ void tryAsync(async () => {
274
+ if (await navigator.storage.persisted())
275
+ return;
276
+ await navigator.storage.persist();
277
+ });
278
+ };
262
279
  const evoluDeps = disposer.use(createCommonEvoluDeps({
280
+ requestPersistentStorage,
263
281
  ...deps,
264
282
  createDbWorker,
265
283
  createBroadcastChannel,
@@ -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.0",
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.14.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.14.0"
48
48
  },
49
49
  "publishConfig": {
50
50
  "access": "public"
@@ -148,26 +148,59 @@ describe("createEvoluDeps", () => {
148
148
  });
149
149
  });
150
150
 
151
- describe("storage unavailable", () => {
152
- const workerId = createIdFromString<"SharedWorker">("worker");
151
+ describe("persistent storage request", () => {
152
+ const setupStorage = (isPersisted: boolean) => ({
153
+ persisted: mock.fn(() => Promise.resolve(isPersisted)),
154
+ persist: mock.fn(() => Promise.resolve(true)),
155
+ });
153
156
 
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);
157
+ it("asks the browser once to keep the site's data", async () => {
158
+ const storage = setupStorage(false);
159
+ using setup = setupWebEvoluDeps({ storage });
158
160
 
159
- setup.post({ type: "StorageUnavailable" });
161
+ setup.deps.requestPersistentStorage?.();
162
+ setup.deps.requestPersistentStorage?.();
163
+ await waitForMacrotask();
160
164
 
161
- assertSame(onStorageUnavailable.mock.callCount(), 1);
165
+ assertSame(storage.persisted.mock.callCount(), 1);
166
+ assertSame(storage.persist.mock.callCount(), 1);
162
167
  });
163
168
 
164
- it("works without a callback", () => {
165
- using setup = setupWebEvoluDeps();
166
- setup.connect(workerId);
169
+ it("does not ask when the site's data is already persistent", async () => {
170
+ const storage = setupStorage(true);
171
+ using setup = setupWebEvoluDeps({ storage });
167
172
 
168
- setup.post({ type: "StorageUnavailable" });
173
+ setup.deps.requestPersistentStorage?.();
174
+ await waitForMacrotask();
169
175
 
170
- assertSame(setup.reloadApp.mock.callCount(), 0);
176
+ assertSame(storage.persist.mock.callCount(), 0);
177
+ });
178
+
179
+ it("ignores a browser that refuses to answer", async () => {
180
+ const storage = {
181
+ persisted: mock.fn(() =>
182
+ Promise.reject(new TypeError("Storage is disabled.")),
183
+ ),
184
+ persist: mock.fn(() => Promise.resolve(true)),
185
+ };
186
+ using setup = setupWebEvoluDeps({ storage });
187
+
188
+ setup.deps.requestPersistentStorage?.();
189
+ await waitForMacrotask();
190
+
191
+ assertSame(storage.persist.mock.callCount(), 0);
192
+ });
193
+
194
+ it("uses the app's request instead", async () => {
195
+ const storage = setupStorage(false);
196
+ const requestPersistentStorage = mock.fn<() => void>();
197
+ using setup = setupWebEvoluDeps({ storage, requestPersistentStorage });
198
+
199
+ setup.deps.requestPersistentStorage?.();
200
+ await waitForMacrotask();
201
+
202
+ assertSame(requestPersistentStorage.mock.callCount(), 1);
203
+ assertSame(storage.persisted.mock.callCount(), 0);
171
204
  });
172
205
  });
173
206
 
@@ -443,7 +476,8 @@ const setupWebEvoluDeps = ({
443
476
  isSessionStorageAvailable = true,
444
477
  refusalReloads,
445
478
  reloadedFor,
446
- onStorageUnavailable,
479
+ storage,
480
+ requestPersistentStorage,
447
481
  }: {
448
482
  hasFocus?: boolean;
449
483
  isAutomaticReload?: boolean;
@@ -452,7 +486,9 @@ const setupWebEvoluDeps = ({
452
486
  refusalReloads?: string;
453
487
  /** The stored waiting workers, as a previous page load left them. */
454
488
  reloadedFor?: string;
455
- onStorageUnavailable?: () => void;
489
+ /** Stands in for `navigator.storage`. */
490
+ storage?: Pick<StorageManager, "persist" | "persisted">;
491
+ requestPersistentStorage?: () => void;
456
492
  } = {}) => {
457
493
  using disposer = new DisposableStack();
458
494
  const sharedWorkerPort = createClosableNativePort<unknown>();
@@ -539,12 +575,17 @@ const setupWebEvoluDeps = ({
539
575
  );
540
576
  let hasFocus = initialHasFocus;
541
577
  disposer.use(testStubGlobal("document", { hasFocus: () => hasFocus }));
578
+ if (storage) {
579
+ disposer.use(
580
+ testStubGlobal("navigator", { locks: navigator.locks, storage }),
581
+ );
582
+ }
542
583
  const reloadApp = mock.fn<ReloadApp>();
543
584
 
544
585
  const deps = createEvoluDeps({
545
586
  console: createConsole({ level: "silent" }),
546
587
  reloadApp,
547
- ...(onStorageUnavailable && { onStorageUnavailable }),
588
+ ...(requestPersistentStorage && { requestPersistentStorage }),
548
589
  });
549
590
  const builds = channels.find(
550
591
  (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
  *
@@ -75,9 +61,20 @@ export interface StorageUnavailableDep {
75
61
  * the user comes back to it.
76
62
  *
77
63
  * 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.
64
+ * Browsing or a Firefox private window, the database is kept in memory, and
65
+ * {@link Evolu.devicePersistence} resolves to `NotPersisted`, so the app can
66
+ * tell the user. Data that exists only locally, or has not synced yet, is lost
67
+ * when the tab hosting the database closes or navigates away, even while other
68
+ * tabs stay open.
69
+ *
70
+ * After the first local mutation of a database the browser stores, this tab
71
+ * asks the browser once with `navigator.storage.persist()` not to delete the
72
+ * site's data when disk space runs low. Chrome and Safari decide silently, and
73
+ * Firefox asks the user, so every tab asks until the user allows it. A custom
74
+ * {@link RequestPersistentStorageDep.requestPersistentStorage} replaces the
75
+ * request, for example with `constVoid` to never ask. See [Will my data stay on
76
+ * the
77
+ * device?](https://www.evolu.dev/docs/faq#will-my-data-stay-on-the-device).
81
78
  *
82
79
  * A custom {@link ReloadApp} replaces the default page reload, for example to
83
80
  * save state first. It should end by reloading the page, because the other
@@ -88,7 +85,7 @@ export const createEvoluDeps = (
88
85
  deps: Partial<ConsoleDep> &
89
86
  Partial<ReloadAppDep> &
90
87
  Partial<SharedWorkerUnsupportedDep> &
91
- Partial<StorageUnavailableDep> = {},
88
+ Partial<RequestPersistentStorageDep> = {},
92
89
  ): EvoluDeps => {
93
90
  installOneTabSharedWorkerPolyfill();
94
91
  const reloadThisApp = deps.reloadApp ?? reloadApp;
@@ -110,6 +107,7 @@ export const createEvoluDeps = (
110
107
  const earlyAnnouncedWorkerIds = new Set<SharedWorkerId>();
111
108
  // Checks whether the user left this tab while another build waits.
112
109
  let focusCheckId: ReturnType<typeof setInterval> | null = null;
110
+ let isPersistentStorageRequested = false;
113
111
 
114
112
  using disposer = new DisposableStack();
115
113
  const buildsBroadcastChannel = disposer.use(
@@ -225,12 +223,6 @@ export const createEvoluDeps = (
225
223
  break;
226
224
  }
227
225
 
228
- case "StorageUnavailable": {
229
- deps.onStorageUnavailable?.();
230
- forward(message);
231
- break;
232
- }
233
-
234
226
  case "SharedWorkerUnsupported": {
235
227
  if (deps.onSharedWorkerUnsupported) {
236
228
  deps.onSharedWorkerUnsupported();
@@ -292,8 +284,18 @@ export const createEvoluDeps = (
292
284
  },
293
285
  };
294
286
 
287
+ const requestPersistentStorage = (): void => {
288
+ if (isPersistentStorageRequested) return;
289
+ isPersistentStorageRequested = true;
290
+ void tryAsync(async () => {
291
+ if (await navigator.storage.persisted()) return;
292
+ await navigator.storage.persist();
293
+ });
294
+ };
295
+
295
296
  const evoluDeps = disposer.use(
296
297
  createCommonEvoluDeps({
298
+ requestPersistentStorage,
297
299
  ...deps,
298
300
  createDbWorker,
299
301
  createBroadcastChannel,
@@ -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