diff --git a/.changeset/four-pens-cheer.md b/.changeset/four-pens-cheer.md new file mode 100644 index 000000000000..fb5d66be6997 --- /dev/null +++ b/.changeset/four-pens-cheer.md @@ -0,0 +1,5 @@ +--- +"@sveltejs/kit": minor +--- + +feat: use the `transport` hook and `devalue` to serialize state in `pushState`/`replaceState` diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 4486d9228215..7f0a7f380527 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -39,7 +39,13 @@ import { } from './constants.js'; import { validate_page_exports } from '../../utils/exports.js'; import { compact } from '../../utils/array.js'; -import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM, validate_depends } from '../shared.js'; +import { + INVALIDATED_PARAM, + stringify, + TRAILING_SLASH_PARAM, + validate_depends, + parse as unstringify +} from '../shared.js'; import { get_message, get_status } from '../../utils/error.js'; import { writable } from 'svelte/store'; import { page, update, navigating } from './state.svelte.js'; @@ -1590,7 +1596,7 @@ async function navigate({ const entry = { [HISTORY_INDEX]: (current_history_index += change), [NAVIGATION_INDEX]: (current_navigation_index += change), - [STATES_KEY]: state + [STATES_KEY]: stringify(state, app.hooks.transport) }; const fn = replace_state ? history.replaceState : history.pushState; @@ -2144,18 +2150,8 @@ export function pushState(url, state) { throw new Error('Cannot call pushState(...) on the server'); } - if (DEV) { - if (!started) { - throw new Error('Cannot call pushState(...) before router is initialized'); - } - - try { - // use `devalue.stringify` as a convenient way to ensure we exclude values that can't be properly rehydrated, such as custom class instances - devalue.stringify(state); - } catch (error) { - // @ts-expect-error - throw new Error(`Could not serialize state${error.path}`); - } + if (DEV && !started) { + throw new Error('Cannot call pushState(...) before router is initialized'); } update_scroll_positions(current_history_index); @@ -2164,13 +2160,13 @@ export function pushState(url, state) { [HISTORY_INDEX]: (current_history_index += 1), [NAVIGATION_INDEX]: current_navigation_index, [PAGE_URL_KEY]: page.url.href, - [STATES_KEY]: state + [STATES_KEY]: stringify(state, app.hooks.transport) }; history.pushState(opts, '', resolve_url(url)); has_navigated = true; - page.state = state; + page.state = unstringify(opts[STATES_KEY], app.hooks.transport); root.$set({ // we need to assign a new page object so that subscribers are correctly notified page: untrack(() => clone_page(page)) @@ -2191,30 +2187,20 @@ export function replaceState(url, state) { throw new Error('Cannot call replaceState(...) on the server'); } - if (DEV) { - if (!started) { - throw new Error('Cannot call replaceState(...) before router is initialized'); - } - - try { - // use `devalue.stringify` as a convenient way to ensure we exclude values that can't be properly rehydrated, such as custom class instances - devalue.stringify(state); - } catch (error) { - // @ts-expect-error - throw new Error(`Could not serialize state${error.path}`); - } + if (DEV && !started) { + throw new Error('Cannot call replaceState(...) before router is initialized'); } const opts = { [HISTORY_INDEX]: current_history_index, [NAVIGATION_INDEX]: current_navigation_index, [PAGE_URL_KEY]: page.url.href, - [STATES_KEY]: state + [STATES_KEY]: stringify(state, app.hooks.transport) }; history.replaceState(opts, '', resolve_url(url)); - page.state = state; + page.state = unstringify(opts[STATES_KEY], app.hooks.transport); root.$set({ page: untrack(() => clone_page(page)) }); @@ -2519,7 +2505,9 @@ function _start_router() { if (history_index === current_history_index) return; const scroll = scroll_positions[history_index]; - const state = event.state[STATES_KEY] ?? {}; + const state = event.state[STATES_KEY] + ? unstringify(event.state[STATES_KEY], app.hooks.transport) + : {}; const url = new URL(event.state[PAGE_URL_KEY] ?? location.href); const navigation_index = event.state[NAVIGATION_INDEX]; const is_hash_change = current.url ? strip_hash(location) === strip_hash(current.url) : false; diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index edfdc6916986..5e1ab1af52f0 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -46,6 +46,17 @@ export function stringify_remote_arg(value, transport) { return base64_encode(bytes).replaceAll('=', '').replaceAll('+', '-').replaceAll('/', '_'); } +/** + * Parses `string` with `devalue.parse`, using the provided transport decoders. + * @param {string} string + * @param {Transport} transport + */ +export function parse(string, transport) { + const decoders = Object.fromEntries(Object.entries(transport).map(([k, v]) => [k, v.decode])); + + return devalue.parse(string, decoders); +} + /** * Parses the argument (if any) for a remote function * @param {string} string @@ -59,9 +70,7 @@ export function parse_remote_arg(string, transport) { base64_decode(string.replaceAll('-', '+').replaceAll('_', '/')) ); - const decoders = Object.fromEntries(Object.entries(transport).map(([k, v]) => [k, v.decode])); - - return devalue.parse(json_string, decoders); + return parse(json_string, transport); } /** diff --git a/packages/kit/test/apps/basics/src/app.d.ts b/packages/kit/test/apps/basics/src/app.d.ts index cbfdcaafa47a..09b482d31183 100644 --- a/packages/kit/test/apps/basics/src/app.d.ts +++ b/packages/kit/test/apps/basics/src/app.d.ts @@ -1,3 +1,5 @@ +import type { Foo } from '$lib'; + declare global { namespace App { interface Locals { @@ -12,6 +14,7 @@ declare global { interface PageState { active?: boolean; count?: number; + foo?: Foo; } } } diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/foo/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/foo/+page.svelte new file mode 100644 index 000000000000..74a70a10af81 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/foo/+page.svelte @@ -0,0 +1,24 @@ + + + + + + + +

foo: {page.state.foo?.bar() ?? 'nope'}

+

count: {page.state.count ?? 'nope'}

diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 1704a8c7da32..4579f7a2e735 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1497,6 +1497,24 @@ test.describe('Shallow routing', () => { await page.locator('button').click(); await expect(page.locator('p')).toHaveText('count: 1'); }); + + test('pushState properly serializes objects', async ({ page }) => { + await page.goto('/shallow-routing/push-state/foo'); + await expect(page.locator('[data-testid=foo]')).toHaveText('foo: nope'); + await expect(page.locator('[data-testid=count]')).toHaveText('count: nope'); + + await page.getByText('push state').click(); + await expect(page.locator('[data-testid=foo]')).toHaveText('foo: it works?!'); + await expect(page.locator('[data-testid=count]')).toHaveText('count: 0'); + + await page.getByText('bump count').click(); + await expect(page.locator('[data-testid=foo]')).toHaveText('foo: it works?!'); + await expect(page.locator('[data-testid=count]')).toHaveText('count: 0'); // Ensure count is not bumped + + await page.goBack(); + await expect(page.locator('[data-testid=foo]')).toHaveText('foo: nope'); + await expect(page.locator('[data-testid=count]')).toHaveText('count: nope'); + }); }); test.describe('reroute', () => {