Repository: sveltejs/kit (js)

Issue:
@sveltejs/adapter-node error when used as handler with native node http package
### Describe the bug

Using `@sveltejs/adapter-node` v1.1.6 fails with a cryptic error message when trying to use the handler with the inbuilt node http package, but works with express.

### Reproduction

This fails:

```js
import http from 'http'
import { handler } from './build/handler.js'

http.createServer(handler).listen(3000)
```

This works:

```js
import express from 'express'
import { handler } from './build/handler.js'

express().use(handler).listen(3000)
```

### Logs

```Shell
Error: Not found: /
    at resolve (file:///project/build/server/index.js:3178:18)
    at resolve (file:///project/build/server/index.js:3045:34)
    at #options.hooks.handle (file:///project/build/server/index.js:3223:59)
    at respond (file:///project/build/server/index.js:3043:43)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async Array.ssr (file:///project/build/handler.js:21839:3)
```

### System Info

```Shell
System:
    OS: macOS 13.2
    CPU: (6) x64 Intel(R) Core(TM) i5-8500B CPU @ 3.00GHz
    Memory: 2.70 GB / 32.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 18.10.0 - ~/Library/pnpm/node
    npm: 8.19.2 - ~/Library/pnpm/npm
  Browsers:
    Brave Browser: 106.1.44.112
    Chrome: 109.0.5414.119
    Chrome Canary: 112.0.5579.0
    Firefox: 109.0
    Safari: 16.3
    Safari Technology Preview: 16.4
  npmPackages:
    @sveltejs/adapter-node: 1.1.5 => 1.1.6 
    @sveltejs/kit: ^1.3.10 => 1.3.10 
    svelte: ^3.55.1 => 3.55.1 
    vite: ^4.1.1 => 4.1.1
```


### Severity

serious, but I can work around it

### Additional Information

Rolling back to `@sveltejs/adapter-node` v1.1.5 works.

I don't want to add express as an extra dependency and the real server script includes other things (static file caching, proxy and http compression)

Pull request:
fix: serve_prerendered breaks on node http server
fixes #8888 
Tests have been disabled for adapter-node since #2931, so didn't add any in this one. Will look into re-enabling.

### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [x] 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
- [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check`

### Changesets
- [x] 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:`.


Patch:
diff --git a/.changeset/breezy-cycles-thank.md b/.changeset/breezy-cycles-thank.md
deleted file mode 100644
index 23d8356a9..000000000
--- a/.changeset/breezy-cycles-thank.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-fix: ensure types of all form actions are accessible even if differing
diff --git a/.changeset/cyan-ants-float.md b/.changeset/cyan-ants-float.md
deleted file mode 100644
index 4a7193bc3..000000000
--- a/.changeset/cyan-ants-float.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': minor
----
-
-feat: allow $app/paths to be used without an app
diff --git a/.changeset/fuzzy-kangaroos-relate.md b/.changeset/fuzzy-kangaroos-relate.md
deleted file mode 100644
index 0c27b68dd..000000000
--- a/.changeset/fuzzy-kangaroos-relate.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-fix: correctly handle HttpErrors on the client side
diff --git a/.changeset/late-dancers-vanish.md b/.changeset/late-dancers-vanish.md
new file mode 100644
index 000000000..2d3e6b395
--- /dev/null
+++ b/.changeset/late-dancers-vanish.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/adapter-node': patch
+---
+
+fix: make serve_prerendered middleware work with node http server
diff --git a/.changeset/little-countries-cough.md b/.changeset/little-countries-cough.md
new file mode 100644
index 000000000..7649ca44d
--- /dev/null
+++ b/.changeset/little-countries-cough.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': minor
+---
+
+feat: add snapshot mechanism for preserving ephemeral DOM state
diff --git a/.changeset/loud-pans-fly.md b/.changeset/loud-pans-fly.md
deleted file mode 100644
index 1e595deb3..000000000
--- a/.changeset/loud-pans-fly.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-docs: discourage use of `goto` with external URLs
diff --git a/.changeset/silent-icons-guess.md b/.changeset/silent-icons-guess.md
deleted file mode 100644
index 84f11ea69..000000000
--- a/.changeset/silent-icons-guess.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-fix: correctly serialize request url when using load `fetch`
diff --git a/.changeset/slimy-donkeys-itch.md b/.changeset/slimy-donkeys-itch.md
deleted file mode 100644
index 01aa7b70c..000000000
--- a/.changeset/slimy-donkeys-itch.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@sveltejs/kit': patch
----
-
-fix: ensure endpoints can fetch endpoints on the same host but not part of the application
diff --git a/.changeset/tricky-moose-travel.md b/.changeset/tricky-moose-travel.md
new file mode 100644
index 000000000..1dcdba11d
--- /dev/null
+++ b/.changeset/tricky-moose-travel.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+chore(deps): update dependency undici to v5.18.0
diff --git a/documentation/docs/30-advanced/65-snapshots.md b/documentation/docs/30-advanced/65-snapshots.md
new file mode 100644
index 000000000..41971b98e
--- /dev/null
+++ b/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`.
\ No newline at end of file
diff --git a/packages/adapter-auto/CHANGELOG.md b/packages/adapter-auto/CHANGELOG.md
index e85f37994..07ff0460f 100644
--- a/packages/adapter-auto/CHANGELOG.md
+++ b/packages/adapter-auto/CHANGELOG.md
@@ -1,5 +1,14 @@
 # @sveltejs/adapter-auto
 
+## 1.0.3
+
+### Patch Changes
+
+- fix: pin adapter versions ([#8874](https://github.com/sveltejs/kit/pull/8874))
+
+- Updated dependencies [[`f8dd7754`](https://github.com/sveltejs/kit/commit/f8dd7754ea21b11063613aac7d0d6cd98fc28b8e), [`fafe7d5f`](https://github.com/sveltejs/kit/commit/fafe7d5f033b249d554879b5ab7bc641f0ace362), [`e56a13c6`](https://github.com/sveltejs/kit/commit/e56a13c68a7d885f83f519c43f3510f10fee4ca5), [`02bd767d`](https://github.com/sveltejs/kit/commit/02bd767d3277a840b480fe94444cd1c886ef519b), [`97d68b13`](https://github.com/sveltejs/kit/commit/97d68b1381c1e0d052a0978824e96b9f50d248a9), [`2ef9b7d1`](https://github.com/sveltejs/kit/commit/2ef9b7d124809ce6a9804abbeaa4647a99fac97c), [`64a279f7`](https://github.com/sveltejs/kit/commit/64a279f7f92dcd4855af83fcbb09ea5ba1521ee8)]:
+  - @sveltejs/kit@1.4.0
+
 ## 1.0.2
 
 ### Patch Changes
diff --git a/packages/adapter-auto/adapters.js b/packages/adapter-auto/adapters.js
index 12b77910e..bbd36575c 100644
--- a/packages/adapter-auto/adapters.js
+++ b/packages/adapter-auto/adapters.js
@@ -1,22 +1,28 @@
+// List of adapters to check for. `version` is used to pin the installed adapter version and should point
+// to the latest version of the adapter that is compatible with adapter-auto's current peerDependency version of SvelteKit.
 export const adapters = [
 	{
 		name: 'Vercel',
 		test: () => !!process.env.VERCEL,
-		module: '@sveltejs/adapter-vercel'
+		module: '@sveltejs/adapter-vercel',
+		version: '1'
 	},
 	{
 		name: 'Cloudflare Pages',
 		test: () => !!process.env.CF_PAGES,
-		module: '@sveltejs/adapter-cloudflare'
+		module: '@sveltejs/adapter-cloudflare',
+		version: '2'
 	},
 	{
 		name: 'Netlify',
 		test: () => !!process.env.NETLIFY,
-		module: '@sveltejs/adapter-netlify'
+		module: '@sveltejs/adapter-netlify',
+		version: '1'
 	},
 	{
 		name: 'Azure Static Web Apps',
 		test: () => process.env.GITHUB_ACTION_REPOSITORY === 'Azure/static-web-apps-deploy',
-		module: 'svelte-adapter-azure-swa'
+		module: 'svelte-adapter-azure-swa',
+		version: '0.13'
 	}
 ];
diff --git a/packages/adapter-auto/index.js b/packages/adapter-auto/index.js
index 6caacb8b7..9e73f5092 100644
--- a/packages/adapter-auto/index.js
+++ b/packages/adapter-auto/index.js
@@ -5,11 +5,11 @@ import { adapters } from './adapters.js';
 import { dirname, join } from 'path';
 import { existsSync } from 'fs';
 
-/** @type {Record<string, (name: string) => string>} */
+/** @type {Record<string, (name: string, version: string) => string>} */
 const commands = {
-	npm: (name) => `npm install -D ${name}`,
-	pnpm: (name) => `pnpm add -D ${name}`,
-	yarn: (name) => `yarn add -D ${name}`
+	npm: (name, version) => `npm install -D ${name}@${version}`,
+	pnpm: (name, version) => `pnpm add -D ${name}@${version}`,
+	yarn: (name, version) => `yarn add -D ${name}@${version}`
 };
 
 function detect_lockfile() {
@@ -64,7 +64,7 @@ async function get_adapter() {
 			error.message.startsWith(`Cannot find package '${match.module}'`)
 		) {
 			const package_manager = detect_package_manager();
-			const command = commands[package_manager](match.module);
+			const command = commands[package_manager](match.module, match.version);
 
 			try {
 				console.log(`Installing ${match.module}...`);
diff --git a/packages/adapter-auto/package.json b/packages/adapter-auto/package.json
index 78e1a1781..d2ff8d04b 100644
--- a/packages/adapter-auto/package.json
+++ b/packages/adapter-auto/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/adapter-auto",
-	"version": "1.0.2",
+	"version": "1.0.3",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json
index e530fc192..d07c26e11 100644
--- a/packages/adapter-node/package.json
+++ b/packages/adapter-node/package.json
@@ -32,6 +32,7 @@
 		"prepublishOnly": "pnpm build"
 	},
 	"devDependencies": {
+		"@polka/url": "^1.0.0-next.21",
 		"@sveltejs/kit": "workspace:^",
 		"@types/node": "^16.18.6",
 		"c8": "^7.12.0",
diff --git a/packages/adapter-node/rollup.config.js b/packages/adapter-node/rollup.config.js
index 93c9b3502..4c00ce5f1 100644
--- a/packages/adapter-node/rollup.config.js
+++ b/packages/adapter-node/rollup.config.js
@@ -1,7 +1,7 @@
 import { nodeResolve } from '@rollup/plugin-node-resolve';
 import commonjs from '@rollup/plugin-commonjs';
 import json from '@rollup/plugin-json';
-import { builtinModules } from 'module';
+import { builtinModules } from 'node:module';
 
 export default [
 	{
diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js
index 650ab3ce6..331b6ccca 100644
--- a/packages/adapter-node/src/handler.js
+++ b/packages/adapter-node/src/handler.js
@@ -3,6 +3,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import sirv from 'sirv';
 import { fileURLToPath } from 'node:url';
+import { parse as polka_url_parser } from '@polka/url';
 import { getRequest, setResponse } from '@sveltejs/kit/node';
 import { Server } from 'SERVER';
 import { manifest, prerendered } from 'MANIFEST';
@@ -50,7 +51,7 @@ function serve_prerendered() {
 	const handler = serve(path.join(dir, 'prerendered'));
 
 	return (req, res, next) => {
-		let pathname = req.path;
+		let { pathname, search, query } = polka_url_parser(req);
 
 		try {
 			pathname = decodeURIComponent(pathname);
@@ -65,8 +66,7 @@ function serve_prerendered() {
 		// remove or add trailing slash as appropriate
 		let location = pathname.at(-1) === '/' ? pathname.slice(0, -1) : pathname + '/';
 		if (prerendered.has(location)) {
-			const search = req.url.split('?')[1];
-			if (search) location += `?${search}`;
+			if (query) location += search;
 			res.writeHead(308, { location }).end();
 		} else {
 			next();
diff --git a/packages/kit/CHANGELOG.md b/packages/kit/CHANGELOG.md
index 921ee2317..71fca7680 100644
--- a/packages/kit/CHANGELOG.md
+++ b/packages/kit/CHANGELOG.md
@@ -1,5 +1,25 @@
 # @sveltejs/kit
 
+## 1.4.0
+
+### Minor Changes
+
+- feat: allow $app/paths to be used without an app ([#8838](https://github.com/sveltejs/kit/pull/8838))
+
+### Patch Changes
+
+- fix: ensure types of all form actions are accessible even if differing ([#8877](https://github.com/sveltejs/kit/pull/8877))
+
+- fix: correctly handle HttpErrors on the client side ([#8829](https://github.com/sveltejs/kit/pull/8829))
+
+- docs: discourage use of `goto` with external URLs ([#8837](https://github.com/sveltejs/kit/pull/8837))
+
+- fix: prevent crawling empty urls (`<img src="">`) ([#8883](https://github.com/sveltejs/kit/pull/8883))
+
+- fix: correctly serialize request url when using load `fetch` ([#8876](https://github.com/sveltejs/kit/pull/8876))
+
+- fix: ensure endpoints can fetch endpoints on the same host but not part of the application ([#8869](https://github.com/sveltejs/kit/pull/8869))
+
 ## 1.3.10
 
 ### Patch Changes
diff --git a/packages/kit/package.json b/packages/kit/package.json
index d44fabdd8..2c0c11784 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/kit",
-	"version": "1.3.10",
+	"version": "1.4.0",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
@@ -22,7 +22,7 @@
 		"set-cookie-parser": "^2.5.1",
 		"sirv": "^2.0.2",
 		"tiny-glob": "^0.2.9",
-		"undici": "5.16.0"
+		"undici": "5.18.0"
 	},
 	"devDependencies": {
 		"@playwright/test": "^1.29.2",
diff --git a/packages/kit/src/core/postbuild/crawl.js b/packages/kit/src/core/postbuild/crawl.js
index 255d6e443..fd1bd4c9e 100644
--- a/packages/kit/src/core/postbuild/crawl.js
+++ b/packages/kit/src/core/postbuild/crawl.js
@@ -165,7 +165,7 @@ export function crawl(html) {
 							} else if (name === 'rel') {
 								rel = value;
 							} else if (name === 'src') {
-								hrefs.push(value);
+								if (value) hrefs.push(value);
 							} else if (name === 'srcset') {
 								const candidates = [];
 								let insideURL = true;
@@ -183,7 +183,7 @@ export function crawl(html) {
 								candidates.push(value);
 								for (const candidate of candidates) {
 									const src = candidate.split(WHITESPACE)[0];
-									hrefs.push(src);
+									if (src) hrefs.push(src);
 								}
 							}
 						} else {
diff --git a/packages/kit/src/core/postbuild/fixtures/basic-src/input.html b/packages/kit/src/core/postbuild/fixtures/basic-src/input.html
index 82c55c8f2..52d4ee72b 100644
--- a/packages/kit/src/core/postbuild/fixtures/basic-src/input.html
+++ b/packages/kit/src/core/postbuild/fixtures/basic-src/input.html
@@ -3,5 +3,6 @@
 	<head></head>
 	<body>
 		<img alt="A potato" src="/potato.jpg" />
+		<img alt="empty" src="" />
 	</body>
 </html>
diff --git a/packages/kit/src/core/sync/write_root.js b/packages/kit/src/core/sync/write_root.js
index e40fe3021..a82e2d007 100644
--- a/packages/kit/src/core/sync/write_root.js
+++ b/packages/kit/src/core/sync/write_root.js
@@ -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, '')
@@ -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')}
 
diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js
index 6c1e8f1c1..d4fd032dc 100644
--- a/packages/kit/src/core/sync/write_types/index.js
+++ b/packages/kit/src/core/sync/write_types/index.js
@@ -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>;`
 		);
 	}
 
diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js
index ef4cf180d..a0b3b6317 100644
--- a/packages/kit/src/runtime/client/client.js
+++ b/packages/kit/src/runtime/client/client.js
@@ -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,
@@ -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';
 
@@ -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) {
@@ -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;
 
@@ -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
@@ -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));
 
@@ -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
@@ -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,
@@ -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)
 			}
 		};
 
@@ -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();
 
@@ -1105,6 +1155,7 @@ export function create_client({ target }) {
 			intent,
 			url,
 			redirect_chain,
+			previous_history_index,
 			{
 				scroll,
 				keepfocus,
@@ -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);
 				}
 			});
 
@@ -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
@@ -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,
@@ -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);
+					}
 				}
 			});
 
diff --git a/packages/kit/src/runtime/client/constants.js b/packages/kit/src/runtime/client/constants.js
index b7a8d9d61..cbb2353ed 100644
--- a/packages/kit/src/runtime/client/constants.js
+++ b/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';
 
diff --git a/packages/kit/src/runtime/client/session-storage.js b/packages/kit/src/runtime/client/session-storage.js
new file mode 100644
index 000000000..dc2639ef1
--- /dev/null
+++ b/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
+	}
+}
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index 7ccacaed3..ec569666c 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -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
 		};
 
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index ccc9a9d5e..0a211ff54 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -1192,3 +1192,11 @@ export interface SubmitFunction<
 		  }) => void)
 	>;
 }
