Skip to content

Commit

Permalink
feat: add onNavigate lifecycle function, to enable view transitions (
Browse files Browse the repository at this point in the history
…#9605)

Fixes #5689. This adds an onNavigate lifecycle function.

---------

Co-authored-by: Rich Harris <git@rich-harris.dev>
Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
Co-authored-by: Geoff Rich <4992896+geoffrich@users.noreply.github.com>
Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com>
Co-authored-by: Tee Ming <chewteeming01@gmail.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>
  • Loading branch information
7 people committed Aug 29, 2023
1 parent 7670f86 commit 86c7199
Show file tree
Hide file tree
Showing 17 changed files with 292 additions and 131 deletions.
5 changes: 5 additions & 0 deletions .changeset/fifty-plants-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: onNavigate lifecycle function
31 changes: 27 additions & 4 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,7 @@ export interface LoadEvent<
*
* Setting the same header multiple times (even in separate `load` functions) is an error — you can only set a given header once.
*
* You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](https://kit.svelte.dev/docs/types#public-types-cookies) API in a server-only `load` function instead.
* You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](https://kit.svelte.dev/docs/types#public-types-cookies) API in a server-only `load` function instead.
*
* `setHeaders` has no effect when a `load` function runs in the browser.
*/
Expand Down Expand Up @@ -860,6 +860,11 @@ export interface Navigation {
* In case of a history back/forward navigation, the number of steps to go back/forward
*/
delta?: number;
/**
* A promise that resolves once the navigation is complete, and rejects if the navigation
* fails or is aborted. In the case of a `willUnload` navigation, the promise will never resolve
*/
complete: Promise<void>;
}

/**
Expand All @@ -872,6 +877,24 @@ export interface BeforeNavigate extends Navigation {
cancel(): void;
}

/**
* The argument passed to [`onNavigate`](https://kit.svelte.dev/docs/modules#$app-navigation-onnavigate) callbacks.
*/
export interface OnNavigate extends Navigation {
/**
* The type of navigation:
* - `form`: The user submitted a `<form>`
* - `link`: Navigation was triggered by a link click
* - `goto`: Navigation was triggered by a `goto(...)` call or a redirect
* - `popstate`: Navigation was triggered by back/forward navigation
*/
type: Exclude<NavigationType, 'enter' | 'leave'>;
/**
* Since `onNavigate` callbacks are called immediately before a client-side navigation, they will never be called with a navigation that unloads the page.
*/
willUnload: false;
}

/**
* The argument passed to [`afterNavigate`](https://kit.svelte.dev/docs/modules#$app-navigation-afternavigate) callbacks.
*/
Expand All @@ -886,7 +909,7 @@ export interface AfterNavigate extends Omit<Navigation, 'type'> {
*/
type: Exclude<NavigationType, 'leave'>;
/**
* Since `afterNavigate` is called after a navigation completes, it will never be called with a navigation that unloads the page.
* Since `afterNavigate` callbacks are called after a navigation completes, they will never be called with a navigation that unloads the page.
*/
willUnload: false;
}
Expand Down Expand Up @@ -1007,7 +1030,7 @@ export interface RequestEvent<
*
* Setting the same header multiple times (even in separate `load` functions) is an error — you can only set a given header once.
*
* You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](https://kit.svelte.dev/docs/types#public-types-cookies) API instead.
* You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](https://kit.svelte.dev/docs/types#public-types-cookies) API instead.
*/
setHeaders(headers: Record<string, string>): void;
/**
Expand Down Expand Up @@ -1064,7 +1087,7 @@ export interface RouteDefinition<Config = any> {
methods: HttpMethod[];
};
page: {
methods: Extract<HttpMethod, 'GET' | 'POST'>[];
methods: Array<Extract<HttpMethod, 'GET' | 'POST'>>;
};
pattern: RegExp;
prerender: PrerenderOption;
Expand Down
14 changes: 14 additions & 0 deletions packages/kit/src/runtime/app/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ export const preloadCode = /* @__PURE__ */ client_method('preload_code');
*/
export const beforeNavigate = /* @__PURE__ */ client_method('before_navigate');

/**
* A lifecycle function that runs the supplied `callback` immediately before we navigate to a new URL.
*
* If you return a `Promise`, SvelteKit will wait for it to resolve before completing the navigation. This allows you to — for example — use `document.startViewTransition`. Avoid promises that are slow to resolve, since navigation will appear stalled to the user.
*
* If a function (or a `Promise` that resolves to a function) is returned from the callback, it will be called once the DOM has updated.
*
* `onNavigate` must be called during a component initialization. It remains active as long as the component is mounted.
* @type {(callback: (navigation: import('@sveltejs/kit').OnNavigate) => import('../../types/internal.js').MaybePromise<(() => void) | void>) => void}
* @param {(navigation: import('@sveltejs/kit').OnNavigate) => void} callback
* @returns {void}
*/
export const onNavigate = /* @__PURE__ */ client_method('on_navigate');

/**
* A lifecycle function that runs the supplied `callback` when the current component mounts, and also whenever we navigate to a new URL.
*
Expand Down
137 changes: 104 additions & 33 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export function create_client(app, target) {
/** @type {Array<(navigation: import('@sveltejs/kit').BeforeNavigate) => void>} */
before_navigate: [],

/** @type {Array<(navigation: import('@sveltejs/kit').OnNavigate) => import('types').MaybePromise<(() => void) | void>>} */
on_navigate: [],

/** @type {Array<(navigation: import('@sveltejs/kit').AfterNavigate) => void>} */
after_navigate: []
};
Expand Down Expand Up @@ -299,7 +302,8 @@ export function create_client(app, target) {
url: new URL(location.href)
},
willUnload: false,
type: 'enter'
type: 'enter',
complete: Promise.resolve()
};
callbacks.after_navigate.forEach((fn) => fn(navigation));

