From 8cc30bd3b703917b86c133e678b8585bcb4f99e6 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 14 Jul 2026 09:02:35 -0400 Subject: [PATCH 01/10] WIP --- packages/kit/src/runtime/client/client.js | 23 +++- .../src/runtime/client/snapshot-storage.js | 122 ++++++++++++++++++ .../src/routes/snapshot/file/+page.svelte | 23 ++++ .../kit/test/apps/basics/test/client.test.js | 37 ++++++ 4 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 packages/kit/src/runtime/client/snapshot-storage.js create mode 100644 packages/kit/test/apps/basics/src/routes/snapshot/file/+page.svelte diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 971f8a2a2281..19a8b5671c8f 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -11,6 +11,7 @@ import { decode_pathname, strip_hash, make_trackable, normalize_path } from '../ import { dev_fetch, initial_fetch, lock_fetch, subsequent_fetch, unlock_fetch } from './fetcher.js'; import { parse, parse_server_route } from './parse.js'; import * as storage from './session-storage.js'; +import * as snapshot_storage from './snapshot-storage.js'; import { find_anchor, resolve_url, @@ -29,7 +30,6 @@ import { PRELOAD_PRIORITIES, SCROLL_KEY, STATES_KEY, - SNAPSHOT_KEY, PAGE_URL_KEY } from './constants.js'; import { validate_page_exports } from '../../utils/exports.js'; @@ -88,9 +88,16 @@ const scroll_positions = storage.get(SCROLL_KEY) ?? {}; /** * navigation index -> any + * + * In-memory map of snapshots, populated once from IndexedDB during client + * initialisation (see `initialize`) so that restores stay synchronous. The + * persistent backing store is IndexedDB (see `snapshot-storage.js`), which uses + * the structured clone algorithm and therefore supports values — + * `File`/`Blob`/`Map`/`Set`/cyclic structures — that `sessionStorage` cannot + * represent. New captures write through to IndexedDB so they survive reload. * @type {Record} */ -const snapshots = storage.get(SNAPSHOT_KEY) ?? {}; +const snapshots = {}; /** * @deprecated this is a temporary measure to avoid a regression, replace with nested `RenderNode` classes @@ -157,6 +164,7 @@ function clear_onward_history(current_history_index, current_navigation_index) { i = current_navigation_index + 1; while (snapshots[i]) { delete snapshots[i]; + void snapshot_storage.del(i); i += 1; } } @@ -397,6 +405,12 @@ export async function start(_app, _target, hydrate) { ); } + // populate the in-memory snapshot map from IndexedDB once, up front, so + // that `restore_snapshot` (called below during hydrate/navigate, and later + // on popstate) never needs to read from disk asynchronously + const persisted_snapshots = await snapshot_storage.load_all(); + for (const k in persisted_snapshots) snapshots[k] = persisted_snapshots[k]; + // if we reload the page, or Cmd-Shift-T back to it, // recover scroll position const scroll = scroll_positions[current_history_index]; @@ -525,6 +539,10 @@ function reset_invalidation() { function capture_snapshot(index) { if (components.some((c) => c?.snapshot)) { snapshots[index] = components.map((c) => c?.snapshot?.capture()); + // write through to IndexedDB; fire-and-forget on the navigation path + // (the page stays alive, so the write lands) — `persist_state` relies + // on `commit()` inside the storage layer to flush before unload + void snapshot_storage.set(index, snapshots[index]); } } @@ -540,7 +558,6 @@ function persist_state() { storage.set(SCROLL_KEY, scroll_positions); capture_snapshot(current_navigation_index); - storage.set(SNAPSHOT_KEY, snapshots); } /** diff --git a/packages/kit/src/runtime/client/snapshot-storage.js b/packages/kit/src/runtime/client/snapshot-storage.js new file mode 100644 index 000000000000..c006b31bab41 --- /dev/null +++ b/packages/kit/src/runtime/client/snapshot-storage.js @@ -0,0 +1,122 @@ +import { SNAPSHOT_KEY } from './constants.js'; + +const STORE = 'snapshots'; + +/** @type {Promise | null} */ +let db_promise = null; + +/** @returns {Promise} */ +function open() { + if (db_promise) return db_promise; + + db_promise = new Promise((resolve, reject) => { + /** @type {IDBOpenDBRequest} */ + const request = indexedDB.open(SNAPSHOT_KEY, 1); + + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE); + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + + return db_promise; +} + +/** + * Load all snapshots into an in-memory map keyed by navigation index. + * + * Values are stored via the structured clone algorithm, so `File`/`Blob`/`Map`/ + * `Set`/cyclic structures etc. are supported without serialization. Call this + * once during client initialisation (before any `restore_snapshot`) so that + * restores can stay synchronous. + * + * @returns {Promise>} + */ +export async function load_all() { + /** @type {Record} */ + const result = {}; + + try { + const db = await open(); + await /** @type {Promise} */ ( + new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, 'readonly'); + const request = transaction.objectStore(STORE).openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + result[/** @type {number} */ (cursor.key)] = cursor.value; + cursor.continue(); + } + }; + request.onerror = () => reject(request.error); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }) + ); + } catch { + // IndexedDB may be unavailable (e.g. private browsing) — return what we have + } + + return result; +} + +/** + * Persist a snapshot value for a given navigation index. + * + * Values are stored via the structured clone algorithm, so `File`/`Blob`/`Map`/ + * `Set`/cyclic structures etc. are supported without serialization. The returned + * promise resolves once the write is durable; callers that fire-and-forget + * (e.g. the navigation capture path, where the page stays alive) may ignore it. + * + * @param {number} index + * @param {any} value + * @returns {Promise} + */ +export async function set(index, value) { + try { + const db = await open(); + await /** @type {Promise} */ ( + new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, 'readwrite'); + transaction.objectStore(STORE).put(value, index); + // Flush promptly so that writes initiated from `visibilitychange` + // (the last reliable signal before unload) have the best chance to + // land before the document is destroyed. Auto-commit would otherwise + // wait for the current task to unwind. + if (typeof transaction.commit === 'function') transaction.commit(); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }) + ); + } catch { + // snapshot persistence is best-effort + } +} + +/** + * Delete the snapshot value for a given navigation index. + * + * @param {number} index + * @returns {Promise} + */ +export async function del(index) { + try { + const db = await open(); + await /** @type {Promise} */ ( + new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, 'readwrite'); + transaction.objectStore(STORE).delete(index); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }) + ); + } catch { + // best-effort + } +} diff --git a/packages/kit/test/apps/basics/src/routes/snapshot/file/+page.svelte b/packages/kit/test/apps/basics/src/routes/snapshot/file/+page.svelte new file mode 100644 index 000000000000..1d689ad51206 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/snapshot/file/+page.svelte @@ -0,0 +1,23 @@ + + + (file = e.currentTarget.files?.[0] ?? null)} +/> + +{#if file} +

