diff --git a/.changeset/hot-dogs-fry.md b/.changeset/hot-dogs-fry.md new file mode 100644 index 000000000000..8ffeb6d87d7b --- /dev/null +++ b/.changeset/hot-dogs-fry.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Adds beforeNavigate/afterNavigate lifecycle functions diff --git a/documentation/docs/05-modules.md b/documentation/docs/05-modules.md index 6cefc0074f31..609856109fc1 100644 --- a/documentation/docs/05-modules.md +++ b/documentation/docs/05-modules.md @@ -19,9 +19,19 @@ import { amp, browser, dev, mode, prerendering } from '$app/env'; ### $app/navigation ```js -import { disableScrollHandling, goto, invalidate, prefetch, prefetchRoutes } from '$app/navigation'; +import { + disableScrollHandling, + goto, + invalidate, + prefetch, + prefetchRoutes, + beforeNavigate, + afterNavigate +} from '$app/navigation'; ``` +- `afterNavigate(({ from, to }: { from: URL, to: URL }) => void)` - a lifecycle function that runs when the components mounts, and after subsequent navigations while the component remains mounted +- `beforeNavigate(({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void)` — a function that runs whenever navigation is triggered whether by clicking a link, calling `goto`, or using the browser back/forward controls. This includes navigation to external sites. `to` will be `null` if the user is closing the page. Calling `cancel` will prevent the navigation from proceeding - `disableScrollHandling` will, if called when the page is being updated following a navigation (in `onMount` or an action, for example), prevent SvelteKit from applying its normal scroll management. You should generally avoid this, as breaking user expectations of scroll behaviour can be disorienting. - `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: - `replaceState` (boolean, default `false`) If `true`, will replace the current `history` entry rather than creating a new one with `pushState` diff --git a/packages/kit/.eslintrc.json b/packages/kit/.eslintrc.json index b5e49a258d98..c8debbb5c06e 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, + "beforeNavigate": true, + "afterNavigate": true } } diff --git a/packages/kit/src/runtime/app/navigation.js b/packages/kit/src/runtime/app/navigation.js index cb7506b95a23..8b75cf27d4b9 100644 --- a/packages/kit/src/runtime/app/navigation.js +++ b/packages/kit/src/runtime/app/navigation.js @@ -19,6 +19,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 beforeNavigate = import.meta.env.SSR ? () => {} : beforeNavigate_; +export const afterNavigate = import.meta.env.SSR ? () => {} : afterNavigate_; /** * @type {import('$app/navigation').goto} @@ -61,3 +63,17 @@ async function prefetchRoutes_(pathnames) { await Promise.all(promises); } + +/** + * @type {import('$app/navigation').beforeNavigate} + */ +function beforeNavigate_(fn) { + if (router) router.before_navigate(fn); +} + +/** + * @type {import('$app/navigation').afterNavigate} + */ +function afterNavigate_(fn) { + if (router) router.after_navigate(fn); +} diff --git a/packages/kit/src/runtime/client/renderer.js b/packages/kit/src/runtime/client/renderer.js index a130fdc9b915..5a5856d5a5d8 100644 --- a/packages/kit/src/runtime/client/renderer.js +++ b/packages/kit/src/runtime/client/renderer.js @@ -291,7 +291,7 @@ export class Renderer { // opts must be passed if we're navigating if (opts) { - const { hash, scroll, keepfocus } = opts; + const { scroll, keepfocus } = opts; if (!keepfocus) { getSelection()?.removeAllRanges(); @@ -302,7 +302,7 @@ export class Renderer { await tick(); if (this.autoscroll) { - const deep_linked = hash && document.getElementById(hash.slice(1)); + const deep_linked = info.url.hash && document.getElementById(info.url.hash.slice(1)); if (scroll) { scrollTo(scroll.x, scroll.y); } else if (deep_linked) { @@ -378,6 +378,11 @@ export class Renderer { }); this.started = true; + + if (this.router) { + const navigation = { from: null, to: new URL(location.href) }; + this.router.callbacks.after_navigate.forEach((fn) => fn(navigation)); + } } /** diff --git a/packages/kit/src/runtime/client/router.js b/packages/kit/src/runtime/client/router.js index a8f87e71ffe0..a4f590bae483 100644 --- a/packages/kit/src/runtime/client/router.js +++ b/packages/kit/src/runtime/client/router.js @@ -1,3 +1,4 @@ +import { onMount } from 'svelte'; import { get_base_uri } from './utils'; function scroll_state() { @@ -53,8 +54,21 @@ 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); + } + + this.callbacks = { + /** @type {Array<({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void>} */ + before_navigate: [], + + /** @type {Array<({ from, to }: { from: URL | null, to: URL }) => void>} */ + after_navigate: [] + }; } init_listeners() { @@ -66,8 +80,23 @@ export class Router { // Reset scrollRestoration to auto when leaving page, allowing page reload // and back-navigation from other pages to use the browser to restore the // scrolling position. - addEventListener('beforeunload', () => { - history.scrollRestoration = 'auto'; + addEventListener('beforeunload', (e) => { + let should_block = false; + + const intent = { + from: this.renderer.current.url, + to: null, + cancel: () => (should_block = true) + }; + + this.callbacks.before_navigate.forEach((fn) => fn(intent)); + + if (should_block) { + e.preventDefault(); + e.returnValue = ''; + } else { + history.scrollRestoration = 'auto'; + } }); // Setting scrollRestoration to manual again when returning to this page. @@ -122,7 +151,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 @@ -155,8 +184,6 @@ export class Router { // Ignore if has a target if (a instanceof SVGAElement ? a.target.baseVal : a.target) return; - if (!this.owns(url)) return; - // Check if new url only differs by hash if (url.href.split('#')[0] === location.href.split('#')[0]) { // Call `pushState` to add url to history so going back works. @@ -169,20 +196,48 @@ export class Router { return; } - const noscroll = a.hasAttribute('sveltekit:noscroll'); - this._navigate(url, noscroll ? scroll_state() : null, false, [], url.hash, {}, 'pushState'); - event.preventDefault(); + this._navigate({ + url, + scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null, + keepfocus: false, + chain: [], + details: { + state: {}, + replaceState: false + }, + accepted: () => event.preventDefault(), + blocked: () => event.preventDefault() + }); }); addEventListener('popstate', (event) => { if (event.state && this.enabled) { - const url = new URL(location.href); - this._navigate(url, event.state['sveltekit:scroll'], false, [], url.hash, null, null); + // if a popstate-driven navigation is cancelled, we need to counteract it + // with history.go, which means we end up back here, hence this check + if (event.state['sveltekit:index'] === this.current_history_index) return; + + this._navigate({ + url: new URL(location.href), + scroll: event.state['sveltekit:scroll'], + keepfocus: false, + chain: [], + details: null, + accepted: () => { + this.current_history_index = event.state['sveltekit:index']; + }, + blocked: () => { + const delta = this.current_history_index - event.state['sveltekit:index']; + history.go(delta); + } + }); } }); } - /** @param {URL} url */ + /** + * Returns true if `url` has the same origin and basepath as the app + * @param {URL} url + */ owns(url) { return url.origin === location.origin && url.pathname.startsWith(this.base); } @@ -218,16 +273,19 @@ export class Router { ) { const url = new URL(href, get_base_uri(document)); - if (this.enabled && this.owns(url)) { - return this._navigate( + if (this.enabled) { + return this._navigate({ url, - noscroll ? scroll_state() : null, + scroll: noscroll ? scroll_state() : null, keepfocus, chain, - url.hash, - state, - replaceState ? 'replaceState' : 'pushState' - ); + details: { + state, + replaceState + }, + accepted: () => {}, + blocked: () => {} + }); } location.href = url.href; @@ -258,22 +316,73 @@ export class Router { return this.renderer.load(info); } + /** @param {({ from, to }: { from: URL | null, to: URL }) => void} fn */ + after_navigate(fn) { + onMount(() => { + this.callbacks.after_navigate.push(fn); + + return () => { + const i = this.callbacks.after_navigate.indexOf(fn); + this.callbacks.after_navigate.splice(i, 1); + }; + }); + } + /** - * @param {URL} url - * @param {{ x: number, y: number }?} scroll - * @param {boolean} keepfocus - * @param {string[]} chain - * @param {string} hash - * @param {any} state - * @param {'pushState' | 'replaceState' | null} method + * @param {({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void} fn */ - async _navigate(url, scroll, keepfocus, chain, hash, state, method) { - const info = this.parse(url); + before_navigate(fn) { + onMount(() => { + this.callbacks.before_navigate.push(fn); + + return () => { + const i = this.callbacks.before_navigate.indexOf(fn); + this.callbacks.before_navigate.splice(i, 1); + }; + }); + } + /** + * @param {{ + * url: URL; + * scroll: { x: number, y: number } | null; + * keepfocus: boolean; + * chain: string[]; + * details: { + * replaceState: boolean; + * state: any; + * } | null; + * accepted: () => void; + * blocked: () => void; + * }} opts + */ + async _navigate({ url, scroll, keepfocus, chain, details, accepted, blocked }) { + const from = this.renderer.current.url; + let should_block = false; + + const intent = { + from, + to: url, + cancel: () => (should_block = true) + }; + + this.callbacks.before_navigate.forEach((fn) => fn(intent)); + + if (should_block) { + blocked(); + return; + } + + const info = this.parse(url); if (!info) { - throw new Error('Attempted to navigate to a URL that does not belong to this app'); + location.href = url.href; + return new Promise(() => { + // never resolves + }); } + accepted(); + if (!this.navigating) { dispatchEvent(new CustomEvent('sveltekit:navigation-start')); } @@ -289,13 +398,24 @@ export class Router { } info.url = new URL(url.origin + pathname + url.search + url.hash); - if (method) history[method](state, '', info.url); - await this.renderer.handle_navigation(info, chain, false, { hash, scroll, keepfocus }); + if (details) { + const change = details.replaceState ? 0 : 1; + details.state['sveltekit:index'] = this.current_history_index += change; + history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', info.url); + } + + await this.renderer.handle_navigation(info, chain, false, { + scroll, + keepfocus + }); this.navigating--; if (!this.navigating) { dispatchEvent(new CustomEvent('sveltekit:navigation-end')); + + const navigation = { from, to: url }; + this.callbacks.after_navigate.forEach((fn) => fn(navigation)); } } } diff --git a/packages/kit/test/ambient.d.ts b/packages/kit/test/ambient.d.ts index 9f347efdde12..ea7820b66d38 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 beforeNavigate: (fn: (url: URL) => void | boolean) => void; + const afterNavigate: (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..c5421be53848 100644 --- a/packages/kit/test/apps/basics/src/routes/__layout.svelte +++ b/packages/kit/test/apps/basics/src/routes/__layout.svelte @@ -12,17 +12,31 @@ - +
{foo.bar}
diff --git a/packages/kit/test/apps/basics/src/routes/after-navigate/a.svelte b/packages/kit/test/apps/basics/src/routes/after-navigate/a.svelte new file mode 100644 index 000000000000..36560e6ef927 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/after-navigate/a.svelte @@ -0,0 +1,17 @@ + + +

