@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.
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +7 -4
- package/dist/src/local-first/Evolu.d.ts +19 -23
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +60 -17
- package/dist/src/local-first/Shared.worker.js +6 -2
- package/package.json +3 -3
- package/src/Sqlite.ts +7 -4
- package/src/local-first/Evolu.test.ts +92 -16
- package/src/local-first/Evolu.ts +80 -35
- package/src/local-first/Shared.worker.ts +7 -3
package/dist/src/Sqlite.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/src/Sqlite.js
CHANGED
|
@@ -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
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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,
|
|
42
|
-
* the user comes back
|
|
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
|
|
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<
|
|
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,
|
|
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,
|
|
74
|
-
* the user comes back
|
|
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
|
|
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:
|
|
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,
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
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
|
|
70
|
-
|
|
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
|
+
"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.
|
|
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.
|
|
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
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
|
152
|
-
const
|
|
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("
|
|
155
|
-
const
|
|
156
|
-
using setup = setupWebEvoluDeps({
|
|
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.
|
|
163
|
+
setup.deps.requestPersistentStorage?.();
|
|
164
|
+
setup.deps.requestPersistentStorage?.();
|
|
165
|
+
await waitForMacrotask();
|
|
160
166
|
|
|
161
|
-
assertSame(
|
|
167
|
+
assertSame(storage.persisted.mock.callCount(), 1);
|
|
168
|
+
assertSame(storage.persist.mock.callCount(), 1);
|
|
162
169
|
});
|
|
163
170
|
|
|
164
|
-
it("
|
|
165
|
-
|
|
166
|
-
setup
|
|
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.
|
|
175
|
+
setup.deps.requestPersistentStorage?.();
|
|
176
|
+
await waitForMacrotask();
|
|
169
177
|
|
|
170
|
-
assertSame(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
623
|
+
...(requestPersistentStorage && { requestPersistentStorage }),
|
|
548
624
|
});
|
|
549
625
|
const builds = channels.find(
|
|
550
626
|
(channel) => channel.name === buildsBroadcastChannelName,
|
package/src/local-first/Evolu.ts
CHANGED
|
@@ -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,
|
|
75
|
-
* the user comes back
|
|
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
|
|
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<
|
|
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:
|
|
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,
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|