@okyrychenko-dev/react-action-guard 0.3.3 → 0.4.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
 
@@ -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,6 +206,23 @@ 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
228
  #### `useConfirmableBlocker(blockerId, config, options)`
@@ -196,7 +247,7 @@ Creates a blocker that requires user confirmation before being removed.
196
247
  **Example:**
197
248
 
198
249
  ```jsx
199
- function UnsavedChangesGuard() {
250
+ function UnsavedChangesGuard({ hasUnsavedChanges, discardChanges }) {
200
251
  const { isBlocking, requestRemoval } = useConfirmableBlocker(
201
252
  "unsaved-changes",
202
253
  {
@@ -306,6 +357,56 @@ function NetworkStatusBlocker() {
306
357
  }
307
358
  ```
308
359
 
360
+ ### Provider (Optional)
361
+
362
+ #### `UIBlockingProvider`
363
+
364
+ Provides an isolated store instance for SSR, testing, or micro-frontends. Without the provider, hooks use a global store.
365
+
366
+ **Props:**
367
+
368
+ - `children: ReactNode` - Child components
369
+ - `enableDevtools?: boolean` - Enable Redux DevTools (default: true in development)
370
+ - `devtoolsName?: string` - Name for DevTools (default: "UIBlocking")
371
+ - `middlewares?: Middleware[]` - Initial middlewares to register
372
+
373
+ **Example:**
374
+
375
+ ```jsx
376
+ import { UIBlockingProvider } from "@okyrychenko-dev/react-action-guard";
377
+
378
+ // SSR - each request gets isolated state
379
+ function App() {
380
+ return (
381
+ <UIBlockingProvider>
382
+ <MyApp />
383
+ </UIBlockingProvider>
384
+ );
385
+ }
386
+
387
+ // Testing - isolated state per test, no cleanup needed
388
+ function renderWithProvider(ui) {
389
+ return render(
390
+ <UIBlockingProvider>{ui}</UIBlockingProvider>
391
+ );
392
+ }
393
+
394
+ // Micro-frontends - each app has its own blocking state
395
+ function MicroFrontend() {
396
+ return (
397
+ <UIBlockingProvider devtoolsName="MicroApp-Blocking">
398
+ <MicroApp />
399
+ </UIBlockingProvider>
400
+ );
401
+ }
402
+ ```
403
+
404
+ #### Context Hooks
405
+
406
+ - `useUIBlockingContext()` - Get store from context (throws if outside provider)
407
+ - `useIsInsideUIBlockingProvider()` - Check if inside a provider
408
+ - `useUIBlockingStoreFromContext(selector)` - Select state from context store
409
+
309
410
  ### Store
310
411
 
311
412
  #### `useUIBlockingStore`
@@ -369,7 +470,10 @@ The library includes a powerful middleware system that allows you to hook into b
369
470
  Track blocker events with your analytics provider (Google Analytics, Mixpanel, Amplitude, or custom).
370
471
 
371
472
  ```jsx
372
- import { configureMiddleware, createAnalyticsMiddleware } from "@okyrychenko-dev/react-action-guard";
473
+ import {
474
+ configureMiddleware,
475
+ createAnalyticsMiddleware,
476
+ } from "@okyrychenko-dev/react-action-guard";
373
477
 
374
478
  // Google Analytics
375
479
  configureMiddleware([createAnalyticsMiddleware({ provider: "ga" })]);
@@ -405,7 +509,10 @@ configureMiddleware([loggerMiddleware]);
405
509
  Monitor blocker performance and detect slow operations.
406
510
 
407
511
  ```jsx
408
- import { configureMiddleware, createPerformanceMiddleware } from "@okyrychenko-dev/react-action-guard";
512
+ import {
513
+ configureMiddleware,
514
+ createPerformanceMiddleware,
515
+ } from "@okyrychenko-dev/react-action-guard";
409
516
 
