Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/kit/src/core/sync/write_client_manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'}';
`
);
Expand Down
4 changes: 3 additions & 1 deletion packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)}));` : ''}
Expand All @@ -79,6 +80,7 @@ export async function get_hooks() {
handleError,
reroute,
init,
serialize
};
}

Expand Down
4 changes: 2 additions & 2 deletions packages/kit/src/runtime/app/forms.js
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/runtime/client/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export type NavigationFinished = {
state: NavigationState;
props: {
constructors: Array<typeof SvelteComponent>;
components?: Array<SvelteComponent>;
components?: SvelteComponent[];
page: Page;
form?: Record<string, any> | null;
[key: `data_${number}`]: Record<string, any>;
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/runtime/server/data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
6 changes: 4 additions & 2 deletions packages/kit/src/runtime/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -90,7 +91,8 @@ export class Server {
},
handleError: ({ error }) => console.error(error),
handleFetch: ({ request, fetch }) => fetch(request),
reroute: () => {}
reroute: () => {},
serialize: {}
};
} else {
throw error;
Expand Down
33 changes: 27 additions & 6 deletions packages/kit/src/runtime/server/page/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<string, (value: any) => 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<string, (value: any) => 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);
}

/**
Expand Down
33 changes: 24 additions & 9 deletions packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was is necessary after all due to the dynamic imports? If so we should add a comment here stating why this is necessary.

Copy link
Member

@Rich-Harris Rich-Harris Dec 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels suuuuuper hacky, surely we can find another approach?

return;
}
const { fulfil, reject } = deferred.get(id);
deferred.delete(id);
if (error) reject(error);
else fulfil(data);
}
try_to_resolve();
}`);
}

Expand All @@ -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
);
}

Expand All @@ -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}`
];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)})`;
}
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,14 @@ export interface ServerHooks {
handle: Handle;
handleError: HandleServerError;
reroute: Reroute;
serialize: Record<string, (value: any) => any>;
init?: ServerInit;
}

export interface ClientHooks {
handleError: HandleClientError;
reroute: Reroute;
deserialize: Record<string, (value: any) => any>;
init?: ClientInit;
}

Expand Down
7 changes: 7 additions & 0 deletions packages/kit/test/apps/basics/src/hooks.client.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { env } from '$env/dynamic/public';
import { Foo } from './lib';

window.PUBLIC_DYNAMIC = env.PUBLIC_DYNAMIC;

Expand All @@ -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');
}
5 changes: 5 additions & 0 deletions packages/kit/test/apps/basics/src/hooks.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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();
}
5 changes: 5 additions & 0 deletions packages/kit/test/apps/basics/src/lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class Foo {
bar() {
return 'It works!';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Foo } from '../../lib';

export function load() {
return { foo: new Foo() };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script lang="ts">
import type { PageData } from './$types';

let { data }: { data: PageData } = $props();
</script>

<h1>{data.foo.bar()}</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Foo } from '../../lib';

/** @satisfies {import('./$types').Actions} */
export const actions = {
default: async () => {
return { foo: new Foo() };
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script lang="ts">
let { form } = $props();
</script>

<form method="POST">
<button type="submit">submit</button>
</form>

<h1>{form?.foo?.bar()}</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Foo } from '../../lib';

/** @satisfies {import('./$types').Actions} */
export const actions = {
default: async () => {
return { foo: new Foo() };
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script lang="ts">
import { enhance } from '$app/forms';

let { form } = $props();
</script>

<form method="POST" use:enhance>
<button type="submit">submit</button>
</form>

{#if form}
<h1>{form?.foo?.bar()}</h1>
{/if}

<a href="/serialization-basic">To basic form</a>
25 changes: 25 additions & 0 deletions packages/kit/test/apps/basics/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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!');
});
});
Loading