Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: snapshots #8710

Merged
merged 27 commits into from
Feb 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/little-countries-cough.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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 @@ -30,7 +31,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 @@ -51,12 +52,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[]>} */
Copy link
Contributor

@tomecko tomecko Jan 24, 2023

Choose a reason for hiding this comment

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

Suggested change
/** @type {Record<string, any[]>} */
/** @type {Record<number, any[]>} */

I see initial_history_index and current_history_index which both seem to be numbers to be used as an index for snapshots.

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, base }) {
/** @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 = [];
dummdidumm marked this conversation as resolved.
Show resolved Hide resolved

/** @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, base }) {
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, base }) {
* @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, base }) {

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 @@ -383,10 +430,12 @@ export function create_client({ target, base }) {

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 @@ -444,7 +493,7 @@ export function create_client({ target, base }) {
},
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 @@ -1081,7 +1130,8 @@ export function create_client({ target, base }) {
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 @@ -1095,6 +1145,7 @@ export function create_client({ target, base }) {
intent,
url,
redirect_chain,
previous_history_index,
{
scroll,
keepfocus,
Expand Down Expand Up @@ -1373,12 +1424,10 @@ export function create_client({ target, base }) {
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 @@ -1525,7 +1574,7 @@ export function create_client({ target, base }) {
});
});

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 @@ -1543,8 +1592,9 @@ export function create_client({ target, base }) {
}

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 @@ -1555,10 +1605,15 @@ export function create_client({ target, base }) {
},
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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} />
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>b</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>c</h1>
Loading