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 cd12710efdbc..51ebb0f82e65 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -42,8 +42,10 @@ import { compact } from '../../utils/array.js'; import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM, + validate_load_response, + stringify, validate_depends, - validate_load_response + parse as unstringify } from '../shared.js'; import { get_message, get_status } from '../../utils/error.js'; import { writable } from 'svelte/store'; @@ -1641,7 +1643,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; @@ -2198,18 +2200,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); @@ -2218,13 +2210,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)) @@ -2245,30 +2237,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)) }); @@ -2570,7 +2552,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 ae3ee4a06c10..e0c661d0e182 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -66,6 +66,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 @@ -79,9 +90,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 fec98e4a214e..5581cd562c62 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1621,6 +1621,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', () => {