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 diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index c02e1274509d..2b0ef882405f 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 snapshots from './snapshot-storage.svelte.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'; @@ -86,12 +86,6 @@ const resetters = []; */ const scroll_positions = storage.get(SCROLL_KEY) ?? {}; -/** - * navigation index -> any - * @type {Record} - */ -const snapshots = storage.get(SNAPSHOT_KEY) ?? {}; - /** * @deprecated this is a temporary measure to avoid a regression, replace with nested `RenderNode` classes */ @@ -154,11 +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]; - i += 1; - } + snapshots.truncate(current_navigation_index); } /** @@ -365,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) : []; @@ -524,13 +515,16 @@ function reset_invalidation() { /** @param {number} index */ function capture_snapshot(index) { if (components.some((c) => c?.snapshot)) { - snapshots[index] = components.map((c) => c?.snapshot?.capture()); + 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); }); } @@ -540,7 +534,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.svelte.js b/packages/kit/src/runtime/client/snapshot-storage.svelte.js new file mode 100644 index 000000000000..2caff7dbbbd5 --- /dev/null +++ b/packages/kit/src/runtime/client/snapshot-storage.svelte.js @@ -0,0 +1,273 @@ +import { DEV } from 'esm-env'; +import { SNAPSHOT_KEY } from './constants.js'; +import * as storage from './session-storage.js'; + +const STORE = 'snapshots'; + +/** + * `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} + */ +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) => { + /** @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); + })); +} + +/** + * 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, 'readwrite'); + const store = transaction.objectStore(STORE); + const request = store.openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + const key = /** @type {[string, number]} */ (cursor.key); + if (Array.isArray(key) && key[0] === current) { + // only load the current session's snapshots into memory + 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 + cursor.delete(); + } + cursor.continue(); + } + }; + request.onerror = () => reject(request.error); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }) + ); + } catch { + failed = true; + + // fall back to sessionStorage + Object.assign(snapshots, storage.get(SNAPSHOT_KEY)); + } +} + +/** + * Persist a snapshot value for a given navigation index in memory, + * and (in the background, fire-and-forget style) to IndexedDB + * + * @param {number} index + * @param {any} value + */ +export function set(index, value) { + snapshots[index] = $state.snapshot(value); + + void put(index, snapshots[index]); + storage.set(SNAPSHOT_KEY, snapshots); +} + +/** + * @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); + } + + storage.set(SNAPSHOT_KEY, snapshots); +} + +/** + * Persist a snapshot value to IndexedDB + * + * @param {number} index + * @param {any} value + * @returns {Promise} + */ +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, [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 + // 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 (e) { + if (DEV && /** @type {Error} */ (e).name === 'DataCloneError') { + console.warn('Could not serialize snapshot value. It will not survive a page reload'); + } + } +} + +/** + * Delete the snapshot value for a given navigation index. + * + * @param {number} index + * @returns {Promise} + */ +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([current, 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..05530ce0ff90 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/snapshot/file/+page.svelte @@ -0,0 +1,25 @@ + + + (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 5439d55c4116..f974fade03aa 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1325,6 +1325,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', () => {