diff --git a/.changeset/hot-dogs-fry.md b/.changeset/hot-dogs-fry.md new file mode 100644 index 000000000000..678763888a99 --- /dev/null +++ b/.changeset/hot-dogs-fry.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +Adds a "onBeforeNavigate" navigation interceptor and a "onNavigate" lifecycle function diff --git a/documentation/docs/05-modules.md b/documentation/docs/05-modules.md index 9a085d046507..4820835b8559 100644 --- a/documentation/docs/05-modules.md +++ b/documentation/docs/05-modules.md @@ -19,7 +19,14 @@ import { amp, browser, dev, mode, prerendering } from '$app/env'; ### $app/navigation ```js -import { goto, invalidate, prefetch, prefetchRoutes } from '$app/navigation'; +import { + goto, + invalidate, + prefetch, + prefetchRoutes, + onBeforeNavigate, + onNavigate +} from '$app/navigation'; ``` - `goto(href, { replaceState, noscroll, keepfocus, state })` returns a `Promise` that resolves when SvelteKit navigates (or fails to navigate, in which case the promise rejects) to the specified `href`. The second argument is optional: @@ -30,6 +37,19 @@ import { goto, invalidate, prefetch, prefetchRoutes } from '$app/navigation'; - `invalidate(href)` causes any `load` functions belonging to the currently active page to re-run if they `fetch` the resource in question. It returns a `Promise` that resolves when the page is subsequently updated. - `prefetch(href)` programmatically prefetches the given page, which means a) ensuring that the code for the page is loaded, and b) calling the page's `load` function with the appropriate options. This is the same behaviour that SvelteKit triggers when the user taps or mouses over an `` element with [sveltekit:prefetch](#anchor-options-sveltekit-prefetch). If the next navigation is to `href`, the values returned from `load` will be used, making navigation instantaneous. Returns a `Promise` that resolves when the prefetch is complete. - `prefetchRoutes(routes)` — programmatically prefetches the code for routes that haven't yet been fetched. Typically, you might call this to speed up subsequent navigation. If no argument is given, all routes will be fetched; otherwise, you can specify routes by any matching pathname such as `/about` (to match `src/routes/about.svelte`) or `/blog/*` (to match `src/routes/blog/[slug].svelte`). Unlike `prefetch`, this won't call `load` for individual pages. Returns a `Promise` that resolves when the routes have been prefetched. +- `onBeforeNavigate((url: URL) => void | boolean | Promise)` — a navigation interceptor that triggers before we navigate to a new route. With this function we can lookup the url we are going to navigate to as well as prevent the navigation from completing. + +```js +onBeforeNavigate(async (url) => { + // the URL we are going to navigate to + console.log(url); + + // cancel or allow the navigation in the return statement + return false; +}); +``` + +- `onNavigate(() => void)` - a lifecycle function that runs when the page mounts, and also whenever SvelteKit navigates to a new URL but stays on this component. ### $app/paths diff --git a/packages/kit/.eslintrc.json b/packages/kit/.eslintrc.json index b5e49a258d98..f18580720b17 100644 --- a/packages/kit/.eslintrc.json +++ b/packages/kit/.eslintrc.json @@ -8,6 +8,8 @@ "goto": true, "invalidate": true, "prefetch": true, - "prefetchRoutes": true + "prefetchRoutes": true, + "onBeforeNavigate": true, + "onNavigate": true } } diff --git a/packages/kit/src/runtime/app/navigation.js b/packages/kit/src/runtime/app/navigation.js index 0d32fca7006c..012ac62d26f0 100644 --- a/packages/kit/src/runtime/app/navigation.js +++ b/packages/kit/src/runtime/app/navigation.js @@ -16,6 +16,8 @@ export const goto = import.meta.env.SSR ? guard('goto') : goto_; export const invalidate = import.meta.env.SSR ? guard('invalidate') : invalidate_; export const prefetch = import.meta.env.SSR ? guard('prefetch') : prefetch_; export const prefetchRoutes = import.meta.env.SSR ? guard('prefetchRoutes') : prefetchRoutes_; +export const onBeforeNavigate = import.meta.env.SSR ? () => {} : onBeforeNavigate_; +export const onNavigate = import.meta.env.SSR ? () => {} : onNavigate_; /** * @type {import('$app/navigation').goto} @@ -51,3 +53,17 @@ async function prefetchRoutes_(pathnames) { await Promise.all(promises); } + +/** + * @type {import('$app/navigation').onBeforeNavigate} + */ +function onBeforeNavigate_(fn) { + return router.on_before_navigate(fn); +} + +/** + * @type {import('$app/navigation').onNavigate} + */ +function onNavigate_(fn) { + return router.on_navigate(fn); +} diff --git a/packages/kit/src/runtime/client/router.js b/packages/kit/src/runtime/client/router.js index 81fb8941c67c..6bdbd6617fce 100644 --- a/packages/kit/src/runtime/client/router.js +++ b/packages/kit/src/runtime/client/router.js @@ -1,3 +1,5 @@ +import { getStores } from '$app/stores'; +import { onMount } from 'svelte'; import { get_base_uri } from './utils'; function scroll_state() { @@ -53,8 +55,16 @@ export class Router { // make it possible to reset focus document.body.setAttribute('tabindex', '-1'); - // create initial history entry, so we can return here - history.replaceState(history.state || {}, '', location.href); + // keeping track of the history index in order to prevent popstate navigation events if needed + this.current_history_index = history.state?.['sveltekit:index'] ?? 0; + + if (this.current_history_index === 0) { + // create initial history entry, so we can return here + history.replaceState({ ...history.state, 'sveltekit:index': 0 }, '', location.href); + } + + /** @type {((url: URL) => void | boolean | Promise)[]} */ + this.on_before_navigate_callbacks = []; } init_listeners() { @@ -122,7 +132,7 @@ export class Router { addEventListener('sveltekit:trigger_prefetch', trigger_prefetch); /** @param {MouseEvent} event */ - addEventListener('click', (event) => { + addEventListener('click', async (event) => { if (!this.enabled) return; // Adapted from https://github.com/visionmedia/page.js @@ -157,28 +167,59 @@ export class Router { if (!this.owns(url)) return; + event.preventDefault(); + + const allow_navigation = await this.trigger_on_before_navigate_callbacks(url); + if (!allow_navigation) return; + const noscroll = a.hasAttribute('sveltekit:noscroll'); const i1 = url_string.indexOf('#'); const i2 = location.href.indexOf('#'); const u1 = i1 >= 0 ? url_string.substring(0, i1) : url_string; const u2 = i2 >= 0 ? location.href.substring(0, i2) : location.href; - history.pushState({}, '', url.href); + history.pushState({ 'sveltekit:index': ++this.current_history_index }, '', url.href); if (u1 === u2) { window.dispatchEvent(new HashChangeEvent('hashchange')); } this._navigate(url, noscroll ? scroll_state() : null, false, [], url.hash); - event.preventDefault(); }); - addEventListener('popstate', (event) => { + addEventListener('popstate', async (event) => { if (event.state && this.enabled) { const url = new URL(location.href); + + const delta = this.current_history_index - event.state['sveltekit:index']; + // the delta check is used in order to prevent the double execution of the popstate event when we prevent the navigation from completing + if (delta !== 0) { + const allow_navigation = await this.trigger_on_before_navigate_callbacks(url); + if (!allow_navigation) { + // "disabling" the back/forward browser button click + history.go(delta); + return; + } + } + + this.current_history_index = event.state['sveltekit:index']; this._navigate(url, event.state['sveltekit:scroll'], false, []); } }); } + /** + * @param {URL} url + * @returns {Promise} + */ + async trigger_on_before_navigate_callbacks(url) { + if (this.on_before_navigate_callbacks.length == 0) return true; + + const allow_navigation = !( + await Promise.all(this.on_before_navigate_callbacks.map((callback) => callback(url))) + ).some((result) => result === false); + + return allow_navigation; + } + /** @param {URL} url */ owns(url) { return url.origin === location.origin && url.pathname.startsWith(this.base); @@ -216,7 +257,13 @@ export class Router { ) { const url = new URL(href, get_base_uri(document)); + const allow_navigation = await this.trigger_on_before_navigate_callbacks(url); + if (!allow_navigation) return; + if (this.enabled && this.owns(url)) { + state['sveltekit:index'] = replaceState + ? this.current_history_index + : ++this.current_history_index; history[replaceState ? 'replaceState' : 'pushState'](state, '', href); return this._navigate(url, noscroll ? scroll_state() : null, keepfocus, chain, url.hash); } @@ -249,6 +296,47 @@ export class Router { return this.renderer.load(info); } + /** @param {() => void} fn */ + on_navigate(fn) { + let mounted = false; + + const unsubscribe = getStores().page.subscribe(() => { + if (mounted) fn(); + }); + + onMount(() => { + mounted = true; + fn(); + + return () => { + unsubscribe(); + mounted = false; + }; + }); + } + + /** + * @param {(url: URL) => void | boolean | Promise} fn + */ + on_before_navigate(fn) { + onMount(() => { + const existing_on_before_navigate_callback = this.on_before_navigate_callbacks.find( + (cb) => cb === fn + ); + + if (!existing_on_before_navigate_callback) { + this.on_before_navigate_callbacks.push(fn); + } + + return () => { + const index = this.on_before_navigate_callbacks.findIndex((cb) => cb === fn); + if (index !== -1) { + this.on_before_navigate_callbacks.splice(index, 1); + } + }; + }); + } + /** * @param {URL} url * @param {{ x: number, y: number }?} scroll @@ -280,7 +368,7 @@ export class Router { if (incorrect) { info.path = has_trailing_slash ? info.path.slice(0, -1) : info.path + '/'; - history.replaceState({}, '', `${this.base}${info.path}${location.search}`); + history.replaceState(history.state || {}, '', `${this.base}${info.path}${location.search}`); } } diff --git a/packages/kit/test/ambient.d.ts b/packages/kit/test/ambient.d.ts index 9f347efdde12..eac44e1a1d09 100644 --- a/packages/kit/test/ambient.d.ts +++ b/packages/kit/test/ambient.d.ts @@ -18,6 +18,8 @@ declare global { const invalidate: (url: string) => Promise; const prefetch: (url: string) => Promise; + const onBeforeNavigate: (fn: (url: URL) => void | boolean | Promise) => void; + const onNavigate: (fn: () => void) => void; const prefetchRoutes: (urls?: string[]) => Promise; } diff --git a/packages/kit/test/apps/basics/src/routes/__layout.svelte b/packages/kit/test/apps/basics/src/routes/__layout.svelte index 070de74c9f47..969c6ed17823 100644 --- a/packages/kit/test/apps/basics/src/routes/__layout.svelte +++ b/packages/kit/test/apps/basics/src/routes/__layout.svelte @@ -12,10 +12,10 @@ + +

