Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smart-lamps-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: support SvelteKit scroll management in app-owned scroll containers
12 changes: 12 additions & 0 deletions documentation/docs/30-advanced/30-link-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<main data-sveltekit-scroll-container>
<slot />
</main>
```

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:
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/sync/write_app_types.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
`;
Expand Down
13 changes: 7 additions & 6 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
get_router_options,
is_external_url,
origin,
scroll_to,
scroll_state,
load_css
} from './utils.js';
Expand Down Expand Up @@ -188,11 +189,11 @@ function reset_scroll_and_focus(url, scroll, keepfocus, active_element) {

if (autoscroll) {
if (scroll) {
scrollTo(scroll.x, scroll.y);
scroll_to(scroll.x, scroll.y);
} else if ((deep_linked = get_hash_element(url))) {
deep_linked.scrollIntoView();
} else {
scrollTo(0, 0);
scroll_to(0, 0);
}
}

Expand Down Expand Up @@ -518,7 +519,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);
}
}

Expand Down Expand Up @@ -3087,7 +3088,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) {
Expand Down Expand Up @@ -3251,7 +3252,7 @@ function _start_router() {
current_history_index = history_index;
current_noscroll_index = no_scroll_index;
current_keepfocus_index = keep_focus_index;
if (!noscroll && scroll) scrollTo(scroll.x, scroll.y);
if (!noscroll && scroll) scroll_to(scroll.x, scroll.y);
return;
}

Expand Down Expand Up @@ -3622,7 +3623,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 {
Expand Down
28 changes: 28 additions & 0 deletions packages/kit/src/runtime/client/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div class="shell" data-sveltekit-scroll-container="false">
<nav>
<a href="/scroll/custom-container/a">a</a>
<a href="/scroll/custom-container/b">b</a>
</nav>

<main id="scroll-container" data-sveltekit-scroll-container>
<slot />
</main>
</div>

<style>
.shell {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
}

nav {
flex: none;
padding: 0.5rem;
}

main {
flex: 1;
overflow: auto;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<h1>custom container a</h1>
<a href="/scroll/custom-container/b">b</a>
<div class="spacer"></div>
<p id="bottom-a">bottom a</p>

<style>
.spacer {
height: 180vh;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<h1>custom container b</h1>
<a href="/scroll/custom-container/a">a</a>
<div class="spacer"></div>
<p id="bottom-b">bottom b</p>

<style>
.spacer {
height: 180vh;
}
</style>
36 changes: 36 additions & 0 deletions packages/kit/test/apps/basics/test/cross-platform/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@
});

test.describe('Navigation lifecycle functions', () => {
test('beforeNavigate prevents navigation triggered by link click', async ({ page, baseURL }) => {

Check warning on line 151 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-svelte-async (dev)

flaky test: beforeNavigate prevents navigation triggered by link click

retries: 2
await page.goto('/navigation-lifecycle/before-navigate/prevent-navigation');

await page.click('[href="/navigation-lifecycle/before-navigate/a"]');
Expand Down Expand Up @@ -469,7 +469,7 @@
expect(await in_view('#go-to-encöded')).toBe(true);
});

test('url-supplied non-ascii anchor works on navigation to page after manual scroll', async ({

Check warning on line 472 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (24, windows-latest, chromium, build)

flaky test: url-supplied non-ascii anchor works on navigation to page after manual scroll

retries: 2
page,
in_view,
clicknav,
Expand Down Expand Up @@ -541,6 +541,42 @@
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();
Expand Down Expand Up @@ -638,7 +674,7 @@
await expect(page.locator('input')).toBeFocused();
});

test('scroll positions are recovered on reloading the page', async ({

Check warning on line 677 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (24, windows-latest, chromium, build)

flaky test: scroll positions are recovered on reloading the page

retries: 2
page,
app,
browserName,
Expand Down Expand Up @@ -883,7 +919,7 @@
);
});

test('chooses correct route when hash route is preloaded but regular route is clicked', async ({

Check warning on line 922 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (24, windows-latest, chromium, build)

flaky test: chooses correct route when hash route is preloaded but regular route is clicked

retries: 2
app,
page
}) => {
Expand Down Expand Up @@ -987,7 +1023,7 @@
await expect(page.locator('#page-url-hash')).toHaveText('#target');
});

test('clicking on a hash link focuses the associated element', async ({ page }) => {

Check warning on line 1026 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit (24, ubuntu-latest, chromium, current)

flaky test: clicking on a hash link focuses the associated element

retries: 2
await page.goto('/routing/hashes/focus');
await page.locator('a[href="#example"]').click();
await expect(page.getByRole('textbox')).toBeFocused();
Expand Down Expand Up @@ -1147,7 +1183,7 @@
expect(tabs.length > 1);
});

test('responds to <button formtarget="_blank" submission with new tab', async ({ page }) => {

Check warning on line 1186 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (24, macOS-latest, webkit, build)

flaky test: responds to <button formtarget="_blank" submission with new tab

retries: 2
await page.goto('/routing/form-target-blank');

let tabs = page.context().pages();
Expand All @@ -1159,7 +1195,7 @@
expect(tabs.length > 1);
});

test('ignores links with no href', async ({ page }) => {

Check warning on line 1198 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (24, macOS-latest, webkit, dev)

flaky test: ignores links with no href

retries: 2
await page.goto('/routing/missing-href');
const selector = '[data-testid="count"]';

Expand All @@ -1169,7 +1205,7 @@
expect(await page.textContent(selector)).toBe('count: 1');
});

test('trailing slash redirect', async ({ page, clicknav }) => {

Check warning on line 1208 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (24, macOS-latest, webkit, dev)

flaky test: trailing slash redirect

retries: 2
await page.goto('/routing/trailing-slash');

await clicknav('a[href="/routing/trailing-slash/always"]');
Expand Down Expand Up @@ -1231,7 +1267,7 @@
await expect(page.locator('p')).toHaveText('one=1; three=3; two=2');
});

test("fetch during SSR doesn't un- and re-escape cookies", async ({ page }) => {

Check warning on line 1270 in packages/kit/test/apps/basics/test/cross-platform/client.test.js

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (24, macOS-latest, webkit, build)

flaky test: fetch during SSR doesn't un- and re-escape cookies

retries: 2
await page.goto('/cookies/collect-without-re-escaping');
await expect(page.locator('p')).toHaveText('cookie-special-characters="foo"');
});
Expand Down
Loading