Skip to content

"onBeforeNavigate" navigation interceptor and "onNavigate" lifecycle function #2982

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 57 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
7496981
feature/add-onBeforeNavigate-listner
zommerberg Dec 4, 2021
3598ff0
minor fixes
zommerberg Dec 4, 2021
d5e665b
naming
zommerberg Dec 5, 2021
83e145b
minor naming changs
zommerberg Dec 5, 2021
667ae97
minor improvements
zommerberg Dec 5, 2021
a6cba14
implement requested changes
zommerberg Dec 8, 2021
e8fff7f
add newline
zommerberg Dec 8, 2021
a521968
newline fix
zommerberg Dec 8, 2021
c19f3cd
docs fixes
zommerberg Dec 8, 2021
73f8d2b
docs fixes #2
zommerberg Dec 8, 2021
32f305e
docs fixes #2
zommerberg Dec 8, 2021
c0a409b
docs spacing
zommerberg Dec 8, 2021
4d54105
minor docs fix
zommerberg Dec 8, 2021
9014c14
speeling fix
zommerberg Dec 8, 2021
a2e46ef
remove if(browser) check
zommerberg Dec 10, 2021
dc3773d
types
zommerberg Dec 10, 2021
7c9def1
snake_case
zommerberg Dec 12, 2021
6055ae7
snake case #2
zommerberg Dec 12, 2021
0c53d10
naming
zommerberg Dec 12, 2021
6c909d4
Update documentation/docs/05-modules.md
zommerberg Dec 13, 2021
3864e40
Update pnpm-lock.yaml
zommerberg Dec 13, 2021
e4c56e1
Merge branch 'sveltejs:master' into master
zommerberg Dec 13, 2021
4d71414
add onNavigate method
zommerberg Dec 13, 2021
714aad1
Merge branch 'sveltejs:master' into master
zommerberg Dec 14, 2021
6ec847a
Merge branch 'sveltejs:master' into master
zommerberg Dec 16, 2021
79e35d6
Merge branch 'sveltejs:master' into master
zommerberg Dec 22, 2021
4d7c34f
Update packages/kit/src/runtime/client/router.js
zommerberg Dec 23, 2021
406d43a
Update packages/kit/test/test.js
zommerberg Dec 23, 2021
a795d78
minor naming and types improvements
zommerberg Dec 23, 2021
0e168e0
add docs
zommerberg Dec 23, 2021
d949133
mroe docs fixes
zommerberg Dec 23, 2021
41c2c56
snake case
zommerberg Dec 23, 2021
42f280d
improve back/forwar browser handling
zommerberg Dec 24, 2021
15f97ba
improve sveltekit:index
zommerberg Dec 24, 2021
a0f3b2f
remove custom event, add callbacks.
zommerberg Dec 24, 2021
9da5934
typo fix
zommerberg Dec 24, 2021
eafddd4
docs
zommerberg Dec 24, 2021
0709fad
remove logs
zommerberg Dec 24, 2021
34cc46e
namig
zommerberg Dec 24, 2021
35af505
Merge branch 'sveltejs:master' into master
zommerberg Dec 26, 2021
da3ad4b
Update packages/kit/src/runtime/client/router.js
zommerberg Dec 28, 2021
7a63543
fixes
zommerberg Dec 28, 2021
9f78e51
improve types
zommerberg Dec 28, 2021
460bb3a
nicer format
zommerberg Dec 28, 2021
90d7689
Added second parameter to `goto` function in tests
PatrickG Dec 28, 2021
0bfa83c
Fixed `history.state` when fixing trailing slash
PatrickG Dec 28, 2021
e5c2056
Added tests for `onBeforeNavigate`
PatrickG Dec 28, 2021
37cf894
Merge pull request #1 from PatrickG/master
zommerberg Dec 28, 2021
efb271f
lints
zommerberg Dec 28, 2021
2ff3e9b
Merge branch 'sveltejs:master' into master
zommerberg Dec 28, 2021
bee8487
add more tests
zommerberg Dec 28, 2021
e679bb4
Merge branch 'sveltejs:master' into master
zommerberg Dec 28, 2021
4a9a355
Merge branch 'sveltejs:master' into master
zommerberg Dec 29, 2021
c86cac6
Merge branch 'sveltejs:master' into master
zommerberg Dec 30, 2021
656e7de
reafactor
zommerberg Jan 2, 2022
a8be2d6
fix if statement
zommerberg Jan 2, 2022
eea4279
fix if statement 2
zommerberg Jan 2, 2022
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/hot-dogs-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

