diff --git a/.changeset/wise-olives-compete.md b/.changeset/wise-olives-compete.md
new file mode 100644
index 00000000000..02c62424ad9
--- /dev/null
+++ b/.changeset/wise-olives-compete.md
@@ -0,0 +1,5 @@
+---
+'@builder.io/qwik-city': minor
+---
+
+`usePreventNavigate` lets you prevent navigation while your app's state is unsaved. It works asynchronously for SPA navigation and falls back to the browser's default dialogs for other navigations.
diff --git a/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.md b/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.md
index e2bbf33c76d..f5301225b60 100644
--- a/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.md
+++ b/packages/docs/src/routes/api/qwik-city-middleware-request-handler/index.md
@@ -924,7 +924,7 @@ The base pathname of the request, which can be configured at build time. Default
Convenience method to set the Cache-Control header. Depending on your CDN, you may want to add another cacheControl with the second argument set to `CDN-Cache-Control` or any other value (we provide the most common values for auto-complete, but you can use any string you want).
-See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control and https://qwik.dev/docs/caching/#CDN-Cache-Controls for more information.
+See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control and https://qwik.dev/docs/caching/\#CDN-Cache-Controls for more information.
diff --git a/packages/docs/src/routes/api/qwik-city/api.json b/packages/docs/src/routes/api/qwik-city/api.json
index 4e3e7dd3d0a..3882fe4444a 100644
--- a/packages/docs/src/routes/api/qwik-city/api.json
+++ b/packages/docs/src/routes/api/qwik-city/api.json
@@ -450,6 +450,20 @@
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/types.ts",
"mdFile": "qwik-city.pathparams.md"
},
+ {
+ "name": "PreventNavigateCallback",
+ "id": "preventnavigatecallback",
+ "hierarchy": [
+ {
+ "name": "PreventNavigateCallback",
+ "id": "preventnavigatecallback"
+ }
+ ],
+ "kind": "TypeAlias",
+ "content": "```typescript\nexport type PreventNavigateCallback = (url?: number | URL) => ValueOrPromise;\n```",
+ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/types.ts",
+ "mdFile": "qwik-city.preventnavigatecallback.md"
+ },
{
"name": "QWIK_CITY_SCROLLER",
"id": "qwik_city_scroller",
@@ -642,7 +656,7 @@
}
],
"kind": "TypeAlias",
- "content": "```typescript\nexport type RouteNavigate = QRL<(path?: string | number, options?: {\n type?: Exclude;\n forceReload?: boolean;\n replaceState?: boolean;\n scroll?: boolean;\n} | boolean) => Promise>;\n```\n**References:** [NavigationType](#navigationtype)",
+ "content": "```typescript\nexport type RouteNavigate = QRL<(path?: string | number | URL, options?: {\n type?: Exclude;\n forceReload?: boolean;\n replaceState?: boolean;\n scroll?: boolean;\n} | boolean) => Promise>;\n```\n**References:** [NavigationType](#navigationtype)",
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/types.ts",
"mdFile": "qwik-city.routenavigate.md"
},
@@ -842,6 +856,20 @@
"editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/use-functions.ts",
"mdFile": "qwik-city.usenavigate.md"
},
+ {
+ "name": "usePreventNavigate$",
+ "id": "usepreventnavigate_",
+ "hierarchy": [
+ {
+ "name": "usePreventNavigate$",
+ "id": "usepreventnavigate_"
+ }
+ ],
+ "kind": "Function",
+ "content": "Prevent navigation attempts. This hook registers a callback that will be called before SPA or browser navigation.\n\nReturn `true` to prevent navigation.\n\n\\#\\#\\#\\# SPA Navigation\n\nFor Single-Page-App (SPA) navigation (via ``, `const nav = useNavigate()`, and browser backwards/forwards inside SPA history), the callback will be provided with the target, either a URL or a number. It will only be a number if `nav(number)` was called to navigate forwards or backwards in SPA history.\n\nIf you return a Promise, the navigation will be blocked until the promise resolves.\n\nThis can be used to show a nice dialog to the user, and wait for the user to confirm, or to record the url, prevent the navigation, and navigate there later via `nav(url)`.\n\n\\#\\#\\#\\# Browser Navigation\n\nHowever, when the user navigates away by clicking on a regular ``, reloading, or moving backwards/forwards outside SPA history, this callback will not be awaited. This is because the browser does not provide a way to asynchronously prevent these navigations.\n\nIn this case, returning returning `true` will tell the browser to show a confirmation dialog, which cannot be customized. You are also not able to show your own `window.confirm()` dialog during the callback, the browser won't allow it. If you return a Promise, it will be considered as `true`.\n\nWhen the callback is called from the browser, no url will be provided. Use this to know whether you can show a dialog or just return `true` to prevent the navigation.\n\n\n```typescript\nusePreventNavigate$: (qrl: PreventNavigateCallback) => void\n```\n\n\n
\n**Returns:**\n\nvoid",
+ "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/use-functions.ts",
+ "mdFile": "qwik-city.usepreventnavigate_.md"
+ },
{
"name": "validator$",
"id": "validator_",
diff --git a/packages/docs/src/routes/api/qwik-city/index.md b/packages/docs/src/routes/api/qwik-city/index.md
index 338c5f116b4..7629ab29945 100644
--- a/packages/docs/src/routes/api/qwik-city/index.md
+++ b/packages/docs/src/routes/api/qwik-city/index.md
@@ -1682,6 +1682,16 @@ export declare type PathParams = Record;
[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/types.ts)
+## PreventNavigateCallback
+
+```typescript
+export type PreventNavigateCallback = (
+ url?: number | URL,
+) => ValueOrPromise;
+```
+
+[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/types.ts)
+
## QWIK_CITY_SCROLLER
```typescript
@@ -2100,7 +2110,7 @@ URL
```typescript
export type RouteNavigate = QRL<
(
- path?: string | number,
+ path?: string | number | URL,
options?:
| {
type?: Exclude;
@@ -2387,6 +2397,63 @@ useNavigate: () => RouteNavigate;
[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/use-functions.ts)
+## usePreventNavigate$
+
+Prevent navigation attempts. This hook registers a callback that will be called before SPA or browser navigation.
+
+Return `true` to prevent navigation.
+
+\#### SPA Navigation
+
+For Single-Page-App (SPA) navigation (via ``, `const nav = useNavigate()`, and browser backwards/forwards inside SPA history), the callback will be provided with the target, either a URL or a number. It will only be a number if `nav(number)` was called to navigate forwards or backwards in SPA history.
+
+If you return a Promise, the navigation will be blocked until the promise resolves.
+
+This can be used to show a nice dialog to the user, and wait for the user to confirm, or to record the url, prevent the navigation, and navigate there later via `nav(url)`.
+
+\#### Browser Navigation
+
+However, when the user navigates away by clicking on a regular ``, reloading, or moving backwards/forwards outside SPA history, this callback will not be awaited. This is because the browser does not provide a way to asynchronously prevent these navigations.
+
+In this case, returning returning `true` will tell the browser to show a confirmation dialog, which cannot be customized. You are also not able to show your own `window.confirm()` dialog during the callback, the browser won't allow it. If you return a Promise, it will be considered as `true`.
+
+When the callback is called from the browser, no url will be provided. Use this to know whether you can show a dialog or just return `true` to prevent the navigation.
+
+```typescript
+usePreventNavigate$: (qrl: PreventNavigateCallback) => void
+```
+
+
+**Returns:**
+
+void
+
+[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/use-functions.ts)
+
## validator$
```typescript
diff --git a/packages/docs/src/routes/docs/(qwikcity)/routing/index.mdx b/packages/docs/src/routes/docs/(qwikcity)/routing/index.mdx
index ee3567fc169..6fd04e3fad7 100644
--- a/packages/docs/src/routes/docs/(qwikcity)/routing/index.mdx
+++ b/packages/docs/src/routes/docs/(qwikcity)/routing/index.mdx
@@ -19,7 +19,8 @@ contributors:
- mrhoodz
- chsanch
- RumNCodeDev
-updated_at: '2023-10-02T22:44:45Z'
+ - wmertens
+updated_at: '2024-09-05T10:32:00Z'
created_at: '2023-03-20T23:45:13Z'
---
@@ -238,6 +239,97 @@ export default component$(() => {
> The `Link` component uses the `useNavigate()` hook [internally](https://github.com/QwikDev/qwik/blob/e452582f4728cbcb7bf85d03293e757302286683/packages/qwik-city/runtime/src/link-component.tsx#L33).
+### Preventing navigation
+
+If the user can lose state by navigating away from the page, you can use `usePreventNavigate(callback)` to conditionally prevent the navigation.
+
+The callback will be called with the URL that the user is trying to navigate to. If the callback returns `true`, the navigation will be prevented.
+
+You can return a Promise, and qwik-city will wait until the promise resolves before navigating.
+
+However, in some cases the browser will navigate without calling qwik-city, such as when the user reloads the tab or navigates using `` instead of ``. When this happens, the answer must be synchronous, and user interaction is not allowed.
+
+You can tell the difference between qwik-city and browser navigation by looking at the provided URL. If the URL is `undefined`, the browser is navigating away, and you must respond synchronously.
+
+Examples:
+
+- using a modal library:
+
+```tsx
+export default component$(() => {
+ const okToNavigate = useSignal(true);
+ usePreventNavigate$((url) => {
+ if (!okToNavigate.value) {
+ // we we didn't get a url, the browser is navigating away
+ // and we must respond synchronously without dialogs
+ if (!url) return true;
+
+ // Here we assume that the confirmDialog function shows a modal and returns a promise for the result
+ return confirmDialog(
+ `Do you want to lose changes and go to ${url}?`
+ ).then(answer => !answer);
+ // or simply using the browser confirm dialog:
+ // return !confirm(`Do you want to lose changes and go to ${url}?`);
+ }
+ });
+
+ return (
+
+ Do you want to lose changes and go to {String(navSig.value)}?
+
+
+
+
+ )}
+
+ );
+});
+```
+
### ``
The `Link` component with the `reload` prop can be used together to refresh the current page.
diff --git a/packages/qwik-city/src/buildtime/build-layout.unit.ts b/packages/qwik-city/src/buildtime/build-layout.unit.ts
index baf61ff281e..902fc2e7273 100644
--- a/packages/qwik-city/src/buildtime/build-layout.unit.ts
+++ b/packages/qwik-city/src/buildtime/build-layout.unit.ts
@@ -3,7 +3,7 @@ import { assert, testAppSuite } from '../utils/test-suite';
const test = testAppSuite('Build Layout');
test('total layouts', ({ ctx: { layouts } }) => {
- assert.equal(layouts.length, 10, JSON.stringify(layouts, null, 2));
+ assert.equal(layouts.length, 11, JSON.stringify(layouts, null, 2));
});
test('nested named layout', ({ assertLayout }) => {
diff --git a/packages/qwik-city/src/runtime/src/api.md b/packages/qwik-city/src/runtime/src/api.md
index fb15214a174..9d638391b9b 100644
--- a/packages/qwik-city/src/runtime/src/api.md
+++ b/packages/qwik-city/src/runtime/src/api.md
@@ -308,6 +308,9 @@ export interface PageModule extends RouteModule {
// @public (undocumented)
export type PathParams = Record;
+// @public (undocumented)
+export type PreventNavigateCallback = (url?: number | URL) => ValueOrPromise;
+
// @public (undocumented)
export const QWIK_CITY_SCROLLER = "_qCityScroller";
@@ -402,7 +405,7 @@ export interface RouteLocation {
}
// @public (undocumented)
-export type RouteNavigate = QRL<(path?: string | number, options?: {
+export type RouteNavigate = QRL<(path?: string | number | URL, options?: {
type?: Exclude;
forceReload?: boolean;
replaceState?: boolean;
@@ -471,6 +474,14 @@ export const useLocation: () => RouteLocation;
// @public (undocumented)
export const useNavigate: () => RouteNavigate;
+// @public
+export const usePreventNavigate$: (qrl: PreventNavigateCallback) => void;
+
+// Warning: (ae-internal-missing-underscore) The name "usePreventNavigateQrl" should be prefixed with an underscore because the declaration is marked as @internal
+//
+// @internal
+export const usePreventNavigateQrl: (fn: QRL) => void;
+
// Warning: (ae-forgotten-export) The symbol "ValidatorConstructor" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
diff --git a/packages/qwik-city/src/runtime/src/contexts.ts b/packages/qwik-city/src/runtime/src/contexts.ts
index fb94e1e75fb..f0fc2f36080 100644
--- a/packages/qwik-city/src/runtime/src/contexts.ts
+++ b/packages/qwik-city/src/runtime/src/contexts.ts
@@ -6,6 +6,7 @@ import type {
RouteAction,
RouteLocation,
RouteNavigate,
+ RoutePreventNavigate,
RouteStateInternal,
} from './types';
@@ -25,3 +26,6 @@ export const RouteActionContext = /*#__PURE__*/ createContextId('qc
export const RouteInternalContext =
/*#__PURE__*/ createContextId>('qc-ir');
+
+export const RoutePreventNavigateContext =
+ /*#__PURE__*/ createContextId('qc-p');
diff --git a/packages/qwik-city/src/runtime/src/index.ts b/packages/qwik-city/src/runtime/src/index.ts
index b9eed4a3817..8a572a2c9fd 100644
--- a/packages/qwik-city/src/runtime/src/index.ts
+++ b/packages/qwik-city/src/runtime/src/index.ts
@@ -1,47 +1,57 @@
export type { FormSubmitCompletedDetail as FormSubmitSuccessDetail } from './form-component';
export type {
- MenuData,
+ Action,
+ ActionConstructor,
+ ActionReturn,
+ ActionStore,
ContentHeading,
ContentMenu,
Cookie,
CookieOptions,
CookieValue,
+ DataValidator,
+ DeferReturn,
DocumentHead,
DocumentHeadProps,
DocumentHeadValue,
DocumentLink,
DocumentMeta,
- DocumentStyle,
DocumentScript,
+ DocumentStyle,
+ FailOfRest,
+ FailReturn,
+ GetValidatorType,
+ JSONObject,
+ JSONValue,
+ Loader,
+ LoaderSignal,
+ MenuData,
+ NavigationType,
PageModule,
PathParams,
- RequestHandler,
+ PreventNavigateCallback,
+ QwikCityPlan,
RequestEvent,
- RequestEventLoader,
RequestEventAction,
+ RequestEventBase,
RequestEventCommon,
- QwikCityPlan,
+ RequestEventLoader,
+ RequestHandler,
ResolvedDocumentHead,
RouteData,
RouteLocation,
- StaticGenerateHandler,
- Action,
- Loader,
- ActionStore,
- LoaderSignal,
- ActionConstructor,
- FailReturn,
- ZodConstructor,
- StaticGenerate,
RouteNavigate,
- NavigationType,
- DeferReturn,
- RequestEventBase,
- JSONObject,
- JSONValue,
- ValidatorErrorType,
+ ServerFunction,
+ ServerQRL,
+ StaticGenerate,
+ StaticGenerateHandler,
+ StrictUnion,
+ TypedDataValidator,
ValidatorErrorKeyDotNotation,
+ ValidatorErrorType,
+ ValidatorReturn,
+ ZodConstructor,
} from './types';
export { RouterOutlet } from './router-outlet-component';
@@ -55,6 +65,7 @@ export {
export { type LinkProps, Link } from './link-component';
export { ServiceWorkerRegister } from './sw-component';
export { useDocumentHead, useLocation, useContent, useNavigate } from './use-functions';
+export { usePreventNavigate$, usePreventNavigateQrl } from './use-functions';
export { routeAction$, routeActionQrl } from './server-functions';
export { globalAction$, globalActionQrl } from './server-functions';
export { routeLoader$, routeLoaderQrl } from './server-functions';
@@ -66,15 +77,3 @@ export { z } from 'zod';
export { Form } from './form-component';
export type { FormProps } from './form-component';
-
-export type {
- TypedDataValidator,
- DataValidator,
- GetValidatorType,
- FailOfRest,
- ActionReturn,
- StrictUnion,
- ValidatorReturn,
- ServerQRL,
- ServerFunction,
-} from './types';
diff --git a/packages/qwik-city/src/runtime/src/qwik-city-component.tsx b/packages/qwik-city/src/runtime/src/qwik-city-component.tsx
index e0385e63568..d922b9f69e9 100644
--- a/packages/qwik-city/src/runtime/src/qwik-city-component.tsx
+++ b/packages/qwik-city/src/runtime/src/qwik-city-component.tsx
@@ -13,6 +13,7 @@ import {
_weakSerialize,
useStyles$,
_waitUntilRendered,
+ type QRL,
} from '@builder.io/qwik';
import { isBrowser, isDev, isServer } from '@builder.io/qwik/build';
import * as qwikCity from '@qwik-city-plan';
@@ -25,6 +26,7 @@ import {
RouteInternalContext,
RouteLocationContext,
RouteNavigateContext,
+ RoutePreventNavigateContext,
RouteStateContext,
} from './contexts';
import { createDocumentHead, resolveHead } from './head';
@@ -39,6 +41,7 @@ import type {
LoadedRoute,
MutableRouteLocation,
PageModule,
+ PreventNavigateCallback,
ResolvedDocumentHead,
RouteActionValue,
RouteNavigate,
@@ -88,6 +91,16 @@ export interface QwikCityProps {
viewTransition?: boolean;
}
+// Gets populated by registerPreventNav on the client
+const preventNav: {
+ $cbs$?: Set> | undefined;
+ $handler$?: (event: BeforeUnloadEvent) => void;
+} = {};
+
+// Track navigations during prevent so we don't overwrite
+// We need to use an object so we can write into it from qrls
+const internalState = { navCount: 0 };
+
/** @public */
export const QwikCityProvider = component$((props) => {
useStyles$(`:root{view-transition-name:none}`);
@@ -145,6 +158,46 @@ export const QwikCityProvider = component$((props) => {
: undefined
);
+ const registerPreventNav = $((fn$: QRL) => {
+ if (!isBrowser) {
+ return;
+ }
+ preventNav.$handler$ ||= (event: BeforeUnloadEvent) => {
+ // track navigations during prevent so we don't overwrite
+ internalState.navCount++;
+ if (!preventNav.$cbs$) {
+ return;
+ }
+ const prevents = [...preventNav.$cbs$.values()].map((cb) =>
+ cb.resolved ? cb.resolved() : cb()
+ );
+ // this catches both true and Promise
+ // we assume a Promise means to prevent the navigation
+ if (prevents.some(Boolean)) {
+ event.preventDefault();
+ // legacy support
+ event.returnValue = true;
+ }
+ };
+
+ (preventNav.$cbs$ ||= new Set()).add(fn$);
+ // we need the QRLs to be synchronous if possible, for the beforeunload event
+ fn$.resolve();
+ // TS thinks we're a webworker and doesn't know about beforeunload
+ (window as any).addEventListener('beforeunload', preventNav.$handler$);
+
+ return () => {
+ if (preventNav.$cbs$) {
+ preventNav.$cbs$.delete(fn$);
+ if (!preventNav.$cbs$.size) {
+ preventNav.$cbs$ = undefined;
+ // unregister the event listener if no more callbacks, to make older Firefox happy
+ (window as any).removeEventListener('beforeunload', preventNav.$handler$);
+ }
+ }
+ };
+ });
+
const goto: RouteNavigate = $(async (path, opt) => {
const {
type = 'link',
@@ -152,14 +205,41 @@ export const QwikCityProvider = component$((props) => {
replaceState = false,
scroll = true,
} = typeof opt === 'object' ? opt : { forceReload: opt };
- if (typeof path === 'number') {
+ internalState.navCount++;
+
+ const lastDest = routeInternal.value.dest;
+ const dest =
+ path === undefined
+ ? lastDest
+ : typeof path === 'number'
+ ? path
+ : toUrl(path, routeLocation.url);
+
+ if (
+ preventNav.$cbs$ &&
+ (forceReload ||
+ typeof dest === 'number' ||
+ !isSamePath(dest, lastDest) ||
+ !isSameOrigin(dest, lastDest))
+ ) {
+ const ourNavId = internalState.navCount;
+ const prevents = await Promise.all([...preventNav.$cbs$.values()].map((cb) => cb(dest)));
+ if (ourNavId !== internalState.navCount || prevents.some(Boolean)) {
+ if (ourNavId === internalState.navCount && type === 'popstate') {
+ // Popstate events are not cancellable, so we push to undo
+ // TODO keep state?
+ history.pushState(null, '', lastDest);
+ }
+ return;
+ }
+ }
+
+ if (typeof dest === 'number') {
if (isBrowser) {
- history.go(path);
+ history.go(dest);
}
return;
}
- const lastDest = routeInternal.value.dest;
- const dest = path === undefined ? lastDest : toUrl(path, routeLocation.url);
if (!isSameOrigin(dest, lastDest)) {
// Cross-origin nav() should always abort early.
@@ -215,6 +295,7 @@ export const QwikCityProvider = component$((props) => {
useContextProvider(RouteStateContext, loaderState);
useContextProvider(RouteActionContext, actionState);
useContextProvider(RouteInternalContext, routeInternal);
+ useContextProvider(RoutePreventNavigateContext, registerPreventNav);
useTask$(({ track }) => {
async function run() {
diff --git a/packages/qwik-city/src/runtime/src/types.ts b/packages/qwik-city/src/runtime/src/types.ts
index 49cf554a958..1f22d76bf67 100644
--- a/packages/qwik-city/src/runtime/src/types.ts
+++ b/packages/qwik-city/src/runtime/src/types.ts
@@ -80,6 +80,22 @@ export type RouteStateInternal = {
scroll?: boolean;
};
+/**
+ * @param url - The URL that the user is trying to navigate to, or a number to indicate the user is
+ * trying to navigate back/forward in the application history. If it is missing, the event is sent
+ * by the browser and the user is trying to reload or navigate away from the page. In this case,
+ * the function should decide the answer synchronously.
+ * @returns `true` to prevent navigation, `false` to allow navigation, or a Promise that resolves to
+ * `true` or `false`. For browser events, returning `true` or a Promise may show a confirmation
+ * dialog, at the browser's discretion. If the user confirms, the navigation will still be
+ * allowed.
+ * @public
+ */
+export type PreventNavigateCallback = (url?: number | URL) => ValueOrPromise;
+
+/** @internal registers prevent navigate handler and returns cleanup function */
+export type RoutePreventNavigate = QRL<(cb$: QRL) => () => void>;
+
export type ScrollState = {
x: number;
y: number;
@@ -90,7 +106,7 @@ export type ScrollState = {
/** @public */
export type RouteNavigate = QRL<
(
- path?: string | number,
+ path?: string | number | URL,
options?:
| {
type?: Exclude;
diff --git a/packages/qwik-city/src/runtime/src/use-functions.ts b/packages/qwik-city/src/runtime/src/use-functions.ts
index 9124d2a25e5..0dcbdbd7004 100644
--- a/packages/qwik-city/src/runtime/src/use-functions.ts
+++ b/packages/qwik-city/src/runtime/src/use-functions.ts
@@ -1,10 +1,18 @@
-import { noSerialize, useContext, useServerData } from '@builder.io/qwik';
+import {
+ implicit$FirstArg,
+ noSerialize,
+ useContext,
+ useServerData,
+ useVisibleTask$,
+ type QRL,
+} from '@builder.io/qwik';
import {
ContentContext,
DocumentHeadContext,
RouteActionContext,
RouteLocationContext,
RouteNavigateContext,
+ RoutePreventNavigateContext,
} from './contexts';
import type {
RouteLocation,
@@ -12,6 +20,7 @@ import type {
RouteNavigate,
QwikCityEnvData,
RouteAction,
+ PreventNavigateCallback,
} from './types';
/** @public */
@@ -32,6 +41,52 @@ export const useLocation = (): RouteLocation => useContext(RouteLocationContext)
/** @public */
export const useNavigate = (): RouteNavigate => useContext(RouteNavigateContext);
+/** @internal Implementation of usePreventNavigate$ */
+export const usePreventNavigateQrl = (fn: QRL): void => {
+ const registerPreventNav = useContext(RoutePreventNavigateContext);
+ // Note: we have to use a visible task because:
+ // - the onbeforeunload event is synchronous, so we need to preload the callbacks
+ // - to unregister the callback, we need to run code on unmount, which means a visible task
+ // - it allows removing the onbeforeunload event listener when no callbacks are registered, which is better for older Firefox versions
+ // - preventing navigation implies user interaction, so we'll need to load the framework anyway
+ useVisibleTask$(() => registerPreventNav(fn));
+};
+/**
+ * Prevent navigation attempts. This hook registers a callback that will be called before SPA or
+ * browser navigation.
+ *
+ * Return `true` to prevent navigation.
+ *
+ * #### SPA Navigation
+ *
+ * For Single-Page-App (SPA) navigation (via ``, `const nav = useNavigate()`, and browser
+ * backwards/forwards inside SPA history), the callback will be provided with the target, either a
+ * URL or a number. It will only be a number if `nav(number)` was called to navigate forwards or
+ * backwards in SPA history.
+ *
+ * If you return a Promise, the navigation will be blocked until the promise resolves.
+ *
+ * This can be used to show a nice dialog to the user, and wait for the user to confirm, or to
+ * record the url, prevent the navigation, and navigate there later via `nav(url)`.
+ *
+ * #### Browser Navigation
+ *
+ * However, when the user navigates away by clicking on a regular ``, reloading, or moving
+ * backwards/forwards outside SPA history, this callback will not be awaited. This is because the
+ * browser does not provide a way to asynchronously prevent these navigations.
+ *
+ * In this case, returning returning `true` will tell the browser to show a confirmation dialog,
+ * which cannot be customized. You are also not able to show your own `window.confirm()` dialog
+ * during the callback, the browser won't allow it. If you return a Promise, it will be considered
+ * as `true`.
+ *
+ * When the callback is called from the browser, no url will be provided. Use this to know whether
+ * you can show a dialog or just return `true` to prevent the navigation.
+ *
+ * @public
+ */
+export const usePreventNavigate$ = implicit$FirstArg(usePreventNavigateQrl);
+
export const useAction = (): RouteAction => useContext(RouteActionContext);
export const useQwikCityEnv = () => noSerialize(useServerData('qwikcity'));
diff --git a/packages/qwik-city/src/runtime/src/utils.ts b/packages/qwik-city/src/runtime/src/utils.ts
index 106d1f2dc60..48aa5d6cc57 100644
--- a/packages/qwik-city/src/runtime/src/utils.ts
+++ b/packages/qwik-city/src/runtime/src/utils.ts
@@ -6,7 +6,7 @@ import { QACTION_KEY } from './constants';
export const toPath = (url: URL) => url.pathname + url.search + url.hash;
/** Create a URL from a string and baseUrl */
-export const toUrl = (url: string, baseUrl: SimpleURL) => new URL(url, baseUrl.href);
+export const toUrl = (url: string | URL, baseUrl: SimpleURL) => new URL(url, baseUrl.href);
/** Checks only if the origins are the same. */
export const isSameOrigin = (a: SimpleURL, b: SimpleURL) => a.origin === b.origin;
diff --git a/starters/apps/qwikcity-test/src/routes/prevent-navigate/[id]/index.tsx b/starters/apps/qwikcity-test/src/routes/prevent-navigate/[id]/index.tsx
new file mode 100644
index 00000000000..6a8588bb42c
--- /dev/null
+++ b/starters/apps/qwikcity-test/src/routes/prevent-navigate/[id]/index.tsx
@@ -0,0 +1,14 @@
+import { Link, useLocation } from "@builder.io/qwik-city";
+import { component$ } from "@builder.io/qwik";
+
+export default component$(() => {
+ const loc = useLocation();
+ return (
+
+
id {loc.params.id}
+
+ Go up
+
+
+ );
+});
diff --git a/starters/apps/qwikcity-test/src/routes/prevent-navigate/index.tsx b/starters/apps/qwikcity-test/src/routes/prevent-navigate/index.tsx
new file mode 100644
index 00000000000..eb9c9ff73a9
--- /dev/null
+++ b/starters/apps/qwikcity-test/src/routes/prevent-navigate/index.tsx
@@ -0,0 +1,12 @@
+import { Link } from "@builder.io/qwik-city";
+
+export default () => {
+ return (
+