410
517
  configureMiddleware([
411
518
  createPerformanceMiddleware({
@@ -475,7 +582,10 @@ The library is fully tree-shakeable. Import only the features you need to keep y
475
582
  import { useBlocker } from "@okyrychenko-dev/react-action-guard";
476
583
 
477
584
  // Middleware is not included unless you import it
478
- import { configureMiddleware, createAnalyticsMiddleware } from "@okyrychenko-dev/react-action-guard";
585
+ import {
586
+ configureMiddleware,
587
+ createAnalyticsMiddleware,
588
+ } from "@okyrychenko-dev/react-action-guard";
479
589
  ```
480
590
 
481
591
  The package is configured with `"sideEffects": false`, allowing modern bundlers (Webpack, Rollup, Vite) to eliminate unused code automatically.
@@ -740,4 +850,4 @@ See [CHANGELOG.md](./CHANGELOG.md) for a detailed list of changes in each versio
740
850
 
741
851
  ## License
742
852
 
743
- MIT © Olexii Kyrychenko
853
+ MIT © Oleksii Kyrychenko
package/dist/index.cjs CHANGED
@@ -1,9 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  var react = require('react');
4
- var middleware = require('zustand/middleware');
5
4
  var zustand = require('zustand');
5
+ var middleware = require('zustand/middleware');
6
6
  var shallow = require('zustand/shallow');
7
+ var jsxRuntime = require('react/jsx-runtime');
7
8
 
8
9
  // src/hooks/useAsyncAction/useAsyncAction.ts
9
10
 
@@ -67,13 +68,40 @@ var createUIBlockingActions = (set, get) => ({
67
68
  *
68
69
  */
69
70
  addBlocker: (id, config = {}) => {
71
+ const existingBlocker = get().activeBlockers.get(id);
72
+ if (existingBlocker?.timeoutId) {
73
+ clearTimeout(existingBlocker.timeoutId);
74
+ }
75
+ let timeoutId;
76
+ if (config.timeout && config.timeout > 0) {
77
+ timeoutId = setTimeout(() => {
78
+ if (get().activeBlockers.has(id)) {
79
+ config.onTimeout?.(id);
80
+ void get().runMiddlewares({
81
+ action: "timeout",
82
+ blockerId: id,
83
+ config: {
84
+ scope: config.scope,
85
+ reason: config.reason,
86
+ priority: config.priority,
87
+ timeout: config.timeout
88
+ },
89
+ timestamp: Date.now()
90
+ });
91
+ get().removeBlocker(id);
92
+ }
93
+ }, config.timeout);
94
+ }
70
95
  set((state) => {
71
96
  const newBlockers = new Map(state.activeBlockers);
72
97
  const storedBlocker = {
73
98
  scope: config.scope ?? DEFAULT_SCOPE,
74
99
  reason: config.reason ?? DEFAULT_REASON,
75
100
  priority: config.priority ?? DEFAULT_PRIORITY,
76
- timestamp: config.timestamp ?? Date.now()
101
+ timestamp: config.timestamp ?? Date.now(),
102
+ timeout: config.timeout,
103
+ timeoutId,
104
+ onTimeout: config.onTimeout
77
105
  };
78
106
  newBlockers.set(id, storedBlocker);
79
107
  return { activeBlockers: newBlockers };
@@ -84,7 +112,8 @@ var createUIBlockingActions = (set, get) => ({
84
112
  config: {
85
113
  scope: config.scope,
86
114
  reason: config.reason,
87
- priority: config.priority
115
+ priority: config.priority,
116
+ timeout: config.timeout
88
117
  },
89
118
  timestamp: Date.now()
90
119
  });
@@ -97,6 +126,9 @@ var createUIBlockingActions = (set, get) => ({
97
126
  */
98
127
  removeBlocker: (id) => {
99
128
  const prevBlocker = get().activeBlockers.get(id);
129
+ if (prevBlocker?.timeoutId) {
130
+ clearTimeout(prevBlocker.timeoutId);
131
+ }
100
132
  set((state) => {
101
133
  const newBlockers = new Map(state.activeBlockers);
102
134
  newBlockers.delete(id);
@@ -155,6 +187,12 @@ var createUIBlockingActions = (set, get) => ({
155
187
  * Clear all blockers from the store
156
188
  */
157
189
  clearAllBlockers: () => {
190
+ const { activeBlockers } = get();
191
+ for (const [, blocker] of activeBlockers) {
192
+ if (blocker.timeoutId) {
193
+ clearTimeout(blocker.timeoutId);
194
+ }
195
+ }
158
196
  set({ activeBlockers: /* @__PURE__ */ new Map() });
159
197
  },
160
198
  /**
@@ -164,6 +202,13 @@ var createUIBlockingActions = (set, get) => ({
164
202
  *
165
203
  */
166
204
  clearBlockersForScope: (scope) => {
205
+ const { activeBlockers } = get();
206
+ for (const [, blocker] of activeBlockers) {
207
+ const blockerScopes = normalizeScopeToArray(blocker.scope);
208
+ if (blockerScopes.includes(scope) && blocker.timeoutId) {
209
+ clearTimeout(blocker.timeoutId);
210
+ }
211
+ }
167
212
  set((state) => {
168
213
  const newBlockers = /* @__PURE__ */ new Map();
169
214
  for (const [id, blocker] of state.activeBlockers) {
@@ -176,6 +221,45 @@ var createUIBlockingActions = (set, get) => ({
176
221
  });
177
222
  }
178
223
  });
224
+ var UIBlockingContext = react.createContext(null);
225
+ function UIBlockingProvider({
226
+ children,
227
+ enableDevtools = process.env.NODE_ENV === "development",
228
+ devtoolsName = "UIBlocking",
229
+ middlewares = []
230
+ }) {
231
+ const middlewaresRef = react.useRef(middlewares);
232
+ const store = react.useMemo(() => {
233
+ const baseStore = enableDevtools ? zustand.createStore(
234
+ middleware.devtools(createUIBlockingActions, {
235
+ name: devtoolsName,
236
+ enabled: enableDevtools
237
+ })
238
+ ) : zustand.createStore(createUIBlockingActions);
239
+ middlewaresRef.current.forEach((mw, index) => {
240
+ baseStore.getState().registerMiddleware(`provider-middleware-${index.toString()}`, mw);
241
+ });
242
+ return baseStore;
243
+ }, [enableDevtools, devtoolsName]);
244
+ return /* @__PURE__ */ jsxRuntime.jsx(UIBlockingContext.Provider, { value: store, children });
245
+ }
246
+ function useUIBlockingContext() {
247
+ const store = react.useContext(UIBlockingContext);
248
+ if (!store) {
249
+ throw new Error(
250
+ "useUIBlockingContext must be used within UIBlockingProvider. Either wrap your app in <UIBlockingProvider> or use the global store via useUIBlockingStore."
251
+ );
252
+ }
253
+ return store;
254
+ }
255
+ function useIsInsideUIBlockingProvider() {
256
+ const store = react.useContext(UIBlockingContext);
257
+ return store !== null;
258
+ }
259
+ function useUIBlockingStoreFromContext(selector) {
260
+ const store = useUIBlockingContext();
261
+ return zustand.useStore(store, shallow.useShallow(selector));
262
+ }
179
263
 
180
264
  // src/store/uiBlockingStore.config.ts
181
265
  var devtoolsConfig = {
@@ -186,9 +270,19 @@ var devtoolsConfig = {
186
270
  // src/store/uiBlockingStore.store.ts
187
271
  var { useStore: useUIBlockingStore, useStoreApi: uiBlockingStoreApi } = createShallowStore(middleware.devtools(createUIBlockingActions, devtoolsConfig));
188
272
 
273
+ // src/context/useResolvedStore.ts
274
+ function useResolvedStore() {
275
+ const contextStore = react.useContext(UIBlockingContext);
276
+ return contextStore ?? uiBlockingStoreApi;
277
+ }
278
+ function useResolvedStoreWithSelector(selector) {
279
+ const store = useResolvedStore();
280
+ return zustand.useStore(store, shallow.useShallow(selector));
281
+ }
282
+
189
283
  // src/hooks/useAsyncAction/useAsyncAction.ts
190
- var useAsyncAction = (actionId, scope) => {
191
- const { addBlocker, removeBlocker } = useUIBlockingStore((state) => ({
284
+ var useAsyncAction = (actionId, scope, options) => {
285
+ const { addBlocker, removeBlocker } = useResolvedStoreWithSelector((state) => ({
192
286
  addBlocker: state.addBlocker,
193
287
  removeBlocker: state.removeBlocker
194
288
  }));
@@ -199,19 +293,21 @@ var useAsyncAction = (actionId, scope) => {
199
293
  addBlocker(blockerId, {
200
294
  scope,
201
295
  reason: `Executing ${actionId}`,
202
- priority: ASYNC_ACTION_PRIORITY
296
+ priority: ASYNC_ACTION_PRIORITY,
297
+ timeout: options?.timeout,
298
+ onTimeout: options?.onTimeout
203
299
  });
204
300
  return await asyncFn();
205
301
  } finally {
206
302
  removeBlocker(blockerId);
207
303
  }
208
304
  },
209
- [actionId, scope, addBlocker, removeBlocker]
305
+ [actionId, scope, options?.timeout, options?.onTimeout, addBlocker, removeBlocker]
210
306
  );
211
307
  return executeWithBlocking;
212
308
  };
213
309
  var useBlocker = (blockerId, config, isActive = true) => {
214
- const { addBlocker, removeBlocker } = useUIBlockingStore((state) => ({
310
+ const { addBlocker, removeBlocker } = useResolvedStoreWithSelector((state) => ({
215
311
  addBlocker: state.addBlocker,
216
312
  removeBlocker: state.removeBlocker
217
313
  }));
@@ -235,10 +331,11 @@ function createBlockerConfig(config) {
235
331
  };
236
332
  }
237
333
  var useBlockingInfo = (scope = DEFAULT_SCOPE) => {
238
- const activeBlockers = zustand.useStore(uiBlockingStoreApi, (state) => state.activeBlockers);
334
+ const store = useResolvedStore();
335
+ const activeBlockers = zustand.useStore(store, (state) => state.activeBlockers);
239
336
  return react.useMemo(() => {
240
- return uiBlockingStoreApi.getState().getBlockingInfo(scope);
241
- }, [activeBlockers, scope]);
337
+ return store.getState().getBlockingInfo(scope);
338
+ }, [activeBlockers, scope, store]);
242
339
  };
243
340
  function useConfigRef(config) {
244
341
  const configRef = react.useRef(config);
@@ -250,7 +347,7 @@ function useConfigRef(config) {
250
347
 
251
348
  // src/hooks/useConditionalBlocker/useConditionalBlocker.ts
252
349
  var useConditionalBlocker = (blockerId, config) => {
253
- const { addBlocker, removeBlocker } = useUIBlockingStore((state) => ({
350
+ const { addBlocker, removeBlocker } = useResolvedStoreWithSelector((state) => ({
254
351
  addBlocker: state.addBlocker,
255
352
  removeBlocker: state.removeBlocker
256
353
  }));
@@ -333,7 +430,7 @@ var useConfirmableBlocker = (blockerId, config) => {
333
430
 
334
431
  // src/hooks/useIsBlocked/useIsBlocked.ts
335
432
  var useIsBlocked = (scope) => {
336
- return useUIBlockingStore((state) => state.isBlocked(scope));
433
+ return useResolvedStoreWithSelector((state) => state.isBlocked(scope));
337
434
  };
338
435
 
339
436
  // src/hooks/useScheduledBlocker/useScheduledBlocker.utils.ts
@@ -375,7 +472,7 @@ function isScheduleInPast(startTime, endTime, now) {
375
472
 
376
473
  // src/hooks/useScheduledBlocker/useScheduledBlocker.ts
377
474
  var useScheduledBlocker = (blockerId, config) => {
378
- const { addBlocker, removeBlocker } = useUIBlockingStore((state) => ({
475
+ const { addBlocker, removeBlocker } = useResolvedStoreWithSelector((state) => ({
379
476
  addBlocker: state.addBlocker,
380
477
  removeBlocker: state.removeBlocker
381
478
  }));
@@ -606,9 +703,27 @@ var createPerformanceMiddleware = (config = {}) => {
606
703
  };
607
704
  };
608
705
 
706
+ // src/createTypedHooks.ts
707
+ function createTypedHooks() {
708
+ return {
709
+ useBlocker: (blockerId, config, isActive) => {
710
+ useBlocker(blockerId, config, isActive);
711
+ },
712
+ useIsBlocked: (scope) => {
713
+ return useIsBlocked(scope);
714
+ },
715
+ useAsyncAction: (actionId, scope, options) => {
716
+ return useAsyncAction(actionId, scope, options);
717
+ },
718
+ useBlockingInfo: (scope) => useBlockingInfo(scope)
719
+ };
720
+ }
721
+
722
+ exports.UIBlockingProvider = UIBlockingProvider;
609
723
  exports.configureMiddleware = configureMiddleware;
610
724
  exports.createAnalyticsMiddleware = createAnalyticsMiddleware;
611
725
  exports.createPerformanceMiddleware = createPerformanceMiddleware;
726
+ exports.createTypedHooks = createTypedHooks;
612
727
  exports.loggerMiddleware = loggerMiddleware;
613
728
  exports.uiBlockingStoreApi = uiBlockingStoreApi;
614
729
  exports.useAsyncAction = useAsyncAction;
@@ -617,7 +732,10 @@ exports.useBlockingInfo = useBlockingInfo;
617
732
  exports.useConditionalBlocker = useConditionalBlocker;
618
733
  exports.useConfirmableBlocker = useConfirmableBlocker;
619
734
  exports.useIsBlocked = useIsBlocked;
735
+ exports.useIsInsideUIBlockingProvider = useIsInsideUIBlockingProvider;
620
736
  exports.useScheduledBlocker = useScheduledBlocker;
737
+ exports.useUIBlockingContext = useUIBlockingContext;
621
738
  exports.useUIBlockingStore = useUIBlockingStore;
739
+ exports.useUIBlockingStoreFromContext = useUIBlockingStoreFromContext;
622
740
  //# sourceMappingURL=index.cjs.map
623
741
  //# sourceMappingURL=index.cjs.map