From 8f0d5ba4970292ed90f0e52cf1829dbc019def5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gautier=20Ben=20A=C3=AFm?= Date: Wed, 6 Aug 2025 22:57:23 +0200 Subject: [PATCH 1/6] feat: use devalue for pushState/replaceState --- packages/kit/src/runtime/client/client.js | 44 +++++++------------ packages/kit/src/runtime/shared.js | 15 +++++-- packages/kit/test/apps/basics/src/app.d.ts | 3 ++ .../push-state/foo/+page.svelte | 9 ++++ .../kit/test/apps/basics/test/client.test.js | 11 +++++ 5 files changed, 50 insertions(+), 32 deletions(-) create mode 100644 packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/foo/+page.svelte diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 4486d9228215..1f0fbef82281 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,7 +2160,7 @@ 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)); @@ -2191,25 +2187,15 @@ 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)); @@ -2519,7 +2505,7 @@ function _start_router() { if (history_index === current_history_index) return; const scroll = scroll_positions[history_index]; - const state = event.state[STATES_KEY] ?? {}; + const state = 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..a82880e361b2 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/foo/+page.svelte @@ -0,0 +1,9 @@ + + + + +

foo: {page.state.foo?.bar() ?? '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..bdbeb7032b31 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1497,6 +1497,17 @@ 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('p')).toHaveText('foo: nope'); + + await page.locator('button').click(); + await expect(page.locator('p')).toHaveText('foo: it works?!'); + + await page.goBack(); + await expect(page.locator('p')).toHaveText('foo: nope'); + }); }); test.describe('reroute', () => { From d9356470a20ec76f93fb2d4aef8ca42826e047d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gautier=20Ben=20A=C3=AFm?= <48261497+GauBen@users.noreply.github.com> Date: Wed, 6 Aug 2025 23:04:12 +0200 Subject: [PATCH 2/6] Create four-pens-cheer.md --- .changeset/four-pens-cheer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/four-pens-cheer.md 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` From 84d1ca72b46a1eda522260d093a1a7338807d4ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gautier=20Ben=20A=C3=AFm?= <48261497+GauBen@users.noreply.github.com> Date: Thu, 7 Aug 2025 09:24:59 +0200 Subject: [PATCH 3/6] Update client.js --- packages/kit/src/runtime/client/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 1f0fbef82281..0071f140bf6e 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2505,7 +2505,7 @@ function _start_router() { if (history_index === current_history_index) return; const scroll = scroll_positions[history_index]; - const state = unstringify(event.state[STATES_KEY], app.hooks.transport) ?? {}; + 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; From 7e313d560624ed2beae5bcac8dbad28894d2ace2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gautier=20Ben=20A=C3=AFm?= Date: Thu, 7 Aug 2025 09:33:55 +0200 Subject: [PATCH 4/6] format --- packages/kit/src/runtime/client/client.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 0071f140bf6e..8c2267ee4b07 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2505,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] ? unstringify(event.state[STATES_KEY], app.hooks.transport) : {}; + 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; From d75268ca7dbe08055b079cdc697e808c50a53346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gautier=20Ben=20A=C3=AFm?= Date: Wed, 13 Aug 2025 14:59:51 +0200 Subject: [PATCH 5/6] block reactivity --- packages/kit/src/runtime/client/client.js | 4 ++-- .../push-state/foo/+page.svelte | 19 +++++++++++++++++-- .../kit/test/apps/basics/test/client.test.js | 13 ++++++++++--- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 8c2267ee4b07..7f0a7f380527 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -2166,7 +2166,7 @@ export function pushState(url, state) { 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)) @@ -2200,7 +2200,7 @@ export function replaceState(url, state) { 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)) }); 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 index a82880e361b2..74a70a10af81 100644 --- 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 @@ -4,6 +4,21 @@ import { Foo } from '$lib'; - + -

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

+ + + +

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 bdbeb7032b31..b43321ffa6cc 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1500,13 +1500,20 @@ test.describe('Shallow routing', () => { test('pushState properly serializes objects', async ({ page }) => { await page.goto('/shallow-routing/push-state/foo'); - await expect(page.locator('p')).toHaveText('foo: nope'); + await expect(page.locator('[data-testid=foo]')).toHaveText('foo: nope'); + await expect(page.locator('[data-testid=count]')).toHaveText('count: nope'); - await page.locator('button').click(); - await expect(page.locator('p')).toHaveText('foo: it works?!'); + 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('p')).toHaveText('foo: nope'); + await expect(page.locator('[data-testid=count]')).toHaveText('count: nope'); }); }); From 471e75e7c339c5bec754cc045732ed178d2a43c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gautier=20Ben=20A=C3=AFm?= <48261497+GauBen@users.noreply.github.com> Date: Wed, 13 Aug 2025 15:08:27 +0200 Subject: [PATCH 6/6] Update packages/kit/test/apps/basics/test/client.test.js --- packages/kit/test/apps/basics/test/client.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index b43321ffa6cc..4579f7a2e735 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -1512,7 +1512,7 @@ test.describe('Shallow routing', () => { await expect(page.locator('[data-testid=count]')).toHaveText('count: 0'); // Ensure count is not bumped await page.goBack(); - await expect(page.locator('p')).toHaveText('foo: nope'); + await expect(page.locator('[data-testid=foo]')).toHaveText('foo: nope'); await expect(page.locator('[data-testid=count]')).toHaveText('count: nope'); }); });