Expand Down Expand Up @@ -910,30 +914,17 @@ export function create_client(app, target) {
function before_navigate({ url, type, intent, delta }) {
let should_block = false;

/** @type {import('@sveltejs/kit').Navigation} */
const navigation = {
from: {
params: current.params,
route: { id: current.route?.id ?? null },
url: current.url
},
to: {
params: intent?.params ?? null,
route: { id: intent?.route?.id ?? null },
url
},
willUnload: !intent,
type
};
const nav = create_navigation(current, intent, url, type);

if (delta !== undefined) {
navigation.delta = delta;
nav.navigation.delta = delta;
}

const cancellable = {
...navigation,
...nav.navigation,
cancel: () => {
should_block = true;
nav.reject(new Error('navigation was cancelled'));
}
};

Expand All @@ -942,7 +933,7 @@ export function create_client(app, target) {
callbacks.before_navigate.forEach((fn) => fn(cancellable));
}

return should_block ? null : navigation;
return should_block ? null : nav;
}

/**
Expand Down Expand Up @@ -975,9 +966,9 @@ export function create_client(app, target) {
blocked
}) {
const intent = get_navigation_intent(url, false);
const navigation = before_navigate({ url, type, delta, intent });
const nav = before_navigate({ url, type, delta, intent });

if (!navigation) {
if (!nav) {
blocked();
return;
}
Expand All @@ -990,7 +981,7 @@ export function create_client(app, target) {
navigating = true;

if (started) {
stores.navigating.set(navigation);
stores.navigating.set(nav.navigation);
}

token = nav_token;
Expand All @@ -1017,7 +1008,10 @@ export function create_client(app, target) {
url = intent?.url || url;

// abort if user navigated during update
if (token !== nav_token) return false;
if (token !== nav_token) {
nav.reject(new Error('navigation was aborted'));
return false;
}

if (navigation_result.type === 'redirect') {
if (redirect_chain.length > 10 || redirect_chain.includes(url.pathname)) {
Expand Down Expand Up @@ -1093,6 +1087,28 @@ export function create_client(app, target) {
navigation_result.props.page.url = url;
}

const after_navigate = (
await Promise.all(
callbacks.on_navigate.map((fn) =>
fn(/** @type {import('@sveltejs/kit').OnNavigate} */ (nav.navigation))
)
)
).filter((value) => typeof value === 'function');

if (after_navigate.length > 0) {
function cleanup() {
callbacks.after_navigate = callbacks.after_navigate.filter(
// @ts-ignore
(fn) => !after_navigate.includes(fn)
);
}

after_navigate.push(cleanup);

// @ts-ignore
callbacks.after_navigate.push(...after_navigate);
}

root.$set(navigation_result.props);
} else {
initialize(navigation_result);
Expand Down Expand Up @@ -1142,8 +1158,10 @@ export function create_client(app, target) {
restore_snapshot(current_history_index);
}

nav.fulfil(undefined);

callbacks.after_navigate.forEach((fn) =>
fn(/** @type {import('@sveltejs/kit').AfterNavigate} */ (navigation))
fn(/** @type {import('@sveltejs/kit').AfterNavigate} */ (nav.navigation))
);
stores.navigating.set(null);

Expand Down Expand Up @@ -1339,6 +1357,17 @@ export function create_client(app, target) {
});
},

