Skip to content

Commit 052adf9

Browse files
Rich-Harrisbenmccanndummdidummignatiusmb
authored
Shallow routing (#11307)
* shallow routing * add types * drive-by fix - bad merge * warn on use of history.pushState and history.replaceState * regenerate types * make url the first argument, even though it's optional — this is more future-proof, as we may add options in future * tests * remove state from goto * tidy up * tidy up internal navigate API a bit * more * use current.url for invalidation, not location.href * add docs * copy-paste fail * on second thoughts * links * link * regenerate types * update preloadData docs * fix preloadData docs * drive-by fix * Apply suggestions from code review Co-authored-by: Ignatius Bagus <[email protected]> * tweaks * changeset, breaking change docs * code-golf * this seems unnecessary * mention preloadData * use original replace state * oops * handle SPA case * more involved example - show importing a +page.svelte and correctly handling a click event, and avoid --------- Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Ben McCann <[email protected]> Co-authored-by: Simon H <[email protected]> Co-authored-by: Ignatius Bagus <[email protected]> Co-authored-by: Simon Holthausen <[email protected]>
1 parent a00183a commit 052adf9

File tree

30 files changed

+673
-164
lines changed

30 files changed

+673
-164
lines changed

.changeset/light-moons-dress.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sveltejs/kit": minor
3+
---
4+
5+
feat: implement shallow routing

.changeset/serious-months-happen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sveltejs/kit": major
3+
---
4+
5+
breaking: remove state option from goto in favor of shallow routing
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
---
2+
title: Shallow routing
3+
---
4+
5+
As you navigate around a SvelteKit app, you create _history entries_. Clicking the back and forward buttons traverses through this list of entries, re-running any `load` functions and replacing page components as necessary.
6+
7+
Sometimes, it's useful to create history entries _without_ navigating. For example, you might want to show a modal dialog that the user can dismiss by navigating back. This is particularly valuable on mobile devices, where swipe gestures are often more natural than interacting directly with the UI. In these cases, a modal that is _not_ associated with a history entry can be a source of frustration, as a user may swipe backwards in an attempt to dismiss it and find themselves on the wrong page.
8+
9+
SvelteKit makes this possible with the [`pushState`](/docs/modules#$app-navigation-pushstate) and [`replaceState`](/docs/modules#$app-navigation-replacestate) functions, which allow you to associate state with a history entry without navigating. For example, to implement a history-driven modal:
10+
11+
```svelte
12+
<!--- file: +page.svelte --->
13+
<script>
14+
import { pushState } from '$app/navigation';
15+
import { page } from '$app/stores';
16+
import Modal from './Modal.svelte';
17+
18+
function showModal() {
19+
pushState('', {
20+
showModal: true
21+
});
22+
}
23+
</script>
24+
25+
{#if $page.state.showModal}
26+
<Modal close={() => history.back()} />
27+
{/if}
28+
```
29+
30+
The modal can be dismissed by navigating back (unsetting `$page.state.showModal`) or by interacting with it in a way that causes the `close` callback to run, which will navigate back programmatically.
31+
32+
## API
33+
34+
The first argument to `pushState` is the URL, relative to the current URL. To stay on the current URL, use `''`.
35+
36+
The second argument is the new page state, which can be accessed via the [page store](/docs/modules#$app-stores-page) as `$page.state`. You can make page state type-safe by declaring an [`App.PageState`](/docs/types#app) interface (usually in `src/app.d.ts`).
37+
38+
To set page state without creating a new history entry, use `replaceState` instead of `pushState`.
39+
40+
## Loading data for a route
41+
42+
When shallow routing, you may want to render another `+page.svelte` inside the current page. For example, clicking on a photo thumbnail could pop up the detail view without navigating to the photo page.
43+
44+
For this to work, you need to load the data that the `+page.svelte` expects. A convenient way to do this is to use [`preloadData`](/docs/modules#$app-navigation-preloaddata) inside the `click` handler of an `<a>` element. If the element (or a parent) uses [`data-sveltekit-preload-data`](/docs/link-options#data-sveltekit-preload-data), the data will have already been requested, and `preloadData` will reuse that request.
45+
46+
```svelte
47+
<!--- file: src/routes/photos/+page.svelte --->
48+
<script>
49+
import { preloadData, pushState, goto } from '$app/navigation';
50+
import Modal from './Modal.svelte';
51+
import PhotoPage from './[id]/+page.svelte';
52+
53+
export let data;
54+
</script>
55+
56+
{#each data.thumbnails as thumbnail}
57+
<a
58+
href="/photos/{thumbnail.id}"
59+
on:click={async (e) => {
60+
// bail if opening a new tab, or we're on too small a screen
61+
if (e.metaKey || innerWidth < 640) return;
62+
63+
// prevent navigation
64+
e.preventDefault();
65+
66+
const { href } = e.currentTarget;
67+
68+
// run `load` functions (or rather, get the result of the `load` functions
69+
// that are already running because of `data-sveltekit-preload-data`)
70+
const result = await preloadData(href);
71+
72+
if (result.type === 'loaded' && result.status === 200) {
73+
pushState(href, { selected: result.data });
74+
} else {
75+
// something bad happened! try navigating
76+
goto(href);
77+
}
78+
}}
79+
>
80+
<img alt={thumbnail.alt} src={thumbnail.src} />
81+
</a>
82+
{/each}
83+
84+
{#if $page.state.selected}
85+
<Modal on:close={() => history.goBack()}>
86+
<!-- pass page data to the +page.svelte component,
87+
just like SvelteKit would on navigation -->
88+
<PhotoPage data={$page.state.selected} />
89+
</Modal>
90+
{/if}
91+
```
92+
93+
## Caveats
94+
95+
During server-side rendering, `$page.state` is always an empty object. The same is true for the first page the user lands on — if the user reloads the page, state will _not_ be applied until they navigate.
96+
97+
Shallow routing is a feature that requires JavaScript to work. Be mindful when using it and try to think of sensible fallback behavior in case JavaScript isn't available.

documentation/docs/60-appendix/30-migrating-to-sveltekit-2.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ export function load({ fetch }) {
6666
}
6767
```
6868

69-
## goto(...) no longer accepts external URLs
69+
## goto(...) changes
7070

71-
To navigate to an external URL, use `window.location = url`.
71+
`goto(...)` no longer accepts external URLs. To navigate to an external URL, use `window.location = url`. The `state` option was removed in favor of [shallow routing](shallow-routing).
7272

7373
## paths are now relative by default
7474

packages/create-svelte/templates/default/src/app.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ declare global {
55
// interface Error {}
66
// interface Locals {}
77
// interface PageData {}
8+
// interface PageState {}
89
// interface Platform {}
910
}
1011
}

packages/create-svelte/templates/skeleton/src/app.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ declare global {
55
// interface Error {}
66
// interface Locals {}
77
// interface PageData {}
8+
// interface PageState {}
89
// interface Platform {}
910
}
1011
}

packages/create-svelte/templates/skeletonlib/src/app.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ declare global {
55
// interface Error {}
66
// interface Locals {}
77
// interface PageData {}
8+
// interface PageState {}
89
// interface Platform {}
910
}
1011
}

packages/kit/src/exports/public.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,10 @@ export interface Page<
961961
* The merged result of all data from all `load` functions on the current page. You can type a common denominator through `App.PageData`.
962962
*/
963963
data: App.PageData & Record<string, any>;
964+
/**
965+
* The page state, which can be manipulated using the [`pushState`](https://kit.svelte.dev/docs/modules#$app-navigation-pushstate) and [`replaceState`](https://kit.svelte.dev/docs/modules#$app-navigation-replacestate) functions from `$app/navigation`.
966+
*/
967+
state: App.PageState;
964968
/**
965969
* Filled only after a form submission. See [form actions](https://kit.svelte.dev/docs/form-actions) for more info.
966970
*/

packages/kit/src/runtime/app/navigation.js

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,13 @@ export const disableScrollHandling = /* @__PURE__ */ client_method('disable_scro
1111
* Returns a Promise that resolves when SvelteKit navigates (or fails to navigate, in which case the promise rejects) to the specified `url`.
1212
* For external URLs, use `window.location = url` instead of calling `goto(url)`.
1313
*
14-
* @type {(url: string | URL, opts?: {
15-
* replaceState?: boolean;
16-
* noScroll?: boolean;
17-
* keepFocus?: boolean;
18-
* invalidateAll?: boolean;
19-
* state?: any
20-
* }) => Promise<void>}
14+
* @type {(url: string | URL, opts?: { replaceState?: boolean; noScroll?: boolean; keepFocus?: boolean; invalidateAll?: boolean; }) => Promise<void>}
2115
* @param {string | URL} url Where to navigate to. Note that if you've set [`config.kit.paths.base`](https://kit.svelte.dev/docs/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app.
2216
* @param {Object} [opts] Options related to the navigation
2317
* @param {boolean} [opts.replaceState] If `true`, will replace the current `history` entry rather than creating a new one with `pushState`
2418
* @param {boolean} [opts.noScroll] If `true`, the browser will maintain its scroll position rather than scrolling to the top of the page after navigation
2519
* @param {boolean} [opts.keepFocus] If `true`, the currently focused element will retain focus after navigation. Otherwise, focus will be reset to the body
2620
* @param {boolean} [opts.invalidateAll] If `true`, all `load` functions of the page will be rerun. See https://kit.svelte.dev/docs/load#rerunning-load-functions for more info on invalidation.
27-
* @param {any} [opts.state] The state of the new/updated history entry
2821
* @returns {Promise<void>}
2922
*/
3023
export const goto = /* @__PURE__ */ client_method('goto');
@@ -64,11 +57,11 @@ export const invalidateAll = /* @__PURE__ */ client_method('invalidate_all');
6457
*
6558
* This is the same behaviour that SvelteKit triggers when the user taps or mouses over an `<a>` element with `data-sveltekit-preload-data`.
6659
* If the next navigation is to `href`, the values returned from load will be used, making navigation instantaneous.
67-
* Returns a Promise that resolves when the preload is complete.
60+
* Returns a Promise that resolves with the result of running the new route's `load` functions once the preload is complete.
6861
*
69-
* @type {(href: string) => Promise<void>}
62+
* @type {(href: string) => Promise<Record<string, any>>}
7063
* @param {string} href Page to preload
71-
* @returns {Promise<void>}
64+
* @returns {Promise<{ type: 'loaded'; status: number; data: Record<string, any> } | { type: 'redirect'; location: string }>}
7265
*/
7366
export const preloadData = /* @__PURE__ */ client_method('preload_data');
7467

@@ -126,3 +119,23 @@ export const onNavigate = /* @__PURE__ */ client_method('on_navigate');
126119
* @returns {void}
127120
*/
128121
export const afterNavigate = /* @__PURE__ */ client_method('after_navigate');
122+
123+
/**
124+
* Programmatically create a new history entry with the given `$page.state`. To use the current URL, you can pass `''` as the first argument. Used for [shallow routing](https://kit.svelte.dev/docs/shallow-routing).
125+
*
126+
* @type {(url: string | URL, state: App.PageState) => void}
127+
* @param {string | URL} url
128+
* @param {App.PageState} state
129+
* @returns {void}
130+
*/
131+
export const pushState = /* @__PURE__ */ client_method('push_state');
132+
133+
/**
134+
* Programmatically replace the current history entry with the given `$page.state`. To use the current URL, you can pass `''` as the first argument. Used for [shallow routing](https://kit.svelte.dev/docs/shallow-routing).
135+
*
136+
* @type {(url: string | URL, state: App.PageState) => void}
137+
* @param {string | URL} url
138+
* @param {App.PageState} state
139+
* @returns {void}
140+
*/
141+
export const replaceState = /* @__PURE__ */ client_method('replace_state');

0 commit comments

Comments
 (0)