prevent navigation promise

+
b +
{triggered}
diff --git a/packages/kit/test/apps/basics/src/routes/routing/history/prevent-navigation.svelte b/packages/kit/test/apps/basics/src/routes/routing/history/prevent-navigation.svelte new file mode 100644 index 000000000000..ef218b321d8e --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/routing/history/prevent-navigation.svelte @@ -0,0 +1,13 @@ + + +

prevent navigation

+b +
{triggered}
diff --git a/packages/kit/test/test.js b/packages/kit/test/test.js index 9e4b7c0c1f16..6b65261bb96a 100644 --- a/packages/kit/test/test.js +++ b/packages/kit/test/test.js @@ -102,9 +102,10 @@ async function setup({ port }) { app: { /** * @param {string} url + * @param {{ replaceState?: boolean; noScroll?: boolean }} opts * @returns {Promise} */ - goto: (url) => pages.js.evaluate((url) => goto(url), url), + goto: (url, opts = {}) => pages.js.evaluate(({ url, opts }) => goto(url, opts), { url, opts }), /** * @param {string} url @@ -122,7 +123,19 @@ async function setup({ port }) { * @param {string[]} [urls] * @returns {Promise} */ - prefetchRoutes: (urls) => pages.js.evaluate((urls) => prefetchRoutes(urls), urls) + prefetchRoutes: (urls) => pages.js.evaluate((urls) => prefetchRoutes(urls), urls), + + /** + * @param {(url: URL) => void | boolean | Promise} fn + * @returns {Promise} + */ + onBeforeNavigate: (fn) => pages.js.evaluate((fn) => onBeforeNavigate(fn), fn), + + /** + * @param {() => void} fn + * @returns {Promise} + */ + onNavigate: () => pages.js.evaluate(() => onNavigate(() => {})) }, reset: () => browser && browser.close() diff --git a/packages/kit/test/types.d.ts b/packages/kit/test/types.d.ts index c81d97ded2c5..8dc99c41abe4 100644 --- a/packages/kit/test/types.d.ts +++ b/packages/kit/test/types.d.ts @@ -28,10 +28,15 @@ export interface TestContext { // these are assumed to have been put in the global scope by the layout app: { - goto(url: string): Promise; + goto( + url: string, + opts?: { replaceState?: boolean; noscroll?: boolean; keepfocus?: boolean; state?: any } + ): Promise; invalidate(url: string): Promise; prefetch(url: string): Promise; prefetchRoutes(urls?: string[]): Promise; + onBeforeNavigate(fn: (url: URL) => void | boolean | Promise): void; + onNavigate(fn: () => void): void; }; watcher: any; // watcher type is not exposed diff --git a/packages/kit/types/ambient-modules.d.ts b/packages/kit/types/ambient-modules.d.ts index ec38f363153a..dd04a1beaad0 100644 --- a/packages/kit/types/ambient-modules.d.ts +++ b/packages/kit/types/ambient-modules.d.ts @@ -67,6 +67,17 @@ declare module '$app/navigation' { * Returns a Promise that resolves when the routes have been prefetched. */ export function prefetchRoutes(routes?: string[]): Promise; + + /** + * A navigation interceptor that triggers before we navigate to a new route. + * This is helpful if we want to conditionally prevent a navigation from completing or lookup the upcoming url. + */ + export function onBeforeNavigate(fn: (url: URL) => void | boolean | Promise): any; + + /** + * A lifecycle function that runs when the page mounts, and also whenever SvelteKit navigates to a new URL but stays on this component. + */ + export function onNavigate(fn: () => void): any; } declare module '$app/paths' {