{from?.pathname} -> {to?.pathname}

+
/b diff --git a/packages/kit/test/apps/basics/src/routes/after-navigate/b.svelte b/packages/kit/test/apps/basics/src/routes/after-navigate/b.svelte new file mode 100644 index 000000000000..c1e43e302e50 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/after-navigate/b.svelte @@ -0,0 +1,17 @@ + + +

{from?.pathname} -> {to?.pathname}

+/a diff --git a/packages/kit/test/apps/basics/src/routes/before-navigate/a.svelte b/packages/kit/test/apps/basics/src/routes/before-navigate/a.svelte new file mode 100644 index 000000000000..57f9d40797b8 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/before-navigate/a.svelte @@ -0,0 +1 @@ +

a

diff --git a/packages/kit/test/apps/basics/src/routes/before-navigate/prevent-navigation.svelte b/packages/kit/test/apps/basics/src/routes/before-navigate/prevent-navigation.svelte new file mode 100644 index 000000000000..4d64f38da172 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/before-navigate/prevent-navigation.svelte @@ -0,0 +1,13 @@ + + +

prevent navigation

+a +
{triggered}
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 93f9854c49cb..e8051442208e 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -87,6 +87,66 @@ test.describe.parallel('a11y', () => { }); }); +test.describe.parallel('afterNavigate', () => { + test.skip(({ javaScriptEnabled }) => !javaScriptEnabled); + + test('calls callback', async ({ page, clicknav }) => { + await page.goto('/after-navigate/a'); + expect(await page.textContent('h1')).toBe('undefined -> /after-navigate/a'); + + await clicknav('[href="/after-navigate/b"]'); + expect(await page.textContent('h1')).toBe('/after-navigate/a -> /after-navigate/b'); + }); +}); + +test.describe.parallel('beforeNavigate', () => { + test.skip(({ javaScriptEnabled }) => !javaScriptEnabled); + + test('prevents navigation triggered by link click', async ({ clicknav, page, baseURL }) => { + await page.goto('/before-navigate/prevent-navigation'); + + try { + await clicknav('[href="/before-navigate/a"]'); + expect(false).toBe(true); + } catch (/** @type {any} */ e) { + expect(e.message).toMatch('Timed out'); + } + + expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); + expect(await page.innerHTML('pre')).toBe('true'); + }); + + test('prevents navigation triggered by goto', async ({ page, app, baseURL }) => { + await page.goto('/before-navigate/prevent-navigation'); + await app.goto('/before-navigate/a'); + expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); + expect(await page.innerHTML('pre')).toBe('true'); + }); + + test('prevents navigation triggered by back button', async ({ page, app, baseURL }) => { + await page.goto('/before-navigate/a'); + + await app.goto('/before-navigate/prevent-navigation'); + await page.goBack(); + expect(page.url()).toBe(baseURL + '/before-navigate/prevent-navigation'); + expect(await page.innerHTML('pre')).toBe('true'); + }); + + test('prevents unload', async ({ page }) => { + await page.goto('/before-navigate/prevent-navigation'); + + const type = new Promise((fulfil) => { + page.on('dialog', async (dialog) => { + fulfil(dialog.type()); + await dialog.dismiss(); + }); + }); + + await page.close({ runBeforeUnload: true }); + expect(await type).toBe('beforeunload'); + }); +}); + test.describe('Scrolling', () => { // skip these tests if JS is disabled, since we're testing client-side behaviour test.skip(({ javaScriptEnabled }) => !javaScriptEnabled); diff --git a/packages/kit/test/utils.d.ts b/packages/kit/test/utils.d.ts index 38869c167515..194880ad7948 100644 --- a/packages/kit/test/utils.d.ts +++ b/packages/kit/test/utils.d.ts @@ -12,8 +12,10 @@ export const test: TestType< PlaywrightTestArgs & PlaywrightTestOptions & { app: { - goto: (url: string) => Promise; + goto: (url: string, opts?: { replaceState?: boolean }) => Promise; invalidate: (url: string) => Promise; + beforeNavigate: (url: URL) => void | boolean; + afterNavigate: (url: URL) => void; prefetch: (url: string) => Promise; prefetchRoutes: (urls: string[]) => Promise; }; diff --git a/packages/kit/test/utils.js b/packages/kit/test/utils.js index cdc213217742..9c68598f599e 100644 --- a/packages/kit/test/utils.js +++ b/packages/kit/test/utils.js @@ -10,9 +10,15 @@ export const test = base.extend({ use({ /** * @param {string} url + * @param {{ replaceState?: boolean }} opts * @returns {Promise} */ - goto: (url) => page.evaluate((/** @type {string} */ url) => goto(url), url), + goto: (url, opts) => + page.evaluate( + (/** @type {{ url: string, opts: { replaceState?: boolean } }} */ { url, opts }) => + goto(url, opts), + { url, opts } + ), /** * @param {string} url @@ -20,6 +26,19 @@ export const test = base.extend({ */ invalidate: (url) => page.evaluate((/** @type {string} */ url) => invalidate(url), url), + /** + * @param {(url: URL) => void | boolean | Promise} fn + * @returns {Promise} + */ + beforeNavigate: (fn) => + page.evaluate((/** @type {(url: URL) => any} */ fn) => beforeNavigate(fn), fn), + + /** + * @param {() => void} fn + * @returns {Promise} + */ + afterNavigate: () => page.evaluate(() => afterNavigate(() => {})), + /** * @param {string} url * @returns {Promise} diff --git a/packages/kit/types/ambient-modules.d.ts b/packages/kit/types/ambient-modules.d.ts index 608456d856b3..c4c9671554af 100644 --- a/packages/kit/types/ambient-modules.d.ts +++ b/packages/kit/types/ambient-modules.d.ts @@ -72,6 +72,19 @@ 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 beforeNavigate( + fn: ({ from, to, cancel }: { from: URL; to: URL | null; cancel: () => void }) => void + ): 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 afterNavigate(fn: ({ from, to }: { from: URL | null; to: URL }) => void): any; } declare module '$app/paths' {