@okyrychenko-dev/react-action-guard 0.3.3 → 0.5.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Olexii Kyrychenko
3
+ Copyright (c) 2025 Oleksii Kyrychenko
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -11,13 +11,15 @@
11
11
  - Priority-based blocking system
12
12
  - Scoped blocking (global, specific areas, or multiple scopes)
13
13
  - Automatic cleanup on unmount
14
+ - **Timeout mechanism** - auto-remove blockers after specified time
15
+ - **Provider pattern** - isolated stores for SSR, testing, and micro-frontends
14
16
  - Advanced hooks for different use cases:
15
17
  - Confirmable blockers with custom dialogs
16
18
  - Scheduled blocking for maintenance windows
17
19
  - Conditional blocking based on application state
18
- - Async action wrapping
20
+ - Async action wrapping with timeout support
19
21
  - Advanced middleware system for analytics, logging, and performance monitoring
20
- - TypeScript support with full type safety
22
+ - TypeScript support with full type safety (including optional type-safe scopes)
21
23
  - Built on Zustand for efficient state management
22
24
  - Tree-shakeable - import only what you need
23
25
  - Hooks-based API
@@ -34,8 +36,8 @@ pnpm add @okyrychenko-dev/react-action-guard zustand
34
36
 
35
37
  This package requires the following peer dependencies:
36
38
 
