Repository: sveltejs/kit (js)

Issue:
Use devalue with form actions
### Describe the problem

Some objects, like Date, can be persisted on the client using the `load()` in a `+page.server.js`. The same is not true of Actions in the same file. 

This can be quite confusing. Because they are in the same file, even though load and Actions are not the same, many people consider them to work basically the same because of how similar they are. Not to mention how really similar they were before form actions.

It is also quite inconvenient for the developer. You can easily imagine a situation where in the load function you are sending the client a list of objects. One of the keys in the object is a Date. Let's say `lastModified`. Now, on the same page you fill out a form that creates a new record. You want to use progressive enhancement, so you create an enhance function where you are going to receive the new record, so you can display it alongside the existing ones. But wait, the returned record is not quite the same as the records you got from the initial load because you did not receive a Date. Instead you got some sort of string representing it. Now you have to deal with parsing it. Or, you go back to the load function and make it also just give a string, so you don't have to worry about the different behavior. 

Both solutions make some sense, but now that you have two ways of doing something, both are probably going to be used throughout the code base. Making it harder to maintain overall.

### Describe the proposed solution

Use devalue to persist some objects from the server to the client when using Actions in the same way that is already done using `load()`

### Alternatives considered

Leave as is and make devs deal with the poor dx. 🤷🏻 

### Importance

would make my life easier

### Additional Information

Example: https://github.com/CalebBassham/repro-sk-actions-devalue/blob/master/src/routes/%2Bpage.server.js

Pull request description:
[breaking] use devalue to (de)serialize action data
This is a breaking change for everyone who fetches the actions directly and doesn't go through `use:enhance` (so hopefully close to noone).

```diff
+ import { deserialize } from '$app/forms';

const response = await fetch(action, {
	method: 'POST',
	headers: {
		accept: 'application/json',
		'x-sveltekit-action': 'true'
	},
	cache: 'no-store',
	body: data,
});

- result = await response.json();
+ result = deserialize(await response.text());
```

Allows for Date objects and more be part of the response
Closes #7488

Draft because we first need to decide if we really want this, and if we do, I need to update the types. The public type should stay the same, but we need to adjust the usages and accomodate for the fact that `data` is a `string` until parsed.

### 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
- [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/angry-windows-smell.md b/.changeset/angry-windows-smell.md
new file mode 100644
index 000000000..5b14f7176
--- /dev/null
+++ b/.changeset/angry-windows-smell.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[fix] safely join url segments in manifest
diff --git a/.changeset/cool-emus-drive.md b/.changeset/cool-emus-drive.md
new file mode 100644
index 000000000..3df218b3c
--- /dev/null
+++ b/.changeset/cool-emus-drive.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[breaking] use devalue to (de)serialize action data
diff --git a/documentation/docs/20-core-concepts/30-form-actions.md b/documentation/docs/20-core-concepts/30-form-actions.md
index d204ab26e..08a56262a 100644
--- a/documentation/docs/20-core-concepts/30-form-actions.md
+++ b/documentation/docs/20-core-concepts/30-form-actions.md
@@ -389,7 +389,7 @@ We can also implement progressive enhancement ourselves, without `use:enhance`,
 /// file: src/routes/login/+page.svelte
 <script>
 	import { invalidateAll, goto } from '$app/navigation';
-	import { applyAction } from '$app/forms';
+	import { applyAction, deserialize } from '$app/forms';
 
 	/** @type {import('./$types').ActionData} */
 	export let form;
@@ -406,7 +406,7 @@ We can also implement progressive enhancement ourselves, without `use:enhance`,
 		});
 
 		/** @type {import('@sveltejs/kit').ActionResult} */
