@evolu/web 3.4.0 → 3.5.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.
package/src/Sqlite.ts CHANGED
@@ -1,172 +1,155 @@
1
- import type { CreateSqliteDriver, SqliteRow } from "@evolu/common";
1
+ /**
2
+ * The SQLite driver of Evolu for the web, on `@evolu/sqlite-wasm`.
3
+ *
4
+ * @module
5
+ */
6
+
2
7
  import {
3
- bytesToHex,
4
8
  createPreparedStatementsCache,
9
+ err,
5
10
  exhaustiveCheck,
6
11
  ok,
7
- performanceDurationBetween,
8
- PositiveMillis,
9
- sleep,
10
- tryAsync,
12
+ type CreateSqliteDriver,
13
+ type DatabaseHeldError,
14
+ type Name,
15
+ type Result,
16
+ type SqliteDriver,
17
+ type Task,
18
+ type Typed,
19
+ type TypeName,
11
20
  } from "@evolu/common";
12
- import sqlite3InitModule, {
13
- type Database,
14
- type PreparedStatement,
15
- type SAHPoolUtil,
21
+ import type { WaitForDatabaseRelease } from "@evolu/common/local-first";
22
+ import {
23
+ createEncryptedSqliteDatabase,
24
+ createSqliteDatabase,
25
+ createSqliteWasm,
26
+ OpfsName,
27
+ openSahPool,
28
+ sqliteWasmUrl,
29
+ SqliteVfsPath,
30
+ type deriveLegacySqliteKey,
31
+ type OpfsRootDep,
32
+ type SahPool,
33
+ type SahPoolError,
34
+ type SahPoolOptions,
35
+ type SqliteDatabase,
36
+ type SqliteError,
37
+ type SqliteStatement,
38
+ type SqliteWasm,
39
+ type SqliteWasmDep,
40
+ type SqliteWasmError,
41
+ type SubtleCryptoDep,
16
42
  } from "@evolu/sqlite-wasm";
17
43
 
18
- // @ts-expect-error Missing types.
19
- globalThis.sqlite3ApiConfig = {
20
- warn: (arg: unknown) => {
21
- // Ignore irrelevant warning.
22
- // https://github.com/sqlite/sqlite-wasm/issues/62
23
- if (
24
- typeof arg === "string" &&
25
- arg.startsWith("Ignoring inability to install OPFS sqlite3_vfs")
26
- )
27
- return;
28
- // oxlint-disable-next-line eslint/no-console
29
- console.warn(arg);
30
- },
31
- };
32
-
33
- // Init ASAP.
34
- const sqlite3Promise = sqlite3InitModule();
35
-
36
- const fileName = "evolu1.db";
37
-
38
- export const createWasmSqliteDriver: CreateSqliteDriver =
39
- (name, options) => async (run) => {
40
- const sqlite3 = await sqlite3Promise;
41
-
42
- using disposer = new DisposableStack();
43
- const useDatabase = (database: Database): Database =>
44
- disposer.adopt(database, (database) => {
45
- database.close();
46
- });
44
+ /**
45
+ * Loads SQLite from the {@link sqliteWasmUrl} of `@evolu/sqlite-wasm` for
46
+ * {@link createWasmSqliteDriver}. It passes the fetch to
47
+ * {@link createSqliteWasm}, which compiles the binary while it downloads when
48
+ * the server sends it as `application/wasm`, and from its bytes otherwise.
49
+ *
50
+ * Start it in the worker's composition root, so SQLite loads while the worker
51
+ * waits for its database.
52
+ */
53
+ export const loadSqliteWasm: Task<SqliteWasm, SqliteWasmError> = (run) =>
54
+ run(createSqliteWasm(run.deps.nativeFetch(sqliteWasmUrl)), run.deps);
55
+
56
+ /**
57
+ * Creates the {@link CreateSqliteDriver} of Evolu for the web.
58
+ *
59
+ * A database is the file `/evolu1.db` in a pool of OPFS sync access handles in
60
+ * the directory `.<name>`, in the format of SQLite's opfs-sahpool, and the pool
61
+ * encrypts it in the `encrypted` mode. So the databases `@evolu/web` 3 created
62
+ * with `@evolu/sqlite-wasm` 2.2.4 open unchanged, an encrypted one with the key
63
+ * 2.2.4 derived from the encryption key, which is never rekeyed. 2.2.4 derived
64
+ * it because of a bug in SQLite3 Multiple Ciphers: it took the encryption key,
65
+ * passed in SQLCipher's notation for a raw key, as a passphrase
66
+ * (https://github.com/utelle/SQLite3MultipleCiphers/issues/218), as
67
+ * {@link deriveLegacySqliteKey} of `@evolu/sqlite-wasm` describes. A new
68
+ * encrypted database is encrypted with the encryption key itself, so
69
+ * `@evolu/web` 3.4.1 and earlier cannot open it. A database in the `memory`
70
+ * mode uses no OPFS.
71
+ *
72
+ * In WebKit on macOS, such as Safari, whose file system ignores case by
73
+ * default, names that differ only in case share the directory, and both can
74
+ * hold it at once, so make database names differ in more than case.
75
+ *
76
+ * The pool is opened once, so the driver throws while another context holds a
77
+ * file of it. A DbWorker waits for the files with
78
+ * {@link createWaitForDatabaseRelease} first.
79
+ *
80
+ * The {@link SqliteDriver} contract has no error channel, so every error, such
81
+ * as a query's {@link SqliteError}, is thrown as the cause of an `Error` whose
82
+ * message is SQLite's message, or the error's type for an error without one.
83
+ */
84
+ export const createWasmSqliteDriver =
85
+ (deps: WasmSqliteDriverDeps): CreateSqliteDriver =>
86
+ (name, options) =>
87
+ async (run) => {
88
+ const sqliteWasm = getOrThrowWithMessage(await deps.sqliteWasmLoad);
89
+ const databaseDeps = { ...deps, sqliteWasm };
47
90
 
48
91
  let deleteDatabaseFile = false;
49
- const createOpfsSAHPoolVfs = async (
50
- options: Parameters<typeof sqlite3.installOpfsSAHPoolVfs>[0],
51
- ): Promise<SAHPoolUtil> => {
52
- // Evolu opens a database only while it holds the database lock, but a
53
- // DbWorker that ended without closing it, because its tab closed,
54
- // crashed or navigated away, can hold pool files after the lock has
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
59
- // the files, and it can only release them, so the pool is set up once
60
- // every file opens.
61
- const canOpenPoolFiles = async (): Promise<boolean> => {
62
- const root = await navigator.storage.getDirectory();
63
- // sqlite-wasm keeps the pool in `.${name}/.opaque` in both OPFS modes.
64
- const poolDirectory = await tryAsync(() =>
65
- root.getDirectoryHandle(`.${name}`),
66
- );
67
- if (!poolDirectory.ok) return true;
68
- const opaqueDirectory = await tryAsync(() =>
69
- poolDirectory.value.getDirectoryHandle(".opaque"),
70
- );
71
- if (!opaqueDirectory.ok) return true;
72
- for await (const handle of opaqueDirectory.value.values()) {
73
- if (handle.kind !== "file") continue;
74
- const accessHandle = await tryAsync(() =>
75
- handle.createSyncAccessHandle(),
76
- );
77
- if (!accessHandle.ok) {
78
- // WebKit rejects a held file with InvalidStateError, other
79
- // engines with NoModificationAllowedError, as the spec says.
80
- // WebKit also uses InvalidStateError for a closed or invalid
81
- // handle and a stopped context. A retry opens fresh handles from
82
- // a new listing, and a stopped context ends the loop with its
83
- // worker, so retrying those is harmless.
84
- if (
85
- accessHandle.error instanceof DOMException &&
86
- (accessHandle.error.name === "InvalidStateError" ||
87
- accessHandle.error.name === "NoModificationAllowedError")
88
- )
89
- return false;
90
- throw accessHandle.error;
91
- }
92
- accessHandle.value.close();
93
- }
94
- return true;
95
- };
96
-
97
- const waitStart = run.deps.time.performance.now();
98
- let retryDelay = 50;
99
- let isWaitReported = false;
100
- while (!(await canOpenPoolFiles())) {
101
- const waited = performanceDurationBetween(
102
- waitStart,
103
- run.deps.time.performance.now(),
104
- );
105
- if (!isWaitReported && waited >= 5000) {
106
- isWaitReported = true;
107
- run.deps.console.warn(
108
- `Waiting for an ended DbWorker to release the files of database ${name}.`,
109
- );
110
- }
111
- await run.ok(sleep(PositiveMillis.orThrow(retryDelay)));
112
- retryDelay = Math.min(retryDelay * 2, 1000);
113
- }
92
+ using disposer = new DisposableStack();
114
93
 
115
- const pool = await sqlite3.installOpfsSAHPoolVfs(options);
116
- if (pool.isPaused()) await pool.unpauseVfs();
94
+ const openPool = async (): Promise<SahPool> => {
95
+ const pool = disposer.use(
96
+ getOrThrowWithMessage(await run(openDatabasePool(name), databaseDeps)),
97
+ );
117
98
  disposer.defer(() => {
118
- if (deleteDatabaseFile) pool.unlink(`/${fileName}`);
119
- pool.pauseVfs();
99
+ if (deleteDatabaseFile)
100
+ getOrThrowWithMessage(pool.unlink(databasePath));
120
101
  });
121
102
  return pool;
122
103
  };
123
104
 
124
- let db: Database;
105
+ let database: SqliteDatabase;
125
106
 
126
107
  switch (options?.mode) {
127
108
  case "memory":
128
- // oxlint-disable-next-line react/rules-of-hooks -- useDatabase registers disposal and is not a React Hook.
129
- db = useDatabase(new sqlite3.oo1.DB(":memory:"));
109
+ database = disposer.use(
110
+ getOrThrowWithMessage(
111
+ createSqliteDatabase(databaseDeps)({ type: "Memory" }),
112
+ ),
113
+ );
130
114
  break;
131
115
 
132
116
  case "encrypted": {
133
- // MultipleCiphers encryption requires its VFS wrapper for OPFS SAH-pool.
134
- // @ts-expect-error Missing types (update @evolu/sqlite-wasm types)
135
- // oxlint-disable-next-line typescript/no-unsafe-call
136
- sqlite3.capi.sqlite3mc_vfs_create("opfs", 1);
137
- const pool = await createOpfsSAHPoolVfs({
138
- directory: `.${name}`,
139
- });
140
- // oxlint-disable-next-line react/rules-of-hooks -- useDatabase registers disposal and is not a React Hook.
141
- db = useDatabase(
142
- new pool.OpfsSAHPoolDb(
143
- // SQLite normalizes this URI filename to SAH-pool path "/evolu1.db".
144
- `file:${fileName}?vfs=multipleciphers-opfs-sahpool`,
117
+ const encrypted = getOrThrowWithMessage(
118
+ await run(
119
+ createEncryptedSqliteDatabase({
120
+ type: "EncryptedFile",
121
+ vfs: await openPool(),
122
+ path: databasePath,
123
+ key: options.encryptionKey,
124
+ }),
125
+ databaseDeps,
145
126
  ),
146
127
  );
147
- db.exec(`
148
- PRAGMA cipher = 'sqlcipher';
149
- PRAGMA key = "x'${bytesToHex(options.encryptionKey)}'";
150
- `);
128
+ database = disposer.use(encrypted.database);
151
129
  break;
152
130
  }
153
131
 
154
- case undefined: {
155
- const pool = await createOpfsSAHPoolVfs({ name });
156
- // oxlint-disable-next-line react/rules-of-hooks -- useDatabase registers disposal and is not a React Hook.
157
- db = useDatabase(new pool.OpfsSAHPoolDb(`file:${fileName}`));
132
+ case undefined:
133
+ database = disposer.use(
134
+ getOrThrowWithMessage(
135
+ createSqliteDatabase(databaseDeps)({
136
+ type: "File",
137
+ vfs: await openPool(),
138
+ path: databasePath,
139
+ }),
140
+ ),
141
+ );
158
142
  break;
159
- }
160
143
 
161
144
  default:
162
145
  exhaustiveCheck(options);
163
146
  }
164
147
 
165
148
  const cache = disposer.use(
166
- createPreparedStatementsCache<PreparedStatement>(
167
- (sql) => db.prepare(sql),
149
+ createPreparedStatementsCache<SqliteStatement>(
150
+ (sql) => getOrThrowWithMessage(database.prepare(sql)),
168
151
  (statement) => {
169
- statement.finalize();
152
+ statement[Symbol.dispose]();
170
153
  },
171
154
  ),
172
155
  );
@@ -175,41 +158,15 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
175
158
 
176
159
  return ok({
177
160
  exec: (query) => {
178
- const prepared = cache.get(query);
179
-
180
- if (prepared) {
181
- try {
182
- if (query.parameters.length > 0) prepared.bind(query.parameters);
183
-
184
- const rows = [];
185
- while (prepared.step()) {
186
- rows.push(prepared.get({}));
187
- }
188
-
189
- return {
190
- rows: rows as ReadonlyArray<SqliteRow>,
191
- changes: db.changes(),
192
- };
193
- } finally {
194
- // SQLite refuses to bind a statement whose step failed until it is
195
- // reset. That reset returns the step's error again, which
196
- // PreparedStatement.reset would throw over the original one.
197
- sqlite3.capi.sqlite3_reset(prepared);
198
- }
199
- }
200
-
201
- const rows = db.exec(query.sql, {
202
- returnValue: "resultRows",
203
- rowMode: "object",
204
- bind: query.parameters,
205
- }) as ReadonlyArray<SqliteRow>;
206
-
207
- const changes = db.changes();
208
-
209
- return { rows, changes };
161
+ const statement = cache.get(query);
162
+ return getOrThrowWithMessage(
163
+ statement
164
+ ? statement.run(query.parameters)
165
+ : database.run(query.sql, query.parameters),
166
+ );
210
167
  },
211
168
 
212
- export: () => sqlite3.capi.sqlite3_js_db_export(db),
169
+ export: () => getOrThrowWithMessage(database.export()),
213
170
 
214
171
  deleteDatabase: () => {
215
172
  deleteDatabaseFile = true;
@@ -221,3 +178,73 @@ export const createWasmSqliteDriver: CreateSqliteDriver =
221
178
  },
222
179
  });
223
180
  };
181
+
182
+ /** Dependencies of {@link createWasmSqliteDriver}. */
183
+ export type WasmSqliteDriverDeps = OpfsRootDep &
184
+ SqliteWasmLoadDep &
185
+ SubtleCryptoDep;
186
+
187
+ /** Dependency wrapper for SQLite as {@link loadSqliteWasm} loads it. */
188
+ export interface SqliteWasmLoadDep {
189
+ /**
190
+ * SQLite, as {@link loadSqliteWasm} loads it, still loading or loaded. A
191
+ * failed load throws when a database is opened.
192
+ */
193
+ readonly sqliteWasmLoad: PromiseLike<Result<SqliteWasm, SqliteWasmError>>;
194
+ }
195
+
196
+ /**
197
+ * Creates the {@link WaitForDatabaseRelease} of Evolu for the web. It opens the
198
+ * pool {@link createWasmSqliteDriver} opens for the database, and disposes it
199
+ * once it opens.
200
+ *
201
+ * When another context holds a file of the pool, the pool is opened again after
202
+ * 50 ms, then after twice the previous delay, up to a second, with the
203
+ * `heldTimeout` option of {@link openSahPool}, and the wait fails with
204
+ * {@link DatabaseHeldError} when it is still held 10 seconds after the first
205
+ * attempt started. Evolu then refuses to start the database, and the app
206
+ * receives the error as its `evoluError`. Every other error is thrown, as the
207
+ * driver throws it.
208
+ */
209
+ export const createWaitForDatabaseRelease =
210
+ (deps: OpfsRootDep & SqliteWasmLoadDep): WaitForDatabaseRelease =>
211
+ (name) =>
212
+ async (run) => {
213
+ const sqliteWasm = getOrThrowWithMessage(await deps.sqliteWasmLoad);
214
+ const opened = await run(
215
+ // Longer than the two seconds Chromium gives a terminated worker.
216
+ openDatabasePool(name, { heldTimeout: "10s" }),
217
+ { ...deps, sqliteWasm },
218
+ );
219
+ if (!opened.ok && opened.error.type === "SahPoolHeldError")
220
+ return err({ type: "DatabaseHeldError", name });
221
+ using _pool = getOrThrowWithMessage(opened);
222
+ return ok();
223
+ };
224
+
225
+ // The pool of a database's file is in the directory `.<name>`, as in
226
+ // @evolu/web 3.
227
+ const openDatabasePool = (
228
+ name: Name,
229
+ options?: Pick<SahPoolOptions, "heldTimeout">,
230
+ ): Task<SahPool, SahPoolError, OpfsRootDep & SqliteWasmDep> =>
231
+ // A Name is non-empty and URL-safe, so `.<name>` always passes.
232
+ openSahPool({ ...options, directory: [OpfsName.orThrow(`.${name}`)] });
233
+
234
+ // The path SQLite's opfs-sahpool gave `file:evolu1.db`, which @evolu/web 3
235
+ // opened.
236
+ const databasePath = /*#__PURE__*/ SqliteVfsPath.orThrow("/evolu1.db");
237
+
238
+ // The SqliteDriver contract has no error channel, and the WaitForDatabaseRelease
239
+ // contract only DatabaseHeldError, so any other error is thrown as the cause of
240
+ // an Error, with SQLite's message when it has one.
241
+ const getOrThrowWithMessage = <T>(result: Result<T, Typed<TypeName>>): T => {
242
+ if (result.ok) return result.value;
243
+ const { error } = result;
244
+ throw new Error(
245
+ "message" in error && typeof error.message === "string"
246
+ ? error.message
247
+ : error.type,
248
+ { cause: error },
249
+ );
250
+ };
@@ -6,15 +6,29 @@ installPolyfills();
6
6
 
7
7
  import { createRandomBytes } from "@evolu/common";
8
8
  import { startDbWorker } from "@evolu/common/local-first";
9
- import { createWasmSqliteDriver } from "../Sqlite.ts";
9
+ import {
10
+ createWaitForDatabaseRelease,
11
+ createWasmSqliteDriver,
12
+ loadSqliteWasm,
13
+ } from "../Sqlite.ts";
10
14
  import { createRun } from "../Task.ts";
11
15
  import { createWorkerDeps, createWorkerSelf } from "../Worker.ts";
12
16
 
13
17
  const run = createRun({
14
18
  ...createWorkerDeps(),
15
- createSqliteDriver: createWasmSqliteDriver,
16
19
  lockManager: navigator.locks,
17
20
  randomBytes: createRandomBytes(),
18
21
  });
19
22
 
20
- void run(startDbWorker(createWorkerSelf(self)));
23
+ const sqliteDeps = {
24
+ opfsRoot: navigator.storage,
25
+ // SQLite loads while the DbWorker waits for its database.
26
+ sqliteWasmLoad: run(loadSqliteWasm),
27
+ subtleCrypto: crypto.subtle,
28
+ };
29
+
30
+ void run(startDbWorker(createWorkerSelf(self)), {
31
+ ...run.deps,
32
+ createSqliteDriver: createWasmSqliteDriver(sqliteDeps),
33
+ waitForDatabaseRelease: createWaitForDatabaseRelease(sqliteDeps),
34
+ });
@@ -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,
@@ -222,6 +224,39 @@ describe("createEvoluDeps", () => {
222
224
  assertSame(setup.reloadApp.mock.callCount(), 0);
223
225
  });
224
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
+
225
260
  it("reloads once when the page is restored from the cache", () => {
226
261
  using setup = setupWebEvoluDeps();
227
262
  setup.dispatchPageTransition("pageshow", false);
@@ -57,8 +57,9 @@ export interface SharedWorkerUnsupportedDep {
57
57
  *
58
58
  * When the page enters the browser's back-forward cache, as Safari does on
59
59
  * every navigation away, this tab ends its part as if it closed: it stops the
60
- * database workers it hosts, so another tab takes them over, and it reloads if
61
- * 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.
62
63
  *
63
64
  * Where the browser offers no persistent storage, as in Safari's Private
64
65
  * Browsing or a Firefox private window, the database is kept in memory, and
@@ -293,6 +294,44 @@ export const createEvoluDeps = (
293
294
  });
294
295
  };
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
+
296
335
  const evoluDeps = disposer.use(
297
336
  createCommonEvoluDeps({
298
337
  requestPersistentStorage,
@@ -300,17 +339,21 @@ export const createEvoluDeps = (
300
339
  createDbWorker,
301
340
  createBroadcastChannel,
302
341
  createMessageChannel,
303
- lockManager: navigator.locks,
342
+ lockManager: {
343
+ query: () => navigator.locks.query(),
344
+ request: requestLock,
345
+ },
304
346
  reloadApp: reloadThisApp,
305
347
  sharedWorker,
306
348
  }),
307
349
  );
308
350
 
309
- // A page entering the back-forward cache is frozen with its DbWorkers, which
310
- // keep their database locks, so the other tabs would stall. WebKit also
311
- // releases the page's own locks, so a restored page would work with state
312
- // the other tabs gave up on. The page therefore ends its part as if it
313
- // 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.
314
357
  const reloadRestoredPage = (event: PageTransitionEvent): void => {
315
358
  if (event.persisted) reloadThisApp();
316
359
  };