Repository: sveltejs/kit (js)

Issue:
`$page.data` friction - allow parallel loads and "waterfall" data merges
### Describe the problem

Presumably, one of the main benefits to the new load architecture, specifically around the `await parent()` feature, is to allow all `load`s to run in parallel unless they explicitly don't need to. Also presumably, the most common source of "long" `load`s are network calls. If all of my loads are _blazingly fast_ -- as the kids like to say -- waterfalling isn't that big of a deal, but if I throw a few network calls in there, suddenly that whole "running in parallel" thing is pretty awesome.

Unfortunately, I'm finding that I often have to throw the "running in parallel" benefits out the window because I need to be able to deeply merge `data`. For example, if I have a `layout` key in data which itself is an object being listened to by the root `+layout.svelte` through `$page.data`:

```ts
// /routes/foo/+layout.ts
export const load: LayoutLoad = () => {
  return {
    layout: {
      showFooter: true,
    }
  }
} 

// /routes/foo/bar/+layout.ts
export const load: LayoutLoad = () => {
  const things = await some_stuff_we_dont_want_to_happen_serially();

  return {
    layout: {
      showPotato: true,
    }
  }
}

// /routes/+layout.svelte
<script lang="ts">
  import { page } from '$app/stores';
</script>

<Potato show={!!$page.data?.layout?.showPotato} />
<slot />
<Footer show={!!$page.data?.layout?.showFooter} />
```

See the footgun? `/routes/foo/bar/+layout.ts` will completely overwrite the `layout` key in `data` because it's shallow-ly merged. In order to fix it, we have to change it to:

```ts
// /routes/foo/bar/+layout.ts
export const load: LayoutLoad = async ({ parent }) => {
  const things = await some_stuff_we_dont_want_to_happen_serially();
  const { layout } = await parent();

  return {
    layout: {
      ...layout,
      showPotato: true,
    }
  }
}
```

But, ugh, now that load has to `await parent` when _all I want to do_ is deeply merge the objects! The load itself doesn't actually have any dependency upon the parent at all.

### Describe the proposed solution

Thankfully, the solution is pretty simple. I can't take credit -- @Rich-Harris came up with it when we were whiteboarding in the maintainers' chat. He's smart like that. The solution is to allow data resolution to occur _after_ all `load`s have finished. Rendering doesn't start until all `load`s have resolved anyway, so there's no issue here. Essentially, we allow `load`s to run in parallel, but data resolution to run as a (partial or complete) waterfall afterwards (_if_ a developer needs it to):

```ts
// /routes/foo/bar/+layout.ts
export const load: LayoutLoad = () => {
  const things = await some_stuff_we_dont_want_to_happen_serially();

  // data, in this case, is the merged contents of all datas prior to this `load`, including any `load`s that also return this kind of function
  return (data) => ({
    layout: {
      ...data.layout,
      showPotato: true,
    }
  });
}
```

This allows the `load` to run in parallel, but for its returned _data_ to depend upon prior `load`s' data (which is returned, presumably, _after_ all of their time-consuming network activities have completed).

### Alternatives considered

Just get over it and deal with shallow-ly merged `$page.data` forever. Not a big fan. 😆 

### Importance

would make my life easier

### Additional Information

_No response_

Pull request:
[docs] note on parent and waterfalls
Closes #6017

### 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.
- [ ] 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. All changesets should be `patch` until SvelteKit 1.0


