Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/scroll-state-navigation.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
23 changes: 17 additions & 6 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1543,6 +1545,7 @@ async function navigate({
type,
delta: popped?.delta,
intent,
scroll: popped?.scroll,
// @ts-ignore
event
});
Expand Down Expand Up @@ -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))
);
Expand Down Expand Up @@ -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;

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script>
import { beforeNavigate, onNavigate, afterNavigate } from '$app/navigation';

beforeNavigate((navigation) => {
console.log(
'beforeNavigate:' +
JSON.stringify({
fromScroll: navigation.from?.scroll,
toScroll: navigation.to?.scroll,
type: navigation.type
})
);
});

onNavigate((navigation) => {
console.log(
'onNavigate:' +
JSON.stringify({
fromScroll: navigation.from?.scroll,
toScroll: navigation.to?.scroll,
type: navigation.type
})
);
});

afterNavigate((navigation) => {
console.log(
'afterNavigate:' +
JSON.stringify({
fromScroll: navigation.from?.scroll,
toScroll: navigation.to?.scroll,
type: navigation.type
})
);
});
</script>

<h1>Page A</h1>

<div style="height: 100vh; background: linear-gradient(teal, cyan)"></div>

<a id="to-b" href="/navigation-lifecycle/scroll-state/b">Go to B</a>

<div style="height: 100vh; background: linear-gradient(cyan, blue)"></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script>
import { beforeNavigate, onNavigate, afterNavigate } from '$app/navigation';

beforeNavigate((navigation) => {
console.log(
'beforeNavigate:' +
JSON.stringify({
fromScroll: navigation.from?.scroll,
toScroll: navigation.to?.scroll,
type: navigation.type
})
);
});

onNavigate((navigation) => {
console.log(
'onNavigate:' +
JSON.stringify({
fromScroll: navigation.from?.scroll,
toScroll: navigation.to?.scroll,
type: navigation.type
})
);
});

afterNavigate((navigation) => {
console.log(
'afterNavigate:' +
JSON.stringify({
fromScroll: navigation.from?.scroll,
toScroll: navigation.to?.scroll,
type: navigation.type
})
);
});
</script>

<h1>Page B</h1>

<div style="height: 100vh; background: linear-gradient(orange, yellow)"></div>

<a id="to-a" href="/navigation-lifecycle/scroll-state/a">Go to A</a>

<div style="height: 100vh; background: linear-gradient(yellow, red)"></div>
112 changes: 112 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 @@ -9,7 +9,7 @@
test.describe.configure({ mode: 'parallel' });

test.describe('a11y', () => {
test('resets focus', async ({ page, clicknav, browserName }) => {

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

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (18, windows-latest, chromium, dev)

flaky test: resets focus

retries: 2
const tab = browserName === 'webkit' ? 'Alt+Tab' : 'Tab';

await page.goto('/accessibility/a');
Expand All @@ -32,7 +32,7 @@
expect(await page.evaluate(() => document.documentElement.getAttribute('tabindex'))).toBe(null);
});

test('applies autofocus after a navigation', async ({ page, clicknav }) => {

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

View workflow job for this annotation

GitHub Actions / test-kit-cross-browser (18, windows-latest, chromium, dev)

flaky test: applies autofocus after a navigation

retries: 2
await page.goto('/accessibility/autofocus/a');

await clicknav('[href="/accessibility/autofocus/b"]', {
Expand Down Expand Up @@ -300,6 +300,118 @@
'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', () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading