@okyrychenko-dev/react-action-guard 0.5.0 → 0.6.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
@@ -154,10 +154,12 @@ Gets detailed information about all active blockers for a specific scope.
154
154
  **BlockerInfo:**
155
155
 
156
156
  - `id: string` - Unique identifier of the blocker
157
- - `reason?: string` - Reason for blocking
158
- - `priority: number` - Priority level (0-100)
157
+ - `reason: string` - Reason for blocking (defaults to "Unknown")
158
+ - `priority: number` - Priority level (higher = higher priority, minimum value is 0)
159
159
  - `scope: string | string[]` - Scope(s) being blocked
160
- - `timestamp: number` - When the blocker was added
160
+ - `timestamp: number` - When the blocker was added (milliseconds since epoch)
161
+ - `timeout?: number` - Optional timeout duration in milliseconds
162
+ - `onTimeout?: (blockerId: string) => void` - Optional callback when timeout expires
161
163
 
162
164
  **Example:**
163
165
 
@@ -433,22 +435,30 @@ Direct access to the Zustand store for advanced use cases (requires a selector).
433
435
  **Methods:**
434
436
 
435
437
  - `addBlocker(id, config)` - Manually add a blocker (re-adding with the same ID replaces config)
438
+ - `updateBlocker(id, config)` - Update blocker metadata (timeout restarts if new `timeout` value provided)
436
439
  - `removeBlocker(id)` - Manually remove a blocker
437
440
  - `isBlocked(scope)` - Check if scope is blocked
438
441
  - `getBlockingInfo(scope)` - Get detailed blocking information
439
- - `clearAllBlockers()` - Remove all blockers
440
- - `clearBlockersForScope(scope)` - Remove blockers for specific scope
442
+ - `clearAllBlockers()` - Remove all blockers (emits `"clear"` middleware event)
443
+ - `clearBlockersForScope(scope)` - Remove blockers for specific scope (emits `"clear_scope"` middleware event)
441
444
  - `registerMiddleware(name, middleware)` - Register middleware manually
442
445
  - `unregisterMiddleware(name)` - Unregister middleware manually
443
446
 
447
+ **Note about `updateBlocker` and timeouts:**
448
+ - If you pass a **new** `timeout` value, the timer will be **restarted**
449
+ - If you **don't** pass `timeout`, the existing timer continues unchanged
450
+ - Set `timeout: 0` to **clear** an existing timeout
451
+ - `addBlocker` always resets/creates a new timeout timer when called with the same ID
452
+
444
453
  **Example:**
445
454
 
