Skip to content
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

Add onNavigate lifecycle function, to enable view transitions #9605

Merged
merged 27 commits into from
Aug 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/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
2 changes: 1 addition & 1 deletion packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"@types/node": "^16.18.6",
"@types/sade": "^1.7.4",
"@types/set-cookie-parser": "^2.4.2",
"dts-buddy": "^0.1.9",
"dts-buddy": "^0.2.4",
"marked": "^7.0.0",
"rollup": "^3.7.0",
"svelte": "^4.0.5",
Expand Down
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'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels a bit loose to just return strings here. Should we declare a set of consts that are reused? And should it just be "navigation was aborted" or a more techincal/stable-looking thing like NAV_ABORTED?

const NAV_ABORTED = 'NAV_ABORTED';
const NAV_CANCELLED = 'NAV_CANCELLED';

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you saying the error message part of the public API and the user needs to check which error message is present?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is getting thrown when updating query params via goto()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should open an issue with a reproduction.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be fixed by 1.24.1 (tried to update ?) #10666

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just updated and no more error, thanks

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>
Loading