Skip to content

Commit cb44e28

Browse files
Rich-Harrispavelbelyaev98benmccannbluwyPatrickG
authored
[feat] beforeNavigate and afterNavigate lifecycle functions (#3293)
* feature/add-onBeforeNavigate-listner * minor fixes * naming * minor naming changs * minor improvements * implement requested changes * add newline * newline fix * docs fixes * docs fixes #2 * docs fixes #2 * docs spacing * minor docs fix * speeling fix * remove if(browser) check * types * snake_case * snake case #2 * naming * Update documentation/docs/05-modules.md Co-authored-by: Ben McCann <[email protected]> * Update pnpm-lock.yaml * add onNavigate method * Update packages/kit/src/runtime/client/router.js Co-authored-by: Bjorn Lu <[email protected]> * Update packages/kit/test/test.js Co-authored-by: Bjorn Lu <[email protected]> * minor naming and types improvements * add docs * mroe docs fixes * snake case * improve back/forwar browser handling * improve sveltekit:index * remove custom event, add callbacks. * typo fix * docs * remove logs * namig * Update packages/kit/src/runtime/client/router.js Co-authored-by: Bjorn Lu <[email protected]> * fixes * improve types * nicer format * Added second parameter to `goto` function in tests * Fixed `history.state` when fixing trailing slash * Added tests for `onBeforeNavigate` * lints * add more tests * reafactor * fix if statement * fix if statement 2 * remove unused files * fix test * patch changeset * rename to beforeNavigate/afterNavigate * typo * make beforeNavigate callbacks synchronous * rename internal functions * make _navigate take non-position arguments. bit clearer * remove sveltekit:index tests - this is non-user-facing implementation detail * typo * remove more sveltekit:index stuff * separate out beforeNavigate tests * simplify tests * rename tests * handle no-router case * simplify some stuff * only pass scroll/keepfocus to renderer * focus beforeNavigate tests * manage history index inside _navigate * neaten up * allow _navigate to handle external links; centralise logic * pass { from, to, cancel } to beforeNavigate callbacks * lint * block unload if appropriate * make goto work with external urls * unfocus tests * typechecking * no longer needs to be async * update docs * call afterNavigate callbacks on page load * typechecking * Update documentation/docs/05-modules.md Co-authored-by: Ben McCann <[email protected]> Co-authored-by: pavelBelyaev98 <[email protected]> Co-authored-by: Pavel Belyaev <[email protected]> Co-authored-by: Ben McCann <[email protected]> Co-authored-by: Bjorn Lu <[email protected]> Co-authored-by: Patrick <[email protected]> Co-authored-by: Ignatius Bagus <[email protected]>
1 parent 74717a5 commit cb44e28

File tree

16 files changed

+357
-41
lines changed

16 files changed

+357
-41
lines changed

.changeset/hot-dogs-fry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
Adds beforeNavigate/afterNavigate lifecycle functions

documentation/docs/05-modules.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,19 @@ import { amp, browser, dev, mode, prerendering } from '$app/env';
1919
### $app/navigation
2020

2121
```js
22-
import { disableScrollHandling, goto, invalidate, prefetch, prefetchRoutes } from '$app/navigation';
22+
import {
23+
disableScrollHandling,
24+
goto,
25+
invalidate,
26+
prefetch,
27+
prefetchRoutes,
28+
beforeNavigate,
29+
afterNavigate
30+
} from '$app/navigation';
2331
```
2432

33+
- `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
34+
- `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
2535
- `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.
2636
- `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:
2737
- `replaceState` (boolean, default `false`) If `true`, will replace the current `history` entry rather than creating a new one with `pushState`

packages/kit/.eslintrc.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"goto": true,
99
"invalidate": true,
1010
"prefetch": true,
11-
"prefetchRoutes": true
11+
"prefetchRoutes": true,
12+
"beforeNavigate": true,
13+
"afterNavigate": true
1214
}
1315
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export const goto = import.meta.env.SSR ? guard('goto') : goto_;
1919
export const invalidate = import.meta.env.SSR ? guard('invalidate') : invalidate_;
2020
export const prefetch = import.meta.env.SSR ? guard('prefetch') : prefetch_;
2121
export const prefetchRoutes = import.meta.env.SSR ? guard('prefetchRoutes') : prefetchRoutes_;
22+
export const beforeNavigate = import.meta.env.SSR ? () => {} : beforeNavigate_;
23+
export const afterNavigate = import.meta.env.SSR ? () => {} : afterNavigate_;
2224