on_navigate: (fn) => {
onMount(() => {
callbacks.on_navigate.push(fn);

return () => {
const i = callbacks.on_navigate.indexOf(fn);
callbacks.on_navigate.splice(i, 1);
};
});
},

disable_scroll_handling: () => {
if (DEV && started && !updating) {
throw new Error('Can only disable scroll handling during navigation');
Expand Down Expand Up @@ -1444,19 +1473,17 @@ export function create_client(app, target) {
persist_state();

if (!navigating) {
const nav = create_navigation(current, undefined, null, 'leave');

// If we're navigating, beforeNavigate was already called. If we end up in here during navigation,
// it's due to an external or full-page-reload link, for which we don't want to call the hook again.
/** @type {import('@sveltejs/kit').BeforeNavigate} */
const navigation = {
from: {
params: current.params,
route: { id: current.route?.id ?? null },
url: current.url
},
to: null,
willUnload: true,
type: 'leave',
cancel: () => (should_block = true)
...nav.navigation,
cancel: () => {
should_block = true;
nav.reject(new Error('navigation was cancelled'));
}
};

callbacks.before_navigate.forEach((fn) => fn(navigation));
Expand Down Expand Up @@ -1990,6 +2017,50 @@ function reset_focus() {
}
}

/**
* @param {import('./types').NavigationState} current
* @param {import('./types').NavigationIntent | undefined} intent
* @param {URL | null} url
* @param {Exclude<import('@sveltejs/kit').NavigationType, 'enter'>} type
*/
function create_navigation(current, intent, url, type) {
/** @type {(value: any) => void} */
let fulfil;

/** @type {(error: any) => void} */
let reject;

const complete = new Promise((f, r) => {
fulfil = f;
reject = r;
});

/** @type {import('@sveltejs/kit').Navigation} */
const navigation = {
from: {
params: current.params,
route: { id: current.route?.id ?? null },
url: current.url
},
to: url && {
params: intent?.params ?? null,
route: { id: intent?.route?.id ?? null },
url
},
willUnload: !intent,
type,
complete
};

return {
navigation,
// @ts-expect-error
fulfil,
// @ts-expect-error
reject
};
}

if (DEV) {
// Nasty hack to silence harmless warnings the user can do nothing about
const console_warn = console.warn;
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/runtime/client/singletons.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function init(opts) {
*/
export function client_method(key) {
if (!BROWSER) {
if (key === 'before_navigate' || key === 'after_navigate') {
if (key === 'before_navigate' || key === 'after_navigate' || key === 'on_navigate') {
// @ts-expect-error doesn't recognize that both keys here return void so expects a async function
return () => {};
} else {
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/runtime/client/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { applyAction } from '../app/forms';
import {
afterNavigate,
beforeNavigate,
onNavigate,
goto,
invalidate,
invalidateAll,
Expand Down Expand Up @@ -43,6 +44,7 @@ export interface Client {
// public API, exposed via $app/navigation
after_navigate: typeof afterNavigate;
before_navigate: typeof beforeNavigate;
on_navigate: typeof onNavigate;
disable_scroll_handling(): void;
goto: typeof goto;
invalidate: typeof invalidate;
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export interface ServerMetadataRoute {
};
methods: HttpMethod[];
prerender: PrerenderOption | undefined;
entries: Array<string> | undefined;
entries: string[] | undefined;
}

export interface ServerMetadata {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@
</script>

<h1>{from?.url.pathname} -> {to?.url.pathname}</h1>
<a href="/after-navigate/b">/b</a>
<a href="/navigation-lifecycle/after-navigate/b">/b</a>
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@
</script>

<h1>{from?.url.pathname} -> {to?.url.pathname}</h1>
<a href="/after-navigate/a">/a</a>
<a href="/navigation-lifecycle/after-navigate/a">/a</a>
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
</script>
<h1>prevent navigation</h1>
<a href="/before-navigate/a">a</a>
<a href="/before-navigate/redirect">redirect</a>
<a href="/before-navigate/prevent-navigation?x=1">self</a>
<a href="/navigation-lifecycle/before-navigate/a">a</a>
<a href="/navigation-lifecycle/before-navigate/redirect">redirect</a>
<a href="/navigation-lifecycle/before-navigate/prevent-navigation?x=1">self</a>
<a href="https://google.com" target="_blank" rel="noreferrer">_blank</a>
<a href="https://google.de">external</a>
<a download href="">external</a>
Expand Down

0 comments on commit 86c7199

Please sign in to comment.