@okyrychenko-dev/react-action-guard 1.0.4 → 2.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 +451 -0
- package/README.md +171 -1026
- package/dist/index.cjs +796 -589
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +172 -1225
- package/dist/index.d.ts +172 -1225
- package/dist/index.js +795 -595
- package/dist/index.js.map +1 -1
- package/package.json +47 -53
package/README.md
CHANGED
|
@@ -4,18 +4,14 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
A save can affect an editor and a navigation control owned by different components.
|
|
8
|
+
Core lets the operation publish scopes and reasons, so both consumers react without receiving
|
|
9
|
+
its pending state as props. Unrelated controls remain available.
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- Wrap async work with `useAsyncAction` and get blocker cleanup automatically
|
|
14
|
-
- Coordinate blocking by `scope` across unrelated components
|
|
15
|
-
- Inspect current blockers with `useIsBlocked` and `useBlockingInfo`
|
|
16
|
-
- Isolate state per provider for SSR, tests, and micro-frontends
|
|
17
|
-
- Extend lifecycle events with optional middleware
|
|
18
|
-
- Add advanced hooks for scheduled, confirmable, or conditional flows when needed
|
|
11
|
+
For one local loading button, React state or mutation observation is usually sufficient.
|
|
12
|
+
Use shared scopes when independent features need a common availability policy. React Context
|
|
13
|
+
can also coordinate a small workflow; this library supplies blocker registration, scope matching,
|
|
14
|
+
ordered reasons and lifecycle cleanup rather than requiring you to build that model yourself.
|
|
19
15
|
|
|
20
16
|
## Installation
|
|
21
17
|
|
|
@@ -32,1085 +28,193 @@ This package requires the following peer dependencies:
|
|
|
32
28
|
- [React](https://react.dev/) ^18.0.0 || ^19.0.0
|
|
33
29
|
- [Zustand](https://zustand-demo.pmnd.rs/) ^5.0.0 - State management library
|
|
34
30
|
|
|
35
|
-
## 1.0 Migration
|
|
36
|
-
|
|
37
|
-
`1.0.0` removes public re-exports of internal `react-zustand-toolkit` helpers from the root package.
|
|
38
|
-
|
|
39
|
-
If you previously imported `createShallowStore`, `createStoreToolkit`, `createStoreProvider`, or `createResolvedStoreHooks` from `@okyrychenko-dev/react-action-guard`, import them directly from `@okyrychenko-dev/react-zustand-toolkit` instead.
|
|
40
|
-
|
|
41
31
|
## Quick Start
|
|
42
32
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
function SaveButton() {
|
|
47
|
-
const isSaving = useIsBlocked("form");
|
|
48
|
-
const runSave = useAsyncAction("save-profile", "form");
|
|
49
|
-
|
|
50
|
-
const handleClick = async () => {
|
|
51
|
-
await runSave(async () => {
|
|
52
|
-
await api.saveProfile();
|
|
53
|
-
});
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
return <button onClick={handleClick} disabled={isSaving}>Save</button>;
|
|
57
|
-
}
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## Core Concepts
|
|
61
|
-
|
|
62
|
-
- `scope` lets you coordinate blocking across components like `"form"`, `"navigation"`, or `"checkout"`
|
|
63
|
-
- `useAsyncAction` is the fastest path for async workflows
|
|
64
|
-
- `useBlocker` is the lower-level hook when you already have your own boolean state
|
|
65
|
-
- `UIBlockingProvider` gives you isolated state instead of the default global store
|
|
66
|
-
|
|
67
|
-
## Core Use Cases
|
|
68
|
-
|
|
69
|
-
### Prevent duplicate async actions
|
|
70
|
-
|
|
71
|
-
Use `useAsyncAction` when a button, mutation, or workflow should block while work is in flight.
|
|
72
|
-
|
|
73
|
-
### Coordinate multiple components
|
|
74
|
-
|
|
75
|
-
Use shared scopes when one component starts work and another component should react by disabling UI or showing blocker details.
|
|
76
|
-
|
|
77
|
-
### Isolate blocking domains
|
|
78
|
-
|
|
79
|
-
Use `UIBlockingProvider` when each request, test, or micro-frontend should get its own store instance.
|
|
80
|
-
|
|
81
|
-
## Documentation
|
|
82
|
-
|
|
83
|
-
📚 **Interactive Storybook Documentation** - Run locally to explore live examples and detailed guides for all hooks
|
|
84
|
-
|
|
85
|
-
To run Storybook locally:
|
|
86
|
-
|
|
87
|
-
```bash
|
|
88
|
-
npm run storybook
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
## API Reference
|
|
92
|
-
|
|
93
|
-
### Hooks
|
|
94
|
-
|
|
95
|
-
#### Core hooks
|
|
96
|
-
|
|
97
|
-
Start with these first:
|
|
98
|
-
|
|
99
|
-
- `useAsyncAction`
|
|
100
|
-
- `useBlocker`
|
|
101
|
-
- `useIsBlocked`
|
|
102
|
-
- `useBlockingInfo`
|
|
103
|
-
|
|
104
|
-
#### `useBlocker(blockerId, config, isActive?)`
|
|
33
|
+
One producer, two independent consumers, one real Provider. Paste this into a React application's
|
|
34
|
+
`App.tsx`; the simulated save takes 1.2 seconds:
|
|
105
35
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
- `priority?: number` - Priority level (higher = more important)
|
|
115
|
-
- `timeout?: number` - Auto-remove after N milliseconds
|
|
116
|
-
- `onTimeout?: (blockerId: string) => void` - Callback when auto-removed
|
|
117
|
-
- `isActive?: boolean` - Whether the blocker is active (default: true)
|
|
118
|
-
|
|
119
|
-
When `isActive` is `true`, changing `config` updates the existing blocker via `updateBlocker` without requiring unmount/remount.
|
|
120
|
-
|
|
121
|
-
**Example:**
|
|
122
|
-
|
|
123
|
-
```jsx
|
|
124
|
-
function MyComponent() {
|
|
125
|
-
useBlocker("my-blocker", {
|
|
126
|
-
scope: "form",
|
|
127
|
-
reason: "Form is saving",
|
|
128
|
-
priority: 10,
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
return <div>Content</div>;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// With timeout - auto-removes after 30 seconds
|
|
135
|
-
function SaveButton() {
|
|
136
|
-
const [isSaving, setIsSaving] = useState(false);
|
|
36
|
+
```tsx
|
|
37
|
+
import {
|
|
38
|
+
UIBlockingProvider,
|
|
39
|
+
useAsyncAction,
|
|
40
|
+
useBlockingInfo,
|
|
41
|
+
useIsBlocked,
|
|
42
|
+
} from "@okyrychenko-dev/react-action-guard";
|
|
43
|
+
import { useState, type ReactElement } from "react";
|
|
137
44
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
onTimeout: (id) => {
|
|
143
|
-
console.warn(`Operation ${id} timed out`);
|
|
144
|
-
showNotification("Operation timed out");
|
|
145
|
-
setIsSaving(false);
|
|
146
|
-
},
|
|
147
|
-
}, isSaving);
|
|
45
|
+
function SaveButton(): ReactElement {
|
|
46
|
+
const runSave = useAsyncAction("save-profile", ["profile", "navigation"]);
|
|
47
|
+
const blocked = useIsBlocked("profile");
|
|
48
|
+
const [status, setStatus] = useState("Ready");
|
|
148
49
|
|
|
149
|
-
|
|
150
|
-
setIsSaving(true);
|
|
50
|
+
async function save(): Promise<void> {
|
|
151
51
|
try {
|
|
152
|
-
await
|
|
153
|
-
|
|
154
|
-
|
|
52
|
+
await runSave(() => new Promise<void>((resolve) => setTimeout(resolve, 1200)));
|
|
53
|
+
setStatus("Saved");
|
|
54
|
+
} catch {
|
|
55
|
+
setStatus("Save failed; try again");
|
|
155
56
|
}
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
return <button onClick={handleSave} disabled={isSaving}>Save</button>;
|
|
159
|
-
}
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
#### `useIsBlocked(scope)`
|
|
163
|
-
|
|
164
|
-
Checks if a specific scope (or scopes) is currently blocked.
|
|
165
|
-
|
|
166
|
-
**Parameters:**
|
|
167
|
-
|
|
168
|
-
- `scope?: string | string[]` - Scope(s) to check (default: "global")
|
|
169
|
-
|
|
170
|
-
**Returns:** `boolean` - Whether the scope is blocked
|
|
171
|
-
|
|
172
|
-
**Example:**
|
|
173
|
-
|
|
174
|
-
```jsx
|
|
175
|
-
function SubmitButton() {
|
|
176
|
-
const isFormBlocked = useIsBlocked("form");
|
|
177
|
-
|
|
178
|
-
return <button disabled={isFormBlocked}>Submit</button>;
|
|
179
|
-
}
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
#### `useBlockingInfo(scope)`
|
|
183
|
-
|
|
184
|
-
Gets detailed information about all active blockers for a specific scope.
|
|
185
|
-
|
|
186
|
-
**Parameters:**
|
|
187
|
-
|
|
188
|
-
- `scope?: string` - Scope to get blocking information for (default: "global")
|
|
189
|
-
|
|
190
|
-
**Returns:** `ReadonlyArray<BlockerInfo>` - Array of blocker information objects, sorted by priority (highest first)
|
|
191
|
-
|
|
192
|
-
**BlockerInfo:**
|
|
193
|
-
|
|
194
|
-
- `id: string` - Unique identifier of the blocker
|
|
195
|
-
- `reason: string` - Reason for blocking (defaults to "Unknown")
|
|
196
|
-
- `priority: number` - Priority level (higher = higher priority, minimum value is 0)
|
|
197
|
-
- `scope: string | string[]` - Scope(s) being blocked
|
|
198
|
-
- `timestamp: number` - When the blocker was added (milliseconds since epoch)
|
|
199
|
-
- `timeout?: number` - Optional timeout duration in milliseconds
|
|
200
|
-
- `onTimeout?: (blockerId: string) => void` - Optional callback when timeout expires
|
|
201
|
-
|
|
202
|
-
**Example:**
|
|
203
|
-
|
|
204
|
-
```jsx
|
|
205
|
-
function CheckoutButton() {
|
|
206
|
-
const blockers = useBlockingInfo("checkout");
|
|
207
|
-
|
|
208
|
-
if (blockers.length > 0) {
|
|
209
|
-
const topBlocker = blockers[0]; // Highest priority blocker
|
|
210
|
-
return (
|
|
211
|
-
<Tooltip content={`Blocked: ${topBlocker.reason}`}>
|
|
212
|
-
<Button disabled>Checkout ({blockers.length} blockers)</Button>
|
|
213
|
-
</Tooltip>
|
|
214
|
-
);
|
|
215
57
|
}
|
|
216
58
|
|
|
217
|
-
return <Button>Checkout</Button>;
|
|
218
|
-
}
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
#### `useAsyncAction(actionId, scope, options)`
|
|
222
|
-
|
|
223
|
-
Wraps an async function with automatic blocking/unblocking.
|
|
224
|
-
|
|
225
|
-
**Parameters:**
|
|
226
|
-
|
|
227
|
-
- `actionId: string` - Identifier for the action
|
|
228
|
-
- `scope?: string | string[]` - Scope(s) to block during execution
|
|
229
|
-
- `options?: UseAsyncActionOptions` - Optional configuration
|
|
230
|
-
- `timeout?: number` - Auto-remove blocker after N milliseconds
|
|
231
|
-
- `onTimeout?: (blockerId: string) => void` - Callback when timed out
|
|
232
|
-
|
|
233
|
-
**Returns:** `(asyncFn: () => Promise<T>) => Promise<T>` - Function wrapper
|
|
234
|
-
|
|
235
|
-
**Example:**
|
|
236
|
-
|
|
237
|
-
```jsx
|
|
238
|
-
function MyComponent() {
|
|
239
|
-
const executeWithBlocking = useAsyncAction("save-data", "form");
|
|
240
|
-
|
|
241
|
-
const handleSave = async () => {
|
|
242
|
-
await executeWithBlocking(async () => {
|
|
243
|
-
await saveData();
|
|
244
|
-
});
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
return <button onClick={handleSave}>Save</button>;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// With timeout - prevents infinite blocking if operation hangs
|
|
251
|
-
function ApiComponent() {
|
|
252
|
-
const execute = useAsyncAction("api-call", "global", {
|
|
253
|
-
timeout: 60000, // 1 minute timeout
|
|
254
|
-
onTimeout: (id) => {
|
|
255
|
-
showError("Request timed out. Please try again.");
|
|
256
|
-
},
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
const fetchData = () => execute(async () => {
|
|
260
|
-
const response = await fetch("/api/data");
|
|
261
|
-
return response.json();
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
return <button onClick={fetchData}>Fetch Data</button>;
|
|
265
|
-
}
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
#### Advanced hooks
|
|
269
|
-
|
|
270
|
-
These hooks are useful, but they are not the main onboarding path for most apps:
|
|
271
|
-
|
|
272
|
-
- `useConfirmableBlocker` for confirmation flows
|
|
273
|
-
- `useScheduledBlocker` for time-window blocking
|
|
274
|
-
- `useConditionalBlocker` for polling-based conditional synchronization
|
|
275
|
-
|
|
276
|
-
#### `useConfirmableBlocker(blockerId, config)`
|
|
277
|
-
|
|
278
|
-
Creates a confirmable action with UI blocking while the dialog is open or the action is running.
|
|
279
|
-
Use it for advanced confirmation flows after the core hooks are already a good fit.
|
|
280
|
-
|
|
281
|
-
**Parameters:**
|
|
282
|
-
|
|
283
|
-
- `blockerId: string` - Unique identifier for the blocker
|
|
284
|
-
- `config: ConfirmableBlockerConfig` - Configuration object
|
|
285
|
-
- `confirmMessage: string` - Message to show in confirmation dialog
|
|
286
|
-
- `confirmTitle?: string` - Dialog title (default: "Confirm Action")
|
|
287
|
-
- `confirmButtonText?: string` - Confirm button label (default: "Confirm")
|
|
288
|
-
- `cancelButtonText?: string` - Cancel button label (default: "Cancel")
|
|
289
|
-
- `onConfirm: () => void | Promise<void>` - Callback when user confirms
|
|
290
|
-
- `onCancel?: () => void` - Callback when user cancels
|
|
291
|
-
- Plus all `BlockerConfig` properties (scope, reason, priority, timeout)
|
|
292
|
-
|
|
293
|
-
**Returns:**
|
|
294
|
-
|
|
295
|
-
- `execute: () => void` - Opens the confirmation dialog
|
|
296
|
-
- `isDialogOpen: boolean` - Whether the dialog is open
|
|
297
|
-
- `isExecuting: boolean` - Whether the confirm action is running
|
|
298
|
-
- `confirmConfig: { title, message, confirmText, cancelText }` - UI-ready dialog config
|
|
299
|
-
- `onConfirm: () => Promise<void>` - Confirm handler to wire to your dialog
|
|
300
|
-
- `onCancel: () => void` - Cancel handler to wire to your dialog
|
|
301
|
-
|
|
302
|
-
**Example:**
|
|
303
|
-
|
|
304
|
-
```jsx
|
|
305
|
-
function UnsavedChangesGuard({ discardChanges }) {
|
|
306
|
-
const {
|
|
307
|
-
execute,
|
|
308
|
-
isDialogOpen,
|
|
309
|
-
isExecuting,
|
|
310
|
-
confirmConfig,
|
|
311
|
-
onConfirm,
|
|
312
|
-
onCancel,
|
|
313
|
-
} = useConfirmableBlocker("unsaved-changes", {
|
|
314
|
-
scope: "navigation",
|
|
315
|
-
reason: "Unsaved changes",
|
|
316
|
-
confirmMessage: "You have unsaved changes. Are you sure you want to leave?",
|
|
317
|
-
onConfirm: async () => {
|
|
318
|
-
await discardChanges();
|
|
319
|
-
},
|
|
320
|
-
});
|
|
321
|
-
|
|
322
59
|
return (
|
|
323
60
|
<>
|
|
324
|
-
<button
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
/>
|
|
334
|
-
)}
|
|
335
|
-
{isExecuting && <LoadingOverlay message="Processing..." />}
|
|
61
|
+
<button
|
|
62
|
+
disabled={blocked}
|
|
63
|
+
onClick={() => {
|
|
64
|
+
void save();
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
Save profile
|
|
68
|
+
</button>
|
|
69
|
+
<p role="status">{status}</p>
|
|
336
70
|
</>
|
|
337
71
|
);
|
|
338
72
|
}
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
#### `useScheduledBlocker(blockerId, config)`
|
|
342
|
-
|
|
343
|
-
Blocks UI during a scheduled time period or maintenance window.
|
|
344
|
-
Use it when blocking is driven by time windows rather than user-triggered async work.
|
|
345
|
-
|
|
346
|
-
**Parameters:**
|
|
347
|
-
|
|
348
|
-
- `blockerId: string` - Unique identifier for the blocker
|
|
349
|
-
- `config: ScheduledBlockerConfig`
|
|
350
|
-
- `schedule: BlockingSchedule`
|
|
351
|
-
- `start: string | Date | number` - Start time (ISO string, Date, or timestamp)
|
|
352
|
-
- `end?: string | Date | number` - End time (optional)
|
|
353
|
-
- `duration?: number` - Duration in milliseconds (takes precedence over end)
|
|
354
|
-
- `onScheduleStart?: () => void` - Callback when blocking starts
|
|
355
|
-
- `onScheduleEnd?: () => void` - Callback when blocking ends
|
|
356
|
-
- Plus all `BlockerConfig` properties (scope, reason, priority)
|
|
357
|
-
|
|
358
|
-
**Example:**
|
|
359
|
-
|
|
360
|
-
```jsx
|
|
361
|
-
function MaintenanceWindow() {
|
|
362
|
-
useScheduledBlocker("maintenance", {
|
|
363
|
-
scope: "global",
|
|
364
|
-
reason: "Scheduled maintenance",
|
|
365
|
-
priority: 1000,
|
|
366
|
-
schedule: {
|
|
367
|
-
start: "2024-01-15T02:00:00Z",
|
|
368
|
-
duration: 3600000, // 1 hour in milliseconds
|
|
369
|
-
},
|
|
370
|
-
onScheduleStart: () => {
|
|
371
|
-
console.log("Maintenance started");
|
|
372
|
-
},
|
|
373
|
-
onScheduleEnd: () => {
|
|
374
|
-
console.log("Maintenance completed");
|
|
375
|
-
},
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
return <div>App content</div>;
|
|
379
|
-
}
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
#### `useConditionalBlocker(blockerId, config)`
|
|
383
|
-
|
|
384
|
-
Periodically checks a condition and blocks/unblocks based on the result.
|
|
385
|
-
Use this when polling is an acceptable tradeoff and the condition is not naturally event-driven.
|
|
386
|
-
|
|
387
|
-
**Parameters:**
|
|
388
|
-
|
|
389
|
-
- `blockerId: string` - Unique identifier for the blocker
|
|
390
|
-
- `config: ConditionalBlockerConfig<TState>`
|
|
391
|
-
- `scope: string | string[]` - Required scope(s) to block
|
|
392
|
-
- `condition: (state?: TState) => boolean` - Function that determines if blocking should be active
|
|
393
|
-
- `checkInterval?: number` - How often to check the condition in ms (default: 1000)
|
|
394
|
-
- `state?: TState` - Optional state to pass to the condition function
|
|
395
|
-
- Plus all other `BlockerConfig` properties (reason, priority)
|
|
396
|
-
|
|
397
|
-
**Example:**
|
|
398
|
-
|
|
399
|
-
```jsx
|
|
400
|
-
function NetworkStatusBlocker() {
|
|
401
|
-
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
|
402
|
-
|
|
403
|
-
useEffect(() => {
|
|
404
|
-
const handleOnline = () => setIsOnline(true);
|
|
405
|
-
const handleOffline = () => setIsOnline(false);
|
|
406
|
-
|
|
407
|
-
window.addEventListener("online", handleOnline);
|
|
408
|
-
window.addEventListener("offline", handleOffline);
|
|
409
|
-
|
|
410
|
-
return () => {
|
|
411
|
-
window.removeEventListener("online", handleOnline);
|
|
412
|
-
window.removeEventListener("offline", handleOffline);
|
|
413
|
-
};
|
|
414
|
-
}, []);
|
|
415
|
-
|
|
416
|
-
useConditionalBlocker("network-check", {
|
|
417
|
-
scope: ["form", "navigation"],
|
|
418
|
-
reason: "No network connection",
|
|
419
|
-
priority: 100,
|
|
420
|
-
condition: () => !isOnline,
|
|
421
|
-
checkInterval: 2000,
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
return <div>App content</div>;
|
|
425
|
-
}
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
### Provider (Optional)
|
|
429
|
-
|
|
430
|
-
#### `UIBlockingProvider`
|
|
431
|
-
|
|
432
|
-
Provides an isolated store instance for SSR, testing, or micro-frontends. Without the provider, hooks use a global store.
|
|
433
|
-
|
|
434
|
-
**Props:**
|
|
435
|
-
|
|
436
|
-
- `children: ReactNode` - Child components
|
|
437
|
-
- `middlewares?: Middleware[]` - Initial middlewares to register
|
|
438
|
-
|
|
439
|
-
**Example:**
|
|
440
|
-
|
|
441
|
-
```jsx
|
|
442
|
-
import { UIBlockingProvider } from "@okyrychenko-dev/react-action-guard";
|
|
443
|
-
|
|
444
|
-
// SSR - each request gets isolated state
|
|
445
|
-
function App() {
|
|
446
|
-
return (
|
|
447
|
-
<UIBlockingProvider>
|
|
448
|
-
<MyApp />
|
|
449
|
-
</UIBlockingProvider>
|
|
450
|
-
);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// Testing - isolated state per test, no cleanup needed
|
|
454
|
-
function renderWithProvider(ui) {
|
|
455
|
-
return render(
|
|
456
|
-
<UIBlockingProvider>{ui}</UIBlockingProvider>
|
|
457
|
-
);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Micro-frontends - each app has its own blocking state
|
|
461
|
-
function MicroFrontend() {
|
|
462
|
-
return (
|
|
463
|
-
<UIBlockingProvider>
|
|
464
|
-
<MicroApp />
|
|
465
|
-
</UIBlockingProvider>
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
#### Context Hooks
|
|
471
|
-
|
|
472
|
-
- `useUIBlockingContext()` - Get store API from context (throws if outside provider)
|
|
473
|
-
- `useOptionalUIBlockingContext()` - Get store API from context or `null` outside provider
|
|
474
|
-
- `useIsInsideUIBlockingProvider()` - Check if inside a provider
|
|
475
|
-
- `useUIBlockingStoreFromContext(selector)` - Select state from context store
|
|
476
|
-
- `useResolvedStoreApi()` - Resolve to provider store API or global store API
|
|
477
|
-
- `useResolvedValue(selector)` - Resolve to provider/global store and select with shallow comparison
|
|
478
|
-
|
|
479
|
-
Legacy aliases remain available for backward compatibility:
|
|
480
|
-
- `useOptionalContext()` -> `useOptionalUIBlockingContext()`
|
|
481
|
-
- `useResolvedStore()` -> `useResolvedStoreApi()`
|
|
482
|
-
- `useResolvedStoreWithSelector()` -> `useResolvedValue()`
|
|
483
|
-
|
|
484
|
-
### Store
|
|
485
|
-
|
|
486
|
-
#### `useUIBlockingStore`
|
|
487
|
-
|
|
488
|
-
Direct access to the Zustand store for advanced use cases (requires a selector).
|
|
489
|
-
|
|
490
|
-
**Methods:**
|
|
491
|
-
|
|
492
|
-
- `addBlocker(id, config)` - Manually add a blocker (re-adding with the same ID replaces config)
|
|
493
|
-
- `updateBlocker(id, config)` - Update blocker metadata (timeout restarts if new `timeout` value provided)
|
|
494
|
-
- `removeBlocker(id)` - Manually remove a blocker
|
|
495
|
-
- `isBlocked(scope)` - Check if scope is blocked
|
|
496
|
-
- `getBlockingInfo(scope)` - Get detailed blocking information
|
|
497
|
-
- `clearAllBlockers()` - Remove all blockers (emits `"clear"` middleware event)
|
|
498
|
-
- `clearBlockersForScope(scope)` - Remove blockers for specific scope (emits `"clear_scope"` middleware event)
|
|
499
|
-
- `registerMiddleware(name, middleware)` - Register middleware manually
|
|
500
|
-
- `unregisterMiddleware(name)` - Unregister middleware manually
|
|
501
|
-
|
|
502
|
-
**Note about `updateBlocker` and timeouts:**
|
|
503
|
-
- If you pass a **new** `timeout` value, the timer will be **restarted**
|
|
504
|
-
- If you **don't** pass `timeout`, the existing timer continues unchanged
|
|
505
|
-
- Set `timeout: 0` to **clear** an existing timeout
|
|
506
|
-
- `addBlocker` always resets/creates a new timeout timer when called with the same ID
|
|
507
|
-
|
|
508
|
-
**Example:**
|
|
509
|
-
|
|
510
|
-
```jsx
|
|
511
|
-
import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
|
|
512
|
-
|
|
513
|
-
function AdvancedComponent() {
|
|
514
|
-
const { addBlocker, updateBlocker, removeBlocker } = useUIBlockingStore((state) => ({
|
|
515
|
-
addBlocker: state.addBlocker,
|
|
516
|
-
updateBlocker: state.updateBlocker,
|
|
517
|
-
removeBlocker: state.removeBlocker,
|
|
518
|
-
}));
|
|
519
|
-
|
|
520
|
-
const startBlocking = () => {
|
|
521
|
-
addBlocker("custom-blocker", {
|
|
522
|
-
scope: ["form", "navigation"],
|
|
523
|
-
reason: "Critical operation in progress",
|
|
524
|
-
priority: 100,
|
|
525
|
-
});
|
|
526
|
-
};
|
|
527
|
-
|
|
528
|
-
const updatePriority = () => {
|
|
529
|
-
// Update metadata - timeout continues if not changed
|
|
530
|
-
updateBlocker("custom-blocker", { priority: 200 });
|
|
531
|
-
};
|
|
532
|
-
|
|
533
|
-
const extendTimeout = () => {
|
|
534
|
-
// Update timeout - timer restarts with new value
|
|
535
|
-
updateBlocker("custom-blocker", { timeout: 60000 });
|
|
536
|
-
};
|
|
537
|
-
|
|
538
|
-
const stopBlocking = () => {
|
|
539
|
-
removeBlocker("custom-blocker");
|
|
540
|
-
};
|
|
541
|
-
|
|
542
|
-
return (
|
|
543
|
-
<div>
|
|
544
|
-
<button onClick={startBlocking}>Start</button>
|
|
545
|
-
<button onClick={updatePriority}>Increase Priority</button>
|
|
546
|
-
<button onClick={stopBlocking}>Stop</button>
|
|
547
|
-
</div>
|
|
548
|
-
);
|
|
549
|
-
}
|
|
550
|
-
```
|
|
551
|
-
|
|
552
|
-
For non-hook contexts (tests, utilities, event handlers), use `uiBlockingStoreApi`:
|
|
553
|
-
|
|
554
|
-
```jsx
|
|
555
|
-
import { uiBlockingStoreApi } from "@okyrychenko-dev/react-action-guard";
|
|
556
|
-
|
|
557
|
-
uiBlockingStoreApi.getState().addBlocker("server-call", {
|
|
558
|
-
scope: "global",
|
|
559
|
-
reason: "Server call running",
|
|
560
|
-
});
|
|
561
|
-
```
|
|
562
|
-
|
|
563
|
-
## Middleware System
|
|
564
|
-
|
|
565
|
-
Middleware is optional. Use it when you need analytics, logging, or performance visibility around blocker lifecycle events.
|
|
566
|
-
|
|
567
|
-
Most applications can start without middleware and add it later if blocker observability becomes important.
|
|
568
|
-
|
|
569
|
-
### Middleware Actions
|
|
570
|
-
|
|
571
|
-
Middleware receives events for the following actions:
|
|
572
|
-
|
|
573
|
-
- `"add"` - Blocker was added
|
|
574
|
-
- `"update"` - Blocker metadata was updated
|
|
575
|
-
- `"remove"` - Blocker was removed
|
|
576
|
-
- `"timeout"` - Blocker was auto-removed due to timeout
|
|
577
|
-
- `"clear"` - All blockers were cleared (includes `count` field)
|
|
578
|
-
- `"clear_scope"` - Blockers for specific scope were cleared (includes `scope` and `count` fields)
|
|
579
|
-
|
|
580
|
-
**MiddlewareContext type:**
|
|
581
|
-
```typescript
|
|
582
|
-
{
|
|
583
|
-
action: "add" | "update" | "remove" | "timeout" | "clear" | "clear_scope";
|
|
584
|
-
blockerId: string;
|
|
585
|
-
config?: BlockerConfig;
|
|
586
|
-
timestamp: number;
|
|
587
|
-
prevState?: BlockerConfig; // Available for "update" and "remove"
|
|
588
|
-
scope?: string; // Available for "clear_scope"
|
|
589
|
-
count?: number; // Available for "clear" and "clear_scope"
|
|
590
|
-
}
|
|
591
|
-
```
|
|
592
|
-
|
|
593
|
-
### Built-in Middleware
|
|
594
|
-
|
|
595
|
-
These are extensions around the blocker lifecycle, not the primary onboarding path for the library.
|
|
596
|
-
|
|
597
|
-
#### Analytics Middleware
|
|
598
|
-
|
|
599
|
-
Track blocker events with your analytics provider (Google Analytics, Mixpanel, Amplitude, or custom).
|
|
600
|
-
|
|
601
|
-
```jsx
|
|
602
|
-
import {
|
|
603
|
-
configureMiddleware,
|
|
604
|
-
createAnalyticsMiddleware,
|
|
605
|
-
} from "@okyrychenko-dev/react-action-guard";
|
|
606
|
-
|
|
607
|
-
// Google Analytics
|
|
608
|
-
configureMiddleware([createAnalyticsMiddleware({ provider: "ga" })]);
|
|
609
|
-
|
|
610
|
-
// Mixpanel
|
|
611
|
-
configureMiddleware([createAnalyticsMiddleware({ provider: "mixpanel" })]);
|
|
612
|
-
|
|
613
|
-
// Amplitude
|
|
614
|
-
configureMiddleware([createAnalyticsMiddleware({ provider: "amplitude" })]);
|
|
615
|
-
|
|
616
|
-
// Custom analytics
|
|
617
|
-
configureMiddleware([
|
|
618
|
-
createAnalyticsMiddleware({
|
|
619
|
-
track: (event, data) => {
|
|
620
|
-
myAnalytics.track(event, data);
|
|
621
|
-
},
|
|
622
|
-
}),
|
|
623
|
-
]);
|
|
624
|
-
```
|
|
625
|
-
|
|
626
|
-
#### Logger Middleware
|
|
627
|
-
|
|
628
|
-
Log blocker lifecycle events to the console for debugging.
|
|
629
|
-
|
|
630
|
-
```jsx
|
|
631
|
-
import { configureMiddleware, loggerMiddleware } from "@okyrychenko-dev/react-action-guard";
|
|
632
|
-
|
|
633
|
-
configureMiddleware([loggerMiddleware]);
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
#### Performance Middleware
|
|
637
|
-
|
|
638
|
-
Monitor blocker performance and detect slow operations.
|
|
639
|
-
|
|
640
|
-
```jsx
|
|
641
|
-
import {
|
|
642
|
-
configureMiddleware,
|
|
643
|
-
createPerformanceMiddleware,
|
|
644
|
-
} from "@okyrychenko-dev/react-action-guard";
|
|
645
|
-
|
|
646
|
-
configureMiddleware([
|
|
647
|
-
createPerformanceMiddleware({
|
|
648
|
-
onSlowBlock: (blockerId, duration) => {
|
|
649
|
-
console.warn(`Blocker ${blockerId} was active for ${duration}ms`);
|
|
650
|
-
},
|
|
651
|
-
slowBlockThreshold: 5000, // 5 seconds
|
|
652
|
-
}),
|
|
653
|
-
]);
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
Note: `configureMiddleware` registers middleware on the global store. If you use `UIBlockingProvider`, register middleware via the provider's `middlewares` prop instead.
|
|
657
|
-
Each `configureMiddleware(...)` call replaces previously configured global middlewares (`middleware-*`) and keeps provider-level middlewares untouched.
|
|
658
|
-
|
|
659
|
-
Analytics middleware is SSR-safe: in non-browser environments it becomes a no-op for `ga`, `mixpanel`, and `amplitude` providers.
|
|
660
|
-
|
|
661
|
-
### Custom Middleware
|
|
662
|
-
|
|
663
|
-
Create your own middleware to handle blocker events:
|
|
664
|
-
|
|
665
|
-
```jsx
|
|
666
|
-
import { configureMiddleware } from "@okyrychenko-dev/react-action-guard";
|
|
667
|
-
|
|
668
|
-
const myCustomMiddleware = (context) => {
|
|
669
|
-
const { action, blockerId, config, timestamp, scope, count } = context;
|
|
670
|
-
|
|
671
|
-
if (action === "add") {
|
|
672
|
-
console.log(`Blocker added: ${blockerId}`, config);
|
|
673
|
-
} else if (action === "update") {
|
|
674
|
-
console.log(`Blocker updated: ${blockerId}`, config);
|
|
675
|
-
} else if (action === "remove") {
|
|
676
|
-
console.log(`Blocker removed: ${blockerId}`);
|
|
677
|
-
} else if (action === "timeout") {
|
|
678
|
-
console.log(`Blocker timed out: ${blockerId}`);
|
|
679
|
-
} else if (action === "clear") {
|
|
680
|
-
console.log(`All blockers cleared (${count} total)`);
|
|
681
|
-
} else if (action === "clear_scope") {
|
|
682
|
-
console.log(`Cleared ${count} blocker(s) for scope: ${scope}`);
|
|
683
|
-
}
|
|
684
|
-
};
|
|
685
|
-
|
|
686
|
-
configureMiddleware([myCustomMiddleware]);
|
|
687
|
-
```
|
|
688
|
-
|
|
689
|
-
### Combining Middleware
|
|
690
|
-
|
|
691
|
-
You can combine multiple middleware when you need broader observability:
|
|
692
|
-
|
|
693
|
-
```jsx
|
|
694
|
-
import {
|
|
695
|
-
configureMiddleware,
|
|
696
|
-
createAnalyticsMiddleware,
|
|
697
|
-
loggerMiddleware,
|
|
698
|
-
createPerformanceMiddleware,
|
|
699
|
-
} from "@okyrychenko-dev/react-action-guard";
|
|
700
|
-
|
|
701
|
-
configureMiddleware([
|
|
702
|
-
loggerMiddleware,
|
|
703
|
-
createAnalyticsMiddleware({ provider: "ga" }),
|
|
704
|
-
createPerformanceMiddleware({
|
|
705
|
-
slowBlockThreshold: 3000,
|
|
706
|
-
onSlowBlock: (blockerId, duration) => {
|
|
707
|
-
// Send to error tracking service
|
|
708
|
-
errorTracker.captureMessage(`Slow blocker: ${blockerId}`, {
|
|
709
|
-
duration,
|
|
710
|
-
});
|
|
711
|
-
},
|
|
712
|
-
}),
|
|
713
|
-
]);
|
|
714
|
-
```
|
|
715
|
-
|
|
716
|
-
## Tree Shaking
|
|
717
|
-
|
|
718
|
-
The library is fully tree-shakeable. Import only the features you need to keep your bundle size small:
|
|
719
|
-
|
|
720
|
-
```jsx
|
|
721
|
-
// Only imports the hook you need
|
|
722
|
-
import { useBlocker } from "@okyrychenko-dev/react-action-guard";
|
|
723
|
-
|
|
724
|
-
// Middleware is not included unless you import it
|
|
725
|
-
import {
|
|
726
|
-
configureMiddleware,
|
|
727
|
-
createAnalyticsMiddleware,
|
|
728
|
-
} from "@okyrychenko-dev/react-action-guard";
|
|
729
|
-
```
|
|
730
|
-
|
|
731
|
-
The package is configured with `"sideEffects": false`, allowing modern bundlers (Webpack, Rollup, Vite) to eliminate unused code automatically.
|
|
732
|
-
|
|
733
|
-
## TypeScript
|
|
734
|
-
|
|
735
|
-
The package is written in TypeScript and includes full type definitions.
|
|
736
|
-
|
|
737
|
-
```typescript
|
|
738
|
-
import type {
|
|
739
|
-
// Core types
|
|
740
|
-
BlockerConfig,
|
|
741
|
-
BlockerInfo,
|
|
742
|
-
UIBlockingStore,
|
|
743
|
-
UIBlockingStoreState,
|
|
744
|
-
|
|
745
|
-
// Hook types
|
|
746
|
-
ConfirmableBlockerConfig,
|
|
747
|
-
ConfirmDialogConfig,
|
|
748
|
-
UseConfirmableBlockerReturn,
|
|
749
|
-
ScheduledBlockerConfig,
|
|
750
|
-
ConditionalBlockerConfig,
|
|
751
|
-
BlockingSchedule,
|
|
752
|
-
UseAsyncActionOptions,
|
|
753
|
-
|
|
754
|
-
// Middleware types
|
|
755
|
-
Middleware,
|
|
756
|
-
MiddlewareContext,
|
|
757
|
-
AnalyticsConfig,
|
|
758
|
-
AnalyticsProvider,
|
|
759
|
-
PerformanceConfig,
|
|
760
|
-
|
|
761
|
-
// Type-safe scopes
|
|
762
|
-
BlockerConfigTyped,
|
|
763
|
-
DefaultScopes,
|
|
764
|
-
ScopeValue,
|
|
765
|
-
} from "@okyrychenko-dev/react-action-guard";
|
|
766
|
-
```
|
|
767
|
-
|
|
768
|
-
## Type-Safe Scopes
|
|
769
|
-
|
|
770
|
-
Create typed versions of the hooks to prevent scope typos at compile time:
|
|
771
|
-
|
|
772
|
-
```typescript
|
|
773
|
-
import { createTypedHooks } from "@okyrychenko-dev/react-action-guard";
|
|
774
|
-
|
|
775
|
-
type AppScopes = "global" | "form" | "navigation" | "checkout";
|
|
776
|
-
|
|
777
|
-
const { useBlocker, useIsBlocked, useAsyncAction, useBlockingInfo } =
|
|
778
|
-
createTypedHooks<AppScopes>();
|
|
779
|
-
|
|
780
|
-
useBlocker("save", { scope: "form" }); // OK
|
|
781
|
-
useBlocker("save", { scope: "typo" }); // Type error
|
|
782
|
-
```
|
|
783
|
-
|
|
784
|
-
## Use Cases
|
|
785
|
-
|
|
786
|
-
The first four examples below are the most representative starting points.
|
|
787
|
-
|
|
788
|
-
### Loading States
|
|
789
|
-
|
|
790
|
-
```jsx
|
|
791
|
-
function DataLoader() {
|
|
792
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
793
|
-
|
|
794
|
-
useBlocker(
|
|
795
|
-
"data-loader",
|
|
796
|
-
{
|
|
797
|
-
scope: "content",
|
|
798
|
-
reason: "Loading data",
|
|
799
|
-
},
|
|
800
|
-
isLoading
|
|
801
|
-
);
|
|
802
|
-
|
|
803
|
-
// ... rest of component
|
|
804
|
-
}
|
|
805
|
-
```
|
|
806
|
-
|
|
807
|
-
### Form Submission with Analytics
|
|
808
73
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
function UserForm() {
|
|
813
|
-
const executeWithBlocking = useAsyncAction("submit-form", "form");
|
|
814
|
-
const isBlocked = useIsBlocked("form");
|
|
815
|
-
|
|
816
|
-
const handleSubmit = async (data) => {
|
|
817
|
-
await executeWithBlocking(async () => {
|
|
818
|
-
await submitForm(data);
|
|
819
|
-
});
|
|
820
|
-
};
|
|
74
|
+
function ProfileEditor(): ReactElement {
|
|
75
|
+
const blockers = useBlockingInfo("profile");
|
|
821
76
|
|
|
822
77
|
return (
|
|
823
|
-
|
|
824
|
-
<
|
|
825
|
-
|
|
826
|
-
|
|
78
|
+
<>
|
|
79
|
+
<label>
|
|
80
|
+
Display name <input disabled={blockers.length > 0} />
|
|
81
|
+
</label>
|
|
82
|
+
<p role="status">{blockers[0]?.reason ?? "Editing available"}</p>
|
|
83
|
+
</>
|
|
827
84
|
);
|
|
828
85
|
}
|
|
829
|
-
```
|
|
830
86
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
import { useConfirmableBlocker } from "@okyrychenko-dev/react-action-guard";
|
|
835
|
-
|
|
836
|
-
function FormWithUnsavedWarning() {
|
|
837
|
-
const [formData, setFormData] = useState({});
|
|
838
|
-
const [hasChanges, setHasChanges] = useState(false);
|
|
839
|
-
|
|
840
|
-
const {
|
|
841
|
-
execute,
|
|
842
|
-
isDialogOpen,
|
|
843
|
-
confirmConfig,
|
|
844
|
-
onConfirm,
|
|
845
|
-
onCancel,
|
|
846
|
-
} = useConfirmableBlocker("unsaved-form", {
|
|
847
|
-
scope: "navigation",
|
|
848
|
-
reason: "Unsaved form data",
|
|
849
|
-
priority: 100,
|
|
850
|
-
confirmMessage: "You have unsaved changes. Discard them?",
|
|
851
|
-
onConfirm: () => {
|
|
852
|
-
setFormData({});
|
|
853
|
-
setHasChanges(false);
|
|
854
|
-
},
|
|
855
|
-
});
|
|
87
|
+
function NavigationControl(): ReactElement {
|
|
88
|
+
const blockers = useBlockingInfo("navigation");
|
|
89
|
+
const [page, setPage] = useState("Profile");
|
|
856
90
|
|
|
857
91
|
return (
|
|
858
|
-
|
|
859
|
-
<
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
<button type="button" onClick={execute}>Cancel</button>
|
|
866
|
-
{isDialogOpen && (
|
|
867
|
-
<ConfirmDialog
|
|
868
|
-
title={confirmConfig.title}
|
|
869
|
-
message={confirmConfig.message}
|
|
870
|
-
confirmText={confirmConfig.confirmText}
|
|
871
|
-
cancelText={confirmConfig.cancelText}
|
|
872
|
-
onConfirm={onConfirm}
|
|
873
|
-
onCancel={onCancel}
|
|
874
|
-
/>
|
|
875
|
-
)}
|
|
876
|
-
</form>
|
|
92
|
+
<>
|
|
93
|
+
<button disabled={blockers.length > 0} onClick={() => setPage("Dashboard")}>
|
|
94
|
+
Open dashboard
|
|
95
|
+
</button>
|
|
96
|
+
<p role="status">{blockers[0]?.reason ?? "Navigation available"}</p>
|
|
97
|
+
<p>{page}</p>
|
|
98
|
+
</>
|
|
877
99
|
);
|
|
878
100
|
}
|
|
879
|
-
```
|
|
880
|
-
|
|
881
|
-
### Global Loading Overlay
|
|
882
|
-
|
|
883
|
-
```jsx
|
|
884
|
-
function App() {
|
|
885
|
-
const isGloballyBlocked = useIsBlocked("global");
|
|
886
101
|
|
|
102
|
+
export default function App(): ReactElement {
|
|
887
103
|
return (
|
|
888
|
-
<
|
|
889
|
-
|
|
890
|
-
<
|
|
891
|
-
|
|
104
|
+
<UIBlockingProvider>
|
|
105
|
+
<SaveButton />
|
|
106
|
+
<ProfileEditor />
|
|
107
|
+
<NavigationControl />
|
|
108
|
+
<button onClick={() => alert("Help remains available")}>Help</button>
|
|
109
|
+
</UIBlockingProvider>
|
|
892
110
|
);
|
|
893
111
|
}
|
|
894
112
|
```
|
|
895
113
|
|
|
896
|
-
|
|
114
|
+
The save publishes both explicit scopes. Each consumer reads only its own scope and displays
|
|
115
|
+
`Executing save-profile` while protected. Names match literally: `profile.name` does not inherit
|
|
116
|
+
`profile`. A `global` blocker affects ordinary scopes; omitted scopes default to `global`.
|
|
897
117
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
118
|
+
`useAsyncAction` releases each execution's blocker after success or failure. If its producer
|
|
119
|
+
unmounts, pending work stays protected until settlement; unmount does not cancel the operation.
|
|
120
|
+
Every call still executes. Applications own repeat-submit exclusion, cancellation and backend
|
|
121
|
+
permissions. The navigation control above changes local UI; it does not intercept browser navigation.
|
|
901
122
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
{
|
|
906
|
-
scope: ["navigation", "form"],
|
|
907
|
-
reason: "Processing payment",
|
|
908
|
-
priority: 100,
|
|
909
|
-
},
|
|
910
|
-
step === 3
|
|
911
|
-
);
|
|
123
|
+
Prefer `UIBlockingProvider` for explicit ownership and SSR, using fresh state per request.
|
|
124
|
+
Without a Provider, hooks use a shared global fallback that does not isolate server requests.
|
|
125
|
+
For existing boolean state, register it with `useActionBlocker` instead.
|
|
912
126
|
|
|
913
|
-
|
|
914
|
-
useBlocker(
|
|
915
|
-
"wizard-step",
|
|
916
|
-
{
|
|
917
|
-
scope: "navigation",
|
|
918
|
-
reason: "Wizard in progress",
|
|
919
|
-
priority: 50,
|
|
920
|
-
},
|
|
921
|
-
step < 3
|
|
922
|
-
);
|
|
127
|
+
## Run the complete example
|
|
923
128
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
```
|
|
129
|
+
The [core-only example](https://github.com/okyrychenko-dev/react-action-guard/tree/main/examples/core-coordination)
|
|
130
|
+
adds success/failure, retry, producer detachment and unrelated help. From a repository checkout:
|
|
927
131
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
function App() {
|
|
934
|
-
useScheduledBlocker("weekly-maintenance", {
|
|
935
|
-
scope: "global",
|
|
936
|
-
reason: "Weekly system maintenance",
|
|
937
|
-
priority: 500,
|
|
938
|
-
schedule: {
|
|
939
|
-
start: new Date("2024-01-21T03:00:00Z"),
|
|
940
|
-
duration: 1800000, // 30 minutes
|
|
941
|
-
},
|
|
942
|
-
onScheduleStart: () => {
|
|
943
|
-
showNotification("System maintenance in progress");
|
|
944
|
-
},
|
|
945
|
-
onScheduleEnd: () => {
|
|
946
|
-
showNotification("Maintenance completed");
|
|
947
|
-
window.location.reload();
|
|
948
|
-
},
|
|
949
|
-
});
|
|
950
|
-
|
|
951
|
-
return <YourApp />;
|
|
952
|
-
}
|
|
132
|
+
```bash
|
|
133
|
+
pnpm install --frozen-lockfile
|
|
134
|
+
pnpm --filter @okyrychenko-dev/react-action-guard run build
|
|
135
|
+
pnpm --filter react-action-guard-core-example run dev
|
|
953
136
|
```
|
|
954
137
|
|
|
955
|
-
|
|
138
|
+
## Next steps
|
|
956
139
|
|
|
957
|
-
|
|
958
|
-
|
|
140
|
+
- [Canonical documentation and local site instructions](https://github.com/okyrychenko-dev/react-action-guard/tree/main/packages/docs): concepts, Provider ownership, examples and API reference.
|
|
141
|
+
- [UI controls](https://github.com/okyrychenko-dev/react-action-guard/tree/main/packages/ui): richer control state and accessible reason relationships.
|
|
142
|
+
- [Router integration](https://github.com/okyrychenko-dev/react-action-guard/tree/main/packages/router): browser navigation protection with adapter-specific limits.
|
|
143
|
+
- [Enterprise showcase and local run instructions](https://github.com/okyrychenko-dev/react-action-guard/tree/main/examples/enterprise-demo): checkout coordination, isolated sessions and Query integration.
|
|
959
144
|
|
|
960
|
-
|
|
961
|
-
const [storageUsed, setStorageUsed] = useState(0);
|
|
962
|
-
const STORAGE_LIMIT = 1000000; // 1MB
|
|
963
|
-
|
|
964
|
-
useConditionalBlocker("storage-limit", {
|
|
965
|
-
scope: ["upload", "save"],
|
|
966
|
-
reason: "Storage quota exceeded",
|
|
967
|
-
priority: 200,
|
|
968
|
-
condition: () => storageUsed > STORAGE_LIMIT,
|
|
969
|
-
state: storageUsed,
|
|
970
|
-
checkInterval: 5000, // Check every 5 seconds
|
|
971
|
-
});
|
|
145
|
+
## Core Concepts
|
|
972
146
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
</p>
|
|
978
|
-
<UploadButton />
|
|
979
|
-
</div>
|
|
980
|
-
);
|
|
981
|
-
}
|
|
982
|
-
```
|
|
147
|
+
- `scope` lets you coordinate blocking across components like `"form"`, `"navigation"`, or `"checkout"`
|
|
148
|
+
- `useAsyncAction` is the fastest path for async workflows
|
|
149
|
+
- `useActionBlocker` is the lower-level hook when you already have your own boolean state
|
|
150
|
+
- `UIBlockingProvider` gives you isolated state instead of the default global store
|
|
983
151
|
|
|
984
|
-
###
|
|
152
|
+
### Not React Router's `useBlocker`
|
|
985
153
|
|
|
986
|
-
|
|
154
|
+
`useActionBlocker` coordinates shared UI blocking state. It does not intercept browser navigation like React Router's [`useBlocker`](https://reactrouter.com/api/hooks/useBlocker).
|
|
987
155
|
|
|
988
|
-
|
|
989
|
-
import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
|
|
990
|
-
import { useEffect } from "react";
|
|
156
|
+
The original `useBlocker` export remains available as a deprecated alias for backward compatibility. Prefer `useActionBlocker` in new code, especially when the same application uses React Router.
|
|
991
157
|
|
|
992
|
-
|
|
993
|
-
const { clearBlockersForScope, clearAllBlockers } = useUIBlockingStore(
|
|
994
|
-
(state) => ({
|
|
995
|
-
clearBlockersForScope: state.clearBlockersForScope,
|
|
996
|
-
clearAllBlockers: state.clearAllBlockers,
|
|
997
|
-
})
|
|
998
|
-
);
|
|
158
|
+
## Core Use Cases
|
|
999
159
|
|
|
1000
|
-
|
|
1001
|
-
useEffect(() => {
|
|
1002
|
-
return () => {
|
|
1003
|
-
// Clean up checkout blockers on unmount
|
|
1004
|
-
clearBlockersForScope("checkout");
|
|
1005
|
-
};
|
|
1006
|
-
}, [clearBlockersForScope]);
|
|
160
|
+
### Track async actions
|
|
1007
161
|
|
|
1008
|
-
|
|
1009
|
-
// Clear all blockers when user explicitly cancels
|
|
1010
|
-
clearAllBlockers();
|
|
1011
|
-
navigate("/");
|
|
1012
|
-
};
|
|
162
|
+
Use `useAsyncAction` when related controls should reflect work in flight. It tracks concurrent calls rather than excluding them.
|
|
1013
163
|
|
|
1014
|
-
|
|
1015
|
-
<div>
|
|
1016
|
-
<CheckoutForm />
|
|
1017
|
-
<button onClick={handleCancelOrder}>Cancel Order</button>
|
|
1018
|
-
</div>
|
|
1019
|
-
);
|
|
1020
|
-
}
|
|
1021
|
-
```
|
|
1022
|
-
|
|
1023
|
-
### Managing Session Timeouts with Dynamic Updates
|
|
164
|
+
### Coordinate multiple components
|
|
1024
165
|
|
|
1025
|
-
|
|
166
|
+
Use shared scopes when one component starts work and another component should react by disabling UI or showing blocker details.
|
|
1026
167
|
|
|
1027
|
-
|
|
1028
|
-
import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
|
|
168
|
+
### Isolate blocking domains
|
|
1029
169
|
|
|
1030
|
-
|
|
1031
|
-
const { addBlocker, updateBlocker, removeBlocker } = useUIBlockingStore(
|
|
1032
|
-
(state) => ({
|
|
1033
|
-
addBlocker: state.addBlocker,
|
|
1034
|
-
updateBlocker: state.updateBlocker,
|
|
1035
|
-
removeBlocker: state.removeBlocker,
|
|
1036
|
-
})
|
|
1037
|
-
);
|
|
170
|
+
Recommend `UIBlockingProvider` for production ownership and SSR, with a fresh store for each request, test, or micro-frontend. The global fallback is shared convenience state, not request isolation.
|
|
1038
171
|
|
|
1039
|
-
|
|
1040
|
-
addBlocker("session-timeout", {
|
|
1041
|
-
scope: "global",
|
|
1042
|
-
reason: "Session expiring soon",
|
|
1043
|
-
priority: 50,
|
|
1044
|
-
timeout: 300000, // 5 minutes
|
|
1045
|
-
onTimeout: () => {
|
|
1046
|
-
logout();
|
|
1047
|
-
showNotification("Session expired");
|
|
1048
|
-
},
|
|
1049
|
-
});
|
|
1050
|
-
};
|
|
172
|
+
## API Reference
|
|
1051
173
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
reason: "Session extended",
|
|
1057
|
-
});
|
|
1058
|
-
};
|
|
174
|
+
Continue in the [canonical Core reference and recipes](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard/reference.md)
|
|
175
|
+
for hook options, specialized helpers, provider/context APIs, store actions, middleware,
|
|
176
|
+
typed scopes, cancellation/exclusion and advanced workflow examples. All detailed material
|
|
177
|
+
previously expanded here is retained there.
|
|
1059
178
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
};
|
|
1064
|
-
|
|
1065
|
-
return (
|
|
1066
|
-
<div>
|
|
1067
|
-
<button onClick={startSession}>Start Session</button>
|
|
1068
|
-
<button onClick={extendSession}>Extend Session</button>
|
|
1069
|
-
<button onClick={endSession}>Logout</button>
|
|
1070
|
-
</div>
|
|
1071
|
-
);
|
|
1072
|
-
}
|
|
1073
|
-
```
|
|
179
|
+
Production applications should use `UIBlockingProvider` for explicit ownership. Global fallback
|
|
180
|
+
is shared convenience state and does not isolate SSR requests. Tracking does not prevent
|
|
181
|
+
concurrent work or cancel operations; applications own exclusion and idempotency.
|
|
1074
182
|
|
|
1075
183
|
## Development
|
|
1076
184
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
npm install
|
|
1080
|
-
|
|
1081
|
-
# Run tests
|
|
1082
|
-
npm run test
|
|
1083
|
-
|
|
1084
|
-
# Run tests with coverage
|
|
1085
|
-
npm run test:coverage
|
|
1086
|
-
|
|
1087
|
-
# Build the package
|
|
1088
|
-
npm run build
|
|
185
|
+
This package lives in the [react-action-guard monorepo](https://github.com/okyrychenko-dev/react-action-guard)
|
|
186
|
+
(pnpm workspaces). From the monorepo root:
|
|
1089
187
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
# Lint code
|
|
1094
|
-
npm run lint
|
|
1095
|
-
|
|
1096
|
-
# Fix lint errors
|
|
1097
|
-
npm run lint:fix
|
|
188
|
+
```bash
|
|
189
|
+
# Install dependencies for all packages
|
|
190
|
+
pnpm install
|
|
1098
191
|
|
|
1099
|
-
#
|
|
1100
|
-
|
|
192
|
+
# Run this package's scripts with --filter
|
|
193
|
+
pnpm --filter @okyrychenko-dev/react-action-guard run test:run
|
|
194
|
+
pnpm --filter @okyrychenko-dev/react-action-guard run test:coverage
|
|
195
|
+
pnpm --filter @okyrychenko-dev/react-action-guard run build
|
|
196
|
+
pnpm --filter @okyrychenko-dev/react-action-guard run typecheck
|
|
197
|
+
pnpm --filter @okyrychenko-dev/react-action-guard run lint
|
|
198
|
+
pnpm --filter @okyrychenko-dev/react-action-guard run lint:fix
|
|
199
|
+
pnpm --filter @okyrychenko-dev/react-action-guard run format
|
|
200
|
+
pnpm --filter @okyrychenko-dev/react-action-guard run dev
|
|
1101
201
|
|
|
1102
|
-
#
|
|
1103
|
-
|
|
202
|
+
# Or cd into the package and run scripts directly
|
|
203
|
+
cd packages/core
|
|
204
|
+
pnpm run test:run
|
|
1104
205
|
```
|
|
1105
206
|
|
|
1106
207
|
## Contributing
|
|
1107
208
|
|
|
1108
|
-
Contributions are welcome! Please ensure:
|
|
209
|
+
Contributions are welcome! Please ensure, before opening a PR:
|
|
1109
210
|
|
|
1110
|
-
1. All tests pass (`
|
|
1111
|
-
2. Code is properly typed (`
|
|
1112
|
-
3. Linting passes (`
|
|
1113
|
-
4. Code is formatted (`
|
|
211
|
+
1. All tests pass (`pnpm run test`)
|
|
212
|
+
2. Code is properly typed (`pnpm run typecheck`)
|
|
213
|
+
3. Linting passes (`pnpm run lint`)
|
|
214
|
+
4. Code is formatted (`pnpm run format`)
|
|
215
|
+
5. If the change affects this package's public behavior, add a changeset: `pnpm changeset`
|
|
216
|
+
(see the [Releasing](https://github.com/okyrychenko-dev/react-action-guard#releasing) section
|
|
217
|
+
of the monorepo README)
|
|
1114
218
|
|
|
1115
219
|
## Changelog
|
|
1116
220
|
|
|
@@ -1119,3 +223,44 @@ See [CHANGELOG.md](./CHANGELOG.md) for a detailed list of changes in each versio
|
|
|
1119
223
|
## License
|
|
1120
224
|
|
|
1121
225
|
MIT © Oleksii Kyrychenko
|
|
226
|
+
|
|
227
|
+
## Canonical guides and stabilized Core
|
|
228
|
+
|
|
229
|
+
Follow [concepts](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/concepts.md),
|
|
230
|
+
[workflow guides](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/guides/workflows.md),
|
|
231
|
+
[UI integration](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard-ui/index.md),
|
|
232
|
+
[Router integrations](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard-router/index.md),
|
|
233
|
+
and [advanced ownership](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/advanced/ownership.md).
|
|
234
|
+
The [Core contract](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard/contract.md)
|
|
235
|
+
and [migration guide](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard/migration.md)
|
|
236
|
+
describe current source and planned stabilization, rather than asserting registry publication.
|
|
237
|
+
|
|
238
|
+
## Scheduled windows
|
|
239
|
+
|
|
240
|
+
Scheduled windows are half-open `[start, end)`. Omit both `duration` and `end` to
|
|
241
|
+
request indefinite blocking. A zero duration or equal start/end is empty and emits
|
|
242
|
+
neither start nor end notifications; an epoch-zero end is a real bound.
|
|
243
|
+
|
|
244
|
+
A supplied duration takes precedence over a supplied end, but every supplied field
|
|
245
|
+
is validated. Negative or nonfinite duration, invalid dates, end before start, and
|
|
246
|
+
calculated ends outside the JavaScript Date range cancel the schedule and warn in
|
|
247
|
+
development. Invalid or empty reconfiguration cancels timers and releases only the
|
|
248
|
+
hook-owned registration without emitting completion. Keep registration IDs unique
|
|
249
|
+
among owners in the same store. Long finite starts and ends use bounded timer chunks;
|
|
250
|
+
a window already elapsed when its start callback runs never activates.
|
|
251
|
+
|
|
252
|
+
### Performance lifetime diagnostics
|
|
253
|
+
|
|
254
|
+
`createPerformanceMiddleware` measures each registration from its add event to removal,
|
|
255
|
+
timeout or bulk clear. Updates preserve the original start and refresh scope metadata;
|
|
256
|
+
a replacement add starts a new lifetime. `onSlowBlock` runs once for each ended lifetime
|
|
257
|
+
meeting the threshold, including every affected registration in a bulk clear. Callback
|
|
258
|
+
failures are isolated. Targeted clears leave global and unrelated registrations tracked.
|
|
259
|
+
These durations measure blocker lifetimes, not operation success or execution time.
|
|
260
|
+
|
|
261
|
+
Lifecycle events for individual registrations include an optional opaque `registration`
|
|
262
|
+
identity, stable across updates and renewed on add. Diagnostics use this identity to
|
|
263
|
+
attribute a timeout to the expired registration even when `onTimeout` has already added
|
|
264
|
+
a replacement with the same ID. Event ordering is unchanged. Identity is meaningful
|
|
265
|
+
by reference within the live observation stream, not after serialization; manually
|
|
266
|
+
constructed events without it retain ID-based tracking.
|