Skip to content
Closed
Show file tree
Hide file tree
Changes from 16 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,14 +147,20 @@ 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) }),

reroute: ${universal_hooks_file ? 'universal_hooks.reroute || ' : ''}(() => {})
reroute: ${universal_hooks_file ? 'universal_hooks.reroute || ' : ''}(() => {}),

deserialize: deserializers
};

export const deserialize = (type, value) => deserializers[type]?.(value) ?? null;

export { default as root } from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}';
`
);
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
2 changes: 1 addition & 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
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
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 @@ -71,7 +71,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 || {}
};
} catch (error) {
if (DEV) {
Expand All @@ -81,7 +82,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
31 changes: 22 additions & 9 deletions packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,11 +322,17 @@ export async function render_response({
})`);

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 +348,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 +362,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 +537,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 +579,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 @@ -109,11 +109,13 @@ export interface ServerHooks {
handle: Handle;
handleError: HandleServerError;
reroute: Reroute;
serialize: Record<string, (value: any) => any>;
}

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

export interface Env {
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 @@ -8,3 +9,9 @@ export function handleError({ error, event, status, message }) {
? undefined
: { message: `${/** @type {Error} */ (error).message} (${status} ${message})` };
}

export const deserialize = {
Foo() {
return new Foo();
}
};
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 fs from 'node:fs';
import { sequence } from '@sveltejs/kit/hooks';
import { error, isHttpError, redirect } from '@sveltejs/kit';
import { COOKIE_NAME } from './routes/cookies/shared';
import { Foo } from './lib';

/**
* Transform an error into a POJO, by copying its `name`, `message`
Expand Down Expand Up @@ -154,3 +155,7 @@ export async function handleFetch({ request, fetch }) {

return fetch(request);
}

export const serialize = {
Foo: (value) => value instanceof Foo && {}
};
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,13 @@
<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}
21 changes: 21 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,24 @@ 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('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