37
- - [React](https://react.dev/) ^17.0.0 || ^18.0.0
38
- - [Zustand](https://zustand-demo.pmnd.rs/) - State management library
39
+ - [React](https://react.dev/) ^17.0.0 || ^18.0.0 || ^19.0.0
40
+ - [Zustand](https://zustand-demo.pmnd.rs/) ^4.5.7 || ^5.0.0 - State management library
39
41
 
40
42
  ## Quick Start
41
43
 
@@ -51,7 +53,7 @@ function MyComponent() {
51
53
 
52
54
  ## Documentation
53
55
 
54
- 📚 **[Interactive Storybook Documentation](https://your-storybook-url.com)** - Explore live examples and detailed guides for all hooks
56
+ 📚 **Interactive Storybook Documentation** - Run locally to explore live examples and detailed guides for all hooks
55
57
 
56
58
  To run Storybook locally:
57
59
 
@@ -74,6 +76,8 @@ Automatically adds a blocker when the component mounts and removes it on unmount
74
76
  - `scope?: string | string[]` - Scope(s) to block (default: "global")
75
77
  - `reason?: string` - Reason for blocking (for debugging)
76
78
  - `priority?: number` - Priority level (higher = more important)
79
+ - `timeout?: number` - Auto-remove after N milliseconds
80
+ - `onTimeout?: (blockerId: string) => void` - Callback when auto-removed
77
81
  - `isActive?: boolean` - Whether the blocker is active (default: true)
78
82
 
79
83
  **Example:**
@@ -88,6 +92,33 @@ function MyComponent() {
88
92
 
89
93
  return <div>Content</div>;
90
94
  }
95
+
96
+ // With timeout - auto-removes after 30 seconds
97
+ function SaveButton() {
98
+ const [isSaving, setIsSaving] = useState(false);
99
+
100
+ useBlocker("save-operation", {
101
+ scope: "form",
102
+ reason: "Saving...",
103
+ timeout: 30000,
104
+ onTimeout: (id) => {
105
+ console.warn(`Operation ${id} timed out`);
106
+ showNotification("Operation timed out");
107
+ setIsSaving(false);
108
+ },
109
+ }, isSaving);
110
+
111
+ const handleSave = async () => {
112
+ setIsSaving(true);
113
+ try {
114
+ await saveData();
115
+ } finally {
116
+ setIsSaving(false);
117
+ }
118
+ };
119
+
120
+ return <button onClick={handleSave} disabled={isSaving}>Save</button>;
121
+ }
91
122
  ```
92
123
 
93
124
  #### `useIsBlocked(scope)`
@@ -147,7 +178,7 @@ function CheckoutButton() {
147
178
  }
148
179
  ```
149
180
 
150
- #### `useAsyncAction(actionId, scope)`
181
+ #### `useAsyncAction(actionId, scope, options)`
151
182
 
152
183
  Wraps an async function with automatic blocking/unblocking.
153
184
 
@@ -155,6 +186,9 @@ Wraps an async function with automatic blocking/unblocking.
155
186
 
156
187
  - `actionId: string` - Identifier for the action
157
188
  - `scope?: string | string[]` - Scope(s) to block during execution
189
+ - `options?: UseAsyncActionOptions` - Optional configuration
190
+ - `timeout?: number` - Auto-remove blocker after N milliseconds
191
+ - `onTimeout?: (blockerId: string) => void` - Callback when timed out
158
192
 
159
193
  **Returns:** `(asyncFn: () => Promise<T>) => Promise<T>` - Function wrapper
160
194
 
@@ -172,51 +206,85 @@ function MyComponent() {
172
206
 
173
207
  return <button onClick={handleSave}>Save</button>;
174
208
  }
209
+
210
+ // With timeout - prevents infinite blocking if operation hangs
211
+ function ApiComponent() {
212
+ const execute = useAsyncAction("api-call", "global", {
213
+ timeout: 60000, // 1 minute timeout
214
+ onTimeout: (id) => {
215
+ showError("Request timed out. Please try again.");
216
+ },
217
+ });
218
+
219
+ const fetchData = () => execute(async () => {
220
+ const response = await fetch("/api/data");
221
+ return response.json();
222
+ });
223
+
224
+ return <button onClick={fetchData}>Fetch Data</button>;
225
+ }
175
226
  ```
176
227
 
177
- #### `useConfirmableBlocker(blockerId, config, options)`
228
+ #### `useConfirmableBlocker(blockerId, config)`
178
229
 
179
- Creates a blocker that requires user confirmation before being removed.
230
+ Creates a confirmable action with UI blocking while the dialog is open or the action is running.
180
231
 
181
232
  **Parameters:**
182
233
 
183
234
  - `blockerId: string` - Unique identifier for the blocker
184
- - `config: BlockerConfig` - Configuration object
185
- - `options: ConfirmableBlockerOptions`
186
- - `enabled?: boolean` - Whether the blocker is active (default: true)
187
- - `confirmMessage?: string` - Message to show in confirmation dialog
188
- - `onConfirm?: () => void | Promise<void>` - Callback when user confirms
235
+ - `config: ConfirmableBlockerConfig` - Configuration object
236
+ - `confirmMessage: string` - Message to show in confirmation dialog
237
+ - `confirmTitle?: string` - Dialog title (default: "Confirm Action")
238
+ - `confirmButtonText?: string` - Confirm button label (default: "Confirm")
239
+ - `cancelButtonText?: string` - Cancel button label (default: "Cancel")
240
+ - `onConfirm: () => void | Promise<void>` - Callback when user confirms
241
+ - `onCancel?: () => void` - Callback when user cancels
242
+ - Plus all `BlockerConfig` properties (scope, reason, priority, timeout)
189
243
 
190
244
  **Returns:**
191
245
 
192
- - `isBlocking: boolean` - Whether the blocker is currently active
193
- - `cancel: () => void` - Manually remove the blocker without confirmation
194
- - `requestRemoval: () => Promise<boolean>` - Request removal with confirmation
246
+ - `execute: () => void` - Opens the confirmation dialog
247
+ - `isDialogOpen: boolean` - Whether the dialog is open
248
+ - `isExecuting: boolean` - Whether the confirm action is running
249
+ - `confirmConfig: { title, message, confirmText, cancelText }` - UI-ready dialog config
250
+ - `onConfirm: () => Promise<void>` - Confirm handler to wire to your dialog
251
+ - `onCancel: () => void` - Cancel handler to wire to your dialog
195
252
 
196
253
  **Example:**
197
254
 
198
255
  ```jsx
199
- function UnsavedChangesGuard() {
200
- const { isBlocking, requestRemoval } = useConfirmableBlocker(
201
- "unsaved-changes",
202
- {
203
- scope: "navigation",
204
- reason: "Unsaved changes",
256
+ function UnsavedChangesGuard({ discardChanges }) {
257
+ const {
258
+ execute,
259
+ isDialogOpen,
260
+ isExecuting,
261
+ confirmConfig,
262
+ onConfirm,
263
+ onCancel,
264
+ } = useConfirmableBlocker("unsaved-changes", {
265
+ scope: "navigation",
266
+ reason: "Unsaved changes",
267
+ confirmMessage: "You have unsaved changes. Are you sure you want to leave?",
268
+ onConfirm: async () => {
269
+ await discardChanges();
205
270
  },
206
- {
207
- enabled: hasUnsavedChanges,
208
- confirmMessage: "You have unsaved changes. Are you sure you want to leave?",
209
- onConfirm: async () => {
210
- await discardChanges();
211
- },
212
- }
213
- );
271
+ });
214
272
 
215
273
  return (
216
- <div>
217
- {isBlocking && <UnsavedIndicator />}
218
- <button onClick={requestRemoval}>Leave</button>
219
- </div>
274
+ <>
275
+ <button onClick={execute}>Leave</button>
276
+ {isDialogOpen && (
277
+ <ConfirmDialog
278
+ title={confirmConfig.title}
279
+ message={confirmConfig.message}
280
+ confirmText={confirmConfig.confirmText}
281
+ cancelText={confirmConfig.cancelText}
282
+ onConfirm={onConfirm}
283
+ onCancel={onCancel}
284
+ />
285
+ )}
286
+ {isExecuting && <LoadingOverlay message="Processing..." />}
287
+ </>
220
288
  );
221
289
  }
222
290
  ```
@@ -306,21 +374,72 @@ function NetworkStatusBlocker() {
306
374
  }
307
375
  ```
308
376
 
377
+ ### Provider (Optional)
378
+
379
+ #### `UIBlockingProvider`
380
+
381
+ Provides an isolated store instance for SSR, testing, or micro-frontends. Without the provider, hooks use a global store.
382
+
383
+ **Props:**
384
+
385
+ - `children: ReactNode` - Child components
386
+ - `enableDevtools?: boolean` - Enable Redux DevTools (default: true in development)
387
+ - `devtoolsName?: string` - Name for DevTools (default: "UIBlocking")
388
+ - `middlewares?: Middleware[]` - Initial middlewares to register
389
+
390
+ **Example:**
391
+
392
+ ```jsx
393
+ import { UIBlockingProvider } from "@okyrychenko-dev/react-action-guard";
394
+
395
+ // SSR - each request gets isolated state
396
+ function App() {
397
+ return (
398
+ <UIBlockingProvider>
399
+ <MyApp />
400
+ </UIBlockingProvider>
401
+ );
402
+ }
403
+
404
+ // Testing - isolated state per test, no cleanup needed
405
+ function renderWithProvider(ui) {
406
+ return render(
407
+ <UIBlockingProvider>{ui}</UIBlockingProvider>
408
+ );
409
+ }
410
+
411
+ // Micro-frontends - each app has its own blocking state
412
+ function MicroFrontend() {
413
+ return (
414
+ <UIBlockingProvider devtoolsName="MicroApp-Blocking">
415
+ <MicroApp />
416
+ </UIBlockingProvider>
417
+ );
418
+ }
419
+ ```
420
+
421
+ #### Context Hooks
422
+
423
+ - `useUIBlockingContext()` - Get store from context (throws if outside provider)
424
+ - `useIsInsideUIBlockingProvider()` - Check if inside a provider
425
+ - `useUIBlockingStoreFromContext(selector)` - Select state from context store
426
+
309
427
  ### Store
310
428
 
311
429
  #### `useUIBlockingStore`
312
430
 
313
- Direct access to the Zustand store for advanced use cases.
431
+ Direct access to the Zustand store for advanced use cases (requires a selector).
314
432
 
315
433
  **Methods:**
316
434
 
317
- - `addBlocker(id, config)` - Manually add a blocker
435
+ - `addBlocker(id, config)` - Manually add a blocker (re-adding with the same ID replaces config)
318
436
  - `removeBlocker(id)` - Manually remove a blocker
319
- - `updateBlocker(id, config)` - Update an existing blocker
320
437
  - `isBlocked(scope)` - Check if scope is blocked
321
438
  - `getBlockingInfo(scope)` - Get detailed blocking information
322
439
  - `clearAllBlockers()` - Remove all blockers
323
440
  - `clearBlockersForScope(scope)` - Remove blockers for specific scope
441
+ - `registerMiddleware(name, middleware)` - Register middleware manually
442
+ - `unregisterMiddleware(name)` - Unregister middleware manually
324
443
 
325
444
  **Example:**
326
445
 
@@ -328,7 +447,10 @@ Direct access to the Zustand store for advanced use cases.
328
447
  import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
329
448
 
330
449
  function AdvancedComponent() {
331
- const { addBlocker, removeBlocker, updateBlocker } = useUIBlockingStore();
450
+ const { addBlocker, removeBlocker } = useUIBlockingStore((state) => ({
451
+ addBlocker: state.addBlocker,
452
+ removeBlocker: state.removeBlocker,
453
+ }));
332
454
 
333
455
  const startBlocking = () => {
334
456
  addBlocker("custom-blocker", {
@@ -339,7 +461,10 @@ function AdvancedComponent() {
339
461
  };
340
462
 
341
463
  const updatePriority = () => {
342
- updateBlocker("custom-blocker", {
464
+ // Re-add with the same ID to update the config
465
+ addBlocker("custom-blocker", {
466
+ scope: ["form", "navigation"],
467
+ reason: "Critical operation in progress",
343
468
  priority: 200,
344
469
  });
345
470
  };
@@ -358,6 +483,26 @@ function AdvancedComponent() {
358
483
  }
359
484
  ```
360
485
 
486
+ For non-hook contexts (tests, utilities, event handlers), use `uiBlockingStoreApi`:
487
+
488
+ ```jsx
489
+ import { uiBlockingStoreApi } from "@okyrychenko-dev/react-action-guard";
490
+
491
+ uiBlockingStoreApi.getState().addBlocker("server-call", {
492
+ scope: "global",
493
+ reason: "Server call running",
494
+ });
495
+ ```
496
+
497
+ ### Store Toolkit Helpers
498
+
499
+ Advanced store helpers are re-exported from the internal toolkit for custom setups:
500
+
501
+ - `createShallowStore`
502
+ - `createStoreToolkit`
503
+ - `createStoreProvider`
504
+ - `createResolvedStoreHooks`
505
+
361
506
  ## Middleware System
362
507
 
363
508
  The library includes a powerful middleware system that allows you to hook into blocker lifecycle events for analytics, logging, and performance monitoring.
@@ -369,7 +514,10 @@ The library includes a powerful middleware system that allows you to hook into b
369
514
  Track blocker events with your analytics provider (Google Analytics, Mixpanel, Amplitude, or custom).
370
515
 
371
516
  ```jsx
372
- import { configureMiddleware, createAnalyticsMiddleware } from "@okyrychenko-dev/react-action-guard";
517
+ import {
518
+ configureMiddleware,
519
+ createAnalyticsMiddleware,
520
+ } from "@okyrychenko-dev/react-action-guard";
373
521
 
374
522
  // Google Analytics
375
523
  configureMiddleware([createAnalyticsMiddleware({ provider: "ga" })]);
@@ -405,7 +553,10 @@ configureMiddleware([loggerMiddleware]);
405
553
  Monitor blocker performance and detect slow operations.
406
554
 
407
555
  ```jsx
408
- import { configureMiddleware, createPerformanceMiddleware } from "@okyrychenko-dev/react-action-guard";
556
+ import {
557
+ configureMiddleware,
558
+ createPerformanceMiddleware,
559
+ } from "@okyrychenko-dev/react-action-guard";
409
560
 
410
561
  configureMiddleware([
411
562
  createPerformanceMiddleware({
@@ -417,6 +568,10 @@ configureMiddleware([
417
568
  ]);
418
569
  ```
419
570
 
571
+ Note: `configureMiddleware` registers middleware on the global store. If you use `UIBlockingProvider`, register middleware via the provider's `middlewares` prop instead.
572
+
573
+ Note: Middleware action types include `update` and `cancel`, but the store currently emits `add`, `remove`, and `timeout`.
574
+
420
575
  ### Custom Middleware
421
576
 
422
577
  Create your own middleware to handle blocker events:
@@ -475,7 +630,10 @@ The library is fully tree-shakeable. Import only the features you need to keep y
475
630
  import { useBlocker } from "@okyrychenko-dev/react-action-guard";
476
631
 
477
632
  // Middleware is not included unless you import it
478
- import { configureMiddleware, createAnalyticsMiddleware } from "@okyrychenko-dev/react-action-guard";
633
+ import {
634
+ configureMiddleware,
635
+ createAnalyticsMiddleware,
636
+ } from "@okyrychenko-dev/react-action-guard";
479
637
  ```
480
638
 
481
639
  The package is configured with `"sideEffects": false`, allowing modern bundlers (Webpack, Rollup, Vite) to eliminate unused code automatically.
@@ -489,23 +647,48 @@ import type {
489
647
  // Core types
490
648
  BlockerConfig,
491
649
  BlockerInfo,
492
- UIBlockingState,
650
+ UIBlockingStore,
651
+ UIBlockingStoreState,
493
652
 
494
653
  // Hook types
495
- ConfirmableBlockerOptions,
654
+ ConfirmableBlockerConfig,
655
+ ConfirmDialogConfig,
656
+ UseConfirmableBlockerReturn,
496
657
  ScheduledBlockerConfig,
497
658
  ConditionalBlockerConfig,
498
659
  BlockingSchedule,
660
+ UseAsyncActionOptions,
499
661
 
500
662
  // Middleware types
501
663
  Middleware,
502
664
  MiddlewareContext,
503
665
  AnalyticsConfig,
504
666
  AnalyticsProvider,
505
- PerformanceMiddlewareConfig,
667
+ PerformanceConfig,
668
+
669
+ // Type-safe scopes
670
+ BlockerConfigTyped,
671
+ DefaultScopes,
672
+ ScopeValue,
506
673
  } from "@okyrychenko-dev/react-action-guard";
507
674
  ```
508
675
 
676
+ ## Type-Safe Scopes
677
+
678
+ Create typed versions of the hooks to prevent scope typos at compile time:
679
+
680
+ ```typescript
681
+ import { createTypedHooks } from "@okyrychenko-dev/react-action-guard";
682
+
683
+ type AppScopes = "global" | "form" | "navigation" | "checkout";
684
+
685
+ const { useBlocker, useIsBlocked, useAsyncAction, useBlockingInfo } =
686
+ createTypedHooks<AppScopes>();
687
+
688
+ useBlocker("save", { scope: "form" }); // OK
689
+ useBlocker("save", { scope: "typo" }); // Type error
690
+ ```
691
+
509
692
  ## Use Cases
510
693
 
511
694
  ### Loading States
@@ -560,22 +743,22 @@ function FormWithUnsavedWarning() {
560
743
  const [formData, setFormData] = useState({});
561
744
  const [hasChanges, setHasChanges] = useState(false);
562
745
 
563
- const { requestRemoval } = useConfirmableBlocker(
564
- "unsaved-form",
565
- {
566
- scope: "navigation",
567
- reason: "Unsaved form data",
568
- priority: 100,
746
+ const {
747
+ execute,
748
+ isDialogOpen,
749
+ confirmConfig,
750
+ onConfirm,
751
+ onCancel,
752
+ } = useConfirmableBlocker("unsaved-form", {
753
+ scope: "navigation",
754
+ reason: "Unsaved form data",
755
+ priority: 100,
756
+ confirmMessage: "You have unsaved changes. Discard them?",
757
+ onConfirm: () => {
758
+ setFormData({});
759
+ setHasChanges(false);
569
760
  },
570
- {
571
- enabled: hasChanges,
572
- confirmMessage: "You have unsaved changes. Discard them?",
573
- onConfirm: () => {
574
- setFormData({});
575
- setHasChanges(false);
576
- },
577
- }
578
- );
761
+ });
579
762
 
580
763
  return (
581
764
  <form>
@@ -585,7 +768,17 @@ function FormWithUnsavedWarning() {
585
768
  setHasChanges(true);
586
769
  }}
587
770
  />
588
- <button onClick={requestRemoval}>Cancel</button>
771
+ <button type="button" onClick={execute}>Cancel</button>
772
+ {isDialogOpen && (
773
+ <ConfirmDialog
774
+ title={confirmConfig.title}
775
+ message={confirmConfig.message}
776
+ confirmText={confirmConfig.confirmText}
777
+ cancelText={confirmConfig.cancelText}
778
+ onConfirm={onConfirm}
779
+ onCancel={onCancel}
780
+ />
781
+ )}
589
782
  </form>
590
783
  );
591
784
  }
@@ -740,4 +933,4 @@ See [CHANGELOG.md](./CHANGELOG.md) for a detailed list of changes in each versio
740
933
 
741
934
  ## License
742
935
 
743
- MIT © Olexii Kyrychenko
936
+ MIT © Oleksii Kyrychenko