Skip to content

Commit

Permalink
feat(qwik-city): usePreventNavigate()
Browse files Browse the repository at this point in the history
  • Loading branch information
wmertens committed Sep 16, 2024
1 parent 3ab0c28 commit 608bbc7
Show file tree
Hide file tree
Showing 18 changed files with 562 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .changeset/wise-olives-compete.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 29 additions & 1 deletion packages/docs/src/routes/api/qwik-city/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;\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",
Expand Down Expand Up @@ -670,7 +684,7 @@
}
],
"kind": "TypeAlias",
"content": "```typescript\nexport type RouteNavigate = QRL<(path?: string | number, options?: {\n type?: Exclude<NavigationType, 'initial'>;\n forceReload?: boolean;\n replaceState?: boolean;\n scroll?: boolean;\n} | boolean) => Promise<void>>;\n```\n**References:** [NavigationType](#navigationtype)",
"content": "```typescript\nexport type RouteNavigate = QRL<(path?: string | number | URL, options?: {\n type?: Exclude<NavigationType, 'initial'>;\n forceReload?: boolean;\n replaceState?: boolean;\n scroll?: boolean;\n} | boolean) => Promise<void>>;\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"
},
Expand Down Expand Up @@ -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 `<Link />`<!-- -->, `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 `<a />`<!-- -->, 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<table><thead><tr><th>\n\nParameter\n\n\n</th><th>\n\nType\n\n\n</th><th>\n\nDescription\n\n\n</th></tr></thead>\n<tbody><tr><td>\n\nqrl\n\n\n</td><td>\n\n[PreventNavigateCallback](#preventnavigatecallback)\n\n\n</td><td>\n\n\n</td></tr>\n</tbody></table>\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_",
Expand Down
69 changes: 68 additions & 1 deletion packages/docs/src/routes/api/qwik-city/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1712,6 +1712,16 @@ export declare type PathParams = Record<string, string>;
[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<boolean>;
```
[Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-city/src/runtime/src/types.ts)
## QWIK_CITY_SCROLLER
```typescript
Expand Down Expand Up @@ -2130,7 +2140,7 @@ URL
```typescript
export type RouteNavigate = QRL<
(
path?: string | number,
path?: string | number | URL,
options?:
| {
type?: Exclude<NavigationType, "initial">;
Expand Down Expand Up @@ -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 `<Link />`, `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 `<a />`, 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
```
<table><thead><tr><th>
Parameter
</th><th>
Type
</th><th>
Description
</th></tr></thead>
<tbody><tr><td>
qrl
</td><td>
[PreventNavigateCallback](#preventnavigatecallback)
</td><td>
</td></tr>
</tbody></table>
**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.
Expand Down
94 changes: 93 additions & 1 deletion packages/docs/src/routes/docs/(qwikcity)/routing/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
---

Expand Down Expand Up @@ -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 `<a/>` instead of `<Link />`. 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 (
<div>
<button onClick$={() => (okToNavigate.value = !okToNavigate.value)}>
toggle user state
</button>
application content
</div>
);
});
```

- Using a separate modal:

```tsx
export default component$(() => {
const okToNavigate = useSignal(true);
const navSig = useSignal<URL | number>();
const showConfirm = useSignal(false);
const nav = useNavigate();
usePreventNavigate$((url) => {
if (!okToNavigate.value) {
if (url) {
navSig.value = url;
showConfirm.value = true;
}
return true;
}
});

return (
<div>
<button onClick$={() => (okToNavigate.value = !okToNavigate.value)}>
toggle user state
</button>
application content
{showConfirm.value && (
<div>
<div>
Do you want to lose changes and go to {String(navSig.value)}?
</div>
<button
onClick$={() => {
showConfirm.value = false;
okToNavigate.value = true;
nav(navSig.value!);
}}
>
Yes
</button>
<button onClick$={() => (showConfirm.value = false)}>No</button>
</div>
)}
</div>
);
});
```

### `<Link reload>`

The `Link` component with the `reload` prop can be used together to refresh the current page.
Expand Down
2 changes: 1 addition & 1 deletion packages/qwik-city/src/buildtime/build-layout.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
13 changes: 12 additions & 1 deletion packages/qwik-city/src/runtime/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,9 @@ export interface PageModule extends RouteModule {
// @public (undocumented)
export type PathParams = Record<string, string>;

// @public (undocumented)
export type PreventNavigateCallback = (url?: number | URL) => ValueOrPromise<boolean>;

// @public (undocumented)
export const QWIK_CITY_SCROLLER = "_qCityScroller";

Expand Down Expand Up @@ -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<NavigationType, 'initial'>;
forceReload?: boolean;
replaceState?: boolean;
Expand Down Expand Up @@ -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<PreventNavigateCallback>) => void;

// Warning: (ae-forgotten-export) The symbol "ValibotConstructor" needs to be exported by the entry point index.d.ts
//
// @alpha (undocumented)
Expand Down
4 changes: 4 additions & 0 deletions packages/qwik-city/src/runtime/src/contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
RouteAction,
RouteLocation,
RouteNavigate,
RoutePreventNavigate,
RouteStateInternal,
} from './types';

Expand All @@ -25,3 +26,6 @@ export const RouteActionContext = /*#__PURE__*/ createContextId<RouteAction>('qc

export const RouteInternalContext =
/*#__PURE__*/ createContextId<Signal<RouteStateInternal>>('qc-ir');

export const RoutePreventNavigateContext =
/*#__PURE__*/ createContextId<RoutePreventNavigate>('qc-p');
42 changes: 22 additions & 20 deletions packages/qwik-city/src/runtime/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down
Loading

0 comments on commit 608bbc7

Please sign in to comment.