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\n
\n\nParameter\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\nqrl\n\n\n\n\n[PreventNavigateCallback](#preventnavigatecallback)\n\n\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 +``` + + + +
+ +Parameter + + + +Type + + + +Description + +
+ +qrl + + + +[PreventNavigateCallback](#preventnavigatecallback) + + + +
+**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 ( +
+ + application content +
+ ); +}); +``` + +- Using a separate modal: + +```tsx +export default component$(() => { + const okToNavigate = useSignal(true); + const navSig = useSignal(); + const showConfirm = useSignal(false); + const nav = useNavigate(); + usePreventNavigate$((url) => { + if (!okToNavigate.value) { + if (url) { + navSig.value = url; + showConfirm.value = true; + } + return true; + } + }); + + return ( +
+ + application content + {showConfirm.value && ( +
+
+ 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 ( +
+

Main page

+ + Go to item 5 + +
+ ); +}; diff --git a/starters/apps/qwikcity-test/src/routes/prevent-navigate/layout.tsx b/starters/apps/qwikcity-test/src/routes/prevent-navigate/layout.tsx new file mode 100644 index 00000000000..85a95069658 --- /dev/null +++ b/starters/apps/qwikcity-test/src/routes/prevent-navigate/layout.tsx @@ -0,0 +1,73 @@ +import { Slot, component$, useSignal } from "@builder.io/qwik"; +import { Link, useNavigate, usePreventNavigate$ } from "@builder.io/qwik-city"; + +export default component$(() => { + const okToNavigate = useSignal(true); + const runCount = useSignal(0); + const navSig = useSignal(); + const showConfirm = useSignal(false); + const nav = useNavigate(); + usePreventNavigate$((url) => { + runCount.value++; + if (okToNavigate.value) { + return false; + } + if (!url) { + // beforeunload doesn't allow confirm dialog + // return !window.confirm("really?"); + return true; + } + navSig.value = url; + showConfirm.value = true; + return true; + }); + + return ( +
+
{runCount.value}
+ +
+ + Go home Link + +
+
+ Go home <a> + +
+ +
+
+ {showConfirm.value && ( +
+
+ Do you want to lose changes and go to {String(navSig.value)}? +
+ + +
+ )} +
+ ); +}); diff --git a/starters/dev-server.ts b/starters/dev-server.ts index 35852689ec0..6355b7287e5 100644 --- a/starters/dev-server.ts +++ b/starters/dev-server.ts @@ -213,7 +213,7 @@ export { disableVendorScan: true, vendorRoots: enableCityServer ? [qwikCityMjs] : [], entryStrategy: { - type: "single", + type: "segment", }, client: { manifestOutput(manifest) { diff --git a/starters/e2e/qwikcity/nav.spec.ts b/starters/e2e/qwikcity/nav.spec.ts index 2300733a5e5..0227ca076d5 100644 --- a/starters/e2e/qwikcity/nav.spec.ts +++ b/starters/e2e/qwikcity/nav.spec.ts @@ -183,6 +183,69 @@ test.describe("actions", () => { expect(page.url()).toBe(startUrl); } }); + + test("preventNavigate", async ({ page }) => { + await page.goto("/qwikcity-test/prevent-navigate/"); + const toggleDirty = page.locator("#pn-button"); + const link = page.locator("#pn-link"); + const count = page.locator("#pn-runcount"); + const mpaLink = page.locator("#pn-a"); + const itemLink = page.locator("#pn-link-5"); + const confirmText = page.locator("#pn-confirm-text"); + const confirmYes = page.locator("#pn-confirm-yes"); + // clean SPA nav + await expect(count).toHaveText("0"); + await link.click(); + await expect(link).not.toBeVisible(); + expect(new URL(page.url()).pathname).toBe("/qwikcity-test/"); + await page.goBack(); + await expect(count).toHaveText("0"); + await expect(toggleDirty).toHaveText("is clean"); + await toggleDirty.click(); + await expect(toggleDirty).toHaveText("is dirty"); + // dirty browser nav + let didTrigger = false; + page.once("dialog", async (dialog) => { + didTrigger = true; + expect(dialog.type()).toBe("beforeunload"); + await dialog.accept(); + }); + await page.reload(); + expect(didTrigger).toBe(true); + await expect(count).toHaveText("0"); + await toggleDirty.click(); + + // dirty SPA nav + await link.click(); + await expect(count).toHaveText("1"); + await link.click(); + await expect(count).toHaveText("2"); + expect(new URL(page.url()).pathname).toBe( + "/qwikcity-test/prevent-navigate/", + ); + await expect(confirmText).toContainText("/qwikcity-test/?"); + await itemLink.click(); + await expect(confirmText).toContainText( + "/qwikcity-test/prevent-navigate/5/?", + ); + await confirmYes.click(); + await expect(page.locator("#pn-main")).toBeVisible(); + expect(new URL(page.url()).pathname).toBe( + "/qwikcity-test/prevent-navigate/5/", + ); + + // dirty browser nav w/ prevent + await toggleDirty.click(); + didTrigger = false; + page.once("dialog", async (dialog) => { + didTrigger = true; + expect(dialog.type()).toBe("beforeunload"); + // dismissing doesn't work, ah well + await dialog.accept(); + }); + await mpaLink.click(); + expect(didTrigger).toBe(true); + }); } function tests() {