Skip to content

Commit

Permalink
feat(react-router): update useBlocker hook to provide current and n…
Browse files Browse the repository at this point in the history
…ext location when conditionally blocking (#1790)

* chore: clean up changes

* feat: clean up work and address feedback

* feat: add backwards compat

* ci: apply automated fixes

* feat: give history action to subscribers via notify

* fix: provide go index to history subscribers

* docs: updated docs accourding to feedback

* fix: changed valid notify call

* rename to `type`

* remove non existing `pushstate`event

* cleanup

* typesafe API for shouldBlockFn

* feat: added blocking state to resolver and updated examples

* docs: updated useBlocker and navigation-blocking docs

* test: added type tests to blocker and changed type implementation of blocker

* test: added better unit tests for useBlocker hook

* test: added e2e tests for blocker and legacy blocker

* wip: stash files before merge

* fix: unstashed e2e file after merge

* fix lockfile

* docs

* typesafety for `<Block>`

* always return resolver to not break backwards compatibility

* fix: removed zod from navigation blocking example to fix build problem

* chore: update lock file

* finishing touches

* ci: apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Manuel Schiller <[email protected]>
  • Loading branch information
3 people authored Dec 15, 2024
1 parent 4531b70 commit f420670
Show file tree
Hide file tree
Showing 16 changed files with 1,586 additions and 240 deletions.
150 changes: 142 additions & 8 deletions docs/framework/react/api/router/useBlockerHook.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,61 @@ title: useBlocker hook

The `useBlocker` method is a hook that [blocks navigation](../../guide/navigation-blocking.md) when a condition is met.

> ⚠️ The following new `useBlocker` API is currently _experimental_.
## useBlocker options

The `useBlocker` hook accepts a single _optional_ argument, an option object:
The `useBlocker` hook accepts a single _required_ argument, an option object:

### `options.shouldBlockFn` option

- Required
- Type: `ShouldBlockFn`
- This function should return a `boolean` or a `Promise<boolean>` that tells the blocker if it should block the current navigation
- The function has the argument of type `ShouldBlockFnArgs` passed to it, which tells you information about the current and next route and the action performed
- Think of this function as telling the router if it should block the navigation, so returning `true` mean that it should block the navgation and `false` that it should be allowed

```ts
interface ShouldBlockFnLocation<...> {
routeId: TRouteId
fullPath: TFullPath
pathname: string
params: TAllParams
search: TFullSearchSchema
}

type ShouldBlockFnArgs = {
current: ShouldBlockFnLocation
next: ShouldBlockFnLocation
action: HistoryAction
}
```
### `options.disabled` option
### `options.blockerFn` option
- Optional - defaults to `false`
- Type: `boolean`
- Specifies if the blocker should be entirely disabled or not
### `options.enableBeforeUnload` option
- Optional - defaults to `true`
- Type: `boolean | (() => boolean)`
- Tell the blocker to sometimes or always block the browser `beforeUnload` event or not
### `options.withResolver` option
- Optional - defaults to `false`
- Type: `boolean`
- Specify if the resolver returned by the hook should be used or whether your `shouldBlockFn` function itself resolves the blocking
### `options.blockerFn` option (⚠️ deprecated)
- Optional
- Type: `BlockerFn`
- The function that returns a `boolean` or `Promise<boolean>` indicating whether to allow navigation.
### `options.condition` option
### `options.condition` option (⚠️ deprecated)
- Optional - defaults to `true`
- Type: `boolean`
Expand All @@ -26,8 +70,15 @@ The `useBlocker` hook accepts a single _optional_ argument, an option object:
An object with the controls to allow manual blocking and unblocking of navigation.
- `status` - A string literal that can be either `'blocked'` or `'idle'`
- `proceed` - A function that allows navigation to continue
- `reset` - A function that cancels navigation (`status` will be be reset to `'idle'`)
- `next` - When status is `blocked`, a type narrrowable object that contains information about the next location
- `current` - When status is `blocked`, a type narrrowable object that contains information about the current location
- `action` - When status is `blocked`, a `HistoryAction` string that shows the action that triggered the navigation
- `proceed` - When status is `blocked`, a function that allows navigation to continue
- `reset` - When status is `blocked`, a function that cancels navigation (`status` will be be reset to `'idle'`)
or
`void` when `withResolver` is `false`
## Examples
Expand All @@ -42,8 +93,7 @@ function MyComponent() {
const [formIsDirty, setFormIsDirty] = useState(false)

useBlocker({
blockerFn: () => window.confirm('Are you sure you want to leave?'),
condition: formIsDirty,
shouldBlockFn: () => formIsDirty,
})

// ...
Expand All @@ -58,8 +108,39 @@ import { useBlocker } from '@tanstack/react-router'
function MyComponent() {
const [formIsDirty, setFormIsDirty] = useState(false)

const { proceed, reset, status, next } = useBlocker({
shouldBlockFn: () => formIsDirty,
withResolver: true,
})

// ...

return (
<>
{/* ... */}
{status === 'blocked' && (
<div>
<p>You are navigating to {next.pathname}</p>
<p>Are you sure you want to leave?</p>
<button onClick={proceed}>Yes</button>
<button onClick={reset}>No</button>
</div>
)}
</>
}
```
### Conditional blocking
```tsx
import { useBlocker } from '@tanstack/react-router'

function MyComponent() {
const { proceed, reset, status } = useBlocker({
condition: formIsDirty,
shouldBlockFn: ({ nextLocation }) => {
return !nextLocation.pathname.includes('step/')
},
withResolver: true,
})

// ...
Expand All @@ -75,5 +156,58 @@ function MyComponent() {
</div>
)}
</>
)
}
```
### Without resolver
```tsx
import { useBlocker } from '@tanstack/react-router'

function MyComponent() {
const [formIsDirty, setFormIsDirty] = useState(false)

useBlocker({
shouldBlockFn: ({ nextLocation }) => {
if (nextLocation.pathname.includes('step/')) {
return false
}

const shouldLeave = confirm('Are you sure you want to leave?')
return !shouldLeave
},
})

// ...
}
```
### Type narrowing
```tsx
import { useBlocker } from '@tanstack/react-router'

function MyComponent() {
const [formIsDirty, setFormIsDirty] = useState(false)

// block going from editor-1 to /foo/123?hello=world
const { proceed, reset, status } = useBlocker({
shouldBlockFn: ({ current, next }) => {
if (
current.routeId === '/editor-1' &&
next.fullPath === '/foo/$id' &&
next.params.id === '123' &&
next.search.hello === 'world'
) {
return true
}
return false
},
enableBeforeUnload: false,
withResolver: true,
})

// ...
}
```
79 changes: 60 additions & 19 deletions docs/framework/react/guide/navigation-blocking.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,7 @@ Navigation blocking adds one or more layers of "blockers" to the entire underlyi
- Custom UI
- If the navigation is triggered by something we control at the router level, we can allow you to perform any task or show any UI you'd like to the user to confirm the action. Each blocker's `blocker` function will be asynchronously and sequentially executed. If any blocker function resolves or returns `true`, the navigation will be allowed and all other blockers will continue to do the same until all blockers have been allowed to proceed. If any single blocker resolves or returns `false`, the navigation will be canceled and the rest of the `blocker` functions will be ignored.
- The `onbeforeunload` event
- For page events that we cannot control directly, we rely on the browser's `onbeforeunload` event. If the user attempts to close the tab or window, refresh, or "unload" the page assets in any way, the browser's generic "Are you sure you want to leave?" dialog will be shown. If the user confirms, all blockers will be bypassed and the page will unload. If the user cancels, the unload will be cancelled, and the page will remain as is. It's important to note that **custom blocker functions will not be executed** when the `onbeforeunload` flow is triggered.

## What about the back button?

The back button is a special case. When the user clicks the back button, we cannot intercept or control the browser's behavior in a reliable way, and there is no official way to block it that works across all browsers equally. If you encounter a situation where you need to block the back button, it's recommended to rethink your UI/UX to avoid the back button being destructive to any unsaved user data. Saving data to session storage and restoring it if the user returns to the page is a safe and reliable pattern.
- For page events that we cannot control directly, we rely on the browser's `onbeforeunload` event. If the user attempts to close the tab or window, refresh, or "unload" the page assets in any way, the browser's generic "Are you sure you want to leave?" dialog will be shown. If the user confirms, all blockers will be bypassed and the page will unload. If the user cancels, the unload will be cancelled, and the page will remain as is.

## How do I use navigation blocking?

Expand All @@ -44,8 +40,12 @@ function MyComponent() {
const [formIsDirty, setFormIsDirty] = useState(false)

useBlocker({
blockerFn: () => window.confirm('Are you sure you want to leave?'),
condition: formIsDirty,
shouldBlockFn: () => {
if (!formIsDirty) return false

const shouldLeave = confirm('Are you sure you want to leave?')
return !shouldLeave
},
})

// ...
Expand All @@ -66,33 +66,34 @@ function MyComponent() {

return (
<Block
blocker={() => window.confirm('Are you sure you want to leave?')}
condition={formIsDirty}
shouldBlockFn={() => {
if (!formIsDirty) return false

const shouldLeave = confirm('Are you sure you want to leave?')
return !shouldLeave
}}
/>
)

// OR

return (
<Block
blocker={() => window.confirm('Are you sure you want to leave?')}
condition={formIsDirty}
>
{/* ... */}
<Block shouldBlockFn={() => !formIsDirty} withResolver>
{({ status, proceed, reset }) => <>{/* ... */}</>}
</Block>
)
}
```

## How can I show a custom UI?

In most cases, passing `window.confirm` to the `blockerFn` field of the hook input is enough since it will clearly show the user that the navigation is being blocked.
In most cases, using `window.confirm` in the `shouldBlockFn` function with `withResolver: false` in the hook is enough since it will clearly show the user that the navigation is being blocked and resolve the blocking based on their response.

However, in some situations, you might want to show a custom UI that is intentionally less disruptive and more integrated with your app's design.

**Note:** The return value of `blockerFn` takes precedence, do not pass it if you want to use the manual `proceed` and `reset` functions.
**Note:** The return value of `shouldBlockFn` does not resolve the blocking if `withResolver` is `true`.

### Hook/logical-based custom UI
### Hook/logical-based custom UI with resolver

```tsx
import { useBlocker } from '@tanstack/react-router'
Expand All @@ -101,7 +102,8 @@ function MyComponent() {
const [formIsDirty, setFormIsDirty] = useState(false)

const { proceed, reset, status } = useBlocker({
condition: formIsDirty,
shouldBlockFn: () => formIsDirty,
withResolver: true,
})

// ...
Expand All @@ -120,6 +122,45 @@ function MyComponent() {
}
```
### Hook/logical-based custom UI without resolver
```tsx
import { useBlocker } from '@tanstack/react-router'

function MyComponent() {
const [formIsDirty, setFormIsDirty] = useState(false)

useBlocker({
shouldBlockFn: () => {
if (!formIsDirty) return false

const shouldLeave = new Promise<boolean>((resolve) => {
// Using a modal manager of your choice
modals.open({
title: 'Are you sure you want to leave?',
children: (
<SaveBlocker
confirm={() => {
modals.closeAll()
resolve(true)
}}
reject={() => {
modals.closeAll()
resolve(true)
}}
/>
),
onClose: () => resolve(false),
})
})
return !shouldLeave
},
})

// ...
}
```
### Component-based custom UI
Similarly to the hook, the `Block` component returns the same state and functions as render props:
Expand All @@ -131,7 +172,7 @@ function MyComponent() {
const [formIsDirty, setFormIsDirty] = useState(false)

return (
<Block condition={formIsDirty}>
<Block shouldBlockFn={() => formIsDirty} withResolver>
{({ status, proceed, reset }) => (
<>
{/* ... */}
Expand Down
Loading

0 comments on commit f420670

Please sign in to comment.