446
455
  ```jsx
447
456
  import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
448
457
 
449
458
  function AdvancedComponent() {
450
- const { addBlocker, removeBlocker } = useUIBlockingStore((state) => ({
459
+ const { addBlocker, updateBlocker, removeBlocker } = useUIBlockingStore((state) => ({
451
460
  addBlocker: state.addBlocker,
461
+ updateBlocker: state.updateBlocker,
452
462
  removeBlocker: state.removeBlocker,
453
463
  }));
454
464
 
@@ -461,12 +471,13 @@ function AdvancedComponent() {
461
471
  };
462
472
 
463
473
  const updatePriority = () => {
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",
468
- priority: 200,
469
- });
474
+ // Update metadata - timeout continues if not changed
475
+ updateBlocker("custom-blocker", { priority: 200 });
476
+ };
477
+
478
+ const extendTimeout = () => {
479
+ // Update timeout - timer restarts with new value
480
+ updateBlocker("custom-blocker", { timeout: 60000 });
470
481
  };
471
482
 
472
483
  const stopBlocking = () => {
@@ -507,6 +518,30 @@ Advanced store helpers are re-exported from the internal toolkit for custom setu
507
518
 
508
519
  The library includes a powerful middleware system that allows you to hook into blocker lifecycle events for analytics, logging, and performance monitoring.
509
520
 
521
+ ### Middleware Actions
522
+
523
+ Middleware receives events for the following actions:
524
+
525
+ - `"add"` - Blocker was added
526
+ - `"update"` - Blocker metadata was updated
527
+ - `"remove"` - Blocker was removed
528
+ - `"timeout"` - Blocker was auto-removed due to timeout
529
+ - `"clear"` - All blockers were cleared (includes `count` field)
530
+ - `"clear_scope"` - Blockers for specific scope were cleared (includes `scope` and `count` fields)
531
+
532
+ **MiddlewareContext type:**
533
+ ```typescript
534
+ {
535
+ action: "add" | "update" | "remove" | "timeout" | "clear" | "clear_scope";
536
+ blockerId: string;
537
+ config?: BlockerConfig;
538
+ timestamp: number;
539
+ prevState?: BlockerConfig; // Available for "update" and "remove"
540
+ scope?: string; // Available for "clear_scope"
541
+ count?: number; // Available for "clear" and "clear_scope"
542
+ }
543
+ ```
544
+
510
545
  ### Built-in Middleware
511
546
 
512
547
  #### Analytics Middleware
@@ -570,8 +605,6 @@ configureMiddleware([
570
605
 
571
606
  Note: `configureMiddleware` registers middleware on the global store. If you use `UIBlockingProvider`, register middleware via the provider's `middlewares` prop instead.
572
607
 
573
- Note: Middleware action types include `update` and `cancel`, but the store currently emits `add`, `remove`, and `timeout`.
574
-
575
608
  ### Custom Middleware
576
609
 
577
610
  Create your own middleware to handle blocker events:
@@ -580,14 +613,20 @@ Create your own middleware to handle blocker events:
580
613
  import { configureMiddleware } from "@okyrychenko-dev/react-action-guard";
581
614
 
582
615
  const myCustomMiddleware = (context) => {
583
- const { action, blockerId, config, timestamp } = context;
616
+ const { action, blockerId, config, timestamp, scope, count } = context;
584
617
 
585
618
  if (action === "add") {
586
619
  console.log(`Blocker added: ${blockerId}`, config);
587
- } else if (action === "remove") {
588
- console.log(`Blocker removed: ${blockerId}`);
589
620
  } else if (action === "update") {
590
621
  console.log(`Blocker updated: ${blockerId}`, config);
622
+ } else if (action === "remove") {
623
+ console.log(`Blocker removed: ${blockerId}`);
624
+ } else if (action === "timeout") {
625
+ console.log(`Blocker timed out: ${blockerId}`);
626
+ } else if (action === "clear") {
627
+ console.log(`All blockers cleared (${count} total)`);
628
+ } else if (action === "clear_scope") {
629
+ console.log(`Cleared ${count} blocker(s) for scope: ${scope}`);
591
630
  }
592
631
  };
593
632
 
@@ -887,6 +926,97 @@ function StorageQuotaGuard() {
887
926
  }
888
927
  ```
889
928
 
929
+ ### Clearing Blockers on Navigation
930
+
931
+ Clear blockers when navigating away or on specific events:
932
+
933
+ ```jsx
934
+ import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
935
+ import { useEffect } from "react";
936
+
937
+ function CheckoutPage() {
938
+ const { clearBlockersForScope, clearAllBlockers } = useUIBlockingStore(
939
+ (state) => ({
940
+ clearBlockersForScope: state.clearBlockersForScope,
941
+ clearAllBlockers: state.clearAllBlockers,
942
+ })
943
+ );
944
+
945
+ // Clear checkout-specific blockers when leaving the page
946
+ useEffect(() => {
947
+ return () => {
948
+ // Clean up checkout blockers on unmount
949
+ clearBlockersForScope("checkout");
950
+ };
951
+ }, [clearBlockersForScope]);
952
+
953
+ const handleCancelOrder = () => {
954
+ // Clear all blockers when user explicitly cancels
955
+ clearAllBlockers();
956
+ navigate("/");
957
+ };
958
+
959
+ return (
960
+ <div>
961
+ <CheckoutForm />
962
+ <button onClick={handleCancelOrder}>Cancel Order</button>
963
+ </div>
964
+ );
965
+ }
966
+ ```
967
+
968
+ ### Managing Session Timeouts with Dynamic Updates
969
+
970
+ Extend or update blocker timeouts dynamically:
971
+
972
+ ```jsx
973
+ import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
974
+
975
+ function SessionManager() {
976
+ const { addBlocker, updateBlocker, removeBlocker } = useUIBlockingStore(
977
+ (state) => ({
978
+ addBlocker: state.addBlocker,
979
+ updateBlocker: state.updateBlocker,
980
+ removeBlocker: state.removeBlocker,
981
+ })
982
+ );
983
+
984
+ const startSession = () => {
985
+ addBlocker("session-timeout", {
986
+ scope: "global",
987
+ reason: "Session expiring soon",
988
+ priority: 50,
989
+ timeout: 300000, // 5 minutes
990
+ onTimeout: () => {
991
+ logout();
992
+ showNotification("Session expired");
993
+ },
994
+ });
995
+ };
996
+
997
+ const extendSession = () => {
998
+ // Extend timeout - timer restarts with new value
999
+ updateBlocker("session-timeout", {
1000
+ timeout: 600000, // 10 minutes
1001
+ reason: "Session extended",
1002
+ });
1003
+ };
1004
+
1005
+ const endSession = () => {
1006
+ removeBlocker("session-timeout");
1007
+ logout();
1008
+ };
1009
+
1010
+ return (
1011
+ <div>
1012
+ <button onClick={startSession}>Start Session</button>
1013
+ <button onClick={extendSession}>Extend Session</button>
1014
+ <button onClick={endSession}>Logout</button>
1015
+ </div>
1016
+ );
1017
+ }
1018
+ ```
1019
+
890
1020
  ## Development
