From 3ecd821e85598cdfb37d71b80c6e651995b38758 Mon Sep 17 00:00:00 2001 From: Scarab Systems Date: Sun, 19 Jul 2026 16:23:56 +0000 Subject: [PATCH] fix: support app-owned scroll containers --- .changeset/smart-lamps-dance.md | 5 +++ .../docs/30-advanced/30-link-options.md | 12 +++++++ packages/kit/src/core/sync/write_app_types.js | 1 + packages/kit/src/runtime/client/client.js | 13 +++---- packages/kit/src/runtime/client/utils.js | 28 +++++++++++++++ .../scroll/custom-container/+layout.svelte | 29 +++++++++++++++ .../scroll/custom-container/a/+page.svelte | 10 ++++++ .../scroll/custom-container/b/+page.svelte | 10 ++++++ .../basics/test/cross-platform/client.test.js | 36 +++++++++++++++++++ 9 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 .changeset/smart-lamps-dance.md create mode 100644 packages/kit/test/apps/basics/src/routes/scroll/custom-container/+layout.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/scroll/custom-container/a/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/scroll/custom-container/b/+page.svelte diff --git a/.changeset/smart-lamps-dance.md b/.changeset/smart-lamps-dance.md new file mode 100644 index 000000000000..b6a11bf7defa --- /dev/null +++ b/.changeset/smart-lamps-dance.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: support SvelteKit scroll management in app-owned scroll containers diff --git a/documentation/docs/30-advanced/30-link-options.md b/documentation/docs/30-advanced/30-link-options.md index bf368876ffa9..58eae8538861 100644 --- a/documentation/docs/30-advanced/30-link-options.md +++ b/documentation/docs/30-advanced/30-link-options.md @@ -102,6 +102,18 @@ In certain cases, you may wish to disable this behaviour. Adding a `data-sveltek ...will prevent scrolling after the link is clicked. +## data-sveltekit-scroll-container + +SvelteKit applies its scroll management to the window by default. If your app keeps the document fixed and scrolls inside an app-owned container instead, mark the scrollable element that should own route scroll state with `data-sveltekit-scroll-container`: + +```svelte +
+ +
+``` + +SvelteKit will read and restore that element's `scrollLeft` and `scrollTop` when navigating between routes. Unlike the link options above, this attribute applies to the scrollable app shell element itself. Use a single scroll container for the app shell; if multiple elements have the attribute, the first one in the document whose value is not `"false"` is used. + ## Disabling options To disable any of these options inside an element where they have been enabled, use the `"false"` value: diff --git a/packages/kit/src/core/sync/write_app_types.js b/packages/kit/src/core/sync/write_app_types.js index d0a8c71614a9..2502296efe77 100644 --- a/packages/kit/src/core/sync/write_app_types.js +++ b/packages/kit/src/core/sync/write_app_types.js @@ -68,6 +68,7 @@ declare module "svelte/elements" { 'data-sveltekit-preload-data'?: true | false | '' | 'hover' | 'tap' | undefined | null; 'data-sveltekit-reload'?: true | false | '' | undefined | null; 'data-sveltekit-replacestate'?: true | false | '' | undefined | null; + 'data-sveltekit-scroll-container'?: true | false | '' | undefined | null; } } `; diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 6a3f7daddfed..f393d4e9bdd2 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -19,6 +19,7 @@ import { get_router_options, is_external_url, origin, + scroll_to, scroll_state, load_css } from './utils.js'; @@ -421,7 +422,7 @@ export async function start(_app, _target, hydrate) { function restore_scroll() { if (scroll) { history.scrollRestoration = 'manual'; - scrollTo(scroll.x, scroll.y); + scroll_to(scroll.x, scroll.y); } } @@ -2011,14 +2012,14 @@ async function navigate({ if (autoscroll) { const scroll = popped ? popped.scroll : noscroll ? scroll_state() : null; if (scroll) { - scrollTo(scroll.x, scroll.y); + scroll_to(scroll.x, scroll.y); } else if ((deep_linked = get_hash_element(url))) { // Here we use `scrollIntoView` on the element instead of `scrollTo` // because it natively supports the `scroll-margin` and `scroll-behavior` // CSS properties. deep_linked.scrollIntoView(); } else { - scrollTo(0, 0); + scroll_to(0, 0); } } @@ -2854,7 +2855,7 @@ function _start_router() { // /#top and click on a link that goes to /#top. In those cases just go to // the top of the page, and avoid a history change. if (hash === '' || (hash === 'top' && a.ownerDocument.getElementById('top') === null)) { - scrollTo({ top: 0 }); + scroll_to({ top: 0 }); } else { const element = a.ownerDocument.getElementById(decodeURIComponent(hash)); if (element) { @@ -2979,7 +2980,7 @@ function _start_router() { update_url(url); scroll_positions[current_history_index] = scroll_state(); - if (scroll) scrollTo(scroll.x, scroll.y); + if (scroll) scroll_to(scroll.x, scroll.y); current_history_index = history_index; return; @@ -3341,7 +3342,7 @@ function reset_focus(url, scroll = true) { // If scroll management has already happened earlier, we need to restore // the scroll position after setting the sequential focus navigation starting point - if (scroll) scrollTo(x, y); + if (scroll) scroll_to(x, y); resetting_focus = false; }); } else { diff --git a/packages/kit/src/runtime/client/utils.js b/packages/kit/src/runtime/client/utils.js index 602255582097..8c2f48779f2a 100644 --- a/packages/kit/src/runtime/client/utils.js +++ b/packages/kit/src/runtime/client/utils.js @@ -17,13 +17,41 @@ export function resolve_url(url) { return new URL(url, baseURI); } +function get_scroll_container() { + return /** @type {HTMLElement | null} */ ( + document.querySelector( + '[data-sveltekit-scroll-container]:not([data-sveltekit-scroll-container="false"])' + ) + ); +} + export function scroll_state() { + const scroll_container = get_scroll_container(); + + if (scroll_container) { + return { + x: scroll_container.scrollLeft, + y: scroll_container.scrollTop + }; + } + return { x: pageXOffset, y: pageYOffset }; } +/** + * @param {number | ScrollToOptions} x + * @param {number} [y] + */ +export function scroll_to(x, y) { + const scroll_container = get_scroll_container(); + const scroll = scroll_container ?? window; + if (typeof x === 'number') scroll.scrollTo(x, y ?? 0); + else scroll.scrollTo(x); +} + const warned = new WeakSet(); /** @typedef {keyof typeof valid_link_options} LinkOptionName */ diff --git a/packages/kit/test/apps/basics/src/routes/scroll/custom-container/+layout.svelte b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/+layout.svelte new file mode 100644 index 000000000000..d1fc9a5a036b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/+layout.svelte @@ -0,0 +1,29 @@ +
+ + +
+ +
+
+ + diff --git a/packages/kit/test/apps/basics/src/routes/scroll/custom-container/a/+page.svelte b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/a/+page.svelte new file mode 100644 index 000000000000..d9717dad6a80 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/a/+page.svelte @@ -0,0 +1,10 @@ +

custom container a

+b +
+

bottom a

+ + diff --git a/packages/kit/test/apps/basics/src/routes/scroll/custom-container/b/+page.svelte b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/b/+page.svelte new file mode 100644 index 000000000000..ffd88679e0ac --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/scroll/custom-container/b/+page.svelte @@ -0,0 +1,10 @@ +

custom container b

+a +
+

bottom b

+ + 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 fdd42caf27cb..e7cccedbbd53 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 @@ -541,6 +541,42 @@ test.describe('Scrolling', () => { expect(await page.evaluate(() => scrollY)).toBe(0); }); + test('no-anchor url will scroll app-owned container to top when navigated from scrolled page', async ({ + page, + clicknav + }) => { + await page.goto('/scroll/custom-container/a'); + const scroll_container = page.locator('#scroll-container'); + + await scroll_container.evaluate((node) => node.scrollTo(0, node.scrollHeight)); + await expect.poll(() => scroll_container.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + + await clicknav('nav [href="/scroll/custom-container/b"]'); + await expect.poll(() => scroll_container.evaluate((node) => node.scrollTop)).toBe(0); + }); + + test('app-owned container scroll is restored after hitting the back button', async ({ + page, + clicknav + }) => { + await page.goto('/scroll/custom-container/a'); + const scroll_container = page.locator('#scroll-container'); + + await scroll_container.evaluate((node) => node.scrollTo(0, 300)); + await expect + .poll(() => scroll_container.evaluate((node) => node.scrollTop)) + .toBeGreaterThan(250); + + await clicknav('nav [href="/scroll/custom-container/b"]'); + await expect.poll(() => scroll_container.evaluate((node) => node.scrollTop)).toBe(0); + + await page.goBack(); + await page.waitForURL('/scroll/custom-container/a'); + await expect + .poll(() => scroll_container.evaluate((node) => node.scrollTop)) + .toBeGreaterThan(250); + }); + test('scroll is restored after hitting the back button', async ({ clicknav, page }) => { await page.goto('/anchor'); await page.locator('#scroll-anchor').click();