+
+/**
+ * The type of `export const snapshot` exported from a page or layout component.
+ */
+export interface Snapshot<T = any> {
+	capture: () => T;
+	restore: (snapshot: T) => void;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f0be98413..6af153d93 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -114,6 +114,7 @@ importers:
 
   packages/adapter-node:
     specifiers:
+      '@polka/url': ^1.0.0-next.21
       '@rollup/plugin-commonjs': ^24.0.0
       '@rollup/plugin-json': ^6.0.0
       '@rollup/plugin-node-resolve': ^15.0.1
@@ -132,6 +133,7 @@ importers:
       '@rollup/plugin-node-resolve': 15.0.1_rollup@3.7.0
       rollup: 3.7.0
     devDependencies:
+      '@polka/url': 1.0.0-next.21
       '@sveltejs/kit': link:../kit
       '@types/node': 16.18.6
       c8: 7.12.0
@@ -283,7 +285,7 @@ importers:
       svelte-preprocess: ^5.0.0
       tiny-glob: ^0.2.9
       typescript: ^4.9.4
-      undici: 5.16.0
+      undici: 5.18.0
       uvu: ^0.5.6
       vite: ^4.0.4
     dependencies:
@@ -299,7 +301,7 @@ importers:
       set-cookie-parser: 2.5.1
       sirv: 2.0.2
       tiny-glob: 0.2.9
-      undici: 5.16.0
+      undici: 5.18.0
     devDependencies:
       '@playwright/test': 1.29.2
       '@types/connect': 3.4.35
@@ -4840,8 +4842,8 @@ packages:
       which-boxed-primitive: 1.0.2
     dev: true
 
-  /undici/5.16.0:
-    resolution: {integrity: sha512-KWBOXNv6VX+oJQhchXieUznEmnJMqgXMbs0xxH2t8q/FUAWSJvOSr/rMaZKnX5RIVq7JDn0JbP4BOnKG2SGXLQ==}
+  /undici/5.18.0:
+    resolution: {integrity: sha512-1iVwbhonhFytNdg0P4PqyIAXbdlVZVebtPDvuM36m66mRw4OGrCm2MYynJv/UENFLdP13J1nPVQzVE2zTs1OeA==}
     engines: {node: '>=12.18'}
     dependencies:
       busboy: 1.6.0
diff --git a/renovate.json b/renovate.json
index df2d62cbd..92b4007e7 100644
--- a/renovate.json
+++ b/renovate.json
@@ -3,7 +3,8 @@
 		"github>whitesource/merge-confidence:beta",
 		"config:base",
 		":preserveSemverRanges",
-		"group:allNonMajor"
+		"group:allNonMajor",
+		":semanticCommitTypeAll(chore)"
 	],
 	"pin": {
 		"enabled": false


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/snapshot/+layout.svelte b/packages/kit/test/apps/basics/src/routes/snapshot/+layout.svelte
new file mode 100644
index 000000000..84cdf1564
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/snapshot/+layout.svelte
@@ -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 />
diff --git a/packages/kit/test/apps/basics/src/routes/snapshot/a/+page.svelte b/packages/kit/test/apps/basics/src/routes/snapshot/a/+page.svelte
new file mode 100644
index 000000000..647734468
--- /dev/null
+++ b/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} />
diff --git a/packages/kit/test/apps/basics/src/routes/snapshot/b/+page.svelte b/packages/kit/test/apps/basics/src/routes/snapshot/b/+page.svelte
new file mode 100644
index 000000000..e96ed3db8
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/snapshot/b/+page.svelte
@@ -0,0 +1,1 @@
+<h1>b</h1>
diff --git a/packages/kit/test/apps/basics/src/routes/snapshot/c/+page.svelte b/packages/kit/test/apps/basics/src/routes/snapshot/c/+page.svelte
new file mode 100644
index 000000000..232c276f8
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/snapshot/c/+page.svelte
@@ -0,0 +1,1 @@
+<h1>c</h1>
diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js
index a2403e611..80bc78ab2 100644
--- a/packages/kit/test/apps/basics/test/client.test.js
+++ b/packages/kit/test/apps/basics/test/client.test.js
@@ -626,3 +626,32 @@ test.describe('env in app.html', () => {
 		expect(await page.locator('body').getAttribute('class')).toContain('groovy');
 	});
 });
+
+test.describe('Snapshots', () => {
+	test('recovers snapshotted data', async ({ page, clicknav }) => {
+		await page.goto('/snapshot/a');
+
+		let input = page.locator('input');
+		await input.type('hello');
+
+		await clicknav('[href="/snapshot/b"]');
+		await page.goBack();
+
+		input = page.locator('input');
+		expect(await input.inputValue()).toBe('hello');
+
+		await input.clear();
+		await input.type('works for cross-document navigations');
+
+		await clicknav('[href="/snapshot/c"]');
+		await page.goBack();
+		expect(await page.locator('input').inputValue()).toBe('works for cross-document navigations');
+
+		input = page.locator('input');
+		await input.clear();
+		await input.type('works for reloads');
+
+		await page.reload();
+		expect(await page.locator('input').inputValue()).toBe('works for reloads');
+	});
+});