Skip to content

Commit 8808beb

Browse files
authored
[breaking] use devalue to (de)serialize action data (#7494)
Allows for Date objects and more be part of the response Closes #7488
1 parent ba4f391 commit 8808beb

File tree

9 files changed

+90
-54
lines changed

9 files changed

+90
-54
lines changed

.changeset/cool-emus-drive.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
[breaking] use devalue to (de)serialize action data

documentation/docs/20-core-concepts/30-form-actions.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ We can also implement progressive enhancement ourselves, without `use:enhance`,
389389
/// file: src/routes/login/+page.svelte
390390
<script>
391391
import { invalidateAll, goto } from '$app/navigation';
392-
import { applyAction } from '$app/forms';
392+
import { applyAction, deserialize } from '$app/forms';
393393
394394
/** @type {import('./$types').ActionData} */
395395
export let form;
@@ -406,7 +406,7 @@ We can also implement progressive enhancement ourselves, without `use:enhance`,
406406
});
407407
408408
/** @type {import('@sveltejs/kit').ActionResult} */
409-
const result = await response.json();
409+
const result = deserialize(await response.text());
410410
411411
if (result.type === 'success') {
412412
// re-run all `load` functions, following the successful update
@@ -422,6 +422,8 @@ We can also implement progressive enhancement ourselves, without `use:enhance`,
422422
</form>
423423
```
424424

425+
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.
426+
425427
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:
426428

427429
```diff

packages/kit/src/runtime/app/forms.js

+12-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { invalidateAll } from './navigation.js';
1+
import * as devalue from 'devalue';
22
import { client } from '../client/singletons.js';
3+
import { invalidateAll } from './navigation.js';
34

45
/**
56
* @param {string} name
@@ -15,6 +16,15 @@ const ssr = import.meta.env.SSR;
1516
/** @type {import('$app/forms').applyAction} */
1617
export const applyAction = ssr ? guard('applyAction') : client.apply_action;
1718

19+
/** @type {import('$app/forms').deserialize} */
20+
export function deserialize(result) {
21+
const parsed = JSON.parse(result);
22+
if (parsed.data) {
23+
parsed.data = devalue.parse(parsed.data);
24+
}
25+
return parsed;
26+
}
27+
1828
/** @type {import('$app/forms').enhance} */
1929
export function enhance(form, submit = () => {}) {
2030
/**
@@ -93,7 +103,7 @@ export function enhance(form, submit = () => {}) {
93103
signal: controller.signal
94104
});
95105

96-
result = await response.json();
106+
result = deserialize(await response.text());
97107
} catch (error) {
98108
if (/** @type {any} */ (error)?.name === 'AbortError') return;
99109
result = { type: 'error', error };

packages/kit/src/runtime/server/page/actions.js

+41-39
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as devalue from 'devalue';
12
import { error, json } from '../../../exports/index.js';
23
import { normalize_error } from '../../../utils/error.js';
34
import { is_form_content_type, negotiate } from '../../../utils/http.js';
@@ -41,14 +42,20 @@ export async function handle_action_json_request(event, options, server) {
4142
const data = await call_action(event, actions);
4243

4344
if (data instanceof ValidationError) {
44-
check_serializability(data.data, /** @type {string} */ (event.route.id), 'data');
45-
return action_json({ type: 'invalid', status: data.status, data: data.data });
45+
return action_json({
46+
type: 'invalid',
47+
status: data.status,
48+
// @ts-expect-error we assign a string to what is supposed to be an object. That's ok
49+
// because we don't use the object outside, and this way we have better code navigation
50+
// through knowing where the related interface is used.
51+
data: stringify_action_response(data.data, /** @type {string} */ (event.route.id))
52+
});
4653
} else {
47-
check_serializability(data, /** @type {string} */ (event.route.id), 'data');
4854
return action_json({
4955
type: 'success',
5056
status: data ? 200 : 204,
51-
data: /** @type {Record<string, any> | undefined} */ (data)
57+
// @ts-expect-error see comment above
58+
data: stringify_action_response(data, /** @type {string} */ (event.route.id))
5259
});
5360
}
5461
} catch (e) {
@@ -211,46 +218,41 @@ function maybe_throw_migration_error(server) {
211218
}
212219

213220
/**
214-
* Check that the data can safely be serialized to JSON
215-
* @param {any} value
216-
* @param {string} id
217-
* @param {string} path
221+
* Try to `devalue.uneval` the data object, and if it fails, return a proper Error with context
222+
* @param {any} data
223+
* @param {string} route_id
218224
*/
219-
function check_serializability(value, id, path) {
220-
const type = typeof value;
225+
export function uneval_action_response(data, route_id) {
226+
return try_deserialize(data, devalue.uneval, route_id);
227+
}
221228

222-
if (type === 'string' || type === 'boolean' || type === 'number' || type === 'undefined') {
223-
// primitives are fine
224-
return;
225-
}
229+
/**
230+
* Try to `devalue.stringify` the data object, and if it fails, return a proper Error with context
231+
* @param {any} data
232+
* @param {string} route_id
233+
*/
234+
function stringify_action_response(data, route_id) {
235+
return try_deserialize(data, devalue.stringify, route_id);
236+
}
226237

227-
if (type === 'object') {
228-
// nulls are fine...
229-
if (!value) return;
238+
/**
239+
* @param {any} data
240+
* @param {(data: any) => string} fn
241+
* @param {string} route_id
242+
*/
243+
function try_deserialize(data, fn, route_id) {
244+
try {
245+
return fn(data);
246+
} catch (e) {
247+
// If we're here, the data could not be serialized with devalue
248+
const error = /** @type {any} */ (e);
230249

231-
// ...so are plain arrays...
232-
if (Array.isArray(value)) {
233-
value.forEach((child, i) => {
234-
check_serializability(child, id, `${path}[${i}]`);
235-
});
236-
return;
250+
if ('path' in error) {
251+
let message = `Data returned from action inside ${route_id} is not serializable: ${error.message}`;
252+
if (error.path !== '') message += ` (data.${error.path})`;
253+
throw new Error(message);
237254
}
238255

239-
// ...and objects
240-
// This simple check might potentially run into some weird edge cases
241-
// Refer to https://github.com/lodash/lodash/blob/2da024c3b4f9947a48517639de7560457cd4ec6c/isPlainObject.js?rgh-link-date=2022-07-20T12%3A48%3A07Z#L30
242-
// if that ever happens
243-
if (Object.getPrototypeOf(value) === Object.prototype) {
244-
for (const key in value) {
245-
check_serializability(value[key], id, `${path}.${key}`);
246-
}
247-
return;
248-
}
256+
throw error;
249257
}
250-
251-
throw new Error(
252-
`${path} returned from action in ${id} cannot be serialized as JSON without losing its original type` +
253-
// probably the most common case, so let's give a hint
254-
(value instanceof Date ? ' (Date objects are serialized as strings)' : '')
255-
);
256258
}

packages/kit/src/runtime/server/page/render.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { hash } from '../../hash.js';
44
import { serialize_data } from './serialize_data.js';
55
import { s } from '../../../utils/misc.js';
66
import { Csp } from './csp.js';
7+
import { uneval_action_response } from './actions.js';
78
import { clarify_devalue_error } from '../utils.js';
89

910
// TODO rename this function/module
@@ -201,8 +202,7 @@ export async function render_response({
201202
}
202203

203204
if (form_value) {
204-
// no need to check it can be serialized, we already verified that it's JSON-friendly
205-
serialized.form = devalue.uneval(form_value);
205+
serialized.form = uneval_action_response(form_value, /** @type {string} */ (event.route.id));
206206
}
207207

208208
if (inline_styles.size > 0) {

packages/kit/test/apps/basics/src/routes/actions/form-errors-persist-fields/+page.svelte

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script>
2+
import { deserialize } from '$app/forms';
23
import { browser } from '$app/environment';
34
45
/** @type {import('./$types').ActionData} */
@@ -14,10 +15,9 @@
1415
accept: 'application/json'
1516
}
1617
});
17-
const {
18-
data: { errors, values }
19-
} = await res.json();
20-
form = { errors, values };
18+
// @ts-expect-error don't bother with type narrowing work here
19+
const { data } = deserialize(await res.text());
20+
form = data;
2121
}
2222
</script>
2323

packages/kit/test/apps/basics/src/routes/actions/success-data/+page.svelte

+8-4
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
<script>
2+
import { deserialize } from '$app/forms';
3+
24
/** @type {import('./$types').ActionData} */
35
export let form;
46
57
async function submit({ submitter }) {
68
const res = await fetch(this.action, {
79
method: 'POST',
8-
body: submitter.getAttribute('formenctype') === 'multipart/form-data'
9-
? new FormData(this)
10-
: new URLSearchParams({ username: this['username'].value }),
10+
body:
11+
submitter.getAttribute('formenctype') === 'multipart/form-data'
12+
? new FormData(this)
13+
: new URLSearchParams({ username: this['username'].value }),
1114
headers: {
1215
accept: 'application/json'
1316
}
1417
});
15-
const { data } = await res.json();
18+
// @ts-expect-error don't bother with type narrowing work here
19+
const { data } = deserialize(await res.text());
1620
form = data;
1721
}
1822
</script>

packages/kit/test/apps/basics/test/server.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ test.describe('Shadowed pages', () => {
360360
});
361361

362362
expect(response.status()).toBe(200);
363-
expect(await response.json()).toEqual({ type: 'success', status: 204 });
363+
expect(await response.json()).toEqual({ data: '-1', type: 'success', status: 204 });
364364
});
365365
});
366366

packages/kit/types/ambient.d.ts

+13
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,19 @@ declare module '$app/forms' {
157157
Success extends Record<string, unknown> | undefined = Record<string, any>,
158158
Invalid extends Record<string, unknown> | undefined = Record<string, any>
159159
>(result: ActionResult<Success, Invalid>): Promise<void>;
160+
161+
/**
162+
* Use this function to deserialize the response from a form submission.
163+
* Usage:
164+
* ```
165+
* const res = await fetch('/form?/action', { method: 'POST', body: formData });
166+
* const result = deserialize(await res.text());
167+
* ```
168+
*/
169+
export function deserialize<
170+
Success extends Record<string, unknown> | undefined = Record<string, any>,
171+
Invalid extends Record<string, unknown> | undefined = Record<string, any>
172+
>(serialized: string): ActionResult<Success, Invalid>;
160173
}
161174

162175
/**

0 commit comments

Comments
 (0)