-		const result = await response.json();
+		const result = deserialize(await response.text());
 
 		if (result.type === 'success') {
 			// re-run all `load` functions, following the successful update
@@ -422,6 +422,8 @@ We can also implement progressive enhancement ourselves, without `use:enhance`,
 </form>
 ```
 
+Note that you need to `deserialize` the response before processing it further using the corresponding method from `$app/forms`. `JSON.parse()` isn't enough because form actions - like `load` functions - also support returning `Date` or `BigInt` objects.
+
 If you have a `+server.js` alongside your `+page.server.js`, `fetch` requests will be routed there by default. To `POST` to an action in `+page.server.js` instead, use the custom `x-sveltekit-action` header:
 
 ```diff
diff --git a/documentation/docs/30-advanced/10-advanced-routing.md b/documentation/docs/30-advanced/10-advanced-routing.md
index d60302bb0..92eac003c 100644
--- a/documentation/docs/30-advanced/10-advanced-routing.md
+++ b/documentation/docs/30-advanced/10-advanced-routing.md
@@ -184,11 +184,15 @@ src/routes/
 
 You can also put a `+page` directly inside a `(group)`, for example if `/` should be an `(app)` or a `(marketing)` page.
 
-Pages and layouts inside groups — as in any other directory — will inherit layouts above them, unless they _break out_ of the layout hierarchy as shown in the next section. In the above example, `(app)/+layout.svelte` and `(marketing)/+layout.svelte` both inherit `+layout.svelte`.
+#### Breaking out of layouts
+
+The root layout applies to every page of your app — if omitted, it defaults to `<slot />`. If you want some pages to have a different layout hierarchy than the rest, then you can put your entire app inside one or more groups _except_ the routes that should not inherit the common layouts.
+
+In the example above, the `/admin` route does not inherit either the `(app)` or `(marketing)` layouts.
 
 #### +page@
 
-Conversely, some routes of your app might need to break out of the layout hierarchy. Let's add an `/item/[id]/embed` route inside the `(app)` group from the previous example:
+Pages can break out of the current layout hierarchy on a route-by-route basis. Suppose we have an `/item/[id]/embed` route inside the `(app)` group from the previous example:
 
 ```diff
 src/routes/
@@ -204,6 +208,7 @@ src/routes/
 ```
 
 Ordinarily, this would inherit the root layout, the `(app)` layout, the `item` layout and the `[id]` layout. We can reset to one of those layouts by appending `@` followed by the segment name — or, for the root layout, the empty string. In this example, we can choose from the following options:
+
 - `+page@[id].svelte` - inherits from `src/routes/(app)/item/[id]/+layout.svelte`
 - `+page@item.svelte` - inherits from `src/routes/(app)/item/+layout.svelte`
 - `+page@(app).svelte` - inherits from `src/routes/(app)/+layout.svelte`
@@ -222,8 +227,6 @@ src/routes/
 └ +layout.svelte
 ```
 
-There is no way to break out of the root layout. You can be sure that it's always present in your app and for example put app-wide UI or behavior in it.
-
 #### +layout@
 
 Like pages, layouts can _themselves_ break out of their parent layout hierarchy, using the same technique. For example, a `+layout@.svelte` component would reset the hierarchy for all its child routes.
@@ -235,7 +238,7 @@ src/routes/
 │ │ ├ [id]/
 │ │ │ ├ embed/
 │ │ │ │ └ +page.svelte  // uses (app)/item/[id]/+layout.svelte
-│ │ │ └ +layout.svelte  // inherits from (app)/item/+layout@.svelte
+│ │ │ ├ +layout.svelte  // inherits from (app)/item/+layout@.svelte
 │ │ │ └ +page.svelte    // uses (app)/item/+layout@.svelte
 │ │ └ +layout@.svelte   // inherits from root layout, skipping (app)/+layout.svelte
 │ └ +layout.svelte
diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js
index 6445e887f..429fd27cc 100644
--- a/packages/kit/src/core/generate_manifest/index.js
+++ b/packages/kit/src/core/generate_manifest/index.js
@@ -2,6 +2,7 @@ import { s } from '../../utils/misc.js';
 import { get_mime_lookup } from '../utils.js';
 import { resolve_symlinks } from '../../exports/vite/build/utils.js';
 import { compact } from '../../utils/array.js';
+import { join_relative } from '../../utils/filesystem.js';
 
 /**
  * Generates the data used to write the server-side manifest.js file. This data is used in the Vite
@@ -37,7 +38,7 @@ export function generate_manifest({ build_data, relative_path, routes, format =
 		build_data.manifest_data.nodes.map((_, i) => {
 			if (used_nodes.has(i)) {
 				reindexed.set(i, reindexed.size);
-				return `${relative_path}/nodes/${i}.js`;
+				return join_relative(relative_path, `/nodes/${i}.js`);
 			}
 		})
 	);
@@ -48,7 +49,7 @@ export function generate_manifest({ build_data, relative_path, routes, format =
 
 	build_data.manifest_data.nodes.forEach((node, i) => {
 		bundled_nodes.set(node, {
-			path: `${relative_path}/nodes/${i}.js`,
+			path: join_relative(relative_path, `/nodes/${i}.js`),
 			index: i
 		});
 	});
@@ -111,12 +112,12 @@ export function generate_manifest({ build_data, relative_path, routes, format =
 					types: ${s(route.types)},
 					optional: ${s(route.optional)},
 					page: ${route.page ? `{ layouts: ${get_nodes(route.page.layouts)}, errors: ${get_nodes(route.page.errors)}, leaf: ${reindexed.get(route.page.leaf)} }` : 'null'},
-					endpoint: ${route.endpoint ? loader(`${relative_path}/${resolve_symlinks(build_data.server.vite_manifest, route.endpoint.file).chunk.file}`) : 'null'}
+					endpoint: ${route.endpoint ? loader(join_relative(relative_path, resolve_symlinks(build_data.server.vite_manifest, route.endpoint.file).chunk.file)) : 'null'}
 				}`;
 				}).filter(Boolean).join(',\n\t\t\t\t')}
 			],
 			matchers: async () => {
-				${Array.from(matchers).map(type => `const { match: ${type} } = await ${load(`${relative_path}/entries/matchers/${type}.js`)}`).join('\n\t\t\t\t')}
+				${Array.from(matchers).map(type => `const { match: ${type} } = await ${load(join_relative(relative_path, `/entries/matchers/${type}.js`))}`).join('\n\t\t\t\t')}
 				return { ${Array.from(matchers).join(', ')} };
 			}
 		}
