@okyrychenko-dev/react-action-guard-router 0.2.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +228 -0
- package/README.md +187 -110
- package/dist/index.cjs +30 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -101
- package/dist/index.d.ts +9 -101
- package/dist/index.js +28 -17
- package/dist/index.js.map +1 -1
- package/dist/nextjs/index.cjs +89 -53
- package/dist/nextjs/index.cjs.map +1 -1
- package/dist/nextjs/index.d.cts +1 -100
- package/dist/nextjs/index.d.ts +1 -100
- package/dist/nextjs/index.js +83 -47
- package/dist/nextjs/index.js.map +1 -1
- package/dist/react-router/index.cjs +69 -46
- package/dist/react-router/index.cjs.map +1 -1
- package/dist/react-router/index.d.cts +1 -87
- package/dist/react-router/index.d.ts +1 -87
- package/dist/react-router/index.js +69 -46
- package/dist/react-router/index.js.map +1 -1
- package/dist/tanstack-router/index.cjs +57 -105
- package/dist/tanstack-router/index.cjs.map +1 -1
- package/dist/tanstack-router/index.d.cts +2 -63
- package/dist/tanstack-router/index.d.ts +2 -63
- package/dist/tanstack-router/index.js +58 -106
- package/dist/tanstack-router/index.js.map +1 -1
- package/dist/{types-C3CPuZip.d.cts → types-xxRwsJ1N.d.cts} +0 -35
- package/dist/{types-C3CPuZip.d.ts → types-xxRwsJ1N.d.ts} +0 -35
- package/package.json +80 -66
package/README.md
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
# @okyrychenko-dev/react-action-guard-router
|
|
2
2
|
|
|
3
|
+
See the [versioned capability matrix](https://github.com/okyrychenko-dev/react-action-guard/blob/main/CAPABILITIES.md) for evaluated peers, reproducible checks, evidence kinds, and limitations.
|
|
4
|
+
|
|
3
5
|
[](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard-router)
|
|
4
6
|
[](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard-router)
|
|
5
7
|
[](https://opensource.org/licenses/MIT)
|
|
6
8
|
|
|
7
9
|
> Router integration for React Action Guard - navigation blocking for React Router, Remix, TanStack Router, and Next.js
|
|
8
10
|
|
|
11
|
+
Start with the [core-only coordination example](https://github.com/okyrychenko-dev/react-action-guard/tree/main/examples/core-coordination)
|
|
12
|
+
to see one operation affect two independent controls. Add this package when you need its
|
|
13
|
+
integration. [Canonical documentation and local instructions](https://github.com/okyrychenko-dev/react-action-guard/tree/main/packages/docs)
|
|
14
|
+
provide the next learning step.
|
|
15
|
+
|
|
9
16
|
## Features
|
|
10
17
|
|
|
11
|
-
- 🛣️ **Multi-Router Support** - React Router v6+, Remix, TanStack Router, Next.js Pages Router, and
|
|
18
|
+
- 🛣️ **Multi-Router Support** - React Router v6.19+, Remix, TanStack Router, limited Next.js Pages Router interception, and App Router unload-only protection
|
|
12
19
|
- 🎯 **Scope-Based Blocking** - Synchronize navigation blocking with UI blocking scopes
|
|
13
20
|
- 🚦 **Condition-Based Blocking** - Block based on boolean conditions or functions
|
|
14
21
|
- 💬 **Custom Dialog Support** - `useDialogState` helper for async confirmation dialogs
|
|
@@ -30,12 +37,12 @@ pnpm add @okyrychenko-dev/react-action-guard-router @okyrychenko-dev/react-actio
|
|
|
30
37
|
|
|
31
38
|
This package requires the following peer dependencies:
|
|
32
39
|
|
|
33
|
-
- [@okyrychenko-dev/react-action-guard](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard) ^
|
|
40
|
+
- [@okyrychenko-dev/react-action-guard](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard) ^2.0.0 - The core UI blocking library
|
|
34
41
|
- [React](https://react.dev/) ^18.0.0 || ^19.0.0
|
|
35
42
|
- One of:
|
|
36
|
-
- [react-router-dom](https://reactrouter.com/) ^6.
|
|
37
|
-
- [@tanstack/react-router](https://tanstack.com/router) ^1.
|
|
38
|
-
- [next](https://nextjs.org/) ^13.4.0 - For
|
|
43
|
+
- [react-router-dom](https://reactrouter.com/) ^6.19.0 - For React Router or Remix
|
|
44
|
+
- [@tanstack/react-router](https://tanstack.com/router) ^1.170.41 - For TanStack Router
|
|
45
|
+
- [next](https://nextjs.org/) ^13.4.0 - For limited Pages Router interception and App Router unload-only protection
|
|
39
46
|
- [Zustand](https://zustand-demo.pmnd.rs/) - State management (peer dependency of react-action-guard)
|
|
40
47
|
|
|
41
48
|
## Quick Start
|
|
@@ -45,22 +52,26 @@ This package requires the following peer dependencies:
|
|
|
45
52
|
The most powerful pattern - synchronize navigation blocking with UI blocking:
|
|
46
53
|
|
|
47
54
|
```tsx
|
|
48
|
-
import {
|
|
49
|
-
import { useNavigationBlocker } from
|
|
55
|
+
import { useActionBlocker } from "@okyrychenko-dev/react-action-guard";
|
|
56
|
+
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/react-router";
|
|
50
57
|
|
|
51
58
|
function PaymentForm() {
|
|
52
59
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
53
60
|
|
|
54
61
|
// Block UI during payment processing
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
useActionBlocker(
|
|
63
|
+
"payment-processing",
|
|
64
|
+
{
|
|
65
|
+
scope: "checkout",
|
|
66
|
+
reason: "Processing payment...",
|
|
67
|
+
},
|
|
68
|
+
isProcessing
|
|
69
|
+
);
|
|
59
70
|
|
|
60
71
|
// Block navigation when the same scope is active
|
|
61
72
|
useNavigationBlocker({
|
|
62
|
-
scope:
|
|
63
|
-
message:
|
|
73
|
+
scope: "checkout",
|
|
74
|
+
message: "Payment is processing. Please wait.",
|
|
64
75
|
});
|
|
65
76
|
|
|
66
77
|
// When isProcessing = true, both UI and navigation are blocked
|
|
@@ -72,18 +83,27 @@ function PaymentForm() {
|
|
|
72
83
|
For basic usage without react-action-guard scopes:
|
|
73
84
|
|
|
74
85
|
```tsx
|
|
75
|
-
import { useNavigationBlocker } from
|
|
86
|
+
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/react-router";
|
|
76
87
|
|
|
77
88
|
function EditForm() {
|
|
78
89
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
79
90
|
|
|
80
91
|
useNavigationBlocker({
|
|
81
92
|
when: hasUnsavedChanges,
|
|
82
|
-
message:
|
|
93
|
+
message: "You have unsaved changes. Leave anyway?",
|
|
83
94
|
});
|
|
84
95
|
}
|
|
85
96
|
```
|
|
86
97
|
|
|
98
|
+
<details>
|
|
99
|
+
<summary>Detailed reference and integration patterns</summary>
|
|
100
|
+
|
|
101
|
+
## Canonical reference
|
|
102
|
+
|
|
103
|
+
Read the [public reference](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard-router/reference.md)
|
|
104
|
+
and [integration guide](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard-router/index.md).
|
|
105
|
+
The expandable material below remains available for existing links.
|
|
106
|
+
|
|
87
107
|
## API Reference
|
|
88
108
|
|
|
89
109
|
### Hooks
|
|
@@ -109,7 +129,8 @@ Blocks navigation in router applications based on conditions or scope state.
|
|
|
109
129
|
- `isIntercepting?: boolean` - Active interception state when the router can expose it
|
|
110
130
|
|
|
111
131
|
**Available in:**
|
|
112
|
-
|
|
132
|
+
|
|
133
|
+
- `@okyrychenko-dev/react-action-guard-router/react-router` - React Router v6.19+ & Remix
|
|
113
134
|
- `@okyrychenko-dev/react-action-guard-router/tanstack-router` - TanStack Router
|
|
114
135
|
- `@okyrychenko-dev/react-action-guard-router/nextjs` - Next.js Pages & App Router
|
|
115
136
|
|
|
@@ -118,28 +139,28 @@ Blocks navigation in router applications based on conditions or scope state.
|
|
|
118
139
|
```tsx
|
|
119
140
|
// Scope-based blocking
|
|
120
141
|
useNavigationBlocker({
|
|
121
|
-
scope:
|
|
122
|
-
message:
|
|
142
|
+
scope: "form",
|
|
143
|
+
message: "You have unsaved changes",
|
|
123
144
|
});
|
|
124
145
|
|
|
125
146
|
// Condition-based blocking
|
|
126
147
|
useNavigationBlocker({
|
|
127
148
|
when: hasChanges,
|
|
128
|
-
message:
|
|
129
|
-
onBlock: () => console.log(
|
|
130
|
-
onAllow: () => console.log(
|
|
149
|
+
message: "Discard changes?",
|
|
150
|
+
onBlock: () => console.log("Navigation blocked"),
|
|
151
|
+
onAllow: () => console.log("Navigation allowed"),
|
|
131
152
|
});
|
|
132
153
|
|
|
133
154
|
// Multiple scopes
|
|
134
155
|
useNavigationBlocker({
|
|
135
|
-
scope: [
|
|
136
|
-
message:
|
|
156
|
+
scope: ["form", "validation"],
|
|
157
|
+
message: "Form is being validated",
|
|
137
158
|
});
|
|
138
159
|
|
|
139
160
|
// Function condition
|
|
140
161
|
useNavigationBlocker({
|
|
141
162
|
when: () => formIsDirty() || hasUnsavedData(),
|
|
142
|
-
message:
|
|
163
|
+
message: "You have unsaved work",
|
|
143
164
|
});
|
|
144
165
|
```
|
|
145
166
|
|
|
@@ -150,18 +171,35 @@ Helper hook for managing custom confirmation dialogs.
|
|
|
150
171
|
**Parameters:** None
|
|
151
172
|
|
|
152
173
|
**Returns:**
|
|
174
|
+
|
|
153
175
|
- `dialogState: DialogState<TMessage> | null` - Current dialog state
|
|
154
176
|
- `message: TMessage` - The message passed to confirm
|
|
155
177
|
- `isOpen: boolean` - Whether dialog is open
|
|
156
|
-
- `resolve: (value: boolean) => void` -
|
|
178
|
+
- `resolve: (value: boolean) => void` - Settle and close this specific dialog
|
|
157
179
|
- `confirm: (message: TMessage) => Promise<boolean>` - Show dialog, returns Promise
|
|
158
180
|
- `onConfirm: () => void` - Resolve dialog with `true`
|
|
159
181
|
- `onCancel: () => void` - Resolve dialog with `false`
|
|
160
182
|
|
|
183
|
+
Each request settles once: confirmation resolves `true`; cancellation, replacement by
|
|
184
|
+
another `confirm` call, and hook unmount resolve `false`. Calling `dialogState.resolve`
|
|
185
|
+
also closes that dialog. Repeated calls and resolvers captured from an older dialog
|
|
186
|
+
have no effect on a newer dialog.
|
|
187
|
+
|
|
188
|
+
Effect teardown also cancels and clears the pending dialog when React preserves
|
|
189
|
+
hook state, such as when an `Activity` becomes hidden. Revealing the subtree starts
|
|
190
|
+
with no open dialog and allows new confirmation requests.
|
|
191
|
+
|
|
192
|
+
`confirm`, `onConfirm`, and `onCancel` retain stable references across renders.
|
|
193
|
+
The hook-level `onConfirm` and `onCancel` always act on the current dialog; use its
|
|
194
|
+
`dialogState.resolve` when a captured callback must belong to a specific dialog.
|
|
195
|
+
|
|
161
196
|
**Example:**
|
|
162
197
|
|
|
163
198
|
```tsx
|
|
164
|
-
import {
|
|
199
|
+
import {
|
|
200
|
+
useNavigationBlocker,
|
|
201
|
+
useDialogState,
|
|
202
|
+
} from "@okyrychenko-dev/react-action-guard-router/react-router";
|
|
165
203
|
|
|
166
204
|
function MyComponent() {
|
|
167
205
|
const [hasChanges, setHasChanges] = useState(false);
|
|
@@ -169,18 +207,14 @@ function MyComponent() {
|
|
|
169
207
|
|
|
170
208
|
useNavigationBlocker({
|
|
171
209
|
when: hasChanges,
|
|
172
|
-
onConfirm: confirm,
|
|
210
|
+
onConfirm: confirm, // Returns Promise<boolean>
|
|
173
211
|
});
|
|
174
212
|
|
|
175
213
|
return (
|
|
176
214
|
<>
|
|
177
215
|
<form>...</form>
|
|
178
216
|
{dialogState && (
|
|
179
|
-
<CustomDialog
|
|
180
|
-
message={dialogState.message}
|
|
181
|
-
onConfirm={onConfirm}
|
|
182
|
-
onCancel={onCancel}
|
|
183
|
-
/>
|
|
217
|
+
<CustomDialog message={dialogState.message} onConfirm={onConfirm} onCancel={onCancel} />
|
|
184
218
|
)}
|
|
185
219
|
</>
|
|
186
220
|
);
|
|
@@ -193,37 +227,53 @@ function MyComponent() {
|
|
|
193
227
|
interface CustomMessage {
|
|
194
228
|
title: string;
|
|
195
229
|
body: string;
|
|
196
|
-
severity:
|
|
230
|
+
severity: "warning" | "error";
|
|
197
231
|
}
|
|
198
232
|
|
|
199
233
|
const { dialogState, confirm } = useDialogState<CustomMessage>();
|
|
200
234
|
|
|
201
235
|
useNavigationBlocker({
|
|
202
236
|
when: isDirty,
|
|
203
|
-
onConfirm: () =>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
237
|
+
onConfirm: () =>
|
|
238
|
+
confirm({
|
|
239
|
+
title: "Unsaved Changes",
|
|
240
|
+
body: "Your work will be lost",
|
|
241
|
+
severity: "warning",
|
|
242
|
+
}),
|
|
208
243
|
});
|
|
209
244
|
```
|
|
210
245
|
|
|
246
|
+
### Render-time conditions
|
|
247
|
+
|
|
248
|
+
A function passed as `when` must be a pure condition. Each React render evaluates
|
|
249
|
+
it again, including a stable function created with `useCallback` or one reading a
|
|
250
|
+
ref or external state. Changing a ref or external value alone does not trigger a
|
|
251
|
+
render or refresh protection; arrange a React rerender or subscription.
|
|
252
|
+
`useCallback` is optional and does not cache the condition's result. A true
|
|
253
|
+
condition or a blocked `scope` enables protection (OR semantics).
|
|
254
|
+
|
|
255
|
+
This correction affects consumers whose stable predicates previously retained a
|
|
256
|
+
stale result: protection now follows the current result after rerender. Router
|
|
257
|
+
capabilities are unchanged; Next App Router protects document unload only and
|
|
258
|
+
does not intercept same-document navigation.
|
|
259
|
+
|
|
211
260
|
#### `usePrompt(message, when)` (React Router only)
|
|
212
261
|
|
|
213
262
|
Simple API similar to React Router v5's `usePrompt`.
|
|
214
263
|
|
|
215
264
|
**Parameters:**
|
|
265
|
+
|
|
216
266
|
- `message: string` - Confirmation message
|
|
217
267
|
- `when: boolean` - Condition to activate blocking
|
|
218
268
|
|
|
219
269
|
**Example:**
|
|
220
270
|
|
|
221
271
|
```tsx
|
|
222
|
-
import { usePrompt } from
|
|
272
|
+
import { usePrompt } from "@okyrychenko-dev/react-action-guard-router/react-router";
|
|
223
273
|
|
|
224
274
|
function MyForm() {
|
|
225
275
|
const [hasChanges, setHasChanges] = useState(false);
|
|
226
|
-
usePrompt(
|
|
276
|
+
usePrompt("You have unsaved changes", hasChanges);
|
|
227
277
|
}
|
|
228
278
|
```
|
|
229
279
|
|
|
@@ -232,17 +282,18 @@ function MyForm() {
|
|
|
232
282
|
Standalone hook for blocking browser unload events (tab close, refresh).
|
|
233
283
|
|
|
234
284
|
**Parameters:**
|
|
285
|
+
|
|
235
286
|
- `when: boolean` - Condition to activate blocking
|
|
236
287
|
- `message?: string` - Optional custom message (default: "Are you sure you want to leave?")
|
|
237
288
|
|
|
238
289
|
**Example:**
|
|
239
290
|
|
|
240
291
|
```tsx
|
|
241
|
-
import { useBeforeUnload } from
|
|
292
|
+
import { useBeforeUnload } from "@okyrychenko-dev/react-action-guard-router";
|
|
242
293
|
|
|
243
294
|
function MyComponent() {
|
|
244
295
|
const [hasUnsavedWork, setHasUnsavedWork] = useState(false);
|
|
245
|
-
useBeforeUnload(hasUnsavedWork,
|
|
296
|
+
useBeforeUnload(hasUnsavedWork, "You have unsaved work");
|
|
246
297
|
}
|
|
247
298
|
```
|
|
248
299
|
|
|
@@ -257,12 +308,12 @@ This utility works independently of any router and can be used in any React appl
|
|
|
257
308
|
Full support with React Router v6's `useBlocker` hook.
|
|
258
309
|
|
|
259
310
|
```tsx
|
|
260
|
-
import { useNavigationBlocker } from
|
|
311
|
+
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/react-router";
|
|
261
312
|
|
|
262
313
|
function MyComponent() {
|
|
263
314
|
useNavigationBlocker({
|
|
264
315
|
when: isDirty,
|
|
265
|
-
message:
|
|
316
|
+
message: "Discard changes?",
|
|
266
317
|
});
|
|
267
318
|
}
|
|
268
319
|
```
|
|
@@ -272,33 +323,39 @@ function MyComponent() {
|
|
|
272
323
|
|
|
273
324
|
### TanStack Router
|
|
274
325
|
|
|
275
|
-
|
|
326
|
+
Uses TanStack Router's native `useBlocker` for in-app transitions. Requires TanStack Router 1.170.41 or newer; earlier versions are no longer declared supported.
|
|
276
327
|
|
|
277
328
|
```tsx
|
|
278
|
-
import { useNavigationBlocker } from
|
|
329
|
+
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/tanstack-router";
|
|
279
330
|
|
|
280
331
|
function MyComponent() {
|
|
281
332
|
useNavigationBlocker({
|
|
282
333
|
when: isDirty,
|
|
283
|
-
message:
|
|
334
|
+
message: "Discard changes?",
|
|
284
335
|
});
|
|
285
336
|
}
|
|
286
337
|
```
|
|
287
338
|
|
|
288
|
-
|
|
339
|
+
Sync and async `onConfirm` are evaluated once per blocked navigation attempt. Active blocking without a message denies silently and emits `onBlock`. Denial, rejection, and thrown confirmation errors retain the current location. Acceptance permits that transition once and emits `onAllow`; subsequent transitions remain protected. Superseded confirmations and results received after unmount or authorization option changes cannot authorize navigation. Replacing `when` or changing the effective `scope` contents invalidates a pending confirmation even when the computed blocking condition remains true. Equivalent scope arrays preserve the attempt: array identity, order, and duplicates do not affect scope comparison. Changes to observer callbacks `onBlock` and `onAllow` do not invalidate a pending confirmation; the attempt retains the callbacks it started with.
|
|
340
|
+
|
|
341
|
+
`scope` and `when` retain their existing OR behavior. `blockBrowserUnload` controls native TanStack unload protection. Its one-shot bypass suppresses a second browser prompt after accepted document navigation; later unloads remain protected. The adapter does not register a separate shared unload handler. Unload uses a browser-controlled prompt, not asynchronous custom confirmation.
|
|
342
|
+
|
|
343
|
+
**Not-found limitation:** In the verified TanStack Router 1.170.41, navigation from an unmatched URL (`__notFound__`) to a matched route bypasses native `useBlocker` before this adapter's callback runs, even with `when: true` or an active scope. `onBlock`, `onConfirm`, and `onAllow` are not called for that transition. A blocker mounted above the not-found UI therefore cannot protect this exit; do not rely on it to guard unsaved work there. Subsequent matched-to-matched navigation remains guarded. This is upstream behavior introduced by [the upstream TanStack Router change](https://github.com/TanStack/router/pull/4917), covered by real-router tests for both condition sources. The adapter does not compensate for the bypass.
|
|
344
|
+
|
|
345
|
+
Compatibility tests exercise the public hook at 1.170.41 with real memory history for in-app navigation and browser history in the DOM test environment for unload protection. The unload tests intercept document location assignment and dispatch beforeunload events; they do not establish browser prompt UI behavior. Separate Playwright tests verified same-document back/forward on matched routes in Chrome 154.0.8037.57 on 2026-10-03: custom-dialog cancellation retains the original route and URL, confirmation reaches the intended destination once, and subsequent history attempts remain protected. This evidence does not establish other browsers or cross-document history transitions. Reproduction steps, evaluated versions, and result artifacts are documented in [browser-history verification](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/router/browser/README.md). The verified floor was raised from 1.170.28 because the older locked router-core/history combination skipped blockers for external navigation. The native API is documented in [TanStack navigation blocking](https://tanstack.com/router/latest/docs/guide/navigation-blocking).
|
|
289
346
|
|
|
290
347
|
### Next.js Pages Router
|
|
291
348
|
|
|
292
|
-
|
|
349
|
+
Limited interception through Next.js router events.
|
|
293
350
|
|
|
294
351
|
```tsx
|
|
295
352
|
// pages/edit.tsx
|
|
296
|
-
import { useNavigationBlocker } from
|
|
353
|
+
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/nextjs";
|
|
297
354
|
|
|
298
355
|
function EditPage() {
|
|
299
356
|
useNavigationBlocker({
|
|
300
357
|
when: isDirty,
|
|
301
|
-
message:
|
|
358
|
+
message: "Discard changes?",
|
|
302
359
|
});
|
|
303
360
|
}
|
|
304
361
|
```
|
|
@@ -306,39 +363,50 @@ function EditPage() {
|
|
|
306
363
|
**Async `onConfirm` behavior:**
|
|
307
364
|
Pages Router does not allow pausing transitions. When you return a Promise from `onConfirm`, the hook cancels the current navigation and re-attempts it via `router.push(url)` if confirmed. This may not preserve original transition options (e.g., `shallow`, `scroll`, `locale`).
|
|
308
365
|
|
|
366
|
+
Only the current confirmation attempt can resume navigation. Cleanup, disabling protection, or changing the protected scope or message invalidates pending answers. Callback rerenders and equivalent scopes preserve the current attempt. `onAllow` fires once for an accepted attempt; the replay permission is consumed once and cleared when the retry finishes or another URL is attempted.
|
|
367
|
+
|
|
368
|
+
Selected packed Next 15.5.23 production Pages/App browser evidence and its limitations are recorded in the [runtime guide](https://github.com/okyrychenko-dev/react-action-guard/blob/main/scripts/next-runtime/README.md) and [capability matrix](https://github.com/okyrychenko-dev/react-action-guard/blob/main/CAPABILITIES.md). This does not establish every supported peer or navigation option.
|
|
369
|
+
|
|
309
370
|
### Next.js App Router
|
|
310
371
|
|
|
311
|
-
|
|
372
|
+
Unload-only protection: the hook requests a browser `beforeunload` prompt, subject to browser policy. It does not intercept same-document `Link`, `push`, or back/forward navigation, and does not evaluate `onConfirm`. App Router does not expose an official navigation-blocking API.
|
|
312
373
|
|
|
313
374
|
```tsx
|
|
314
375
|
// app/edit/page.tsx
|
|
315
|
-
|
|
376
|
+
"use client";
|
|
316
377
|
|
|
317
|
-
import {
|
|
378
|
+
import { useState } from "react";
|
|
379
|
+
import { useAppRouterBlocker } from "@okyrychenko-dev/react-action-guard-router/nextjs";
|
|
318
380
|
|
|
319
|
-
function EditPage() {
|
|
320
|
-
|
|
381
|
+
export default function EditPage() {
|
|
382
|
+
const [isDirty, setIsDirty] = useState(false);
|
|
383
|
+
|
|
384
|
+
useAppRouterBlocker({
|
|
321
385
|
when: isDirty,
|
|
322
|
-
message:
|
|
386
|
+
message: "You have unsaved changes",
|
|
323
387
|
});
|
|
388
|
+
|
|
389
|
+
return <input onChange={() => setIsDirty(true)} />;
|
|
324
390
|
}
|
|
325
391
|
```
|
|
326
392
|
|
|
327
393
|
**App Router Limitations:**
|
|
328
|
-
|
|
394
|
+
|
|
395
|
+
- `beforeunload` can request the browser’s native prompt for document unload (close, refresh, or cross-document navigation), subject to browser policy.
|
|
396
|
+
- Same-document App Router back/forward is not intercepted.
|
|
329
397
|
- ❌ `<Link>` component navigation is NOT blocked
|
|
330
398
|
- ❌ `router.push()` is NOT blocked
|
|
331
399
|
|
|
332
|
-
|
|
400
|
+
Pages Router provides route-event interception with the replay limitations described above.
|
|
333
401
|
|
|
334
402
|
## Adapter Capabilities
|
|
335
403
|
|
|
336
|
-
| Adapter
|
|
337
|
-
|
|
|
338
|
-
| React Router
|
|
339
|
-
| TanStack Router
|
|
340
|
-
| Next.js Pages Router | Blocking condition is armed | No
|
|
341
|
-
| Next.js App Router
|
|
404
|
+
| Adapter | `isBlocking` meaning | `isIntercepting` | Async `onConfirm` | Caveats |
|
|
405
|
+
| -------------------- | --------------------------- | ---------------- | ----------------- | -------------------------------------------------------- |
|
|
406
|
+
| React Router | Blocking condition is armed | Yes | Yes | Best semantic fidelity |
|
|
407
|
+
| TanStack Router | Blocking condition is armed | No | Yes | Native useBlocker; memory-history transitions verified |
|
|
408
|
+
| Next.js Pages Router | Blocking condition is armed | No | Yes | Re-attempts confirmed navigation with `router.push(url)` |
|
|
409
|
+
| Next.js App Router | Blocking condition is armed | No | No | beforeunload only; no same-document interception |
|
|
342
410
|
|
|
343
411
|
---
|
|
344
412
|
|
|
@@ -353,7 +421,7 @@ function SignupForm() {
|
|
|
353
421
|
|
|
354
422
|
useNavigationBlocker({
|
|
355
423
|
when: isDirty,
|
|
356
|
-
message:
|
|
424
|
+
message: "Your signup progress will be lost. Continue?",
|
|
357
425
|
});
|
|
358
426
|
}
|
|
359
427
|
```
|
|
@@ -366,14 +434,18 @@ function MultiStepWizard() {
|
|
|
366
434
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
367
435
|
|
|
368
436
|
// Block UI during submission
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
437
|
+
useActionBlocker(
|
|
438
|
+
"wizard-submit",
|
|
439
|
+
{
|
|
440
|
+
scope: "wizard",
|
|
441
|
+
reason: "Submitting form...",
|
|
442
|
+
},
|
|
443
|
+
isSubmitting
|
|
444
|
+
);
|
|
373
445
|
|
|
374
446
|
// Block navigation during submission OR if wizard incomplete
|
|
375
447
|
useNavigationBlocker({
|
|
376
|
-
scope:
|
|
448
|
+
scope: "wizard",
|
|
377
449
|
when: currentStep < 5,
|
|
378
450
|
message: `You're on step ${currentStep}/5. Exit wizard?`,
|
|
379
451
|
});
|
|
@@ -383,22 +455,24 @@ function MultiStepWizard() {
|
|
|
383
455
|
### E-Commerce Checkout
|
|
384
456
|
|
|
385
457
|
```tsx
|
|
386
|
-
import { useBlockingMutation } from
|
|
387
|
-
import { useNavigationBlocker } from
|
|
458
|
+
import { useBlockingMutation } from "@okyrychenko-dev/react-action-guard-tanstack";
|
|
459
|
+
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/react-router";
|
|
388
460
|
|
|
389
461
|
function CheckoutPage() {
|
|
390
462
|
const paymentMutation = useBlockingMutation({
|
|
391
463
|
mutationFn: processPayment,
|
|
392
|
-
blockingConfig: { scope:
|
|
464
|
+
blockingConfig: { scope: "checkout" },
|
|
393
465
|
});
|
|
394
466
|
|
|
395
467
|
useNavigationBlocker({
|
|
396
|
-
scope:
|
|
397
|
-
message:
|
|
468
|
+
scope: "checkout",
|
|
469
|
+
message: "Payment is processing. Leave this page?",
|
|
398
470
|
});
|
|
399
471
|
}
|
|
400
472
|
```
|
|
401
473
|
|
|
474
|
+
UI blocking and navigation confirmation do not cancel the payment request or roll back a transaction. Cancellation, server authorization, and idempotency belong to the application and its backend.
|
|
475
|
+
|
|
402
476
|
### File Upload with Progress
|
|
403
477
|
|
|
404
478
|
```tsx
|
|
@@ -423,31 +497,29 @@ TypeScript-friendly API surface:
|
|
|
423
497
|
```typescript
|
|
424
498
|
import type {
|
|
425
499
|
// Core types
|
|
426
|
-
|
|
500
|
+
BaseNavigationBlockerOptions,
|
|
427
501
|
NavigationBlockerReturn,
|
|
428
502
|
DialogState,
|
|
429
|
-
|
|
430
|
-
ConfirmationCallbacks,
|
|
431
|
-
} from '@okyrychenko-dev/react-action-guard-router';
|
|
503
|
+
} from "@okyrychenko-dev/react-action-guard-router";
|
|
432
504
|
|
|
433
505
|
// Router-specific types
|
|
434
|
-
import type { UseNavigationBlockerOptions } from
|
|
506
|
+
import type { UseNavigationBlockerOptions } from "@okyrychenko-dev/react-action-guard-router/react-router";
|
|
435
507
|
```
|
|
436
508
|
|
|
437
509
|
**Typed scopes with `react-action-guard`:**
|
|
438
510
|
|
|
439
511
|
```tsx
|
|
440
|
-
import { createTypedHooks } from
|
|
512
|
+
import { createTypedHooks } from "@okyrychenko-dev/react-action-guard";
|
|
441
513
|
|
|
442
|
-
type AppScopes =
|
|
443
|
-
const {
|
|
514
|
+
type AppScopes = "form" | "checkout" | "navigation";
|
|
515
|
+
const { useActionBlocker } = createTypedHooks<AppScopes>();
|
|
444
516
|
|
|
445
517
|
function MyComponent() {
|
|
446
|
-
|
|
518
|
+
useActionBlocker("id", { scope: "form" }); // ✅ Typed in the core package
|
|
447
519
|
|
|
448
520
|
useNavigationBlocker({
|
|
449
|
-
scope:
|
|
450
|
-
message:
|
|
521
|
+
scope: "form", // ✅ Reuses the same scope values cleanly
|
|
522
|
+
message: "Leave form?",
|
|
451
523
|
});
|
|
452
524
|
}
|
|
453
525
|
```
|
|
@@ -460,10 +532,11 @@ Tree-shakeable by router adapter. Import only what you need:
|
|
|
460
532
|
|
|
461
533
|
```tsx
|
|
462
534
|
// Only React Router code is bundled
|
|
463
|
-
import { useNavigationBlocker } from
|
|
535
|
+
import { useNavigationBlocker } from "@okyrychenko-dev/react-action-guard-router/react-router";
|
|
464
536
|
```
|
|
465
537
|
|
|
466
538
|
**Approximate sizes (minified):**
|
|
539
|
+
|
|
467
540
|
- Core utilities: ~2 KB
|
|
468
541
|
- React Router adapter: ~2.5 KB
|
|
469
542
|
- TanStack Router adapter: ~2.5 KB
|
|
@@ -471,26 +544,27 @@ import { useNavigationBlocker } from '@okyrychenko-dev/react-action-guard-router
|
|
|
471
544
|
|
|
472
545
|
---
|
|
473
546
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
```bash
|
|
477
|
-
# Install dependencies
|
|
478
|
-
npm install
|
|
547
|
+
</details>
|
|
479
548
|
|
|
480
|
-
|
|
481
|
-
npm run test
|
|
482
|
-
|
|
483
|
-
# Build the package
|
|
484
|
-
npm run build
|
|
485
|
-
|
|
486
|
-
# Type checking
|
|
487
|
-
npm run typecheck
|
|
549
|
+
## Development
|
|
488
550
|
|
|
489
|
-
|
|
490
|
-
|
|
551
|
+
This package lives in the [react-action-guard monorepo](https://github.com/okyrychenko-dev/react-action-guard)
|
|
552
|
+
(pnpm workspaces). From the monorepo root:
|
|
491
553
|
|
|
492
|
-
|
|
493
|
-
|
|
554
|
+
```bash
|
|
555
|
+
# Install dependencies for all packages
|
|
556
|
+
pnpm install
|
|
557
|
+
|
|
558
|
+
# Run this package's scripts with --filter
|
|
559
|
+
pnpm --filter @okyrychenko-dev/react-action-guard-router run test:run
|
|
560
|
+
pnpm --filter @okyrychenko-dev/react-action-guard-router run build
|
|
561
|
+
pnpm --filter @okyrychenko-dev/react-action-guard-router run typecheck
|
|
562
|
+
pnpm --filter @okyrychenko-dev/react-action-guard-router run lint
|
|
563
|
+
pnpm --filter @okyrychenko-dev/react-action-guard-router run lint:fix
|
|
564
|
+
|
|
565
|
+
# Or cd into the package and run scripts directly
|
|
566
|
+
cd packages/router
|
|
567
|
+
pnpm run test:run
|
|
494
568
|
```
|
|
495
569
|
|
|
496
570
|
---
|
|
@@ -505,12 +579,15 @@ npm run lint:fix
|
|
|
505
579
|
|
|
506
580
|
## Contributing
|
|
507
581
|
|
|
508
|
-
Contributions are welcome! Please ensure:
|
|
582
|
+
Contributions are welcome! Please ensure, before opening a PR:
|
|
509
583
|
|
|
510
|
-
1. Tests pass (`
|
|
511
|
-
2. Code is properly typed (`
|
|
512
|
-
3. Linting passes (`
|
|
513
|
-
4. Code is formatted (`
|
|
584
|
+
1. Tests pass (`pnpm run test:run`)
|
|
585
|
+
2. Code is properly typed (`pnpm run typecheck`)
|
|
586
|
+
3. Linting passes (`pnpm run lint`)
|
|
587
|
+
4. Code is formatted (`pnpm run format`)
|
|
588
|
+
5. If the change affects this package's public behavior, add a changeset: `pnpm changeset`
|
|
589
|
+
(see the [Releasing](https://github.com/okyrychenko-dev/react-action-guard#releasing) section
|
|
590
|
+
of the monorepo README)
|
|
514
591
|
|
|
515
592
|
---
|
|
516
593
|
|