Repository: sveltejs/kit (js)

Issue:
Returning a bad "redirect" from svelte module load does not trigger handleError hook
### Describe the bug

If the load function of a page module returns an object with the `redirect` property set but the `status` property is undefined, sveltekit generates an error to the dev to let them know the mistake.  However, this error does not flow into the `handleError` hook where the dev is likely to have set up error logging.

### Reproduction

1. Clone https://github.com/moatra/sveltekit-redirect-error-handler
2. `npm install`
3. `npm run dev`
4. Load `http://localhost:3000/err_gen` in a new browser tab.
  *  Observe the logs (first block in **Logs**). The error appears in both `handleError` and the `__error.svelte` component.
5. Load `http://localhost:3000/bad_redirect` in a new browser tab (or otherwise force SSR)
  * Observe the logs (second block in **Logs**).  The error only appers in the `__error.svelte` component.


### Logs

Visiting `/err_gen`:

```
handleError() called for /err_gen Error: throwing an error from err_gen.svelte
    at load (err_gen.svelte:3:18)
    at load_node (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1133:30)
    at respond$1 (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1347:21)
    at async render_page (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1538:19)
    at async resolve (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1863:10)
    at async Object.handle (//home/thomas/Projects/sveltekit-redirect-error-handler/src/hooks:8:19)
    at async respond (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1829:10)
    at async file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:2157:24
The __error.svelte component saw an error: Error: throwing an error from err_gen.svelte
    at load (err_gen.svelte:3:18)
    at load_node (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1133:30)
    at respond$1 (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1347:21)
    at async render_page (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1538:19)
    at async resolve (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1863:10)
    at async Object.handle (//home/thomas/Projects/sveltekit-redirect-error-handler/src/hooks:8:19)
    at async respond (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1829:10)
    at async file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:2157:24
```

Visiting `/bad_redirect`:
```
The __error.svelte component saw an error: Error: "redirect" property returned from load() must be accompanied by a 3xx status code
    at normalize (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:832:12)
    at load_node (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1148:11)
    at async respond$1 (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1347:15)
    at async render_page (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1538:19)
    at async resolve (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1863:10)
    at async Object.handle (//home/thomas/Projects/sveltekit-redirect-error-handler/src/hooks.js:12:19)
    at async respond (file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:1829:10)
    at async file:///home/thomas/Projects/sveltekit-redirect-error-handler/node_modules/@sveltejs/kit/dist/chunks/index.js:2157:24
```


### System Info

```shell
❯ npx envinfo --system --binaries --browsers --npmPackages "{svelte,@sveltejs/*,vite}"

  System:
    OS: Linux 5.15 Fedora Linux 35 (Workstation Edition)
    CPU: (16) x64 AMD Ryzen 7 3700X 8-Core Processor
    Memory: 23.68 GB / 31.33 GB
    Container: Yes
    Shell: 5.1.8 - /bin/bash
  Binaries:
    Node: 16.13.0 - /usr/bin/node
    npm: 8.1.0 - /usr/bin/npm
  Browsers:
    Firefox: 96.0
  npmPackages:
    @sveltejs/adapter-auto: next => 1.0.0-next.11 
    @sveltejs/kit: next => 1.0.0-next.232 
    svelte: ^3.44.0 => 3.46.2
```


### Severity

annoyance

### Additional Information

_No response_

Pull request:
throw load validation errors so they are caught by handleError
closes #3388

### 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
- [x] This message body should clearly illustrate what problems it solves.
- [x] 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. All changesets should be `patch` until SvelteKit 1.0


