From 608bbc707f9e2ea24a4200f5c63cd0644d67bdbc Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Thu, 5 Sep 2024 08:42:41 +0200 Subject: [PATCH] feat(qwik-city): usePreventNavigate() --- .changeset/wise-olives-compete.md | 5 + .../docs/src/routes/api/qwik-city/api.json | 30 +++++- .../docs/src/routes/api/qwik-city/index.md | 69 +++++++++++++- .../routes/docs/(qwikcity)/routing/index.mdx | 94 ++++++++++++++++++- .../src/buildtime/build-layout.unit.ts | 2 +- packages/qwik-city/src/runtime/src/api.md | 13 ++- .../qwik-city/src/runtime/src/contexts.ts | 4 + packages/qwik-city/src/runtime/src/index.ts | 42 +++++---- .../src/runtime/src/qwik-city-component.tsx | 89 +++++++++++++++++- packages/qwik-city/src/runtime/src/types.ts | 18 +++- .../src/runtime/src/use-functions.ts | 62 +++++++++++- packages/qwik-city/src/runtime/src/utils.ts | 2 +- .../qwik/src/optimizer/src/plugins/plugin.ts | 2 +- .../routes/prevent-navigate/[id]/index.tsx | 14 +++ .../src/routes/prevent-navigate/index.tsx | 12 +++ .../src/routes/prevent-navigate/layout.tsx | 73 ++++++++++++++ starters/dev-server.ts | 2 +- starters/e2e/qwikcity/nav.spec.ts | 63 +++++++++++++ 18 files changed, 562 insertions(+), 34 deletions(-) create mode 100644 .changeset/wise-olives-compete.md create mode 100644 starters/apps/qwikcity-test/src/routes/prevent-navigate/[id]/index.tsx create mode 100644 starters/apps/qwikcity-test/src/routes/prevent-navigate/index.tsx create mode 100644 starters/apps/qwikcity-test/src/routes/prevent-navigate/layout.tsx diff --git a/.changeset/wise-olives-compete.md b/.changeset/wise-olives-compete.md new file mode 100644 index 00000000000..79ae46d4b15 --- /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. To use it, add `experimental: ['preventNavigate']` to your `qwikVite` options. diff --git a/packages/docs/src/routes/api/qwik-city/api.json b/packages/docs/src/routes/api/qwik-city/api.json index ab78ed2768e..c25cf12234d 100644 --- a/packages/docs/src/routes/api/qwik-city/api.json +++ b/packages/docs/src/routes/api/qwik-city/api.json @@ -478,6 +478,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", @@ -670,7 +684,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" }, @@ -870,6 +884,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": "valibot$", "id": "valibot_", diff --git a/packages/docs/src/routes/api/qwik-city/index.md b/packages/docs/src/routes/api/qwik-city/index.md index 212e33ef9f6..6f9deb2b2c1 100644 --- a/packages/docs/src/routes/api/qwik-city/index.md +++ b/packages/docs/src/routes/api/qwik-city/index.md @@ -1712,6 +1712,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 @@ -2130,7 +2140,7 @@ URL ```typescript export type RouteNavigate = QRL< ( - path?: string | number, + path?: string | number | URL, options?: | { type?: Exclude; @@ -2411,6 +2421,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) + ## valibot$ > This API is provided as an alpha preview for developers and may change based on feedback that we receive. Do not use this API in a production environment. 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 c7033bc468b..46484efdd14 100644 --- a/packages/qwik-city/src/runtime/src/api.md +++ b/packages/qwik-city/src/runtime/src/api.md @@ -318,6 +318,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"; @@ -412,7 +415,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; @@ -478,6 +481,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 "ValibotConstructor" needs to be exported by the entry point index.d.ts // // @alpha (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 3c2b1c3689b..2b374e0cdde 100644 --- a/packages/qwik-city/src/runtime/src/index.ts +++ b/packages/qwik-city/src/runtime/src/index.ts @@ -1,47 +1,48 @@ export type { FormSubmitCompletedDetail as FormSubmitSuccessDetail } from './form-component'; export type { - MenuData, + Action, + ActionConstructor, + ActionStore, ContentHeading, ContentMenu, Cookie, CookieOptions, CookieValue, + DeferReturn, DocumentHead, DocumentHeadProps, DocumentHeadValue, DocumentLink, DocumentMeta, - DocumentStyle, DocumentScript, + DocumentStyle, + FailReturn, + 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, + StaticGenerate, + StaticGenerateHandler, ValidatorErrorKeyDotNotation, + ValidatorErrorType, + ZodConstructor, } from './types'; export { RouterOutlet } from './router-outlet-component'; @@ -55,6 +56,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'; 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 7b1b0560f56..fc2f975e646 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 d73371a75a0..12845d7c9af 100644 --- a/packages/qwik-city/src/runtime/src/types.ts +++ b/packages/qwik-city/src/runtime/src/types.ts @@ -81,6 +81,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; @@ -91,7 +107,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..91bda8f6422 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,57 @@ export const useLocation = (): RouteLocation => useContext(RouteLocationContext) /** @public */ export const useNavigate = (): RouteNavigate => useContext(RouteNavigateContext); +/** @internal Implementation of usePreventNavigate$ */ +export const usePreventNavigateQrl = (fn: QRL): void => { + if (!__EXPERIMENTAL__.preventNavigate) { + throw new Error( + 'usePreventNavigate$ is experimental and must be enabled with `experimental: ["preventNavigate"]` in the `qwikVite` plugin.' + ); + } + 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/packages/qwik/src/optimizer/src/plugins/plugin.ts b/packages/qwik/src/optimizer/src/plugins/plugin.ts index f1a5dbb3afe..2625760a242 100644 --- a/packages/qwik/src/optimizer/src/plugins/plugin.ts +++ b/packages/qwik/src/optimizer/src/plugins/plugin.ts @@ -55,7 +55,7 @@ const CLIENT_STRIP_CTX_NAME = [ ]; /** List experimental features here */ -export const experimental = ['valibot'] as const; +export const experimental = ['preventNavigate', 'valibot'] as const; /** * Use `__EXPERIMENTAL__.x` to check if feature `x` is enabled. It will be replaced with `true` or * `false` via an exact string replacement. 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 1953e11937c..b4eba88d131 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() {