Adds a "onBeforeNavigate" navigation interceptor and a "onNavigate" lifecycle function
22 changes: 21 additions & 1 deletion documentation/docs/05-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 `<a>` 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<void | boolean>)` — 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

Expand Down
4 changes: 3 additions & 1 deletion packages/kit/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"goto": true,
"invalidate": true,
"prefetch": true,
"prefetchRoutes": true
"prefetchRoutes": true,
"onBeforeNavigate": true,
"onNavigate": true
}
}
16 changes: 16 additions & 0 deletions packages/kit/src/runtime/app/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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);
}
102 changes: 95 additions & 7 deletions packages/kit/src/runtime/client/router.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getStores } from '$app/stores';
import { onMount } from 'svelte';
import { get_base_uri } from './utils';

function scroll_state() {
Expand Down Expand Up @@ -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<void | boolean>)[]} */
this.on_before_navigate_callbacks = [];
}

init_listeners() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<boolean>}
*/
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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<void | boolean>} 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
Expand Down Expand Up @@ -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}`);
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/kit/test/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ declare global {

const invalidate: (url: string) => Promise<void>;
const prefetch: (url: string) => Promise<void>;
const onBeforeNavigate: (fn: (url: URL) => void | boolean | Promise<void | boolean>) => void;
const onNavigate: (fn: () => void) => void;
const prefetchRoutes: (urls?: string[]) => Promise<void>;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/kit/test/apps/basics/src/routes/__layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
</script>

<script>
import { goto, invalidate, prefetch, prefetchRoutes } from '$app/navigation';
import { goto, invalidate, prefetch, prefetchRoutes, onBeforeNavigate, onNavigate } from '$app/navigation';

if (typeof window !== 'undefined') {
Object.assign(window, { goto, invalidate, prefetch, prefetchRoutes });
Object.assign(window, { goto, invalidate, prefetch, prefetchRoutes, onBeforeNavigate, onNavigate });
}

/** @type {{ bar: string }} */
Expand Down
107 changes: 107 additions & 0 deletions packages/kit/test/apps/basics/src/routes/routing/_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,113 @@ export default function (test, is_dev) {
assert.equal(page.url(), 'https://www.google.com/');
});


test('history index gets set on first render', '/routing/history/a', async ({ js, page }) => {
if (js) {
const state = await page.evaluate('history.state');
assert.equal(state?.['sveltekit:index'], 0);
}
});

test('history index increases after navigating by clicking a link', '/routing/history/a', async ({ js, page, clicknav }) => {
if (js) {
await clicknav('[href="/routing/history/b"]');
const state = await page.evaluate('history.state');
assert.equal(state?.['sveltekit:index'], 1);
}
});

test('history index increases after navigating by using goto', '/routing/history/a', async ({ js, app, base, page }) => {
if (js) {
await app.goto(base + '/routing/history/b');
const state = await page.evaluate('history.state');
assert.equal(state?.['sveltekit:index'], 1);
}
});

test('history index stays after navigating by using goto with replaceState', '/routing/history/a', async ({ js, app, base, page }) => {
if (js) {
await app.goto(base + '/routing/history/b', { replaceState: true });
const state = await page.evaluate('history.state');
assert.equal(state?.['sveltekit:index'], 0);
}
});

test('history index stays after fixing tralingSlash', '/routing/history/a', async ({ js, app, base, page }) => {
if (js) {
await app.goto(base + '/routing/history/b/');
const state = await page.evaluate('history.state');
assert.equal(state?.['sveltekit:index'], 1);
}
});

test('history index decreases after navigating back', '/routing/history/a', async ({ js, clicknav, app, base, page }) => {
if (js) {
await clicknav('[href="/routing/history/b"]');
await app.goto(base + '/routing/history/c');
await page.goBack();
const state1 = await page.evaluate('history.state');
assert.equal(state1?.['sveltekit:index'], 1);
await clicknav('button');
const state2 = await page.evaluate('history.state');
assert.equal(state2?.['sveltekit:index'], 0);
await clicknav('[href="/routing/history/b"]');
const state3 = await page.evaluate('history.state');
assert.equal(state3?.['sveltekit:index'], 1);
}
});

test('history index survives a reload', '/routing/history/a', async ({ js, clicknav, app, base, page }) => {
if (js) {
await clicknav('[href="/routing/history/b"]');
await page.reload({ waitUntil: 'networkidle' });
await app.goto(base + '/routing/history/c');
const state = await page.evaluate('history.state');
assert.equal(state?.['sveltekit:index'], 2);
}
});

test('onBeforeNavigate can prevent navigation by clicking a link', '/routing/history/a', async ({ js, clicknav, page, app, base }) => {
if (js) {
await app.goto(base + '/routing/history/prevent-navigation');

try {
await clicknav('[href="/routing/history/b"]');
assert.unreachable('should have thrown');
} catch (/** @type {any} */ e) {
assert.instance(e, Error);
assert.match(e.message, 'Timed out');
}

const state = await page.evaluate('history.state');
assert.equal(state?.['sveltekit:index'], 1);
assert.equal(page.url(), base + '/routing/history/prevent-navigation');
assert.equal(await page.innerHTML('pre'), 'true', 'onBeforeNavigate not triggered');
}
});

test('onBeforeNavigate can prevent navigation by using goto', '/routing/history/a', async ({ js, page, app, base }) => {
if (js) {
await app.goto(base + '/routing/history/prevent-navigation-promise');
await app.goto(base + '/routing/history/b');
const state = await page.evaluate('history.state');
assert.equal(state?.['sveltekit:index'], 1);
assert.equal(page.url(), base + '/routing/history/prevent-navigation-promise');
assert.equal(await page.innerHTML('pre'), 'true', 'onBeforeNavigate not triggered');
}
});

test('onBeforeNavigate can prevent navigation using the browser controls', '/routing/history/a', async ({ js, page, app, base }) => {
if (js) {
await app.goto(base + '/routing/history/prevent-navigation');
await page.goBack();
const state = await page.evaluate('history.state');
assert.equal(state?.['sveltekit:index'], 1);
assert.equal(page.url(), base + '/routing/history/prevent-navigation');
assert.equal(await page.innerHTML('pre'), 'true', 'onBeforeNavigate not triggered');
}
});

// skipping this test because it causes a bunch of failures locally
test.skip('watch new route in dev', '/routing', async ({ page, base, js, watcher }) => {
if (!is_dev || js) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<h1>a</h1>
<a href="/routing/history/b">b</a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<h1>b</h1>
<button on:click={() => history.back()}>go back</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<h1>c</h1>
<button on:click={() => history.back()}>go back</button>
Loading