Patch:
diff --git a/.changeset/brave-jobs-occur.md b/.changeset/brave-jobs-occur.md
new file mode 100644
index 000000000..6aef9677f
--- /dev/null
+++ b/.changeset/brave-jobs-occur.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+feat: Pages marked for prerendering fail during ssr at runtime
diff --git a/.changeset/famous-birds-cheer.md b/.changeset/famous-birds-cheer.md
new file mode 100644
index 000000000..8b4141cbb
--- /dev/null
+++ b/.changeset/famous-birds-cheer.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Throw load validation errors so that they are caught by handleError
diff --git a/packages/kit/src/core/build/build_server.js b/packages/kit/src/core/build/build_server.js
index 9d3b5fd54..289abecf0 100644
--- a/packages/kit/src/core/build/build_server.js
+++ b/packages/kit/src/core/build/build_server.js
@@ -70,7 +70,10 @@ export class Server {
 			method_override: ${s(config.kit.methodOverride)},
 			paths: { base, assets },
 			prefix: assets + '/${config.kit.appDir}/',
-			prerender: ${config.kit.prerender.enabled},
+			prerender: {
+				default: ${config.kit.prerender.default},
+				enabled: ${config.kit.prerender.enabled}
+			},
 			read,
 			root,
 			service_worker: ${has_service_worker ? "base + '/service-worker.js'" : 'null'},
diff --git a/packages/kit/src/core/build/prerender/prerender.js b/packages/kit/src/core/build/prerender/prerender.js
index 8b231689d..070180ece 100644
--- a/packages/kit/src/core/build/prerender/prerender.js
+++ b/packages/kit/src/core/build/prerender/prerender.js
@@ -131,8 +131,7 @@ export async function prerender({ config, entries, files, log }) {
 
 		const response = await server.respond(new Request(`http://sveltekit-prerender${encoded}`), {
 			getClientAddress,
-			prerender: {
-				default: config.kit.prerender.default,
+			prerendering: {
 				dependencies
 			}
 		});
@@ -272,9 +271,8 @@ export async function prerender({ config, entries, files, log }) {
 
 	const rendered = await server.respond(new Request('http://sveltekit-prerender/[fallback]'), {
 		getClientAddress,
-		prerender: {
+		prerendering: {
 			fallback: true,
-			default: false,
 			dependencies: new Map()
 		}
 	});
diff --git a/packages/kit/src/core/dev/plugin.js b/packages/kit/src/core/dev/plugin.js
index f9058b072..02e3a6301 100644
--- a/packages/kit/src/core/dev/plugin.js
+++ b/packages/kit/src/core/dev/plugin.js
@@ -315,7 +315,10 @@ export async function create_plugin(config, cwd) {
 									assets
 								},
 								prefix: '',
-								prerender: config.kit.prerender.enabled,
+								prerender: {
+									default: config.kit.prerender.default,
+									enabled: config.kit.prerender.enabled
+								},
 								read: (file) => fs.readFileSync(path.join(config.kit.files.assets, file)),
 								root,
 								router: config.kit.browser.router,
diff --git a/packages/kit/src/runtime/load.js b/packages/kit/src/runtime/load.js
index 5e031989c..099e3ccaf 100644
--- a/packages/kit/src/runtime/load.js
+++ b/packages/kit/src/runtime/load.js
@@ -46,19 +46,13 @@ export function normalize(loaded) {
 
 	if (loaded.redirect) {
 		if (!loaded.status || Math.floor(loaded.status / 100) !== 3) {
-			return {
-				status: 500,
-				error: new Error(
-					'"redirect" property returned from load() must be accompanied by a 3xx status code'
-				)
-			};
+			throw new Error(
+				'"redirect" property returned from load() must be accompanied by a 3xx status code'
+			);
 		}
 
 		if (typeof loaded.redirect !== 'string') {
-			return {
-				status: 500,
-				error: new Error('"redirect" property returned from load() must be a string')
-			};
+			throw new Error('"redirect" property returned from load() must be a string');
 		}
 	}
 
@@ -67,10 +61,7 @@ export function normalize(loaded) {
 			!Array.isArray(loaded.dependencies) ||
 			loaded.dependencies.some((dep) => typeof dep !== 'string')
 		) {
-			return {
-				status: 500,
-				error: new Error('"dependencies" property returned from load() must be of type string[]')
-			};
+			throw new Error('"dependencies" property returned from load() must be of type string[]');
 		}
 	}
 
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index 3b86da85b..d9296f8ea 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -49,7 +49,7 @@ export async function respond(request, options, state) {
 	/** @type {Record<string, string>} */
 	let params = {};
 
-	if (options.paths.base && !state.prerender?.fallback) {
+	if (options.paths.base && !state.prerendering?.fallback) {
 		if (!decoded.startsWith(options.paths.base)) {
 			return new Response(undefined, { status: 404 });
 		}
@@ -63,7 +63,7 @@ export async function respond(request, options, state) {
 		url = new URL(url.origin + url.pathname.slice(0, -DATA_SUFFIX.length) + url.search);
 	}
 
-	if (!state.prerender || !state.prerender.fallback) {
+	if (!state.prerendering?.fallback) {
 		const matchers = await options.manifest._.matchers();
 
 		for (const candidate of options.manifest._.routes) {
@@ -82,7 +82,7 @@ export async function respond(request, options, state) {
 	if (route?.type === 'page') {
 		const normalized = normalize_path(url.pathname, options.trailing_slash);
 
-		if (normalized !== url.pathname && !state.prerender?.fallback) {
+		if (normalized !== url.pathname && !state.prerendering?.fallback) {
 			return new Response(undefined, {
 				status: 301,
 				headers: {
@@ -173,7 +173,7 @@ export async function respond(request, options, state) {
 					};
 				}
 
-				if (state.prerender && state.prerender.fallback) {
+				if (state.prerendering?.fallback) {
 					return await render_response({
 						event,
 						options,
@@ -275,7 +275,7 @@ export async function respond(request, options, state) {
 					});
 				}
 
-				if (state.prerender) {
+				if (state.prerendering) {
 					return new Response('not found', { status: 404 });
 				}
 
diff --git a/packages/kit/src/runtime/server/page/load_node.js b/packages/kit/src/runtime/server/page/load_node.js
index aa6efacad..bc96fc9e4 100644
--- a/packages/kit/src/runtime/server/page/load_node.js
+++ b/packages/kit/src/runtime/server/page/load_node.js
@@ -52,13 +52,15 @@ export async function load_node({
 	/** @type {import('types').LoadOutput} */
 	let loaded;
 
+	const should_prerender = node.module.prerender ?? options.prerender.default;
+
 	/** @type {import('types').ShadowData} */
 	const shadow = is_leaf
 		? await load_shadow_data(
 				/** @type {import('types').SSRPage} */ (route),
 				event,
 				options,
-				!!state.prerender
+				should_prerender
 		  )
 		: {};
 
@@ -81,7 +83,7 @@ export async function load_node({
 	} else if (module.load) {
 		/** @type {import('types').LoadInput} */
 		const load_input = {
-			url: state.prerender ? create_prerendering_url_proxy(event.url) : event.url,
+			url: state.prerendering ? create_prerendering_url_proxy(event.url) : event.url,
 			params: event.params,
 			props: shadow.body || {},
 			routeId: event.routeId,
@@ -217,9 +219,9 @@ export async function load_node({
 						}
 					);
 
-					if (state.prerender) {
+					if (state.prerendering) {
 						dependency = { response, body: null };
-						state.prerender.dependencies.set(resolved, dependency);
+						state.prerendering.dependencies.set(resolved, dependency);
 					}
 				} else {
 					// external
@@ -364,7 +366,7 @@ export async function load_node({
 	}
 
 	// generate __data.json files when prerendering
-	if (shadow.body && state.prerender) {
+	if (shadow.body && state.prerendering) {
 		const pathname = `${event.url.pathname.replace(/\/$/, '')}/__data.json`;
 
 		const dependency = {
@@ -372,7 +374,7 @@ export async function load_node({
 			body: JSON.stringify(shadow.body)
 		};
 
-		state.prerender.dependencies.set(pathname, dependency);
+		state.prerendering.dependencies.set(pathname, dependency);
 	}
 
 	return {
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index 36e820fcf..395d43269 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -40,7 +40,7 @@ export async function render_response({
 	resolve_opts,
 	stuff
 }) {
-	if (state.prerender) {
+	if (state.prerendering) {
 		if (options.csp.mode === 'nonce') {
 			throw new Error('Cannot use prerendering if config.kit.csp.mode === "nonce"');
 		}
@@ -108,7 +108,7 @@ export async function render_response({
 				routeId: event.routeId,
 				status,
 				stuff,
-				url: state.prerender ? create_prerendering_url_proxy(event.url) : event.url
+				url: state.prerendering ? create_prerendering_url_proxy(event.url) : event.url
 			},
 			components: branch.map(({ node }) => node.module.default)
 		};
@@ -148,7 +148,7 @@ export async function render_response({
 	await csp_ready;
 	const csp = new Csp(options.csp, {
 		dev: options.dev,
-		prerender: !!state.prerender,
+		prerender: !!state.prerendering,
 		needs_nonce: options.template_contains_nonce
 	});
 
@@ -257,7 +257,7 @@ export async function render_response({
 			<script${csp.script_needs_nonce ? ` nonce="${csp.nonce}"` : ''}>${init_service_worker}</script>`;
 	}
 
-	if (state.prerender) {
+	if (state.prerendering) {
 		const http_equiv = [];
 
 		const csp_headers = csp.get_meta();
@@ -295,7 +295,7 @@ export async function render_response({
 		headers.set('permissions-policy', 'interest-cohort=()');
 	}
 
-	if (!state.prerender) {
+	if (!state.prerendering) {
 		const csp_header = csp.get_header();
 		if (csp_header) {
 			headers.set('content-security-policy', csp_header);
diff --git a/packages/kit/src/runtime/server/page/respond.js b/packages/kit/src/runtime/server/page/respond.js
index cfb7e15d3..2d94c8e34 100644
--- a/packages/kit/src/runtime/server/page/respond.js
+++ b/packages/kit/src/runtime/server/page/respond.js
@@ -68,11 +68,11 @@ export async function respond(opts) {
 
 	let page_config = get_page_config(leaf, options);
 
-	if (state.prerender) {
+	if (state.prerendering) {
 		// if the page isn't marked as prerenderable (or is explicitly
 		// marked NOT prerenderable, if `prerender.default` is `true`),
 		// then bail out at this point
-		const should_prerender = leaf.prerender ?? state.prerender.default;
+		const should_prerender = leaf.prerender ?? options.prerender.default;
 		if (!should_prerender) {
 			return new Response(undefined, {
 				status: 204
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index 3d7e7b7de..ae38f18d0 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -94,7 +94,7 @@ export class InternalServer extends Server {
 	respond(
 		request: Request,
 		options: RequestOptions & {
-			prerender?: PrerenderOptions;
+			prerendering?: PrerenderOptions;
 		}
 	): Promise<Response>;
 }
@@ -147,7 +147,6 @@ export interface PrerenderDependency {
 
 export interface PrerenderOptions {
 	fallback?: boolean;
-	default: boolean;
 	dependencies: Map<string, PrerenderDependency>;
 }
 
@@ -254,7 +253,10 @@ export interface SSROptions {
 		assets: string;
 	};
 	prefix: string;
-	prerender: boolean;
+	prerender: {
+		default: boolean;
+		enabled: boolean;
+	};
 	read(file: string): Buffer;
 	root: SSRComponent['default'];
 	router: boolean;
@@ -308,7 +310,7 @@ export interface SSRState {
 	getClientAddress: () => string;
 	initiator?: SSRPage | null;
 	platform?: any;
-	prerender?: PrerenderOptions;
+	prerendering?: PrerenderOptions;
 }
 
 export type StrictBody = string | Uint8Array;


Test patch:
diff --git a/packages/kit/test/apps/basics/package.json b/packages/kit/test/apps/basics/package.json
index 066d8c4dc..6d7fac230 100644
--- a/packages/kit/test/apps/basics/package.json
+++ b/packages/kit/test/apps/basics/package.json
@@ -8,8 +8,8 @@
 		"preview": "node ../../cli.js preview",
 		"check": "tsc && svelte-check",
 		"test": "npm run test:dev && npm run test:build",
-		"test:dev": "cross-env DEV=true playwright test",
-		"test:build": "playwright test"
+		"test:dev": "rimraf test/errors.json && cross-env DEV=true playwright test",
+		"test:build": "rimraf test/errors.json && playwright test"
 	},
 	"devDependencies": {
 		"@sveltejs/kit": "workspace:*",
diff --git a/packages/kit/test/apps/basics/src/routes/prerendering/__error.svelte b/packages/kit/test/apps/basics/src/routes/prerendering/__error.svelte
new file mode 100644
index 000000000..50113b8d0
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/prerendering/__error.svelte
@@ -0,0 +1,15 @@
+<script context="module">
+	export const load = ({ error, status }) => {
+		return {
+			props: {
+				title: `${status}: ${error.message}`
+			}
+		};
+	};
+</script>
+
+<script>
+	export let title = 'Unknown error!';
+</script>
+
+<h1>{title}</h1>
diff --git a/packages/kit/test/apps/basics/src/routes/prerendering/mutative-endpoint.svelte b/packages/kit/test/apps/basics/src/routes/prerendering/mutative-endpoint.svelte
new file mode 100644
index 000000000..17e79b8eb
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/prerendering/mutative-endpoint.svelte
@@ -0,0 +1,5 @@
+<script context="module">
+	export const prerender = true;
+</script>
+
+<h1>I have a mutative endpoint!</h1>
diff --git a/packages/kit/test/apps/basics/src/routes/prerendering/mutative-endpoint.ts b/packages/kit/test/apps/basics/src/routes/prerendering/mutative-endpoint.ts
new file mode 100644
index 000000000..a2acc1313
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/prerendering/mutative-endpoint.ts
@@ -0,0 +1,3 @@
+export const post = () => {
+	return {};
+};
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index 9d60f4964..c642da079 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -1105,6 +1105,15 @@ test.describe.parallel('Errors', () => {
 		expect(response.status()).toBe(500);
 		expect(await response.text()).toMatch('thisvariableisnotdefined is not defined');
 	});
+
+	test('prerendering a page with a mutative page endpoint results in a catchable error', async ({
+		page
+	}) => {
+		await page.goto('/prerendering/mutative-endpoint');
+		expect(await page.textContent('h1')).toBe(
+			'500: Cannot prerender pages that have endpoints with mutative methods'
+		);
+	});
 });
 
 test.describe.parallel('ETags', () => {
@@ -1918,7 +1927,13 @@ test.describe.parallel('Redirects', () => {
 		}
 	});
 
-	test('errors on missing status', async ({ baseURL, page, clicknav }) => {
+	test('errors on missing status', async ({
+		baseURL,
+		page,
+		clicknav,
+		javaScriptEnabled,
+		read_errors
+	}) => {
 		await page.goto('/redirect');
 
 		await clicknav('[href="/redirect/missing-status/a"]');
@@ -1928,6 +1943,14 @@ test.describe.parallel('Redirects', () => {
 		expect(await page.textContent('#message')).toBe(
 			'This is your custom error page saying: ""redirect" property returned from load() must be accompanied by a 3xx status code"'
 		);
+
+		if (!javaScriptEnabled) {
+			// handleError is not invoked for client-side navigation
+			const lines = read_errors('/redirect/missing-status/a').split('\n');
+			expect(lines[0]).toBe(
+				'Error: "redirect" property returned from load() must be accompanied by a 3xx status code'
+			);
+		}
 	});
 
 	test('errors on invalid status', async ({ baseURL, page, clicknav }) => {