891
1021
 
892
1022
  ```bash
package/dist/index.cjs CHANGED
@@ -24,6 +24,16 @@ function normalizeScopeToArray(scope) {
24
24
  }
25
25
 
26
26
  // src/store/uiBlockingStore.actions.ts
27
+ var normalizePriority = (priority, fallback = DEFAULT_PRIORITY) => {
28
+ return priority !== void 0 ? Math.max(0, priority) : fallback;
29
+ };
30
+ var createMiddlewareContext = (action, blockerId, config, prevState) => ({
31
+ action,
32
+ blockerId,
33
+ config,
34
+ timestamp: Date.now(),
35
+ ...prevState && { prevState }
36
+ });
27
37
  var createUIBlockingActions = (set, get) => ({
28
38
  // State
29
39
  activeBlockers: /* @__PURE__ */ new Map(),
@@ -34,7 +44,6 @@ var createUIBlockingActions = (set, get) => ({
34
44
  newMiddlewares.set(name, middleware);
35
45
  return { middlewares: newMiddlewares };
36
46
  });
37
- console.log(`[UIBlocking] Registered middleware: ${name}`);
38
47
  },
39
48
  unregisterMiddleware: (name) => {
40
49
  set((state) => {
@@ -42,7 +51,6 @@ var createUIBlockingActions = (set, get) => ({
42
51
  newMiddlewares.delete(name);
43
52
  return { middlewares: newMiddlewares };
44
53
  });
45
- console.log(`[UIBlocking] Unregistered middleware: ${name}`);
46
54
  },
47
55
  runMiddlewares: async (context) => {
48
56
  const { middlewares } = get();
@@ -72,17 +80,14 @@ var createUIBlockingActions = (set, get) => ({
72
80
  timeoutId = setTimeout(() => {
73
81
  if (get().activeBlockers.has(id)) {
74
82
  config.onTimeout?.(id);
75
- void get().runMiddlewares({
76
- action: "timeout",
77
- blockerId: id,
78
- config: {
83
+ void get().runMiddlewares(
84
+ createMiddlewareContext("timeout", id, {
79
85
  scope: config.scope,
80
86
  reason: config.reason,
81
87
  priority: config.priority,
82
88
  timeout: config.timeout
83
- },
84
- timestamp: Date.now()
85
- });
89
+ })
90
+ );
86
91
  get().removeBlocker(id);
87
92
  }
88
93
  }, config.timeout);
@@ -92,7 +97,7 @@ var createUIBlockingActions = (set, get) => ({
92
97
  const storedBlocker = {
93
98
  scope: config.scope ?? DEFAULT_SCOPE,
94
99
  reason: config.reason ?? DEFAULT_REASON,
95
- priority: config.priority ?? DEFAULT_PRIORITY,
100
+ priority: normalizePriority(config.priority),
96
101
  timestamp: config.timestamp ?? Date.now(),
97
102
  timeout: config.timeout,
98
103
  timeoutId,
@@ -101,17 +106,92 @@ var createUIBlockingActions = (set, get) => ({
101
106
  newBlockers.set(id, storedBlocker);
102
107
  return { activeBlockers: newBlockers };
103
108
  });
104
- void get().runMiddlewares({
105
- action: "add",
106
- blockerId: id,
107
- config: {
109
+ void get().runMiddlewares(
110
+ createMiddlewareContext("add", id, {
108
111
  scope: config.scope,
109
112
  reason: config.reason,
110
113
  priority: config.priority,
111
114
  timeout: config.timeout
112
- },
113
- timestamp: Date.now()
115
+ })
116
+ );
117
+ },
118
+ /**
119
+ * Update blocker metadata. If timeout is updated, the timer will be restarted.
120
+ *
121
+ * @param id - Unique blocker identifier
122
+ * @param config - Partial blocker configuration to merge
123
+ *
124
+ */
125
+ updateBlocker: (id, config = {}) => {
126
+ const existingBlocker = get().activeBlockers.get(id);
127
+ if (!existingBlocker) {
128
+ get().addBlocker(id, config);
129
+ return;
130
+ }
131
+ let timeoutId = existingBlocker.timeoutId;
132
+ const timeoutChanged = config.timeout !== void 0 && config.timeout !== existingBlocker.timeout;
133
+ if (timeoutChanged) {
134
+ if (existingBlocker.timeoutId) {
135
+ clearTimeout(existingBlocker.timeoutId);
136
+ }
137
+ if (config.timeout && config.timeout > 0) {
138
+ const newTimeout = config.timeout;
139
+ const newOnTimeout = config.onTimeout ?? existingBlocker.onTimeout;
140
+ timeoutId = setTimeout(() => {
141
+ if (get().activeBlockers.has(id)) {
142
+ newOnTimeout?.(id);
143
+ void get().runMiddlewares(
144
+ createMiddlewareContext("timeout", id, {
145
+ scope: config.scope ?? existingBlocker.scope,
146
+ reason: config.reason ?? existingBlocker.reason,
147
+ priority: config.priority ?? existingBlocker.priority,
148
+ timeout: newTimeout
149
+ })
150
+ );
151
+ get().removeBlocker(id);
152
+ }
153
+ }, newTimeout);
154
+ } else {
155
+ timeoutId = void 0;
156
+ }
157
+ }
158
+ const updatedBlocker = {
159
+ ...existingBlocker,
160
+ scope: config.scope ?? existingBlocker.scope,
161
+ reason: config.reason ?? existingBlocker.reason,
162
+ priority: normalizePriority(config.priority, existingBlocker.priority),
163
+ timestamp: config.timestamp ?? existingBlocker.timestamp,
164
+ timeout: config.timeout ?? existingBlocker.timeout,
165
+ timeoutId,
166
+ onTimeout: config.onTimeout ?? existingBlocker.onTimeout
167
+ };
168
+ set((state) => {
169
+ const newBlockers = new Map(state.activeBlockers);
170
+ newBlockers.set(id, updatedBlocker);
171
+ return { activeBlockers: newBlockers };
114
172
  });
173
+ void get().runMiddlewares(
174
+ createMiddlewareContext(
175
+ "update",
176
+ id,
177
+ {
178
+ scope: updatedBlocker.scope,
179
+ reason: updatedBlocker.reason,
180
+ priority: updatedBlocker.priority,
181
+ timeout: updatedBlocker.timeout,
182
+ onTimeout: updatedBlocker.onTimeout,
183
+ timestamp: updatedBlocker.timestamp
184
+ },
185
+ {
186
+ scope: existingBlocker.scope,
187
+ reason: existingBlocker.reason,
188
+ priority: existingBlocker.priority,
189
+ timeout: existingBlocker.timeout,
190
+ onTimeout: existingBlocker.onTimeout,
191
+ timestamp: existingBlocker.timestamp
192
+ }
193
+ )
194
+ );
115
195
  },
116
196
  /**
117
197
  * Remove a blocker from the store
@@ -130,13 +210,7 @@ var createUIBlockingActions = (set, get) => ({
130
210
  return { activeBlockers: newBlockers };
131
211
  });
132
212
  if (prevBlocker) {
133
- void get().runMiddlewares({
134
- action: "remove",
135
- blockerId: id,
136
- config: prevBlocker,
137
- timestamp: Date.now(),
138
- prevState: prevBlocker
139
- });
213
+ void get().runMiddlewares(createMiddlewareContext("remove", id, prevBlocker, prevBlocker));
140
214
  }
141
215
  },
142
216
  /**
@@ -183,12 +257,21 @@ var createUIBlockingActions = (set, get) => ({
183
257
  */
184
258
  clearAllBlockers: () => {
185
259
  const { activeBlockers } = get();
260
+ const count = activeBlockers.size;
186
261
  for (const [, blocker] of activeBlockers) {
187
262
  if (blocker.timeoutId) {
188
263
  clearTimeout(blocker.timeoutId);
189
264
  }
190
265
  }
191
266
  set({ activeBlockers: /* @__PURE__ */ new Map() });
267
+ if (count > 0) {
268
+ void get().runMiddlewares({
269
+ action: "clear",
270
+ blockerId: "*",
271
+ timestamp: Date.now(),
272
+ count
273
+ });
274
+ }
192
275
  },
193
276
  /**
194
277
  * Clear all blockers for a specific scope
@@ -198,10 +281,14 @@ var createUIBlockingActions = (set, get) => ({
198
281
  */
199
282
  clearBlockersForScope: (scope) => {
200
283
  const { activeBlockers } = get();
284
+ let count = 0;
201
285
  for (const [, blocker] of activeBlockers) {
202
286
  const blockerScopes = normalizeScopeToArray(blocker.scope);
203
- if (blockerScopes.includes(scope) && blocker.timeoutId) {
204
- clearTimeout(blocker.timeoutId);
287
+ if (blockerScopes.includes(scope)) {
288
+ if (blocker.timeoutId) {
289
+ clearTimeout(blocker.timeoutId);
290
+ }
291
+ count++;
205
292
  }
206
293
  }
207
294
  set((state) => {
@@ -214,6 +301,15 @@ var createUIBlockingActions = (set, get) => ({
214
301
  }
215
302
  return { activeBlockers: newBlockers };
216
303
  });
304
+ if (count > 0) {
305
+ void get().runMiddlewares({
306
+ action: "clear_scope",
307
+ blockerId: "*",
308
+ timestamp: Date.now(),
309
+ scope,
310
+ count
311
+ });
312
+ }
217
313
  }
218
314
  });
219
315
  var {
@@ -273,9 +369,11 @@ var useAsyncAction = (actionId, scope, options) => {
273
369
  addBlocker: state.addBlocker,
274
370
  removeBlocker: state.removeBlocker
275
371
  }));
372
+ const counterRef = react.useRef(0);
276
373
  const executeWithBlocking = react.useCallback(
277
374
  async (asyncFn) => {
278
- const blockerId = `${actionId}-${Date.now().toString()}`;
375
+ counterRef.current += 1;
376
+ const blockerId = `${actionId}-${String(counterRef.current)}`;
279
377
  try {
280
378
  addBlocker(blockerId, {
281
379
  scope,
@@ -610,14 +708,16 @@ var getActionEmoji = (action) => {
610
708
  switch (action) {
611
709
  case "add":
612
710
  return "\u2795";
613
- case "remove":
614
- return "\u2796";
615
711
  case "update":
616
712
  return "\u{1F504}";
617
- case "cancel":
618
- return "\u274C";
713
+ case "remove":
714
+ return "\u2796";
619
715
  case "timeout":
620
716
  return "\u23F1\uFE0F";
717
+ case "clear":
718
+ return "\u{1F9F9}";
719
+ case "clear_scope":
720
+ return "\u{1F3AF}";
621
721
  default:
622
722
  return "\u2753";
623
723
  }
@@ -638,13 +738,23 @@ var extractConfigDetails = (config) => {
638
738
  };
639
739
  var formatLogData = (context) => {
640
740
  const configDetails = extractConfigDetails(context.config);
741
+ const logData = {};
742
+ if (context.scope !== void 0) {
743
+ logData.scope = context.scope;
744
+ }
745
+ if (context.count !== void 0) {
746
+ logData.count = context.count;
747
+ }
748
+ if (Object.keys(configDetails).length > 0) {
749
+ logData.config = configDetails;
750
+ }
641
751
  if (context.prevState) {
642
- return {
643
- config: configDetails,
644
- prevState: extractConfigDetails(context.prevState)
645
- };
752
+ logData.prevState = extractConfigDetails(context.prevState);
753
+ }
754
+ if (Object.keys(logData).length === 0) {
755
+ return configDetails;
646
756
  }
647
- return configDetails;
757
+ return Object.keys(logData).length === 1 && logData.config ? configDetails : logData;
648
758
  };
649
759
 
650
760
  // src/middleware/loggerMiddleware.ts