{file.name}|{file.size}

+{:else} +

empty

+{/if} diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index c9a069649f35..4dc999791be8 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1321,6 +1321,43 @@ test.describe('Snapshots', () => { await expect(page.locator('[data-testid="order"]')).toHaveText('afterNavigate,restore'); }); + + test('recovers a File snapshot across popstate', async ({ page, clicknav }) => { + await page.goto('/snapshot/file'); + + await page.locator('[data-testid="picker"]').setInputFiles({ + name: 'hello.txt', + mimeType: 'text/plain', + buffer: Buffer.from('hello world') + }); + + await expect(page.locator('[data-testid="info"]')).toHaveText('hello.txt|11'); + + await clicknav('[href="/snapshot/b"]'); + await page.goBack(); + + // a File is not JSON-serializable, so this only works because the snapshot + // backend uses the structured clone algorithm (IndexedDB) + await expect(page.locator('[data-testid="info"]')).toHaveText('hello.txt|11'); + }); + + test('recovers a File snapshot across reload', async ({ page }) => { + await page.goto('/snapshot/file'); + + await page.locator('[data-testid="picker"]').setInputFiles({ + name: 'hello.txt', + mimeType: 'text/plain', + buffer: Buffer.from('hello world') + }); + + await expect(page.locator('[data-testid="info"]')).toHaveText('hello.txt|11'); + + await page.reload(); + + // the File is persisted to IndexedDB from `visibilitychange` (the last + // reliable signal before unload) and read back during hydration + await expect(page.locator('[data-testid="info"]')).toHaveText('hello.txt|11'); + }); }); test.describe('Streaming', () => { From 658e8fa4e68c01fc2a5adfcb7c3b6f15320801ec Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 14 Jul 2026 09:29:06 -0400 Subject: [PATCH 02/10] tidy up --- packages/kit/src/runtime/client/client.js | 36 ++------ ...-storage.js => snapshot-storage.svelte.js} | 88 +++++++++++++------ .../src/routes/snapshot/file/+page.svelte | 4 +- 3 files changed, 70 insertions(+), 58 deletions(-) rename packages/kit/src/runtime/client/{snapshot-storage.js => snapshot-storage.svelte.js} (65%) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 19a8b5671c8f..be9294336640 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -11,7 +11,7 @@ import { decode_pathname, strip_hash, make_trackable, normalize_path } from '../ import { dev_fetch, initial_fetch, lock_fetch, subsequent_fetch, unlock_fetch } from './fetcher.js'; import { parse, parse_server_route } from './parse.js'; import * as storage from './session-storage.js'; -import * as snapshot_storage from './snapshot-storage.js'; +import * as snapshots from './snapshot-storage.svelte.js'; import { find_anchor, resolve_url, @@ -86,19 +86,6 @@ const resetters = []; */ const scroll_positions = storage.get(SCROLL_KEY) ?? {}; -/** - * navigation index -> any - * - * In-memory map of snapshots, populated once from IndexedDB during client - * initialisation (see `initialize`) so that restores stay synchronous. The - * persistent backing store is IndexedDB (see `snapshot-storage.js`), which uses - * the structured clone algorithm and therefore supports values — - * `File`/`Blob`/`Map`/`Set`/cyclic structures — that `sessionStorage` cannot - * represent. New captures write through to IndexedDB so they survive reload. - * @type {Record} - */ -const snapshots = {}; - /** * @deprecated this is a temporary measure to avoid a regression, replace with nested `RenderNode` classes */ @@ -161,12 +148,7 @@ function clear_onward_history(current_history_index, current_navigation_index) { i += 1; } - i = current_navigation_index + 1; - while (snapshots[i]) { - delete snapshots[i]; - void snapshot_storage.del(i); - i += 1; - } + snapshots.truncate(current_navigation_index); } /** @@ -405,11 +387,7 @@ export async function start(_app, _target, hydrate) { ); } - // populate the in-memory snapshot map from IndexedDB once, up front, so - // that `restore_snapshot` (called below during hydrate/navigate, and later - // on popstate) never needs to read from disk asynchronously - const persisted_snapshots = await snapshot_storage.load_all(); - for (const k in persisted_snapshots) snapshots[k] = persisted_snapshots[k]; + await snapshots.init(); // if we reload the page, or Cmd-Shift-T back to it, // recover scroll position @@ -538,17 +516,19 @@ function reset_invalidation() { /** @param {number} index */ function capture_snapshot(index) { if (components.some((c) => c?.snapshot)) { - snapshots[index] = components.map((c) => c?.snapshot?.capture()); // write through to IndexedDB; fire-and-forget on the navigation path // (the page stays alive, so the write lands) — `persist_state` relies // on `commit()` inside the storage layer to flush before unload - void snapshot_storage.set(index, snapshots[index]); + void snapshots.set( + index, + components.map((c) => c?.snapshot?.capture()) + ); } } /** @param {number} index */ function restore_snapshot(index) { - snapshots[index]?.forEach((value, i) => { + snapshots.get(index)?.forEach((value, i) => { components[i]?.snapshot?.restore(value); }); } diff --git a/packages/kit/src/runtime/client/snapshot-storage.js b/packages/kit/src/runtime/client/snapshot-storage.svelte.js similarity index 65% rename from packages/kit/src/runtime/client/snapshot-storage.js rename to packages/kit/src/runtime/client/snapshot-storage.svelte.js index c006b31bab41..7c59b0a3322a 100644 --- a/packages/kit/src/runtime/client/snapshot-storage.js +++ b/packages/kit/src/runtime/client/snapshot-storage.svelte.js @@ -1,15 +1,25 @@ +import { DEV } from 'esm-env'; import { SNAPSHOT_KEY } from './constants.js'; const STORE = 'snapshots'; +/** + * In-memory map of snapshots, populated once from IndexedDB during client + * initialisation (see `initialize`) so that restores stay synchronous + * + * @type {Record} + */ +const snapshots = {}; + /** @type {Promise | null} */ let db_promise = null; +let failed = false; +let warned = false; + /** @returns {Promise} */ function open() { - if (db_promise) return db_promise; - - db_promise = new Promise((resolve, reject) => { + return (db_promise ??= new Promise((resolve, reject) => { /** @type {IDBOpenDBRequest} */ const request = indexedDB.open(SNAPSHOT_KEY, 1); @@ -19,25 +29,13 @@ function open() { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); - }); - - return db_promise; + })); } /** * Load all snapshots into an in-memory map keyed by navigation index. - * - * Values are stored via the structured clone algorithm, so `File`/`Blob`/`Map`/ - * `Set`/cyclic structures etc. are supported without serialization. Call this - * once during client initialisation (before any `restore_snapshot`) so that - * restores can stay synchronous. - * - * @returns {Promise>} */ -export async function load_all() { - /** @type {Record} */ - const result = {}; - +export async function init() { try { const db = await open(); await /** @type {Promise} */ ( @@ -47,7 +45,7 @@ export async function load_all() { request.onsuccess = () => { const cursor = request.result; if (cursor) { - result[/** @type {number} */ (cursor.key)] = cursor.value; + snapshots[/** @type {number} */ (cursor.key)] = cursor.value; cursor.continue(); } }; @@ -59,24 +57,54 @@ export async function load_all() { ); } catch { // IndexedDB may be unavailable (e.g. private browsing) — return what we have + failed = true; } - - return result; } /** - * Persist a snapshot value for a given navigation index. + * Persist a snapshot value for a given navigation index in memory, + * and (in the background, fire-and-forget style) to IndexedDB * - * Values are stored via the structured clone algorithm, so `File`/`Blob`/`Map`/ - * `Set`/cyclic structures etc. are supported without serialization. The returned - * promise resolves once the write is durable; callers that fire-and-forget - * (e.g. the navigation capture path, where the page stays alive) may ignore it. + * @param {number} index + * @param {any} value + */ +export function set(index, value) { + snapshots[index] = $state.snapshot(value); + void put(index, snapshots[index]); +} + +/** + * @param {number} index + */ +export function get(index) { + if (DEV && failed && !warned) { + warned = true; + console.warn('Failed to restore snapshots from IndexedDB'); + } + + return snapshots[index]; +} + +/** + * @param {number} index + */ +export function truncate(index) { + let i = index; + + while (snapshots[++i]) { + delete snapshots[i]; + void del(i); + } +} + +/** + * Persist a snapshot value to IndexedDB * * @param {number} index * @param {any} value * @returns {Promise} */ -export async function set(index, value) { +export async function put(index, value) { try { const db = await open(); await /** @type {Promise} */ ( @@ -93,8 +121,10 @@ export async function set(index, value) { transaction.onabort = () => reject(transaction.error); }) ); - } catch { - // snapshot persistence is best-effort + } catch (e) { + if (DEV && /** @type {Error} */ (e).name === 'DataCloneError') { + console.warn('Could not serialize snapshot value. It will not survive a page reload'); + } } } @@ -104,7 +134,7 @@ export async function set(index, value) { * @param {number} index * @returns {Promise} */ -export async function del(index) { +async function del(index) { try { const db = await open(); await /** @type {Promise} */ ( diff --git a/packages/kit/test/apps/basics/src/routes/snapshot/file/+page.svelte b/packages/kit/test/apps/basics/src/routes/snapshot/file/+page.svelte index 1d689ad51206..05530ce0ff90 100644 --- a/packages/kit/test/apps/basics/src/routes/snapshot/file/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/snapshot/file/+page.svelte @@ -3,7 +3,9 @@ /** @type {import('./$types').Snapshot} */ export const snapshot = { - capture: () => file, + capture: () => { + return file; + }, restore: (value) => { file = value; } From f9110c58d1d6132b399e7e09a29d6173461c9c89 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 14 Jul 2026 09:29:49 -0400 Subject: [PATCH 03/10] changeset --- .changeset/fluffy-masks-strive.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fluffy-masks-strive.md diff --git a/.changeset/fluffy-masks-strive.md b/.changeset/fluffy-masks-strive.md new file mode 100644 index 000000000000..a833569e4cbc --- /dev/null +++ b/.changeset/fluffy-masks-strive.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: use IndexedDB for snapshots instead of sessionStorage From 5548608c00d1d710bd611b08378eeab9d5724773 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 14 Jul 2026 09:32:30 -0400 Subject: [PATCH 04/10] tidy up --- packages/kit/src/runtime/client/client.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index be9294336640..2738f3be03d7 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -516,9 +516,6 @@ function reset_invalidation() { /** @param {number} index */ function capture_snapshot(index) { if (components.some((c) => c?.snapshot)) { - // write through to IndexedDB; fire-and-forget on the navigation path - // (the page stays alive, so the write lands) — `persist_state` relies - // on `commit()` inside the storage layer to flush before unload void snapshots.set( index, components.map((c) => c?.snapshot?.capture()) From 74081a5efc62df351ad5fa1f4f001674fda53f24 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 14 Jul 2026 14:36:58 -0400 Subject: [PATCH 05/10] for reasons i don't understand, this is apparently necessary --- packages/kit/src/runtime/client/client.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 05a73f782c20..2b0ef882405f 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -355,6 +355,7 @@ export async function start(_app, _target, hydrate) { app = _app; + await snapshots.init(); await _app.hooks.init?.(); routes = __SVELTEKIT_CLIENT_ROUTING__ ? parse(_app) : []; @@ -387,8 +388,6 @@ export async function start(_app, _target, hydrate) { ); } - await snapshots.init(); - // if we reload the page, or Cmd-Shift-T back to it, // recover scroll position const scroll = scroll_positions[current_history_index]; From efb62fc0722527bd2d7c8f11765fad8bdf6e1321 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 14 Jul 2026 15:22:29 -0400 Subject: [PATCH 06/10] fall back to sessionStorage --- .../kit/src/runtime/client/snapshot-storage.svelte.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/snapshot-storage.svelte.js b/packages/kit/src/runtime/client/snapshot-storage.svelte.js index 7c59b0a3322a..b74f6a4902f3 100644 --- a/packages/kit/src/runtime/client/snapshot-storage.svelte.js +++ b/packages/kit/src/runtime/client/snapshot-storage.svelte.js @@ -1,5 +1,6 @@ import { DEV } from 'esm-env'; import { SNAPSHOT_KEY } from './constants.js'; +import * as storage from './session-storage.js'; const STORE = 'snapshots'; @@ -56,8 +57,10 @@ export async function init() { }) ); } catch { - // IndexedDB may be unavailable (e.g. private browsing) — return what we have failed = true; + + // fall back to sessionStorage + Object.assign(snapshots, storage.get(SNAPSHOT_KEY)); } } @@ -70,7 +73,9 @@ export async function init() { */ export function set(index, value) { snapshots[index] = $state.snapshot(value); + void put(index, snapshots[index]); + storage.set(SNAPSHOT_KEY, snapshots); } /** @@ -95,6 +100,8 @@ export function truncate(index) { delete snapshots[i]; void del(i); } + + storage.set(SNAPSHOT_KEY, snapshots); } /** From a21b41cb256e5e260e914bcbc8da5a877ea9bfa5 Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:41:50 +0000 Subject: [PATCH 07/10] Fix: Snapshot persistence via IndexedDB accumulates without bound across sessions, and `init()` loads every historical snapshot into memory on every hydration, because IndexedDB (unlike the former per-tab `sessionStorage`) persists across tab-close/restart with no cleanup path. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the issue reported at packages/kit/src/runtime/client/snapshot-storage.svelte.js:36 ## Bug confirmed (still present) `packages/kit/src/runtime/client/snapshot-storage.svelte.js` uses a shared, persistent IndexedDB store (`indexedDB.open(SNAPSHOT_KEY, 1)`) for snapshots but never scopes or prunes entries by browsing session. Because IndexedDB (unlike the former per‑tab `sessionStorage`) persists across tab‑close/restart: * In `client.js` `start`, a fresh page load with no `history.state` sets `current_history_index = current_navigation_index = Date.now();`, so each session uses a **unique** numeric key base. * `set(index, value)` → `put(index, value)` writes each captured snapshot keyed by that index. Snapshots may now contain large `File`/`Blob` objects. * The only removal path is `truncate(index)`, which deletes forward‑history entries of the *current* session. Entries under previous sessions' unique `Date.now()`‑based keys never match a current index, so they are never deleted → **unbounded growth** and quota exhaustion risk. * `init()` loads the **entire** store into the in‑memory `snapshots` map on every hydration, so startup cost grows monotonically. The `efb62fc` sessionStorage fallback only added resiliency for when IndexedDB is *unavailable*; the normal path is unchanged, so the issue remains. ## Fix (reworked to use the Web Locks API) The earlier `MAX_SESSIONS` last‑active‑timestamp heuristic was rejected as kludgy. This revision uses `navigator.locks` to determine which sessions are *actually* still alive, so there is no arbitrary retention count and no timestamp bookkeeping. * **Session id** — generated once (`crypto.randomUUID()` with a non‑secure‑context fallback) and stored in `sessionStorage` under `SNAPSHOT_KEY:session` via the existing `storage` helper with identity parse/stringify. It survives reloads (so reload‑restore works) but is discarded when the tab closes. * **Liveness lock** — the first time the session id is resolved, we fire‑and‑forget acquire an exclusive Web Lock `session:` whose callback returns a promise that never resolves, so the lock is held for the tab's lifetime and released automatically by the browser on close/crash. The `request` promise is **not** awaited (it never settles); we only attach `.catch()` so a failed acquisition just leaves pruning conservative. * **Compound keys** — snapshots are stored under `[session_id, index]`, so each entry is attributable to a session. The DB stays at **version 1** — the store has no `keyPath` (out‑of‑line keys), so switching from a plain number key to an array key needs no schema migration or second object store. * **Pruning** — `init()` calls `navigator.locks.query()`, collects the ids of all held `session:*` locks into an `active` set that **always explicitly includes the current session** (guarding against `query()` running before this tab's own lock is granted), then iterates the store: loads only the current session's entries into memory, and deletes any entry whose session id isn't in `active` (this also removes legacy non‑namespaced number keys). * **Graceful degradation** — `get_active_sessions()` returns `null` when `navigator.locks`/`query` is unavailable (older browsers / insecure context). In that case `init()` still loads only the current session's snapshots into memory but **does not prune** anything, since staleness can't be proven — we never delete data we can't confirm is dead. * **Fallback intact** — the `efb62fc` sessionStorage fallback (`init()` catch, `set()`, `truncate()`) and the `import * as storage from './session-storage.js'` alias are reused unchanged. ### Residual edge case A tab that is mid‑startup and hasn't yet been granted its `session:*` lock could, in principle, have its snapshots pruned by another tab's concurrent `init()`. This is acceptable in practice: a fresh session rarely has persisted snapshots yet, and its id is always included in its own `active` set, so it never prunes itself. Co-authored-by: Vercel Co-authored-by: Rich-Harris --- .../runtime/client/snapshot-storage.svelte.js | 134 ++++++++++++++++-- 1 file changed, 126 insertions(+), 8 deletions(-) diff --git a/packages/kit/src/runtime/client/snapshot-storage.svelte.js b/packages/kit/src/runtime/client/snapshot-storage.svelte.js index b74f6a4902f3..83eeb77a91e2 100644 --- a/packages/kit/src/runtime/client/snapshot-storage.svelte.js +++ b/packages/kit/src/runtime/client/snapshot-storage.svelte.js @@ -5,8 +5,26 @@ import * as storage from './session-storage.js'; const STORE = 'snapshots'; /** - * In-memory map of snapshots, populated once from IndexedDB during client - * initialisation (see `initialize`) so that restores stay synchronous + * `sessionStorage` key under which the current browsing session's id is kept. + * `sessionStorage` is per-tab and cleared when the tab is closed, but survives + * reloads — exactly the lifetime we want snapshots (which may contain large + * `File`/`Blob` objects) to have. + */ +const SESSION_KEY = SNAPSHOT_KEY + ':session'; + +/** + * Prefix for the Web Locks we hold, one per browsing session. A held lock named + * `session:` signals that the tab owning `` is still alive; the lock is + * released automatically by the browser when that tab is closed or crashes, so + * the set of currently-held `session:*` locks is an accurate, self-cleaning + * record of which sessions' snapshots are still needed. + */ +const LOCK_PREFIX = 'session:'; + +/** + * In-memory map of snapshots for the current session, populated once from + * IndexedDB during client initialisation (see `init`) so that restores stay + * synchronous * * @type {Record} */ @@ -15,9 +33,89 @@ const snapshots = {}; /** @type {Promise | null} */ let db_promise = null; +/** @type {string | undefined} */ +let session; + let failed = false; let warned = false; +/** + * The id for the current browsing session. Persisted in `sessionStorage` so it + * survives reloads (allowing snapshots to be restored) but is scoped to the tab + * and discarded when the tab is closed. + * + * The first time it is resolved we also (fire-and-forget) acquire an exclusive + * Web Lock named `session:` that is held for the lifetime of the tab, so + * other tabs can tell this session is still active when pruning stale data. + * + * @returns {string} + */ +function get_session() { + if (session === undefined) { + session = storage.get(SESSION_KEY, (value) => value); + + if (!session) { + session = + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + + storage.set(SESSION_KEY, session, (value) => value); + } + + if (typeof navigator !== 'undefined' && navigator.locks) { + // Hold the lock for the lifetime of the tab; it is released + // automatically when the tab is closed or crashes. The callback + // promise intentionally never resolves — do NOT await this. + navigator.locks + .request( + `${LOCK_PREFIX}${session}`, + { mode: 'exclusive' }, + () => new Promise(() => {}) + ) + .catch(() => { + // best-effort; if the lock can't be acquired we simply won't + // advertise this session as active (pruning stays conservative) + }); + } + } + + return session; +} + +/** + * The set of session ids that are still active, derived from the currently-held + * `session:*` Web Locks. The current session is always included, because + * `navigator.locks.query()` may run before this tab's own lock has been granted + * (the request is async), and we must never prune our own snapshots. + * + * Returns `null` when the Web Locks API is unavailable — in that case we cannot + * prove any other session is stale, so callers must not prune. + * + * @param {string} current + * @returns {Promise | null>} + */ +async function get_active_sessions(current) { + if (typeof navigator === 'undefined' || !navigator.locks || !navigator.locks.query) { + return null; + } + + const active = new Set([current]); + + try { + const { held = [] } = await navigator.locks.query(); + for (const lock of held) { + if (lock.name && lock.name.startsWith(LOCK_PREFIX)) { + active.add(lock.name.slice(LOCK_PREFIX.length)); + } + } + } catch { + // if the query fails we still know the current session is active + } + + return active; +} + /** @returns {Promise} */ function open() { return (db_promise ??= new Promise((resolve, reject) => { @@ -34,19 +132,37 @@ function open() { } /** - * Load all snapshots into an in-memory map keyed by navigation index. + * Load the current session's snapshots into an in-memory map keyed by + * navigation index, and prune snapshots belonging to sessions whose tabs are no + * longer alive (determined via the Web Locks API) so they don't accumulate + * indefinitely. */ export async function init() { try { const db = await open(); + const current = get_session(); + + // `null` means we can't prove any session is stale (no Web Locks API), + // so we must not delete other sessions' data. + const active = await get_active_sessions(current); + await /** @type {Promise} */ ( new Promise((resolve, reject) => { - const transaction = db.transaction(STORE, 'readonly'); - const request = transaction.objectStore(STORE).openCursor(); + const transaction = db.transaction(STORE, 'readwrite'); + const store = transaction.objectStore(STORE); + const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (cursor) { - snapshots[/** @type {number} */ (cursor.key)] = cursor.value; + const key = cursor.key; + if (Array.isArray(key) && key[0] === current) { + // only load the current session's snapshots into memory + snapshots[/** @type {number} */ (key[1])] = cursor.value; + } else if (active && !(Array.isArray(key) && active.has(key[0]))) { + // stale (closed session) or legacy (non-namespaced) entry — + // only delete when Web Locks let us prove it isn't active + cursor.delete(); + } cursor.continue(); } }; @@ -114,10 +230,11 @@ export function truncate(index) { export async function put(index, value) { try { const db = await open(); + const current = get_session(); await /** @type {Promise} */ ( new Promise((resolve, reject) => { const transaction = db.transaction(STORE, 'readwrite'); - transaction.objectStore(STORE).put(value, index); + transaction.objectStore(STORE).put(value, [current, index]); // Flush promptly so that writes initiated from `visibilitychange` // (the last reliable signal before unload) have the best chance to // land before the document is destroyed. Auto-commit would otherwise @@ -144,10 +261,11 @@ export async function put(index, value) { async function del(index) { try { const db = await open(); + const current = get_session(); await /** @type {Promise} */ ( new Promise((resolve, reject) => { const transaction = db.transaction(STORE, 'readwrite'); - transaction.objectStore(STORE).delete(index); + transaction.objectStore(STORE).delete([current, index]); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); From fdfeb7bacfc74da0aaeabd7f03cdc8dbeaa95fed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jul 2026 21:48:59 +0000 Subject: [PATCH 08/10] chore: autofix lint --- packages/kit/src/runtime/client/snapshot-storage.svelte.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/kit/src/runtime/client/snapshot-storage.svelte.js b/packages/kit/src/runtime/client/snapshot-storage.svelte.js index 83eeb77a91e2..bd6751691f5e 100644 --- a/packages/kit/src/runtime/client/snapshot-storage.svelte.js +++ b/packages/kit/src/runtime/client/snapshot-storage.svelte.js @@ -68,11 +68,7 @@ function get_session() { // automatically when the tab is closed or crashes. The callback // promise intentionally never resolves — do NOT await this. navigator.locks - .request( - `${LOCK_PREFIX}${session}`, - { mode: 'exclusive' }, - () => new Promise(() => {}) - ) + .request(`${LOCK_PREFIX}${session}`, { mode: 'exclusive' }, () => new Promise(() => {})) .catch(() => { // best-effort; if the lock can't be acquired we simply won't // advertise this session as active (pruning stays conservative) From 6e68708d964da13d98585627c380dcb6ff35104f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 14 Jul 2026 19:03:37 -0400 Subject: [PATCH 09/10] typecheck --- packages/kit/src/runtime/client/snapshot-storage.svelte.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/snapshot-storage.svelte.js b/packages/kit/src/runtime/client/snapshot-storage.svelte.js index bd6751691f5e..eb8827ef891f 100644 --- a/packages/kit/src/runtime/client/snapshot-storage.svelte.js +++ b/packages/kit/src/runtime/client/snapshot-storage.svelte.js @@ -150,7 +150,7 @@ export async function init() { request.onsuccess = () => { const cursor = request.result; if (cursor) { - const key = cursor.key; + const key = /** @type {[string, number]} */ (cursor.key); if (Array.isArray(key) && key[0] === current) { // only load the current session's snapshots into memory snapshots[/** @type {number} */ (key[1])] = cursor.value; From b31721fc50ca7595365774451cc6758961852020 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 15 Jul 2026 22:29:08 -0400 Subject: [PATCH 10/10] Update packages/kit/src/runtime/client/snapshot-storage.svelte.js Co-authored-by: Tee Ming --- packages/kit/src/runtime/client/snapshot-storage.svelte.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/snapshot-storage.svelte.js b/packages/kit/src/runtime/client/snapshot-storage.svelte.js index eb8827ef891f..2caff7dbbbd5 100644 --- a/packages/kit/src/runtime/client/snapshot-storage.svelte.js +++ b/packages/kit/src/runtime/client/snapshot-storage.svelte.js @@ -153,7 +153,7 @@ export async function init() { const key = /** @type {[string, number]} */ (cursor.key); if (Array.isArray(key) && key[0] === current) { // only load the current session's snapshots into memory - snapshots[/** @type {number} */ (key[1])] = cursor.value; + snapshots[key[1]] = cursor.value; } else if (active && !(Array.isArray(key) && active.has(key[0]))) { // stale (closed session) or legacy (non-namespaced) entry — // only delete when Web Locks let us prove it isn't active