Patch:
diff --git a/.changeset/honest-parrots-compare.md b/.changeset/honest-parrots-compare.md
new file mode 100644
index 000000000..4c8442e71
--- /dev/null
+++ b/.changeset/honest-parrots-compare.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Handle `throw error/redirect` in `+server.js`
diff --git a/.changeset/lemon-kids-double.md b/.changeset/lemon-kids-double.md
new file mode 100644
index 000000000..e98b9d4d2
--- /dev/null
+++ b/.changeset/lemon-kids-double.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+handle `set-cookie` in `setHeaders`
diff --git a/.changeset/ninety-fans-cover.md b/.changeset/ninety-fans-cover.md
new file mode 100644
index 000000000..aba7c9ea3
--- /dev/null
+++ b/.changeset/ninety-fans-cover.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Make errors type in Action type less restrictive
diff --git a/documentation/docs/05-load.md b/documentation/docs/05-load.md
index 8631e2139..2f956a7cf 100644
--- a/documentation/docs/05-load.md
+++ b/documentation/docs/05-load.md
@@ -188,6 +188,28 @@ export async function load({ parent }) {
 
 In `+page.js` or `+layout.js` it will return data from `load` functions in parent `+layout.js` files. Implicitly, a missing `+layout.js` is treated as a `({ data }) => data` function, meaning that it will also return data from parent `+layout.server.js` files.
 
+Be careful not to introduce accidental waterfalls when using `await parent()`. If for example you only want to merge parent data into the returned output, call it _after_ fetching your other data.
+
+```diff
+/// file: src/routes/foo/+page.server.js
+// @filename: $types.d.ts
+export type PageLoad = import('@sveltejs/kit').Load<{}, null, { a: number, b: number }>;
+
+// @filename: index.js
+// ---cut---
+/** @type {import('./$types').PageLoad} */
+export async function load({ parent, fetch }) {
+-	const parentData = await parent();
+	const data = await fetch('./some-api');
++	const parentData = await parent();
+
+	return {
+		...data
+		meta: { ...parentData.meta, ...data.meta }
+	};
+}
+```
+
 #### setHeaders
 
 If you need to set headers for the response, you can do so using the `setHeaders` method. This is useful if you want the page to be cached, for example:
diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js
index 338791b4d..9d112214a 100644
--- a/packages/kit/src/runtime/server/endpoint.js
+++ b/packages/kit/src/runtime/server/endpoint.js
@@ -1,3 +1,4 @@
+import { HttpError, Redirect } from '../../index/private.js';
 import { check_method_names, method_not_allowed } from './utils.js';
 
 /**
@@ -29,16 +30,29 @@ export async function render_endpoint(event, route) {
 		return method_not_allowed(mod, method);
 	}
 
-	const response = await handler(
-		/** @type {import('types').RequestEvent<Record<string, any>>} */ (event)
-	);
-
-	if (!(response instanceof Response)) {
-		return new Response(
-			`Invalid response from route ${event.url.pathname}: handler should return a Response object`,
-			{ status: 500 }
+	try {
+		const response = await handler(
+			/** @type {import('types').RequestEvent<Record<string, any>>} */ (event)
 		);
-	}
 
-	return response;
+		if (!(response instanceof Response)) {
+			return new Response(
+				`Invalid response from route ${event.url.pathname}: handler should return a Response object`,
+				{ status: 500 }
+			);
+		}
+
+		return response;
+	} catch (error) {
+		if (error instanceof HttpError) {
+			return new Response(error.message, { status: error.status });
+		} else if (error instanceof Redirect) {
+			return new Response(undefined, {
+				status: error.status,
+				headers: { Location: error.location }
+			});
+		} else {
+			throw error;
+		}
+	}
 }
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index 625fc0539..61d4033a8 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -118,6 +118,9 @@ export async function respond(request, options, state) {
 	/** @type {import('types').ResponseHeaders} */
 	const headers = {};
 
+	/** @type {string[]} */
+	const cookies = [];
+
 	/** @type {import('types').RequestEvent} */
 	const event = {
 		get clientAddress() {
@@ -141,16 +144,26 @@ export async function respond(request, options, state) {
 		setHeaders: (new_headers) => {
 			for (const key in new_headers) {
 				const lower = key.toLowerCase();
+				const value = new_headers[key];
 
-				if (lower in headers) {
-					throw new Error(`"${key}" header is already set`);
-				}
+				if (lower === 'set-cookie') {
+					const new_cookies = /** @type {string[]} */ (Array.isArray(value) ? value : [value]);
 
-				// TODO apply these headers to the response
-				headers[lower] = new_headers[key];
+					for (const cookie of new_cookies) {
+						if (cookies.includes(cookie)) {
+							throw new Error(`"${key}" header already has cookie with same value`);
+						}
 
-				if (state.prerendering && lower === 'cache-control') {
-					state.prerendering.cache = /** @type {string} */ (new_headers[key]);
+						cookies.push(cookie);
+					}
+				} else if (lower in headers) {
+					throw new Error(`"${key}" header is already set`);
+				} else {
+					headers[lower] = value;
+
+					if (state.prerendering && lower === 'cache-control') {
+						state.prerendering.cache = /** @type {string} */ (value);
+					}
 				}
 			}
 		},
@@ -312,19 +325,19 @@ export async function respond(request, options, state) {
 								: await render_page(event, route, options, state, resolve_opts);
 					}
 
-					for (const key in headers) {
-						const value = headers[key];
-						if (key === 'set-cookie') {
-							for (const cookie of Array.isArray(value) ? value : [value]) {
-								response.headers.append(key, /** @type {string} */ (cookie));
-							}
-						} else if (!is_data_request) {
-							// we only want to set cookies on __data.json requests, we don't
-							// want to cache stuff erroneously etc
+					if (!is_data_request) {
+						// we only want to set cookies on __data.json requests, we don't
+						// want to cache stuff erroneously etc
+						for (const key in headers) {
+							const value = headers[key];
 							response.headers.set(key, /** @type {string} */ (value));
 						}
 					}
 
+					for (const cookie of cookies) {
+						response.headers.append('set-cookie', cookie);
+					}
+
 					// respond with 304 if etag matches
 					if (response.status === 200 && response.headers.has('etag')) {
 						let if_none_match_value = request.headers.get('if-none-match');
diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts
index 44b15f463..294330e19 100644
--- a/packages/kit/types/index.d.ts
+++ b/packages/kit/types/index.d.ts
@@ -312,7 +312,7 @@ export interface Action<
 	Params extends Partial<Record<string, string>> = Partial<Record<string, string>>
 > {
 	(event: RequestEvent<Params>): MaybePromise<
-		| { status?: number; errors: Record<string, string>; location?: never }
+		| { status?: number; errors: Record<string, any>; location?: never }
 		| { status?: never; errors?: never; location: string }
 		| void
 	>;


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/errors/endpoint-throw-error/+server.js b/packages/kit/test/apps/basics/src/routes/errors/endpoint-throw-error/+server.js
new file mode 100644
index 000000000..14c9aec1c
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/errors/endpoint-throw-error/+server.js
@@ -0,0 +1,5 @@
+import { error } from '@sveltejs/kit';
+
+export function GET() {
+	throw error(401, 'You shall not pass');
+}
diff --git a/packages/kit/test/apps/basics/src/routes/errors/endpoint-throw-redirect/+server.js b/packages/kit/test/apps/basics/src/routes/errors/endpoint-throw-redirect/+server.js
new file mode 100644
index 000000000..26300742e
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/errors/endpoint-throw-redirect/+server.js
@@ -0,0 +1,5 @@
+import { redirect } from '@sveltejs/kit';
+
+export function GET() {
+	throw redirect(302, '/');
+}
diff --git a/packages/kit/test/apps/basics/src/routes/headers/set-cookie/+layout.server.js b/packages/kit/test/apps/basics/src/routes/headers/set-cookie/+layout.server.js
new file mode 100644
index 000000000..21fd24121
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/headers/set-cookie/+layout.server.js
@@ -0,0 +1,5 @@
+export function load({ setHeaders }) {
+	setHeaders({
+		'set-cookie': 'cookie1=value1'
+	});
+}
diff --git a/packages/kit/test/apps/basics/src/routes/headers/set-cookie/+layout.svelte b/packages/kit/test/apps/basics/src/routes/headers/set-cookie/+layout.svelte
new file mode 100644
index 000000000..0385342ce
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/headers/set-cookie/+layout.svelte
@@ -0,0 +1,1 @@
+<slot />
\ No newline at end of file
diff --git a/packages/kit/test/apps/basics/src/routes/headers/set-cookie/sub/+page.server.js b/packages/kit/test/apps/basics/src/routes/headers/set-cookie/sub/+page.server.js
new file mode 100644
index 000000000..1d7acff07
--- /dev/null
+++ b/packages/kit/test/apps/basics/src/routes/headers/set-cookie/sub/+page.server.js
@@ -0,0 +1,5 @@
+export function load({ setHeaders }) {
+	setHeaders({
+		'set-cookie': 'cookie2=value2'
+	});
+}
diff --git a/packages/kit/test/apps/basics/src/routes/headers/set-cookie/sub/+page.svelte b/packages/kit/test/apps/basics/src/routes/headers/set-cookie/sub/+page.svelte
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js
index 539731fad..1cafac3ba 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -147,6 +147,26 @@ test.describe('Errors', () => {
 			'This is your custom error page saying: "Cannot read properties of undefined (reading \'toUpperCase\')"'
 		);
 	});
+
+	test('throw error(..) in endpoint', async ({ page, read_errors }) => {
+		const res = await page.goto('/errors/endpoint-throw-error');
+
+		const error = read_errors('/errors/endpoint-throw-error');
+		expect(error).toBe(undefined);
+
+		expect(await res?.text()).toBe('You shall not pass');
+		expect(res?.status()).toBe(401);
+	});
+
+	test('throw redirect(..) in endpoint', async ({ page, read_errors }) => {
+		const res = await page.goto('/errors/endpoint-throw-redirect');
+		expect(res?.status()).toBe(200); // redirects are opaque to the browser
+
+		const error = read_errors('/errors/endpoint-throw-redirect');
+		expect(error).toBe(undefined);
+
+		expect(await page.textContent('h1')).toBe('the answer is 42');
+	});
 });
 
 test.describe('Load', () => {
@@ -233,6 +253,14 @@ test.describe('Static files', () => {
 	});
 });
 
+test.describe('setHeaders', () => {
+	test('allows multiple set-cookie headers with different values', async ({ page }) => {
+		const response = await page.goto('/headers/set-cookie/sub');
+		const cookies = (await response?.allHeaders())['set-cookie'];
+		expect(cookies.includes('cookie1=value1') && cookies.includes('cookie2=value2')).toBe(true);
+	});
+});
+
 test.describe('Miscellaneous', () => {
 	test('does not serve version.json with an immutable cache header', async ({ request }) => {
 		// this isn't actually a great test, because caching behaviour is down to adapters.