diff --git a/packages/kit/src/runtime/app/forms.js b/packages/kit/src/runtime/app/forms.js
index 6b1cbacb5..7a0f97539 100644
--- a/packages/kit/src/runtime/app/forms.js
+++ b/packages/kit/src/runtime/app/forms.js
@@ -1,5 +1,6 @@
-import { invalidateAll } from './navigation.js';
+import * as devalue from 'devalue';
 import { client } from '../client/singletons.js';
+import { invalidateAll } from './navigation.js';
 
 /**
  * @param {string} name
@@ -15,6 +16,15 @@ const ssr = import.meta.env.SSR;
 /** @type {import('$app/forms').applyAction} */
 export const applyAction = ssr ? guard('applyAction') : client.apply_action;
 
+/** @type {import('$app/forms').deserialize} */
+export function deserialize(result) {
+	const parsed = JSON.parse(result);
+	if (parsed.data) {
+		parsed.data = devalue.parse(parsed.data);
+	}
+	return parsed;
+}
+
 /** @type {import('$app/forms').enhance} */
 export function enhance(form, submit = () => {}) {
 	/**
@@ -93,7 +103,7 @@ export function enhance(form, submit = () => {}) {
 				signal: controller.signal
 			});
 
-			result = await response.json();
+			result = deserialize(await response.text());
 		} catch (error) {
 			if (/** @type {any} */ (error)?.name === 'AbortError') return;
 			result = { type: 'error', error };
diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js
index d2dfb14a0..e5b799813 100644
--- a/packages/kit/src/runtime/server/page/actions.js
+++ b/packages/kit/src/runtime/server/page/actions.js
@@ -1,3 +1,4 @@
+import * as devalue from 'devalue';
 import { error, json } from '../../../exports/index.js';
 import { normalize_error } from '../../../utils/error.js';
 import { is_form_content_type, negotiate } from '../../../utils/http.js';
@@ -41,14 +42,20 @@ export async function handle_action_json_request(event, options, server) {
 		const data = await call_action(event, actions);
 
 		if (data instanceof ValidationError) {
-			check_serializability(data.data, /** @type {string} */ (event.route.id), 'data');
-			return action_json({ type: 'invalid', status: data.status, data: data.data });
+			return action_json({
+				type: 'invalid',
+				status: data.status,
+				// @ts-expect-error we assign a string to what is supposed to be an object. That's ok
+				// because we don't use the object outside, and this way we have better code navigation
+				// through knowing where the related interface is used.
+				data: stringify_action_response(data.data, /** @type {string} */ (event.route.id))
+			});
 		} else {
-			check_serializability(data, /** @type {string} */ (event.route.id), 'data');
 			return action_json({
 				type: 'success',
 				status: data ? 200 : 204,
-				data: /** @type {Record<string, any> | undefined} */ (data)
+				// @ts-expect-error see comment above
+				data: stringify_action_response(data, /** @type {string} */ (event.route.id))
 			});
 		}
 	} catch (e) {
@@ -211,46 +218,41 @@ function maybe_throw_migration_error(server) {
 }
 
 /**
- * Check that the data can safely be serialized to JSON
- * @param {any} value
- * @param {string} id
- * @param {string} path
+ * Try to `devalue.uneval` the data object, and if it fails, return a proper Error with context
+ * @param {any} data
+ * @param {string} route_id
  */
-function check_serializability(value, id, path) {
-	const type = typeof value;
+export function uneval_action_response(data, route_id) {
+	return try_deserialize(data, devalue.uneval, route_id);
+}
 
-	if (type === 'string' || type === 'boolean' || type === 'number' || type === 'undefined') {
-		// primitives are fine
-		return;
-	}
+/**
+ * Try to `devalue.stringify` the data object, and if it fails, return a proper Error with context
+ * @param {any} data
+ * @param {string} route_id
+ */
+function stringify_action_response(data, route_id) {
+	return try_deserialize(data, devalue.stringify, route_id);
+}
 
-	if (type === 'object') {
-		// nulls are fine...
-		if (!value) return;
+/**
+ * @param {any} data
+ * @param {(data: any) => string} fn
+ * @param {string} route_id
+ */
+function try_deserialize(data, fn, route_id) {
+	try {
+		return fn(data);
+	} catch (e) {
+		// If we're here, the data could not be serialized with devalue
+		const error = /** @type {any} */ (e);
 
-		// ...so are plain arrays...
-		if (Array.isArray(value)) {
-			value.forEach((child, i) => {
-				check_serializability(child, id, `${path}[${i}]`);
-			});
-			return;
+		if ('path' in error) {
+			let message = `Data returned from action inside ${route_id} is not serializable: ${error.message}`;
+			if (error.path !== '') message += ` (data.${error.path})`;
+			throw new Error(message);
 		}
 
-		// ...and objects
-		// This simple check might potentially run into some weird edge cases
-		// Refer to https://github.com/lodash/lodash/blob/2da024c3b4f9947a48517639de7560457cd4ec6c/isPlainObject.js?rgh-link-date=2022-07-20T12%3A48%3A07Z#L30
-		// if that ever happens
-		if (Object.getPrototypeOf(value) === Object.prototype) {
-			for (const key in value) {
-				check_serializability(value[key], id, `${path}.${key}`);
-			}
-			return;
-		}
+		throw error;
 	}
-
-	throw new Error(
-		`${path} returned from action in ${id} cannot be serialized as JSON without losing its original type` +
-			// probably the most common case, so let's give a hint
-			(value instanceof Date ? ' (Date objects are serialized as strings)' : '')
-	);
 }
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index f946d0831..43e030216 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -4,6 +4,7 @@ import { hash } from '../../hash.js';
 import { serialize_data } from './serialize_data.js';
 import { s } from '../../../utils/misc.js';
 import { Csp } from './csp.js';
+import { uneval_action_response } from './actions.js';
 import { clarify_devalue_error } from '../utils.js';
 
 // TODO rename this function/module
@@ -201,8 +202,7 @@ export async function render_response({
 	}
 
 	if (form_value) {
-		// no need to check it can be serialized, we already verified that it's JSON-friendly
-		serialized.form = devalue.uneval(form_value);
+		serialized.form = uneval_action_response(form_value, /** @type {string} */ (event.route.id));
 	}
 
 	if (inline_styles.size > 0) {
diff --git a/packages/kit/src/utils/filesystem.js b/packages/kit/src/utils/filesystem.js
index f4030e803..4863562fe 100644
--- a/packages/kit/src/utils/filesystem.js
+++ b/packages/kit/src/utils/filesystem.js
@@ -112,6 +112,18 @@ export function posixify(str) {
 	return str.replace(/\\/g, '/');
 }
 
+/**
+ * Like `path.join`, but posixified and with a leading ./ if necessary
+ * @param {string[]} str
+ */
+export function join_relative(...str) {
+	let result = posixify(path.join(...str));
+	if (!result.startsWith('.')) {
+		result = `./${result}`;
+	}
+	return result;
+}
+
 /**
  * Prepend given path with `/@fs` prefix
  * @param {string} str
diff --git a/packages/kit/types/ambient.d.ts b/packages/kit/types/ambient.d.ts
index c5a14c3a0..d10b0e329 100644
--- a/packages/kit/types/ambient.d.ts
+++ b/packages/kit/types/ambient.d.ts
@@ -157,6 +157,19 @@ declare module '$app/forms' {
 		Success extends Record<string, unknown> | undefined = Record<string, any>,
 		Invalid extends Record<string, unknown> | undefined = Record<string, any>
 	>(result: ActionResult<Success, Invalid>): Promise<void>;
+
+	/**
+	 * Use this function to deserialize the response from a form submission.
+	 * Usage:
+	 * ```
+	 * const res = await fetch('/form?/action', { method: 'POST', body: formData });
+	 * const result = deserialize(await res.text());
+	 * ```
+	 */
+	export function deserialize<
+		Success extends Record<string, unknown> | undefined = Record<string, any>,
+		Invalid extends Record<string, unknown> | undefined = Record<string, any>
+	>(serialized: string): ActionResult<Success, Invalid>;
 }
 
 /**


Test patch:
diff --git a/packages/kit/test/apps/basics/src/routes/actions/form-errors-persist-fields/+page.svelte b/packages/kit/test/apps/basics/src/routes/actions/form-errors-persist-fields/+page.svelte
index 043cec6ba..5f0eff1cd 100644
--- a/packages/kit/test/apps/basics/src/routes/actions/form-errors-persist-fields/+page.svelte
+++ b/packages/kit/test/apps/basics/src/routes/actions/form-errors-persist-fields/+page.svelte
@@ -1,4 +1,5 @@
 <script>
+	import { deserialize } from '$app/forms';
 	import { browser } from '$app/environment';
 
 	/** @type {import('./$types').ActionData} */
@@ -14,10 +15,9 @@
 				accept: 'application/json'
 			}
 		});
-		const {
-			data: { errors, values }
-		} = await res.json();
-		form = { errors, values };
+		// @ts-expect-error don't bother with type narrowing work here
+		const { data } = deserialize(await res.text());
+		form = data;
 	}
 </script>
 
diff --git a/packages/kit/test/apps/basics/src/routes/actions/success-data/+page.svelte b/packages/kit/test/apps/basics/src/routes/actions/success-data/+page.svelte
index e784d2b20..c24b43e73 100644
--- a/packages/kit/test/apps/basics/src/routes/actions/success-data/+page.svelte
+++ b/packages/kit/test/apps/basics/src/routes/actions/success-data/+page.svelte
@@ -1,18 +1,22 @@
 <script>
+	import { deserialize } from '$app/forms';
+
 	/** @type {import('./$types').ActionData} */
 	export let form;
 
 	async function submit({ submitter }) {
 		const res = await fetch(this.action, {
 			method: 'POST',
-			body: submitter.getAttribute('formenctype') === 'multipart/form-data'
-				? new FormData(this)
-				: new URLSearchParams({ username: this['username'].value }),
+			body:
+				submitter.getAttribute('formenctype') === 'multipart/form-data'
+					? new FormData(this)
+					: new URLSearchParams({ username: this['username'].value }),
 			headers: {
 				accept: 'application/json'
 			}
 		});
-		const { data } = await res.json();
+		// @ts-expect-error don't bother with type narrowing work here
+		const { data } = deserialize(await res.text());
 		form = data;
 	}
 </script>
diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js
index 44c8a52fe..2f56b508a 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -360,7 +360,7 @@ test.describe('Shadowed pages', () => {
 		});
 
 		expect(response.status()).toBe(200);
-		expect(await response.json()).toEqual({ type: 'success', status: 204 });
+		expect(await response.json()).toEqual({ data: '-1', type: 'success', status: 204 });
 	});
 });