2325
/**
2426
* @type {import('$app/navigation').goto}
@@ -61,3 +63,17 @@ async function prefetchRoutes_(pathnames) {
6163

6264
await Promise.all(promises);
6365
}
66+
67+
/**
68+
* @type {import('$app/navigation').beforeNavigate}
69+
*/
70+
function beforeNavigate_(fn) {
71+
if (router) router.before_navigate(fn);
72+
}
73+
74+
/**
75+
* @type {import('$app/navigation').afterNavigate}
76+
*/
77+
function afterNavigate_(fn) {
78+
if (router) router.after_navigate(fn);
79+
}

packages/kit/src/runtime/client/renderer.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ export class Renderer {
291291

292292
// opts must be passed if we're navigating
293293
if (opts) {
294-
const { hash, scroll, keepfocus } = opts;
294+
const { scroll, keepfocus } = opts;
295295

296296
if (!keepfocus) {
297297
getSelection()?.removeAllRanges();
@@ -302,7 +302,7 @@ export class Renderer {
302302
await tick();
303303

304304
if (this.autoscroll) {
305-
const deep_linked = hash && document.getElementById(hash.slice(1));
305+
const deep_linked = info.url.hash && document.getElementById(info.url.hash.slice(1));
306306
if (scroll) {
307307
scrollTo(scroll.x, scroll.y);
308308
} else if (deep_linked) {
@@ -378,6 +378,11 @@ export class Renderer {
378378
});
379379

380380
this.started = true;
381+
382+
if (this.router) {
383+
const navigation = { from: null, to: new URL(location.href) };
384+
this.router.callbacks.after_navigate.forEach((fn) => fn(navigation));
385+
}
381386
}
382387

383388
/**

packages/kit/src/runtime/client/router.js

Lines changed: 152 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { onMount } from 'svelte';
12
import { get_base_uri } from './utils';
23

34
function scroll_state() {
@@ -53,8 +54,21 @@ export class Router {
5354
// make it possible to reset focus
5455
document.body.setAttribute('tabindex', '-1');
5556

56-
// create initial history entry, so we can return here
57-
history.replaceState(history.state || {}, '', location.href);
57+
// keeping track of the history index in order to prevent popstate navigation events if needed
58+
this.current_history_index = history.state?.['sveltekit:index'] ?? 0;
59+
60+
if (this.current_history_index === 0) {
61+
// create initial history entry, so we can return here
62+
history.replaceState({ ...history.state, 'sveltekit:index': 0 }, '', location.href);
63+
}
64+
65+
this.callbacks = {
66+
/** @type {Array<({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void>} */
67+
before_navigate: [],
68+
69+
/** @type {Array<({ from, to }: { from: URL | null, to: URL }) => void>} */
70+
after_navigate: []
71+
};
5872
}
5973

6074
init_listeners() {
@@ -66,8 +80,23 @@ export class Router {
6680
// Reset scrollRestoration to auto when leaving page, allowing page reload
6781
// and back-navigation from other pages to use the browser to restore the
6882
// scrolling position.
69-
addEventListener('beforeunload', () => {
70-
history.scrollRestoration = 'auto';
83+
addEventListener('beforeunload', (e) => {
84+
let should_block = false;
85+
86+
const intent = {
87+
from: this.renderer.current.url,
88+
to: null,
89+
cancel: () => (should_block = true)
90+
};
91+
92+
this.callbacks.before_navigate.forEach((fn) => fn(intent));
93+
94+
if (should_block) {
95+
e.preventDefault();
96+
e.returnValue = '';
97+
} else {
98+
history.scrollRestoration = 'auto';
99+
}
71100
});
72101

73102
// Setting scrollRestoration to manual again when returning to this page.
@@ -122,7 +151,7 @@ export class Router {
122151
addEventListener('sveltekit:trigger_prefetch', trigger_prefetch);
123152

124153
/** @param {MouseEvent} event */
125-
addEventListener('click', (event) => {
154+
addEventListener('click', async (event) => {
126155
if (!this.enabled) return;
127156

128157
// Adapted from https://github.com/visionmedia/page.js
@@ -155,8 +184,6 @@ export class Router {
155184
// Ignore if <a> has a target
156185
if (a instanceof SVGAElement ? a.target.baseVal : a.target) return;
157186

158-
if (!this.owns(url)) return;
159-
160187
// Check if new url only differs by hash
161188
if (url.href.split('#')[0] === location.href.split('#')[0]) {
162189
// Call `pushState` to add url to history so going back works.
@@ -169,20 +196,48 @@ export class Router {
169196
return;
170197
}
171198

172-
const noscroll = a.hasAttribute('sveltekit:noscroll');
173-
this._navigate(url, noscroll ? scroll_state() : null, false, [], url.hash, {}, 'pushState');
174-
event.preventDefault();
199+
this._navigate({
200+
url,
201+
scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null,
202+
keepfocus: false,
203+
chain: [],
204+
details: {
205+
state: {},
206+
replaceState: false
207+
},
208+
accepted: () => event.preventDefault(),
209+
blocked: () => event.preventDefault()
210+
});
175211
});
176212

177213
addEventListener('popstate', (event) => {
178214
if (event.state && this.enabled) {
179-
const url = new URL(location.href);
180-
this._navigate(url, event.state['sveltekit:scroll'], false, [], url.hash, null, null);
215+
// if a popstate-driven navigation is cancelled, we need to counteract it
216+
// with history.go, which means we end up back here, hence this check
217+
if (event.state['sveltekit:index'] === this.current_history_index) return;
218+
219+
this._navigate({
220+
url: new URL(location.href),
221+
scroll: event.state['sveltekit:scroll'],
222+
keepfocus: false,
223+
chain: [],
224+
details: null,
225+
accepted: () => {
226+
this.current_history_index = event.state['sveltekit:index'];
227+
},
228+
blocked: () => {
229+
const delta = this.current_history_index - event.state['sveltekit:index'];
230+
history.go(delta);
231+
}
232+
});
181233
}
182234
});
183235
}
184236

185-
/** @param {URL} url */
237+
/**
238+
* Returns true if `url` has the same origin and basepath as the app
239+
* @param {URL} url
240+
*/
186241
owns(url) {
187242
return url.origin === location.origin && url.pathname.startsWith(this.base);
188243
}
@@ -218,16 +273,19 @@ export class Router {
218273
) {
219274
const url = new URL(href, get_base_uri(document));
220275

221-
if (this.enabled && this.owns(url)) {
222-
return this._navigate(
276+
if (this.enabled) {
277+
return this._navigate({
223278
url,
224-
noscroll ? scroll_state() : null,
279+
scroll: noscroll ? scroll_state() : null,
225280
keepfocus,
226281
chain,
227-
url.hash,
228-
state,
229-
replaceState ? 'replaceState' : 'pushState'
230-
);
282+
details: {
283+
state,
284+
replaceState
285+
},
286+
accepted: () => {},
287+
blocked: () => {}
288+
});
231289
}
232290

233291
location.href = url.href;
@@ -258,22 +316,73 @@ export class Router {
258316
return this.renderer.load(info);
259317
}
260318

319+
/** @param {({ from, to }: { from: URL | null, to: URL }) => void} fn */
320+
after_navigate(fn) {
321+
onMount(() => {
322+
this.callbacks.after_navigate.push(fn);
323+
324+
return () => {
325+
const i = this.callbacks.after_navigate.indexOf(fn);
326+
this.callbacks.after_navigate.splice(i, 1);
327+
};
328+
});
329+
}
330+
261331
/**
262-
* @param {URL} url
263-
* @param {{ x: number, y: number }?} scroll
264-
* @param {boolean} keepfocus
265-
* @param {string[]} chain
266-
* @param {string} hash
267-
* @param {any} state
268-
* @param {'pushState' | 'replaceState' | null} method
332+
* @param {({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void} fn
269333
*/
270-
async _navigate(url, scroll, keepfocus, chain, hash, state, method) {
271-
const info = this.parse(url);
334+
before_navigate(fn) {
335+
onMount(() => {
336+
this.callbacks.before_navigate.push(fn);
337+
338+
return () => {
339+
const i = this.callbacks.before_navigate.indexOf(fn);
340+
this.callbacks.before_navigate.splice(i, 1);
341+
};
342+
});
343+
}
272344

345+
/**
346+
* @param {{
347+
* url: URL;
348+
* scroll: { x: number, y: number } | null;
349+
* keepfocus: boolean;
350+
* chain: string[];
351+
* details: {
352+
* replaceState: boolean;
353+
* state: any;
354+
* } | null;
355+
* accepted: () => void;
356+
* blocked: () => void;
357+
* }} opts
358+
*/
359+
async _navigate({ url, scroll, keepfocus, chain, details, accepted, blocked }) {
360+
const from = this.renderer.current.url;
361+
let should_block = false;
362+
363+
const intent = {
364+
from,
365+
to: url,
366+
cancel: () => (should_block = true)
367+
};
368+
369+
this.callbacks.before_navigate.forEach((fn) => fn(intent));
370+
371+
if (should_block) {
372+
blocked();
373+
return;
374+
}
375+
376+
const info = this.parse(url);
273377
if (!info) {
274-
throw new Error('Attempted to navigate to a URL that does not belong to this app');
378+
location.href = url.href;
379+
return new Promise(() => {
380+
// never resolves
381+
});
275382
}
276383

384+
accepted();
385+
277386
if (!this.navigating) {
278387
dispatchEvent(new CustomEvent('sveltekit:navigation-start'));
279388
}
@@ -289,13 +398,24 @@ export class Router {
289398
}
290399

291400
info.url = new URL(url.origin + pathname + url.search + url.hash);
292-
if (method) history[method](state, '', info.url);
293401

294-
await this.renderer.handle_navigation(info, chain, false, { hash, scroll, keepfocus });
402+
if (details) {
403+
const change = details.replaceState ? 0 : 1;
404+
details.state['sveltekit:index'] = this.current_history_index += change;
405+
history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', info.url);
406+
}
407+
408+
await this.renderer.handle_navigation(info, chain, false, {
409+
scroll,
410+
keepfocus
411+
});
295412

296413
this.navigating--;
297414
if (!this.navigating) {
298415
dispatchEvent(new CustomEvent('sveltekit:navigation-end'));
416+
417+
const navigation = { from, to: url };
418+
this.callbacks.after_navigate.forEach((fn) => fn(navigation));
299419
}
300420
}
301421
}

packages/kit/test/ambient.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ declare global {
1818

1919
const invalidate: (url: string) => Promise<void>;
2020
const prefetch: (url: string) => Promise<void>;
21+
const beforeNavigate: (fn: (url: URL) => void | boolean) => void;
22+
const afterNavigate: (fn: () => void) => void;
2123
const prefetchRoutes: (urls?: string[]) => Promise<void>;
2224
}
2325

0 commit comments

Comments
 (0)