@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/README.md CHANGED
@@ -4,18 +4,14 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@okyrychenko-dev/react-action-guard.svg)](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- > Coordinate UI blocking across async actions and competing interactions in React
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
- `react-action-guard` helps you prevent duplicate submits, conflicting actions, and stale UI interactions without scattering local `isLoading` flags across components.
10
-
11
- ## Why Use It
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
- ```jsx
44
- import { useAsyncAction, useIsBlocked } from "@okyrychenko-dev/react-action-guard";
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
- Automatically adds a blocker when the component mounts and removes it on unmount.
107
-
108
- **Parameters:**
109
-
110
- - `blockerId: string` - Unique identifier for the blocker
111
- - `config: BlockerConfig` - Configuration object
112
- - `scope?: string | string[]` - Scope(s) to block (default: "global")
113
- - `reason?: string` - Reason for blocking (for debugging)
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
- useBlocker("save-operation", {
139
- scope: "form",
140
- reason: "Saving...",
141
- timeout: 30000,
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
- const handleSave = async () => {
150
- setIsSaving(true);
50
+ async function save(): Promise<void> {
151
51
  try {
152
- await saveData();
153
- } finally {
154
- setIsSaving(false);
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 onClick={execute}>Leave</button>
325
- {isDialogOpen && (
326
- <ConfirmDialog
327
- title={confirmConfig.title}
328
- message={confirmConfig.message}
329
- confirmText={confirmConfig.confirmText}
330
- cancelText={confirmConfig.cancelText}
331
- onConfirm={onConfirm}
332
- onCancel={onCancel}
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
- ```jsx
810
- import { useAsyncAction, useIsBlocked } from "@okyrychenko-dev/react-action-guard";
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
- <form onSubmit={handleSubmit}>
824
- <input disabled={isBlocked} />
825
- <button disabled={isBlocked}>Submit</button>
826
- </form>
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
- ### Advanced: Unsaved Changes Protection
832
-
833
- ```jsx
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
- <form>
859
- <input
860
- onChange={(e) => {
861
- setFormData({ ...formData, name: e.target.value });
862
- setHasChanges(true);
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
- <div>
889
- {isGloballyBlocked && <LoadingOverlay />}
890
- <YourApp />
891
- </div>
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
- ### Multi-Step Process with Priority
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
- ```jsx
899
- function MultiStepWizard() {
900
- const [step, setStep] = useState(1);
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
- // Higher priority for payment step
903
- useBlocker(
904
- "payment-step",
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
- // Lower priority for other steps
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
- return <div>Step {step}</div>;
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
- ### Advanced: Scheduled Maintenance Window
929
-
930
- ```jsx
931
- import { useScheduledBlocker } from "@okyrychenko-dev/react-action-guard";
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
- ### Advanced: Dynamic Blocking Based on State
138
+ ## Next steps
956
139
 
957
- ```jsx
958
- import { useConditionalBlocker } from "@okyrychenko-dev/react-action-guard";
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
- function StorageQuotaGuard() {
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
- return (
974
- <div>
975
- <p>
976
- Storage used: {storageUsed} / {STORAGE_LIMIT} bytes
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
- ### Clearing Blockers on Navigation
152
+ ### Not React Router's `useBlocker`
985
153
 
986
- Clear blockers when navigating away or on specific events:
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
- ```jsx
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
- function CheckoutPage() {
993
- const { clearBlockersForScope, clearAllBlockers } = useUIBlockingStore(
994
- (state) => ({
995
- clearBlockersForScope: state.clearBlockersForScope,
996
- clearAllBlockers: state.clearAllBlockers,
997
- })
998
- );
158
+ ## Core Use Cases
999
159
 
1000
- // Clear checkout-specific blockers when leaving the page
1001
- useEffect(() => {
1002
- return () => {
1003
- // Clean up checkout blockers on unmount
1004
- clearBlockersForScope("checkout");
1005
- };
1006
- }, [clearBlockersForScope]);
160
+ ### Track async actions
1007
161
 
1008
- const handleCancelOrder = () => {
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
- return (
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
- Extend or update blocker timeouts dynamically:
166
+ Use shared scopes when one component starts work and another component should react by disabling UI or showing blocker details.
1026
167
 
1027
- ```jsx
1028
- import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
168
+ ### Isolate blocking domains
1029
169
 
1030
- function SessionManager() {
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
- const startSession = () => {
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
- const extendSession = () => {
1053
- // Extend timeout - timer restarts with new value
1054
- updateBlocker("session-timeout", {
1055
- timeout: 600000, // 10 minutes
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
- const endSession = () => {
1061
- removeBlocker("session-timeout");
1062
- logout();
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
- ```bash
1078
- # Install dependencies
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
- # Type checking
1091
- npm run typecheck
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
- # Format code
1100
- npm run format
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
- # Watch mode for development
1103
- npm run dev
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 (`npm run test`)
1111
- 2. Code is properly typed (`npm run typecheck`)
1112
- 3. Linting passes (`npm run lint`)
1113
- 4. Code is formatted (`npm run format`)
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.