Skip to content

feat: use IndexedDB for snapshots instead of sessionStorage#16346

Open
Rich-Harris wants to merge 11 commits into
version-3from
indexeddb-snapshot
Open

feat: use IndexedDB for snapshots instead of sessionStorage#16346
Rich-Harris wants to merge 11 commits into
version-3from
indexeddb-snapshot

Conversation

@Rich-Harris

@Rich-Harris Rich-Harris commented Jul 14, 2026

Copy link
Copy Markdown
Member

This replaces the sessionStorage persistence mechanism for snapshots with IndexedDB. Doing so gives us a much larger quota, and means that we can serialize more things, such as File objects. If my hunch in #12409 (comment) is correct, this means people are less likely to reach for history.state in some circumstances, which means people are less likely to want page.state to be restored on reload.

Caveat: IndexedDB requires https: (except on localhost/127.0.0.1). I think that's probably fine, honestly.

Possible follow-ups:

  • we could presumably do the same thing for page.state. That would be a more complete alternative to feat: use devalue for pushState/replaceState #14129, with the additional benefit of the larger quota

  • I think we can improve upon the snapshot API. It's annoying to have to export it from a +page.svelte component. The requirement exists for a good reason — we need to be able to guarantee that the snapshot belongs to a specific component, across page reloads — but the ergonomics suck. It occurs to me that we could get the same guarantee in the common case by generating a stack trace, which tells us the callsite.

    It wouldn't be bulletproof — if you had multiple instances of a component that called snapshot, or called it via an intermediate layer, we would see duplicates (that we could error on), and if you deployed a new version with different chunks that resulted in a different stack trace, the snapshot wouldn't survive reload. But we could let you specify an optional id to solve those issues


Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests

  • Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpm changeset and following the prompts. Changesets that add features should be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.

Edits

  • Please ensure that 'Allow edits from maintainers' is checked. PRs without this option may be closed.

@pkg-svelte-dev

pkg-svelte-dev Bot commented Jul 14, 2026

Copy link
Copy Markdown

Install the latest version of @sveltejs/kit from b31721f:

pnpm add https://pkg.svelte.dev/@sveltejs/kit/c/b31721fc50ca7595365774451cc6758961852020

Open in pkg.svelte.dev: https://pkg.svelte.dev/repos/kit/pr/16346

@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b31721f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@sveltejs/kit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@svelte-docs-bot

Copy link
Copy Markdown

@Conduitry

Copy link
Copy Markdown
Member

I had a memory of indexedDB not working on Firefox in private browsing windows, but it looks like it got fixed a few years ago, luckily.

The need for a secure context is what's mostly concerning me now, if there isn't some sort of a fallback. There are various reasons during development why someone would be accessing their own site on something other than localhost or 127.0.0.1.

@Rich-Harris

Copy link
Copy Markdown
Member Author

We could fall back to sessionStorage, but that obviously doesn't work if you're serializing File objects and the like. Would that suffice?

@Rich-Harris
Rich-Harris marked this pull request as ready for review July 14, 2026 19:18
Comment thread packages/kit/src/runtime/client/snapshot-storage.svelte.js
…oss 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.

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:<id>` 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 <vercel[bot]@users.noreply.github.com>
Co-authored-by: Rich-Harris <hello@rich-harris.dev>
@Rich-Harris

Copy link
Copy Markdown
Member Author

/autofix

Comment thread packages/kit/src/runtime/client/snapshot-storage.svelte.js Outdated
snapshots[index] = $state.snapshot(value);

void put(index, snapshots[index]);
storage.set(SNAPSHOT_KEY, snapshots);

@teemingc teemingc Jul 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this only be done when put fails instead?


// 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');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one seems to pass regardless because the File is already in-memory. Not sure how to test this better...

Co-authored-by: Tee Ming <chewteeming01@gmail.com>

@Nic-Polumeyv Nic-Polumeyv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The snapshots docs (30-advanced/65-snapshots.md) still say the data "must be serializable as JSON so that it can be persisted to sessionStorage", worth updating to whatever the new contract is, since the happy path and the fallback now accept different things (see inline). A smaller nit, put() warns on DataCloneError only, so quota failures stay silent even in dev.

snapshots[index] = $state.snapshot(value);

void put(index, snapshots[index]);
storage.set(SNAPSHOT_KEY, snapshots);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can throw synchronously for values IndexedDB accepts. session-storage.js runs stringify(value) outside its try, and JSON.stringify throws on BigInt and circular references, both fine under structured clone. capture_snapshot runs unguarded inside navigate() (client.js:1836), so a snapshot containing such a value fails the navigation instead of just losing the fallback copy. Same applies to truncate below. Alternatively the stringify call could move inside the try in session-storage.js, whose only other caller is SCROLL_KEY.

Suggested change
storage.set(SNAPSHOT_KEY, snapshots);
try {
storage.set(SNAPSHOT_KEY, snapshots);
} catch {
// best-effort, JSON.stringify rejects some structured-clonable values (BigInt, circular refs)
}

new Promise((resolve, reject) => {
const transaction = db.transaction(STORE, 'readwrite');
const store = transaction.objectStore(STORE);
const request = store.openCursor();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start() awaits init() before hooks.init and hydration, and this cursor materializes every session's stored values, so a new tab deserializes other tabs' snapshots (potentially multi-megabyte Files) just to prune their keys. getAll(IDBKeyRange.bound([current], [current, []])) would load only this session's values, and the prune could iterate openKeyCursor() instead, or run after hydration entirely.

*
* @returns {string}
*/
function get_session() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicating a tab copies sessionStorage, so the duplicate starts with the same session id and the two tabs then overwrite each other's [session, index] entries. The per-tab sessionStorage map kept duplicates independent after the copy. Since a request for a held lock just queues, the duplicate could detect this at startup with { ifAvailable: true } and mint a fresh id.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants