diff --git a/packages/kit/src/core/sync/write_client_manifest.js b/packages/kit/src/core/sync/write_client_manifest.js index fe6127de934c..9648373bd8bb 100644 --- a/packages/kit/src/core/sync/write_client_manifest.js +++ b/packages/kit/src/core/sync/write_client_manifest.js @@ -147,15 +147,21 @@ export function write_client_manifest(kit, manifest_data, output, metadata) { export const dictionary = ${dictionary}; + const deserializers = ${client_hooks_file ? 'client_hooks.deserialize || ' : ''}{}; + export const hooks = { handleError: ${ client_hooks_file ? 'client_hooks.handleError || ' : '' }(({ error }) => { console.error(error) }), ${client_hooks_file ? 'init: client_hooks.init,' : ''} - reroute: ${universal_hooks_file ? 'universal_hooks.reroute || ' : ''}(() => {}) + reroute: ${universal_hooks_file ? 'universal_hooks.reroute || ' : ''}(() => {}), + + deserialize: deserializers }; + export const deserialize = (type, value) => deserializers[type](value); + export { default as root } from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}'; ` ); diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index 5dabea1f1c44..e0aa84b4d943 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -68,7 +68,8 @@ export async function get_hooks() { let handleFetch; let handleError; let init; - ${server_hooks ? `({ handle, handleFetch, handleError, init } = await import(${s(server_hooks)}));` : ''} + let serialize; + ${server_hooks ? `({ handle, handleFetch, handleError, init, serialize } = await import(${s(server_hooks)}));` : ''} let reroute; ${universal_hooks ? `({ reroute } = await import(${s(universal_hooks)}));` : ''} @@ -79,6 +80,7 @@ export async function get_hooks() { handleError, reroute, init, + serialize }; } diff --git a/packages/kit/src/runtime/app/forms.js b/packages/kit/src/runtime/app/forms.js index bdb4f0fa8321..2f63c235b907 100644 --- a/packages/kit/src/runtime/app/forms.js +++ b/packages/kit/src/runtime/app/forms.js @@ -1,7 +1,7 @@ import * as devalue from 'devalue'; import { DEV } from 'esm-env'; import { invalidateAll } from './navigation.js'; -import { applyAction } from '../client/client.js'; +import { app, applyAction } from '../client/client.js'; export { applyAction }; @@ -30,7 +30,7 @@ export { applyAction }; export function deserialize(result) { const parsed = JSON.parse(result); if (parsed.data) { - parsed.data = devalue.parse(parsed.data); + parsed.data = devalue.parse(parsed.data, app.hooks.deserialize); } return parsed; } diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 17469be00511..912bd2a18449 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -174,7 +174,7 @@ let container; /** @type {HTMLElement} */ let target; /** @type {import('./types.js').SvelteKitApp} */ -let app; +export let app; /** @type {Array<((url: URL) => boolean)>} */ const invalidated = []; @@ -2493,6 +2493,7 @@ async function load_data(url, invalid) { */ function deserialize(data) { return devalue.unflatten(data, { + ...app.hooks.deserialize, Promise: (id) => { return new Promise((fulfil, reject) => { deferreds.set(id, { fulfil, reject }); diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 50a78a049f1c..d907c9015142 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -54,7 +54,7 @@ export type NavigationFinished = { state: NavigationState; props: { constructors: Array; - components?: Array; + components?: SvelteComponent[]; page: Page; form?: Record | null; [key: `data_${number}`]: Record; diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index b278ee31873b..9c4c598aa4a7 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -197,6 +197,7 @@ export function get_data_json(event, options, nodes) { const { iterator, push, done } = create_async_iterator(); const reducers = { + ...options.hooks.serialize, /** @param {any} thing */ Promise: (thing) => { if (typeof thing?.then === 'function') { diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 5aa10ac560f5..e36e5570449c 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -76,7 +76,8 @@ export class Server { handle: module.handle || (({ event, resolve }) => resolve(event)), handleError: module.handleError || (({ error }) => console.error(error)), handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)), - reroute: module.reroute || (() => {}) + reroute: module.reroute || (() => {}), + serialize: module.serialize || {} }; if (module.init) { @@ -90,7 +91,8 @@ export class Server { }, handleError: ({ error }) => console.error(error), handleFetch: ({ request, fetch }) => fetch(request), - reroute: () => {} + reroute: () => {}, + serialize: {} }; } else { throw error; diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js index 97e01e34d770..b5686ce59d1a 100644 --- a/packages/kit/src/runtime/server/page/actions.js +++ b/packages/kit/src/runtime/server/page/actions.js @@ -61,14 +61,22 @@ export async function handle_action_json_request(event, options, server) { // @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)) + data: stringify_action_response( + data.data, + /** @type {string} */ (event.route.id), + options.hooks.serialize + ) }); } else { return action_json({ type: 'success', status: data ? 200 : 204, // @ts-expect-error see comment above - data: stringify_action_response(data, /** @type {string} */ (event.route.id)) + data: stringify_action_response( + data, + /** @type {string} */ (event.route.id), + options.hooks.serialize + ) }); } } catch (e) { @@ -254,18 +262,31 @@ function validate_action_return(data) { * Try to `devalue.uneval` the data object, and if it fails, return a proper Error with context * @param {any} data * @param {string} route_id + * @param {Record any>} serializers */ -export function uneval_action_response(data, route_id) { - return try_deserialize(data, devalue.uneval, route_id); +export function uneval_action_response(data, route_id, serializers) { + const replacer = (/** @type {any} */ thing) => { + if (serializers) { + for (const key in serializers) { + const serialized = serializers[key](thing); + if (serialized) { + return `app.deserialize('${key}', ${devalue.uneval(serialized, replacer)})`; + } + } + } + }; + + return try_deserialize(data, (value) => devalue.uneval(value, replacer), route_id); } /** * Try to `devalue.stringify` the data object, and if it fails, return a proper Error with context * @param {any} data * @param {string} route_id + * @param {Record any>} serializers */ -function stringify_action_response(data, route_id) { - return try_deserialize(data, devalue.stringify, route_id); +function stringify_action_response(data, route_id, serializers) { + return try_deserialize(data, (value) => devalue.stringify(value, serializers), route_id); } /** diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 6197d5ba63c6..49304c3e9407 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -321,12 +321,20 @@ export async function render_response({ deferred.set(id, { fulfil, reject }); })`); + // When resolving, the id might not yet be available due to the data + // be evaluated upon init of kit, so we use a timeout to retry properties.push(`resolve: ({ id, data, error }) => { - const { fulfil, reject } = deferred.get(id); - deferred.delete(id); - - if (error) reject(error); - else fulfil(data); + const try_to_resolve = () => { + if (!deferred.has(id)) { + setTimeout(try_to_resolve, 0); + return; + } + const { fulfil, reject } = deferred.get(id); + deferred.delete(id); + if (error) reject(error); + else fulfil(data); + } + try_to_resolve(); }`); } @@ -342,12 +350,11 @@ export async function render_response({ if (page_config.ssr) { const serialized = { form: 'null', error: 'null' }; - blocks.push(`const data = ${data};`); - if (form_value) { serialized.form = uneval_action_response( form_value, - /** @type {string} */ (event.route.id) + /** @type {string} */ (event.route.id), + options.hooks.serialize ); } @@ -357,7 +364,7 @@ export async function render_response({ const hydrate = [ `node_ids: [${branch.map(({ node }) => node.index).join(', ')}]`, - 'data', + `data: ${data}`, `form: ${serialized.form}`, `error: ${serialized.error}` ]; @@ -532,6 +539,7 @@ function get_data(event, options, nodes, csp, global) { let count = 0; const { iterator, push, done } = create_async_iterator(); + const serializers = options.hooks.serialize; /** @param {any} thing */ function replacer(thing) { @@ -573,6 +581,13 @@ function get_data(event, options, nodes, csp, global) { ); return `${global}.defer(${id})`; + } else { + for (const key in serializers) { + const serialized = serializers[key](thing); + if (serialized) { + return `app.deserialize('${key}', ${devalue.uneval(serialized, replacer)})`; + } + } } } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index cd7b1518b86a..8998c1a97907 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -111,12 +111,14 @@ export interface ServerHooks { handle: Handle; handleError: HandleServerError; reroute: Reroute; + serialize: Record any>; init?: ServerInit; } export interface ClientHooks { handleError: HandleClientError; reroute: Reroute; + deserialize: Record any>; init?: ClientInit; } diff --git a/packages/kit/test/apps/basics/src/hooks.client.js b/packages/kit/test/apps/basics/src/hooks.client.js index 3749d91b5aa3..57e4603d8292 100644 --- a/packages/kit/test/apps/basics/src/hooks.client.js +++ b/packages/kit/test/apps/basics/src/hooks.client.js @@ -1,4 +1,5 @@ import { env } from '$env/dynamic/public'; +import { Foo } from './lib'; window.PUBLIC_DYNAMIC = env.PUBLIC_DYNAMIC; @@ -9,6 +10,12 @@ export function handleError({ error, event, status, message }) { : { message: `${/** @type {Error} */ (error).message} (${status} ${message})` }; } +export const deserialize = { + Foo() { + return new Foo(); + } +}; + export function init() { console.log('init hooks.client.js'); } diff --git a/packages/kit/test/apps/basics/src/hooks.server.js b/packages/kit/test/apps/basics/src/hooks.server.js index e485d038d995..0439ea2aa65f 100644 --- a/packages/kit/test/apps/basics/src/hooks.server.js +++ b/packages/kit/test/apps/basics/src/hooks.server.js @@ -2,6 +2,7 @@ import { error, isHttpError, redirect } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; import fs from 'node:fs'; import { COOKIE_NAME } from './routes/cookies/shared'; +import { Foo } from './lib'; import { _set_from_init } from './routes/init-hooks/+page.server'; /** @@ -156,6 +157,10 @@ export async function handleFetch({ request, fetch }) { return fetch(request); } +export const serialize = { + Foo: (value) => value instanceof Foo && {} +}; + export function init() { _set_from_init(); } diff --git a/packages/kit/test/apps/basics/src/lib/index.js b/packages/kit/test/apps/basics/src/lib/index.js new file mode 100644 index 000000000000..c438bacfb444 --- /dev/null +++ b/packages/kit/test/apps/basics/src/lib/index.js @@ -0,0 +1,5 @@ +export class Foo { + bar() { + return 'It works!'; + } +} diff --git a/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.server.js b/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.server.js new file mode 100644 index 000000000000..82597e515582 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.server.js @@ -0,0 +1,5 @@ +import { Foo } from '../../lib'; + +export function load() { + return { foo: new Foo() }; +} diff --git a/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.svelte b/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.svelte new file mode 100644 index 000000000000..ea812933bc2c --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-basic/+page.svelte @@ -0,0 +1,7 @@ + + +

{data.foo.bar()}

diff --git a/packages/kit/test/apps/basics/src/routes/serialization-form/+page.server.js b/packages/kit/test/apps/basics/src/routes/serialization-form/+page.server.js new file mode 100644 index 000000000000..3e03c614a4c0 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-form/+page.server.js @@ -0,0 +1,8 @@ +import { Foo } from '../../lib'; + +/** @satisfies {import('./$types').Actions} */ +export const actions = { + default: async () => { + return { foo: new Foo() }; + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/serialization-form/+page.svelte b/packages/kit/test/apps/basics/src/routes/serialization-form/+page.svelte new file mode 100644 index 000000000000..4797a1b1a4cb --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-form/+page.svelte @@ -0,0 +1,9 @@ + + +
+ +
+ +

{form?.foo?.bar()}

diff --git a/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.server.js b/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.server.js new file mode 100644 index 000000000000..3e03c614a4c0 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.server.js @@ -0,0 +1,8 @@ +import { Foo } from '../../lib'; + +/** @satisfies {import('./$types').Actions} */ +export const actions = { + default: async () => { + return { foo: new Foo() }; + } +}; diff --git a/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.svelte b/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.svelte new file mode 100644 index 000000000000..4549aab48155 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/serialization-form2/+page.svelte @@ -0,0 +1,15 @@ + + +
+ +
+ +{#if form} +

{form?.foo?.bar()}

+{/if} + +To basic form diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index fb21412290d6..8ce91e563dc4 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1379,3 +1379,28 @@ test.describe.serial('Cookies API', () => { expect(await span.innerText()).toContain('undefined'); }); }); + +test.describe('Serialization', () => { + test('A custom data type can be serialized/deserialized', async ({ page }) => { + await page.goto('/serialization-basic'); + expect(await page.textContent('h1')).toBe('It works!'); + }); + + test('A custom data type can be serialized/deserialized on POST', async ({ page }) => { + await page.goto('/serialization-form'); + await page.click('button'); + expect(await page.textContent('h1')).toBe('It works!'); + + // Test navigating to the basic page works as intended + await page.locator('a').first(); + expect(await page.textContent('h1')).toBe('It works!'); + }); + + test('A custom data type can be serialized/deserialized on POST with use:enhance', async ({ + page + }) => { + await page.goto('/serialization-form2'); + await page.click('button'); + expect(await page.textContent('h1')).toBe('It works!'); + }); +});