diff --git a/.changeset/scroll-state-navigation.md b/.changeset/scroll-state-navigation.md new file mode 100644 index 000000000000..a7d814d3caea --- /dev/null +++ b/.changeset/scroll-state-navigation.md @@ -0,0 +1,12 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add `scroll` property to `NavigationTarget` in navigation callbacks + +Navigation callbacks (`beforeNavigate`, `onNavigate`, and `afterNavigate`) now include scroll position information via the `scroll` property on `from` and `to` targets: + +- `from.scroll`: The scroll position at the moment navigation was triggered +- `to.scroll`: In `beforeNavigate` and `onNavigate`, this is populated for `popstate` navigations (back/forward) with the scroll position that will be restored, and `null` for other navigation types. In `afterNavigate`, this is always the final scroll position after navigation completed. + +This enables use cases like animating transitions based on the target scroll position when using browser back/forward navigation. diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 200ae665893f..8558f481ac68 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1200,6 +1200,19 @@ export interface NavigationTarget< * The URL that is navigated to */ url: URL; + /** + * The scroll position associated with this navigation. + * + * For the `from` target, this is the scroll position at the moment of navigation. + * + * For the `to` target, this represents the scroll position that will be or was restored: + * - In `beforeNavigate` and `onNavigate`, this is only available for `popstate` navigations (back/forward button) + * and will be `null` for other navigation types, since the final scroll position isn't known + * ahead of time. + * - In `afterNavigate`, this is always the scroll position that was applied after the navigation + * completed. + */ + scroll: { x: number; y: number } | null; } /** diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index c4f78548655c..c410814399ee 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -605,7 +605,8 @@ async function initialize(result, target, hydrate) { to: { params: current.params, route: { id: current.route?.id ?? null }, - url: new URL(location.href) + url: new URL(location.href), + scroll: scroll_positions[current_history_index] ?? scroll_state() }, willUnload: false, type: 'enter', @@ -1463,12 +1464,13 @@ function get_page_key(url) { * intent?: import('./types.js').NavigationIntent; * delta?: number; * event?: PopStateEvent | MouseEvent; + * scroll?: { x: number, y: number }; * }} opts */ -function _before_navigate({ url, type, intent, delta, event }) { +function _before_navigate({ url, type, intent, delta, event, scroll }) { let should_block = false; - const nav = create_navigation(current, intent, url, type); + const nav = create_navigation(current, intent, url, type, scroll ?? null); if (delta !== undefined) { nav.navigation.delta = delta; @@ -1543,6 +1545,7 @@ async function navigate({ type, delta: popped?.delta, intent, + scroll: popped?.scroll, // @ts-ignore event }); @@ -1808,6 +1811,11 @@ async function navigate({ nav.fulfil(undefined); + // Update to.scroll to the actual scroll position after navigation completed + if (nav.navigation.to) { + nav.navigation.to.scroll = scroll_state(); + } + after_navigate_callbacks.forEach((fn) => fn(/** @type {import('@sveltejs/kit').AfterNavigate} */ (nav.navigation)) ); @@ -3094,8 +3102,9 @@ function reset_focus(url, scroll = null) { * @param {import('./types.js').NavigationIntent | undefined} intent * @param {URL | null} url * @param {T} type + * @param {{ x: number, y: number } | null} [target_scroll] The scroll position for the target (for popstate navigations) */ -function create_navigation(current, intent, url, type) { +function create_navigation(current, intent, url, type, target_scroll = null) { /** @type {(value: any) => void} */ let fulfil; @@ -3115,12 +3124,14 @@ function create_navigation(current, intent, url, type) { from: { params: current.params, route: { id: current.route?.id ?? null }, - url: current.url + url: current.url, + scroll: scroll_state() }, to: url && { params: intent?.params ?? null, route: { id: intent?.route?.id ?? null }, - url + url, + scroll: target_scroll }, willUnload: !intent, type, diff --git a/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/a/+page.svelte b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/a/+page.svelte new file mode 100644 index 000000000000..2c06ed22932b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/a/+page.svelte @@ -0,0 +1,44 @@ + + +

Page A

+ +
+ +Go to B + +
diff --git a/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/b/+page.svelte b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/b/+page.svelte new file mode 100644 index 000000000000..cdd1aac75d6f --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/navigation-lifecycle/scroll-state/b/+page.svelte @@ -0,0 +1,44 @@ + + +

Page B

+ +
+ +Go to A + +
diff --git a/packages/kit/test/apps/basics/test/cross-platform/client.test.js b/packages/kit/test/apps/basics/test/cross-platform/client.test.js index a2be35a47dc6..f47023909969 100644 --- a/packages/kit/test/apps/basics/test/cross-platform/client.test.js +++ b/packages/kit/test/apps/basics/test/cross-platform/client.test.js @@ -300,6 +300,118 @@ test.describe('Navigation lifecycle functions', () => { 'popstate /navigation-lifecycle/before-navigate/event/b -> /navigation-lifecycle/before-navigate/event/a' ]); }); + + test('scroll state is provided on initial page load', async ({ page }) => { + /** @type {any} */ + let afterNav; + const afterNavPromise = new Promise((resolve) => { + page.on('console', (msg) => { + const text = msg.text(); + if (text.startsWith('afterNavigate:')) { + afterNav = JSON.parse(text.slice('afterNavigate:'.length)); + resolve(afterNav); + } + }); + }); + + await page.goto('/navigation-lifecycle/scroll-state/a'); + await afterNavPromise; + + expect(afterNav.fromScroll).toBe(undefined); + expect(afterNav.toScroll).toEqual({ x: 0, y: 0 }); + expect(afterNav.type).toBe('enter'); + }); + + test('scroll state is provided on link navigation', async ({ page, clicknav, scroll_to }) => { + await page.goto('/navigation-lifecycle/scroll-state/a'); + await scroll_to(0, 500); + + /** @type {any} */ + let beforeNav, onNav, afterNav; + const navPromise = new Promise((resolve) => { + page.on('console', (msg) => { + const text = msg.text(); + if (text.startsWith('beforeNavigate:')) { + beforeNav = JSON.parse(text.slice('beforeNavigate:'.length)); + } else if (text.startsWith('onNavigate:')) { + onNav = JSON.parse(text.slice('onNavigate:'.length)); + } else if (text.startsWith('afterNavigate:')) { + afterNav = JSON.parse(text.slice('afterNavigate:'.length)); + } + + if (beforeNav && onNav && afterNav) resolve(undefined); + }); + }); + + await clicknav('#to-b'); + await navPromise; + + expect(beforeNav.fromScroll).toEqual({ x: 0, y: 500 }); + expect(beforeNav.toScroll).toBe(null); + expect(beforeNav.type).toBe('link'); + + expect(onNav.fromScroll).toEqual({ x: 0, y: 500 }); + expect(onNav.toScroll).toBe(null); + expect(onNav.type).toBe('link'); + + expect(afterNav.fromScroll).toEqual({ x: 0, y: 500 }); + expect(afterNav.toScroll).toEqual({ x: 0, y: 0 }); + expect(afterNav.type).toBe('link'); + }); + + test('scroll state is provided on popstate navigation', async ({ page, clicknav, scroll_to }) => { + await page.goto('/navigation-lifecycle/scroll-state/a'); + await scroll_to(0, 500); + + /** @type {any} */ + let afterNav; + let navPromise = new Promise((resolve) => { + page.on('console', (msg) => { + const text = msg.text(); + if (text.startsWith('afterNavigate:')) { + afterNav = JSON.parse(text.slice('afterNavigate:'.length)); + resolve(undefined); + } + }); + }); + + await clicknav('#to-b'); + await navPromise; + + const savedScrollY = afterNav.fromScroll.y; + + /** @type {any} */ + let beforeNav, onNav; + navPromise = new Promise((resolve) => { + page.on('console', (msg) => { + const text = msg.text(); + if (text.startsWith('beforeNavigate:')) { + beforeNav = JSON.parse(text.slice('beforeNavigate:'.length)); + } else if (text.startsWith('onNavigate:')) { + onNav = JSON.parse(text.slice('onNavigate:'.length)); + } else if (text.startsWith('afterNavigate:')) { + afterNav = JSON.parse(text.slice('afterNavigate:'.length)); + } + + if (beforeNav && onNav && afterNav) resolve(undefined); + }); + }); + + await page.goBack(); + await page.waitForURL('/navigation-lifecycle/scroll-state/a'); + await navPromise; + + expect(beforeNav.fromScroll).toEqual({ x: 0, y: 0 }); + expect(beforeNav.toScroll).toEqual({ x: 0, y: savedScrollY }); + expect(beforeNav.type).toBe('popstate'); + + expect(onNav.fromScroll).toEqual({ x: 0, y: 0 }); + expect(onNav.toScroll).toEqual({ x: 0, y: savedScrollY }); + expect(onNav.type).toBe('popstate'); + + expect(afterNav.toScroll).toEqual({ x: 0, y: savedScrollY }); + expect(afterNav.type).toBe('popstate'); + }); }); test.describe('Scrolling', () => { diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index c41dd5ea556e..98d22ee98dbf 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1175,6 +1175,19 @@ declare module '@sveltejs/kit' { * The URL that is navigated to */ url: URL; + /** + * The scroll position associated with this navigation. + * + * For the `from` target, this is the scroll position at the moment of navigation. + * + * For the `to` target, this represents the scroll position that will be or was restored: + * - In `beforeNavigate` and `onNavigate`, this is only available for `popstate` navigations (back/forward button) + * and will be `null` for other navigation types, since the final scroll position isn't known + * ahead of time. + * - In `afterNavigate`, this is always the scroll position that was applied after the navigation + * completed. + */ + scroll: { x: number; y: number } | null; } /**