Skip to content

Commit

Permalink
feat: snapshots (#8710)
Browse files Browse the repository at this point in the history
* add failing test

* make it possible to read snapshot props

* refactor sessionStorage stuff a bit

* snapshots

* working

* beef up test

* tidy up

* fix type

* run all tests

* err....

* lint

* account for missing layouts etc

* thank you, past us, for creating accessors for export const automatically

* don't restore if navigation was blocked

* update scroll position at same time as snapshot

* DRY

* types

* docs

* changeset

* add a comment

* only capture snapshot when appropriate

* Update packages/kit/src/runtime/client/client.js

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* document caveat around large snapshots

* destroy alternate timelines

* fix

---------

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
  • Loading branch information
Rich-Harris and dummdidumm committed Feb 6, 2023
1 parent b108439 commit 63613bf
Show file tree
Hide file tree
Showing 14 changed files with 218 additions and 40 deletions.
5 changes: 5 additions & 0 deletions .changeset/little-countries-cough.md
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add snapshot mechanism for preserving ephemeral DOM state
33 changes: 33 additions & 0 deletions documentation/docs/30-advanced/65-snapshots.md
@@ -0,0 +1,33 @@
---
title: Snapshots
---

Ephemeral DOM state — like scroll positions on sidebars, the content of `<input>` elements and so on — is discarded when you navigate from one page to another.

For example, if the user fills out a form but clicks a link before submitting, then hits the browser's back button, the values they filled in will be lost. In cases where it's valuable to preserve that input, you can take a _snapshot_ of DOM state, which can then be restored if the user navigates back.

To do this, export a `snapshot` object with `capture` and `restore` methods from a `+page.svelte` or `+layout.svelte`:

```svelte
/// file: +page.svelte
<script>
let comment = '';
/** @type {import('./$types').Snapshot<string>} */
export const snapshot = {
capture: () => comment,
restore: (value) => comment = value
};
</script>
<form method="POST">
<textarea bind:value={comment} />
<button>Post comment</button>
</form>
```

When you navigate away from this page, the `capture` function is called immediately before the page updates, and the returned value is associated with the current entry in the browser's history stack. If you navigate back, the `restore` function is called with the stored value as soon as the page is updated.

The data must be serializable as JSON so that it can be persisted to `sessionStorage`. This allows the state to be restored when the page is reloaded, or when the user navigates back from a different site.

> Avoid returning very large objects from `capture` — once captured, objects will be retained in memory for the duration of the session, and in extreme cases may be too large to persist to `sessionStorage`.
11 changes: 6 additions & 5 deletions packages/kit/src/core/sync/write_root.js
Expand Up @@ -21,16 +21,16 @@ export function write_root(manifest_data, output) {

let l = max_depth;

let pyramid = `<svelte:component this={components[${l}]} data={data_${l}} {form} />`;
let pyramid = `<svelte:component this={constructors[${l}]} bind:this={components[${l}]} data={data_${l}} {form} />`;

while (l--) {
pyramid = `
{#if components[${l + 1}]}
<svelte:component this={components[${l}]} data={data_${l}}>
{#if constructors[${l + 1}]}
<svelte:component this={constructors[${l}]} bind:this={components[${l}]} data={data_${l}}>
${pyramid.replace(/\n/g, '\n\t\t\t\t\t')}
</svelte:component>
{:else}
<svelte:component this={components[${l}]} data={data_${l}} {form} />
<svelte:component this={constructors[${l}]} bind:this={components[${l}]} data={data_${l}} {form} />
{/if}
`
.replace(/^\t\t\t/gm, '')
Expand All @@ -49,7 +49,8 @@ export function write_root(manifest_data, output) {
export let stores;
export let page;
export let components;
export let constructors;
export let components = [];
export let form;
${levels.map((l) => `export let data_${l} = null;`).join('\n\t\t\t\t')}
Expand Down
35 changes: 19 additions & 16 deletions packages/kit/src/core/sync/write_types/index.js
Expand Up @@ -198,23 +198,26 @@ function update_types(config, routes, route, to_delete = new Set()) {

// These could also be placed in our public types, but it would bloat them unnecessarily and we may want to change these in the future
if (route.layout || route.leaf) {
// If T extends the empty object, void is also allowed as a return type
declarations.push(`type MaybeWithVoid<T> = {} extends T ? T | void : T;`);
// Returns the key of the object whose values are required.
declarations.push(
`export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];`
);
// Helper type to get the correct output type for load functions. It should be passed the parent type to check what types from App.PageData are still required.
// If none, void is also allowed as a return type.
declarations.push(
`type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>`
);
// null & {} == null, we need to prevent that in some situations
declarations.push(`type EnsureDefined<T> = T extends null | undefined ? {} : T;`);
// Takes a union type and returns a union type where each type also has all properties
// of all possible types (typed as undefined), making accessing them more ergonomic
declarations.push(
`type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;`
// If T extends the empty object, void is also allowed as a return type
`type MaybeWithVoid<T> = {} extends T ? T | void : T;`,

// Returns the key of the object whose values are required.
`export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];`,

// Helper type to get the correct output type for load functions. It should be passed the parent type to check what types from App.PageData are still required.
// If none, void is also allowed as a return type.
`type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>`,

// null & {} == null, we need to prevent that in some situations
`type EnsureDefined<T> = T extends null | undefined ? {} : T;`,

// Takes a union type and returns a union type where each type also has all properties
// of all possible types (typed as undefined), making accessing them more ergonomic
`type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;`,

// Re-export `Snapshot` from @sveltejs/kit — in future we could use this to infer <T> from the return type of `snapshot.capture`
`export type Snapshot<T = any> = Kit.Snapshot<T>;`
);
}

Expand Down
91 changes: 73 additions & 18 deletions packages/kit/src/runtime/client/client.js
Expand Up @@ -15,6 +15,7 @@ import {
is_external_url,
scroll_state
} from './utils.js';
import * as storage from './session-storage.js';
import {
lock_fetch,
unlock_fetch,
Expand All @@ -31,7 +32,7 @@ import { HttpError, Redirect } from '../control.js';
import { stores } from './singletons.js';
import { unwrap_promises } from '../../utils/promises.js';
import * as devalue from 'devalue';
import { INDEX_KEY, PRELOAD_PRIORITIES, SCROLL_KEY } from './constants.js';
import { INDEX_KEY, PRELOAD_PRIORITIES, SCROLL_KEY, SNAPSHOT_KEY } from './constants.js';
import { validate_common_exports } from '../../utils/exports.js';
import { compact } from '../../utils/array.js';

Expand All @@ -52,12 +53,10 @@ default_error_loader();

/** @typedef {{ x: number, y: number }} ScrollPosition */
/** @type {Record<number, ScrollPosition>} */
let scroll_positions = {};
try {
scroll_positions = JSON.parse(sessionStorage[SCROLL_KEY]);
} catch {
// do nothing
}
const scroll_positions = storage.get(SCROLL_KEY) ?? {};

/** @type {Record<string, any[]>} */
const snapshots = storage.get(SNAPSHOT_KEY) ?? {};

/** @param {number} index */
function update_scroll_positions(index) {
Expand All @@ -75,6 +74,14 @@ export function create_client({ target }) {
/** @type {Array<((url: URL) => boolean)>} */
const invalidated = [];

/**
* An array of the `+layout.svelte` and `+page.svelte` component instances
* that currently live on the page — used for capturing and restoring snapshots.
* It's updated/manipulated through `bind:this` in `Root.svelte`.
* @type {import('svelte').SvelteComponent[]}
*/
const components = [];

/** @type {{id: string, promise: Promise<import('./types').NavigationResult>} | null} */
let load_cache = null;

Expand Down Expand Up @@ -158,6 +165,20 @@ export function create_client({ target }) {
await update(intent, url, []);
}

/** @param {number} index */
function capture_snapshot(index) {
if (components.some((c) => c?.snapshot)) {
snapshots[index] = components.map((c) => c?.snapshot?.capture());
}
}

/** @param {number} index */
function restore_snapshot(index) {
snapshots[index]?.forEach((value, i) => {
components[i]?.snapshot?.restore(value);
});
}

/**
* @param {string | URL} url
* @param {{ noScroll?: boolean; replaceState?: boolean; keepFocus?: boolean; state?: any; invalidateAll?: boolean }} opts
Expand Down Expand Up @@ -238,11 +259,20 @@ export function create_client({ target }) {
* @param {import('./types').NavigationIntent | undefined} intent
* @param {URL} url
* @param {string[]} redirect_chain
* @param {number} [previous_history_index]
* @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean, details: { replaceState: boolean, state: any } | null}} [opts]
* @param {{}} [nav_token] To distinguish between different navigation events and determine the latest. Needed for example for redirects to keep the original token
* @param {() => void} [callback]
*/
async function update(intent, url, redirect_chain, opts, nav_token = {}, callback) {
async function update(
intent,
url,
redirect_chain,
previous_history_index,
opts,
nav_token = {},
callback
) {
token = nav_token;
let navigation_result = intent && (await load_route(intent));

Expand Down Expand Up @@ -301,11 +331,28 @@ export function create_client({ target }) {

updating = true;

// `previous_history_index` will be undefined for invalidation
if (previous_history_index) {
update_scroll_positions(previous_history_index);
capture_snapshot(previous_history_index);
}

if (opts && opts.details) {
const { details } = opts;
const change = details.replaceState ? 0 : 1;
details.state[INDEX_KEY] = current_history_index += change;
history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', url);

if (!details.replaceState) {
// if we navigated back, then pushed a new state, we can
// release memory by pruning the scroll/snapshot lookup
let i = current_history_index + 1;
while (snapshots[i] || scroll_positions[i]) {
delete snapshots[i];
delete scroll_positions[i];
i += 1;
}
}
}

// reset preload synchronously after the history state has been set to avoid race conditions
Expand Down Expand Up @@ -384,10 +431,12 @@ export function create_client({ target }) {

root = new Root({
target,
props: { ...result.props, stores },
props: { ...result.props, stores, components },
hydrate: true
});

restore_snapshot(current_history_index);

/** @type {import('types').AfterNavigate} */
const navigation = {
from: null,
Expand Down Expand Up @@ -445,7 +494,7 @@ export function create_client({ target }) {
},
props: {
// @ts-ignore Somehow it's getting SvelteComponent and SvelteComponentDev mixed up
components: compact(branch).map((branch_node) => branch_node.node.component)
constructors: compact(branch).map((branch_node) => branch_node.node.component)
}
};

Expand Down Expand Up @@ -1091,7 +1140,8 @@ export function create_client({ target }) {
return;
}

update_scroll_positions(current_history_index);
// store this before calling `accepted()`, which may change the index
const previous_history_index = current_history_index;

accepted();

Expand All @@ -1105,6 +1155,7 @@ export function create_client({ target }) {
intent,
url,
redirect_chain,
previous_history_index,
{
scroll,
keepfocus,
Expand Down Expand Up @@ -1385,12 +1436,10 @@ export function create_client({ target }) {
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
update_scroll_positions(current_history_index);
storage.set(SCROLL_KEY, scroll_positions);

try {
sessionStorage[SCROLL_KEY] = JSON.stringify(scroll_positions);
} catch {
// do nothing
}
capture_snapshot(current_history_index);
storage.set(SNAPSHOT_KEY, snapshots);
}
});

Expand Down Expand Up @@ -1537,7 +1586,7 @@ export function create_client({ target }) {
});
});

addEventListener('popstate', (event) => {
addEventListener('popstate', async (event) => {
if (event.state?.[INDEX_KEY]) {
// if a popstate-driven navigation is cancelled, we need to counteract it
// with history.go, which means we end up back here, hence this check
Expand All @@ -1555,8 +1604,9 @@ export function create_client({ target }) {
}

const delta = event.state[INDEX_KEY] - current_history_index;
let blocked = false;

navigate({
await navigate({
url: new URL(location.href),
scroll,
keepfocus: false,
Expand All @@ -1567,10 +1617,15 @@ export function create_client({ target }) {
},
blocked: () => {
history.go(-delta);
blocked = true;
},
type: 'popstate',
delta
});

if (!blocked) {
restore_snapshot(current_history_index);
}
}
});

Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/runtime/client/constants.js
@@ -1,3 +1,4 @@
export const SNAPSHOT_KEY = 'sveltekit:snapshot';
export const SCROLL_KEY = 'sveltekit:scroll';
export const INDEX_KEY = 'sveltekit:index';

Expand Down
25 changes: 25 additions & 0 deletions packages/kit/src/runtime/client/session-storage.js
@@ -0,0 +1,25 @@
/**
* Read a value from `sessionStorage`
* @param {string} key
*/
export function get(key) {
try {
return JSON.parse(sessionStorage[key]);
} catch {
// do nothing
}
}

/**
* Write a value to `sessionStorage`
* @param {string} key
* @param {any} value
*/
export function set(key, value) {
const json = JSON.stringify(value);
try {
sessionStorage[key] = json;
} catch {
// do nothing
}
}
2 changes: 1 addition & 1 deletion packages/kit/src/runtime/server/page/render.js
Expand Up @@ -90,7 +90,7 @@ export async function render_response({
navigating: writable(null),
updated
},
components: await Promise.all(branch.map(({ node }) => node.component())),
constructors: await Promise.all(branch.map(({ node }) => node.component())),
form: form_value
};

Expand Down
@@ -0,0 +1,5 @@
<a href="/snapshot/a">a</a>
<a href="/snapshot/b">b</a>
<a href="/snapshot/c" data-sveltekit-reload>c</a>

<slot />
11 changes: 11 additions & 0 deletions packages/kit/test/apps/basics/src/routes/snapshot/a/+page.svelte
@@ -0,0 +1,11 @@
<script>
let message = '';
/** @type {import('./$types').Snapshot<string>} */
export const snapshot = {
capture: () => message,
restore: (snapshot) => (message = snapshot)
};
</script>

<input bind:value={message} />
@@ -0,0 +1 @@
<h1>b</h1>
@@ -0,0 +1 @@
<h1>c</h1>

0 comments on commit 63613bf

Please sign in to comment.