@okyrychenko-dev/react-action-guard 1.0.5 → 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/dist/index.js CHANGED
@@ -1,470 +1,503 @@
1
- import { createStoreProvider, createShallowStore } from '@okyrychenko-dev/react-zustand-toolkit';
2
- import { useStore } from 'zustand';
3
- import 'zustand/react/shallow';
4
- import { jsx } from 'react/jsx-runtime';
5
- import { useShallow, shallow } from 'zustand/shallow';
1
+ import { createStoreProvider, createShallowStore, createResolvedStoreHooks } from '@okyrychenko-dev/react-zustand-toolkit';
2
+ import { isFunction, isDefined, isUndefined, isNonEmptyArray, isString, isReadonlyArray, isNull, isArray } from '@okyrychenko-dev/type-utils';
6
3
  import { devtools } from 'zustand/middleware';
7
- import { useCallback, useRef, useMemo, useEffect, useState } from 'react';
4
+ import { jsx } from 'react/jsx-runtime';
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
+ import { shallow } from 'zustand/shallow';
8
7
 
9
8
  // src/context/UIBlockingContext.tsx
10
9
 
11
- // src/store/uiBlockingStore.constants.ts
10
+ // src/store/scope/scope.constants.ts
12
11
  var DEFAULT_SCOPE = "global";
12
+ var normalizedScopeCache = /* @__PURE__ */ new Map();
13
+ var NORMALIZED_SCOPE_CACHE_MAX_SIZE = 256;
14
+ function getNormalizedScopeCacheKey(scopes) {
15
+ return JSON.stringify(scopes);
16
+ }
17
+ function refreshNormalizedScopeCacheEntry(cacheKey, scopes) {
18
+ normalizedScopeCache.delete(cacheKey);
19
+ normalizedScopeCache.set(cacheKey, scopes);
20
+ return scopes;
21
+ }
22
+ function evictOldestNormalizedScopeCacheEntry() {
23
+ for (const cacheKey of normalizedScopeCache.keys()) {
24
+ normalizedScopeCache.delete(cacheKey);
25
+ break;
26
+ }
27
+ }
28
+ function getCachedNormalizedScope(scopes) {
29
+ const normalizedScope = Object.freeze([...new Set(scopes)].sort());
30
+ const cacheKey = getNormalizedScopeCacheKey(normalizedScope);
31
+ const cachedScope = normalizedScopeCache.get(cacheKey);
32
+ if (isDefined(cachedScope)) {
33
+ return refreshNormalizedScopeCacheEntry(cacheKey, cachedScope);
34
+ }
35
+ normalizedScopeCache.set(cacheKey, normalizedScope);
36
+ if (normalizedScopeCache.size > NORMALIZED_SCOPE_CACHE_MAX_SIZE) {
37
+ evictOldestNormalizedScopeCacheEntry();
38
+ }
39
+ return normalizedScope;
40
+ }
41
+ function normalizeScope(scope) {
42
+ if (isUndefined(scope)) {
43
+ return getCachedNormalizedScope([DEFAULT_SCOPE]);
44
+ }
45
+ if (isString(scope)) {
46
+ return getCachedNormalizedScope([scope]);
47
+ }
48
+ return getCachedNormalizedScope(scope);
49
+ }
50
+ function resolveScope(explicitScope, inheritedScope) {
51
+ if (isUndefined(explicitScope) || isReadonlyArray(explicitScope) && !isNonEmptyArray(explicitScope)) {
52
+ return inheritedScope;
53
+ }
54
+ return explicitScope;
55
+ }
56
+ function matchesObservedScopes(blockerScope, observedScopes) {
57
+ if (!isNonEmptyArray(observedScopes)) {
58
+ return false;
59
+ }
60
+ if (isString(blockerScope)) {
61
+ return blockerScope === DEFAULT_SCOPE || observedScopes.includes(blockerScope);
62
+ }
63
+ return blockerScope.includes(DEFAULT_SCOPE) || observedScopes.some((scope) => blockerScope.includes(scope));
64
+ }
65
+ function scopeAffectsObservation(blockerScope, observedScope) {
66
+ return matchesObservedScopes(blockerScope, normalizeScope(observedScope));
67
+ }
68
+ function createScopeObservationMatcher(observedScope) {
69
+ const observedScopes = normalizeScope(observedScope);
70
+ return (blockerScope) => matchesObservedScopes(blockerScope, observedScopes);
71
+ }
72
+ function scopeMatchesTarget(blockerScope, targetScope) {
73
+ const blockerScopes = normalizeScope(blockerScope);
74
+ if (blockerScopes.includes(DEFAULT_SCOPE)) {
75
+ return false;
76
+ }
77
+ const targetScopes = normalizeScope(targetScope);
78
+ return targetScopes.some((scope) => blockerScopes.includes(scope));
79
+ }
80
+
81
+ // src/store/blockingInfo/blockingInfo.utils.ts
82
+ function sameScope(first, second) {
83
+ if (first === second) {
84
+ return true;
85
+ }
86
+ if (isString(first) || isString(second)) {
87
+ return false;
88
+ }
89
+ return first.length === second.length && first.every((scope, index) => scope === second[index]);
90
+ }
91
+ function sameBlocker(first, second) {
92
+ if (first === second) {
93
+ return true;
94
+ }
95
+ const { id, scope, reason, priority, timestamp, timeout, onTimeout } = first;
96
+ const {
97
+ id: previousId,
98
+ scope: previousScope,
99
+ reason: previousReason,
100
+ priority: previousPriority,
101
+ timestamp: previousTimestamp,
102
+ timeout: previousTimeout,
103
+ onTimeout: previousOnTimeout
104
+ } = second;
105
+ return id === previousId && sameScope(scope, previousScope) && reason === previousReason && priority === previousPriority && timestamp === previousTimestamp && timeout === previousTimeout && onTimeout === previousOnTimeout;
106
+ }
107
+ function areBlockingInfosEqual(first, second) {
108
+ return first === second || first.length === second.length && first.every((blocker, index) => {
109
+ const previous = second[index];
110
+ return isDefined(previous) && sameBlocker(blocker, previous);
111
+ });
112
+ }
113
+ function matchingBlockers(snapshot, matchesScope) {
114
+ return snapshot.filter(({ scope }) => matchesScope(scope));
115
+ }
116
+ function orderedBlockers(blockers) {
117
+ return Object.freeze(
118
+ blockers.sort(({ priority: first }, { priority: second }) => second - first)
119
+ );
120
+ }
121
+ function projectBlockingInfo(snapshot, scope) {
122
+ const matchesScope = createScopeObservationMatcher(scope);
123
+ return orderedBlockers(matchingBlockers(snapshot, matchesScope));
124
+ }
125
+ function createBlockingInfoSelector(scope) {
126
+ const matchesScope = createScopeObservationMatcher(scope);
127
+ let previousSnapshot;
128
+ let previousMatches = [];
129
+ let result = Object.freeze([]);
130
+ return ({ blockingSnapshot }) => {
131
+ if (blockingSnapshot === previousSnapshot) {
132
+ return result;
133
+ }
134
+ const matches = matchingBlockers(blockingSnapshot, matchesScope);
135
+ const unchanged = areBlockingInfosEqual(matches, previousMatches);
136
+ previousSnapshot = blockingSnapshot;
137
+ if (!unchanged) {
138
+ previousMatches = matches;
139
+ result = orderedBlockers([...matches]);
140
+ }
141
+ return result;
142
+ };
143
+ }
144
+
145
+ // src/store/uiBlockingStore.constants.ts
13
146
  var DEFAULT_REASON = "Unknown";
14
147
  var DEFAULT_PRIORITY = 0;
15
148
  var ASYNC_ACTION_PRIORITY = 20;
16
149
  var DEVTOOLS_NAME = "UIBlocking";
17
- function normalizeScopeToArray(scope) {
18
- if (typeof scope === "string") {
19
- return [scope];
150
+ function validateTimeout(timeout) {
151
+ if (isUndefined(timeout)) {
152
+ return;
153
+ }
154
+ if (!Number.isFinite(timeout) || timeout > 0 && Date.now() + timeout > Number.MAX_SAFE_INTEGER) {
155
+ throw new RangeError("Blocker timeout must be finite with a safely representable deadline");
20
156
  }
21
- return Array.from(scope);
22
157
  }
23
158
 
24
- // src/store/uiBlockingStore.actions.ts
25
- function normalizePriority(priority, fallback = DEFAULT_PRIORITY) {
26
- return priority !== void 0 ? Math.max(0, priority) : fallback;
159
+ // src/store/blockingLifecycle/blockingLifecycle.ts
160
+ function copyScope(scope) {
161
+ return isString(scope) ? scope : Object.freeze([...scope]);
27
162
  }
28
- function createMiddlewareContext(action, blockerId, config, prevState) {
29
- return {
30
- action,
31
- blockerId,
32
- config,
33
- timestamp: Date.now(),
34
- ...prevState && { prevState }
35
- };
163
+ function publicConfig(blocker) {
164
+ const { scope, reason, priority, timestamp, timeout, onTimeout } = blocker;
165
+ return { scope, reason, priority, timestamp, timeout, onTimeout };
166
+ }
167
+ function freezeBlocker(blocker) {
168
+ return Object.freeze({ ...blocker, scope: copyScope(blocker.scope) });
36
169
  }
37
- function toPublicBlockerConfig(blocker) {
170
+ function normalizeBlocker(id, config) {
38
171
  return {
39
- scope: blocker.scope,
40
- reason: blocker.reason,
41
- priority: blocker.priority,
42
- timestamp: blocker.timestamp,
43
- timeout: blocker.timeout,
44
- onTimeout: blocker.onTimeout
172
+ id,
173
+ scope: copyScope(config.scope ?? DEFAULT_SCOPE),
174
+ reason: config.reason ?? DEFAULT_REASON,
175
+ priority: Math.max(0, config.priority ?? DEFAULT_PRIORITY),
176
+ timestamp: config.timestamp ?? Date.now(),
177
+ timeout: config.timeout,
178
+ onTimeout: config.onTimeout
45
179
  };
46
180
  }
47
- var createUIBlockingActions = (set, get) => ({
48
- // State
49
- activeBlockers: /* @__PURE__ */ new Map(),
50
- middlewares: /* @__PURE__ */ new Map(),
51
- registerMiddleware: (name, middleware) => {
52
- set((state) => {
53
- const newMiddlewares = new Map(state.middlewares);
54
- newMiddlewares.set(name, middleware);
55
- return { middlewares: newMiddlewares };
56
- });
57
- },
58
- unregisterMiddleware: (name) => {
59
- set((state) => {
60
- const newMiddlewares = new Map(state.middlewares);
61
- newMiddlewares.delete(name);
62
- return { middlewares: newMiddlewares };
63
- });
64
- },
65
- runMiddlewares: async (context) => {
66
- const { middlewares } = get();
67
- for (const [, middleware] of middlewares) {
181
+ function createBlockingLifecycle() {
182
+ const blockers = /* @__PURE__ */ new Map();
183
+ const subscribers = /* @__PURE__ */ new Map();
184
+ const observers = /* @__PURE__ */ new Map();
185
+ const pendingPublications = [];
186
+ let snapshot = Object.freeze([]);
187
+ let isDelivering = false;
188
+ function getSnapshot() {
189
+ return snapshot;
190
+ }
191
+ function deliverEvent(event2) {
192
+ const eventObservers = [...observers];
193
+ for (const [registration, observer] of eventObservers) {
194
+ if (!observers.has(registration)) {
195
+ continue;
196
+ }
68
197
  try {
69
- await middleware(context);
198
+ void Promise.resolve(observer(event2)).catch(() => void 0);
70
199
  } catch {
71
200
  }
72
201
  }
73
- },
74
- // Actions
75
- /**
76
- * Adds a new blocker or overwrites an existing one.
77
- *
78
- * If a blocker with the same ID already exists, it will be replaced and any
79
- * existing timeout will be cleared. The blocker is active immediately after
80
- * being added. Triggers middleware with 'add' action.
81
- *
82
- * @param id - Unique identifier for the blocker. Must be unique within the store.
83
- * @param config - Blocker configuration. All fields are optional with defaults applied.
84
- *
85
- * @example
86
- * Basic usage
87
- * ```ts
88
- * store.getState().addBlocker('save-operation', {
89
- * scope: 'form',
90
- * reason: 'Saving data...',
91
- * priority: 70
92
- * });
93
- * ```
94
- *
95
- * @example
96
- * With timeout
97
- * ```ts
98
- * store.getState().addBlocker('api-call', {
99
- * scope: 'global',
100
- * reason: 'Loading...',
101
- * timeout: 30000, // Auto-remove after 30 seconds
102
- * onTimeout: (id) => console.warn(`${id} timed out`)
103
- * });
104
- * ```
105
- *
106
- * @see {@link removeBlocker} to remove a blocker
107
- * @see {@link updateBlocker} to update an existing blocker
108
- */
109
- addBlocker: (id, config = {}) => {
110
- const existingBlocker = get().activeBlockers.get(id);
111
- if (existingBlocker?.timeoutId) {
112
- clearTimeout(existingBlocker.timeoutId);
202
+ }
203
+ function drainPublications() {
204
+ if (isDelivering) {
205
+ return;
113
206
  }
114
- let timeoutId;
115
- if (config.timeout && config.timeout > 0) {
116
- timeoutId = setTimeout(() => {
117
- const blocker = get().activeBlockers.get(id);
118
- if (blocker) {
119
- blocker.onTimeout?.(id);
120
- void get().runMiddlewares(
121
- createMiddlewareContext("timeout", id, {
122
- scope: blocker.scope,
123
- reason: blocker.reason,
124
- priority: blocker.priority,
125
- timeout: blocker.timeout
126
- })
127
- );
128
- get().removeBlocker(id);
207
+ isDelivering = true;
208
+ try {
209
+ for (const publication of pendingPublications) {
210
+ if (publication.kind === "snapshot") {
211
+ for (const subscriber of subscribers.values()) {
212
+ try {
213
+ subscriber(publication.snapshot);
214
+ } catch {
215
+ }
216
+ }
217
+ if (publication.event) {
218
+ deliverEvent(publication.event);
219
+ }
220
+ } else {
221
+ deliverEvent(publication.event);
129
222
  }
130
- }, config.timeout);
223
+ }
224
+ } finally {
225
+ pendingPublications.length = 0;
226
+ isDelivering = false;
131
227
  }
132
- set((state) => {
133
- const newBlockers = new Map(state.activeBlockers);
134
- const storedBlocker = {
135
- scope: config.scope ?? DEFAULT_SCOPE,
136
- reason: config.reason ?? DEFAULT_REASON,
137
- priority: normalizePriority(config.priority),
138
- timestamp: config.timestamp ?? Date.now(),
139
- timeout: config.timeout,
140
- timeoutId,
141
- onTimeout: config.onTimeout
142
- };
143
- newBlockers.set(id, storedBlocker);
144
- return { activeBlockers: newBlockers };
228
+ }
229
+ function publish(transitionEvent) {
230
+ snapshot = Object.freeze(Array.from(blockers.values(), ({ config }) => freezeBlocker(config)));
231
+ pendingPublications.push({ kind: "snapshot", snapshot, event: transitionEvent });
232
+ drainPublications();
233
+ }
234
+ function emit(event2) {
235
+ pendingPublications.push({ kind: "event", event: event2 });
236
+ drainPublications();
237
+ }
238
+ function event(action, blockerId) {
239
+ return { action, blockerId, timestamp: Date.now() };
240
+ }
241
+ function cancelTimeout(blocker) {
242
+ if (isDefined(blocker.timeoutId)) {
243
+ clearTimeout(blocker.timeoutId);
244
+ }
245
+ }
246
+ function handleTimeout(id, blocker) {
247
+ if (blockers.get(id) !== blocker) {
248
+ return;
249
+ }
250
+ const { onTimeout } = blocker.config;
251
+ try {
252
+ onTimeout?.(id);
253
+ } catch {
254
+ }
255
+ const { registration } = blocker;
256
+ const { scope, reason, priority, timeout: duration } = blocker.config;
257
+ emit({
258
+ ...event("timeout", id),
259
+ registration,
260
+ config: { scope, reason, priority, timeout: duration }
145
261
  });
146
- void get().runMiddlewares(
147
- createMiddlewareContext("add", id, {
262
+ if (blockers.get(id) === blocker) {
263
+ remove(id);
264
+ }
265
+ }
266
+ function scheduleTimeout(id, blocker) {
267
+ const { timeout } = blocker.config;
268
+ if (isUndefined(timeout) || timeout <= 0) {
269
+ return;
270
+ }
271
+ let remaining = timeout;
272
+ function scheduleChunk() {
273
+ const delay = Math.min(remaining, 2147483647);
274
+ const startedAt = performance.now();
275
+ blocker.timeoutId = setTimeout(() => {
276
+ if (blockers.get(id) !== blocker) {
277
+ return;
278
+ }
279
+ remaining -= Math.max(delay, performance.now() - startedAt);
280
+ if (remaining <= 0) {
281
+ handleTimeout(id, blocker);
282
+ return;
283
+ }
284
+ scheduleChunk();
285
+ }, delay);
286
+ }
287
+ scheduleChunk();
288
+ }
289
+ function add(id, config = {}) {
290
+ validateTimeout(config.timeout);
291
+ const previous = blockers.get(id);
292
+ if (previous) {
293
+ cancelTimeout(previous);
294
+ }
295
+ const blocker = {
296
+ config: normalizeBlocker(id, config),
297
+ registration: Object.freeze({})
298
+ };
299
+ blockers.set(id, blocker);
300
+ scheduleTimeout(id, blocker);
301
+ publish({
302
+ ...event("add", id),
303
+ registration: blocker.registration,
304
+ config: {
148
305
  scope: config.scope,
149
306
  reason: config.reason,
150
307
  priority: config.priority,
151
308
  timeout: config.timeout
152
- })
153
- );
154
- },
155
- /**
156
- * Updates an existing blocker or creates it if it doesn't exist.
157
- *
158
- * Merges the provided configuration with existing blocker data. If the timeout
159
- * is changed, the timeout timer is restarted. Triggers middleware with 'update' action.
160
- *
161
- * @param id - Identifier of the blocker to update
162
- * @param config - Partial configuration to merge with existing blocker data
163
- *
164
- * @example
165
- * Update blocker reason
166
- * ```ts
167
- * store.getState().updateBlocker('save', {
168
- * reason: 'Saving additional data...'
169
- * });
170
- * ```
171
- *
172
- * @example
173
- * Extend timeout
174
- * ```ts
175
- * store.getState().updateBlocker('api-call', {
176
- * timeout: 60000 // Extend to 60 seconds
177
- * });
178
- * ```
179
- *
180
- * @see {@link addBlocker} to create a new blocker
181
- */
182
- updateBlocker: (id, config = {}) => {
183
- const existingBlocker = get().activeBlockers.get(id);
184
- if (!existingBlocker) {
185
- get().addBlocker(id, config);
309
+ }
310
+ });
311
+ }
312
+ function applyUpdate(id, config, replace2) {
313
+ const previous = blockers.get(id);
314
+ if (!previous) {
315
+ add(id, config);
186
316
  return;
187
317
  }
188
- let timeoutId = existingBlocker.timeoutId;
189
- const timeoutChanged = config.timeout !== void 0 && config.timeout !== existingBlocker.timeout;
318
+ const oldConfig = previous.config;
319
+ let nextConfig;
320
+ if (replace2) {
321
+ nextConfig = normalizeBlocker(id, {
322
+ ...config,
323
+ timestamp: config.timestamp ?? oldConfig.timestamp
324
+ });
325
+ } else {
326
+ nextConfig = {
327
+ ...oldConfig,
328
+ scope: copyScope(config.scope ?? oldConfig.scope),
329
+ reason: config.reason ?? oldConfig.reason,
330
+ priority: Math.max(0, config.priority ?? oldConfig.priority),
331
+ timestamp: config.timestamp ?? oldConfig.timestamp,
332
+ timeout: config.timeout ?? oldConfig.timeout,
333
+ onTimeout: config.onTimeout ?? oldConfig.onTimeout
334
+ };
335
+ }
336
+ const timeoutChanged = nextConfig.timeout !== oldConfig.timeout;
190
337
  if (timeoutChanged) {
191
- if (existingBlocker.timeoutId) {
192
- clearTimeout(existingBlocker.timeoutId);
193
- }
194
- if (config.timeout && config.timeout > 0) {
195
- const newTimeout = config.timeout;
196
- timeoutId = setTimeout(() => {
197
- const blocker = get().activeBlockers.get(id);
198
- if (blocker) {
199
- blocker.onTimeout?.(id);
200
- void get().runMiddlewares(
201
- createMiddlewareContext("timeout", id, {
202
- scope: blocker.scope,
203
- reason: blocker.reason,
204
- priority: blocker.priority,
205
- timeout: blocker.timeout
206
- })
207
- );
208
- get().removeBlocker(id);
209
- }
210
- }, newTimeout);
211
- } else {
212
- timeoutId = void 0;
213
- }
338
+ validateTimeout(nextConfig.timeout);
214
339
  }
215
- const updatedBlocker = {
216
- ...existingBlocker,
217
- scope: config.scope ?? existingBlocker.scope,
218
- reason: config.reason ?? existingBlocker.reason,
219
- priority: normalizePriority(config.priority, existingBlocker.priority),
220
- timestamp: config.timestamp ?? existingBlocker.timestamp,
221
- timeout: config.timeout ?? existingBlocker.timeout,
222
- timeoutId,
223
- onTimeout: config.onTimeout ?? existingBlocker.onTimeout
224
- };
225
- set((state) => {
226
- const newBlockers = new Map(state.activeBlockers);
227
- newBlockers.set(id, updatedBlocker);
228
- return { activeBlockers: newBlockers };
229
- });
230
- void get().runMiddlewares(
231
- createMiddlewareContext(
232
- "update",
233
- id,
234
- {
235
- scope: updatedBlocker.scope,
236
- reason: updatedBlocker.reason,
237
- priority: updatedBlocker.priority,
238
- timeout: updatedBlocker.timeout,
239
- onTimeout: updatedBlocker.onTimeout,
240
- timestamp: updatedBlocker.timestamp
241
- },
242
- {
243
- scope: existingBlocker.scope,
244
- reason: existingBlocker.reason,
245
- priority: existingBlocker.priority,
246
- timeout: existingBlocker.timeout,
247
- onTimeout: existingBlocker.onTimeout,
248
- timestamp: existingBlocker.timestamp
249
- }
250
- )
251
- );
252
- },
253
- /**
254
- * Removes a blocker from the store.
255
- *
256
- * Clears any associated timeout and triggers middleware with 'remove' action.
257
- * Safe to call even if the blocker doesn't exist (no-op in that case).
258
- *
259
- * @param id - Identifier of the blocker to remove
260
- *
261
- * @example
262
- * ```ts
263
- * // Add and remove a blocker
264
- * store.getState().addBlocker('temp', { scope: 'form' });
265
- * // ... later
266
- * store.getState().removeBlocker('temp');
267
- * ```
268
- *
269
- * @see {@link addBlocker} to add a blocker
270
- * @see {@link clearAllBlockers} to remove all blockers at once
271
- */
272
- removeBlocker: (id) => {
273
- const prevBlocker = get().activeBlockers.get(id);
274
- if (prevBlocker?.timeoutId) {
275
- clearTimeout(prevBlocker.timeoutId);
340
+ let blocker = previous;
341
+ if (timeoutChanged) {
342
+ cancelTimeout(previous);
343
+ blocker = { config: nextConfig, registration: previous.registration };
344
+ } else {
345
+ blocker.config = nextConfig;
276
346
  }
277
- set((state) => {
278
- const newBlockers = new Map(state.activeBlockers);
279
- newBlockers.delete(id);
280
- return { activeBlockers: newBlockers };
281
- });
282
- if (prevBlocker) {
283
- const publicConfig = toPublicBlockerConfig(prevBlocker);
284
- void get().runMiddlewares(createMiddlewareContext("remove", id, publicConfig, publicConfig));
347
+ blockers.set(id, blocker);
348
+ if (timeoutChanged) {
349
+ scheduleTimeout(id, blocker);
285
350
  }
286
- },
287
- /**
288
- * Checks if one or more scopes are currently blocked.
289
- *
290
- * Returns true if ANY blocker affects the specified scope(s). Global scope
291
- * blockers will cause this to return true for any scope check.
292
- *
293
- * @param scope - Scope(s) to check. Defaults to "global" if not specified.
294
- * Can be a single scope string or array of scopes.
295
- * @returns true if any of the specified scopes are currently blocked
296
- *
297
- * @example
298
- * Single scope check
299
- * ```ts
300
- * const isFormBlocked = store.getState().isBlocked('form');
301
- * console.log(isFormBlocked); // true or false
302
- * ```
303
- *
304
- * @example
305
- * Multiple scopes check
306
- * ```ts
307
- * const isBlocked = store.getState().isBlocked(['form', 'navigation']);
308
- * // Returns true if either 'form' OR 'navigation' has active blockers
309
- * ```
310
- *
311
- * @see {@link getBlockingInfo} for detailed blocker information
312
- */
313
- isBlocked: (scope = DEFAULT_SCOPE) => {
314
- const { activeBlockers } = get();
315
- const scopes = normalizeScopeToArray(scope);
316
- for (const [, blocker] of activeBlockers) {
317
- if (blocker.scope === DEFAULT_SCOPE) {
318
- return true;
319
- }
320
- const blockerScopes = normalizeScopeToArray(blocker.scope);
321
- if (scopes.some((s) => blockerScopes.includes(s))) {
322
- return true;
323
- }
351
+ publish({
352
+ ...event("update", id),
353
+ registration: blocker.registration,
354
+ config: publicConfig(nextConfig),
355
+ prevState: publicConfig(oldConfig)
356
+ });
357
+ }
358
+ function update(id, config = {}) {
359
+ applyUpdate(id, config, false);
360
+ }
361
+ function replace(id, config) {
362
+ applyUpdate(id, config, true);
363
+ }
364
+ function remove(id) {
365
+ const previous = blockers.get(id);
366
+ if (previous) {
367
+ cancelTimeout(previous);
324
368
  }
325
- return false;
326
- },
327
- /**
328
- * Gets detailed information about all blockers affecting a specific scope.
329
- *
330
- * Returns an array of blocker objects sorted by priority (highest first).
331
- * Includes global blockers and scope-specific blockers. Each blocker object
332
- * contains id, reason, priority, scope, timestamp, and timeout information.
333
- *
334
- * @param scope - Scope to get blocking information for
335
- * @returns Array of {@link BlockerInfo} objects, sorted by priority descending
336
- *
337
- * @example
338
- * Display blocker details
339
- * ```ts
340
- * const blockers = store.getState().getBlockingInfo('form');
341
- * blockers.forEach(blocker => {
342
- * console.log(`${blocker.id}: ${blocker.reason}`);
343
- * console.log(`Priority: ${blocker.priority}`);
344
- * });
345
- * ```
346
- *
347
- * @example
348
- * Get highest priority blocker
349
- * ```ts
350
- * const blockers = store.getState().getBlockingInfo('navigation');
351
- * const topBlocker = blockers[0]; // Highest priority
352
- * if (topBlocker) {
353
- * console.log(`Top blocker: ${topBlocker.reason}`);
354
- * }
355
- * ```
356
- *
357
- * @see {@link isBlocked} for a simple boolean check
358
- * @see {@link BlockerInfo} for the structure of returned objects
359
- */
360
- getBlockingInfo: (scope) => {
361
- const { activeBlockers } = get();
362
- const blockers = [];
363
- for (const [id, blocker] of activeBlockers) {
364
- const blockerScopes = normalizeScopeToArray(blocker.scope);
365
- if (blocker.scope === DEFAULT_SCOPE || blockerScopes.includes(scope)) {
366
- const { timeoutId: _timeoutId, ...publicBlocker } = blocker;
367
- blockers.push({ id, ...publicBlocker });
368
- }
369
+ blockers.delete(id);
370
+ if (previous) {
371
+ const config = publicConfig(previous.config);
372
+ publish({
373
+ ...event("remove", id),
374
+ registration: previous.registration,
375
+ config,
376
+ prevState: config
377
+ });
378
+ } else {
379
+ publish();
369
380
  }
370
- return blockers.sort((a, b) => b.priority - a.priority);
371
- },
372
- /**
373
- * Removes all active blockers from the store.
374
- *
375
- * Clears all timeouts and triggers middleware with 'clear' action.
376
- * Useful for resetting the blocking state.
377
- *
378
- * @example
379
- * ```ts
380
- * // Clear all blockers on logout
381
- * store.getState().clearAllBlockers();
382
- * ```
383
- *
384
- * @see {@link clearBlockersForScope} to clear blockers for a specific scope
385
- */
386
- clearAllBlockers: () => {
387
- const { activeBlockers } = get();
388
- const count = activeBlockers.size;
389
- for (const [, blocker] of activeBlockers) {
390
- if (blocker.timeoutId) {
391
- clearTimeout(blocker.timeoutId);
392
- }
381
+ }
382
+ function clear() {
383
+ const count = blockers.size;
384
+ for (const blocker of blockers.values()) {
385
+ cancelTimeout(blocker);
393
386
  }
394
- set({ activeBlockers: /* @__PURE__ */ new Map() });
387
+ blockers.clear();
395
388
  if (count > 0) {
396
- void get().runMiddlewares({
397
- action: "clear",
398
- blockerId: "*",
399
- timestamp: Date.now(),
400
- count
401
- });
389
+ publish({ ...event("clear", "*"), count });
390
+ } else {
391
+ publish();
402
392
  }
403
- },
404
- /**
405
- * Removes all blockers that affect a specific scope.
406
- *
407
- * Clears timeouts for removed blockers and triggers middleware with
408
- * 'clear_scope' action. Global scope blockers are NOT cleared by this method.
409
- *
410
- * @param scope - Scope to clear blockers for
411
- *
412
- * @example
413
- * Clear form blockers
414
- * ```ts
415
- * // Clear all blockers affecting 'form' scope
416
- * store.getState().clearBlockersForScope('form');
417
- * ```
418
- *
419
- * @example
420
- * Clear on navigation
421
- * ```ts
422
- * // Clear navigation blockers when user navigates away
423
- * store.getState().clearBlockersForScope('navigation');
424
- * ```
425
- *
426
- * @see {@link clearAllBlockers} to clear all blockers
427
- */
428
- clearBlockersForScope: (scope) => {
429
- const { activeBlockers } = get();
393
+ }
394
+ function clearScope(scope) {
430
395
  let count = 0;
431
- for (const [, blocker] of activeBlockers) {
432
- const blockerScopes = normalizeScopeToArray(blocker.scope);
433
- if (blockerScopes.includes(scope)) {
434
- if (blocker.timeoutId) {
435
- clearTimeout(blocker.timeoutId);
436
- }
396
+ for (const [id, blocker] of blockers) {
397
+ if (scopeMatchesTarget(blocker.config.scope, scope)) {
398
+ cancelTimeout(blocker);
399
+ blockers.delete(id);
437
400
  count++;
438
401
  }
439
402
  }
440
- set((state) => {
441
- const newBlockers = /* @__PURE__ */ new Map();
442
- for (const [id, blocker] of state.activeBlockers) {
443
- const blockerScopes = normalizeScopeToArray(blocker.scope);
444
- if (!blockerScopes.includes(scope)) {
445
- newBlockers.set(id, blocker);
446
- }
447
- }
448
- return { activeBlockers: newBlockers };
449
- });
450
403
  if (count > 0) {
451
- void get().runMiddlewares({
452
- action: "clear_scope",
453
- blockerId: "*",
454
- timestamp: Date.now(),
455
- scope,
456
- count
457
- });
404
+ publish({ ...event("clear_scope", "*"), scope, count });
405
+ } else {
406
+ publish();
458
407
  }
459
408
  }
460
- });
409
+ function isBlocked(scope = DEFAULT_SCOPE) {
410
+ for (const { config } of blockers.values()) {
411
+ if (scopeAffectsObservation(config.scope, scope)) {
412
+ return true;
413
+ }
414
+ }
415
+ return false;
416
+ }
417
+ function getBlockingInfo(scope) {
418
+ return projectBlockingInfo(getSnapshot(), scope);
419
+ }
420
+ function subscribe(listener) {
421
+ const registration = /* @__PURE__ */ Symbol();
422
+ subscribers.set(registration, listener);
423
+ return () => {
424
+ subscribers.delete(registration);
425
+ };
426
+ }
427
+ function observe(observer) {
428
+ const registration = /* @__PURE__ */ Symbol();
429
+ observers.set(registration, observer);
430
+ return () => {
431
+ observers.delete(registration);
432
+ };
433
+ }
434
+ return {
435
+ add,
436
+ update,
437
+ replace,
438
+ remove,
439
+ clear,
440
+ clearScope,
441
+ isBlocked,
442
+ getBlockingInfo,
443
+ getSnapshot,
444
+ subscribe,
445
+ observe
446
+ };
447
+ }
448
+
449
+ // src/store/uiBlockingStore.actions.ts
450
+ function createActions(lifecycle) {
451
+ return () => ({
452
+ blockingSnapshot: lifecycle.getSnapshot(),
453
+ observeBlockingEvents: lifecycle.observe,
454
+ addBlocker: lifecycle.add,
455
+ updateBlocker: lifecycle.update,
456
+ replaceBlocker: lifecycle.replace,
457
+ removeBlocker: lifecycle.remove,
458
+ clearAllBlockers: lifecycle.clear,
459
+ clearBlockersForScope: lifecycle.clearScope,
460
+ isBlocked: lifecycle.isBlocked,
461
+ getBlockingInfo: lifecycle.getBlockingInfo
462
+ });
463
+ }
464
+ function createLifecycleStateCreator(enhance) {
465
+ return (set, get, api) => {
466
+ const lifecycle = createBlockingLifecycle();
467
+ function setWithLifecycle(...args) {
468
+ const current = get();
469
+ if (args[1] === true) {
470
+ const [update2] = args;
471
+ const next2 = isFunction(update2) ? update2(current) : update2;
472
+ if (!Object.is(next2, current)) {
473
+ set({ ...next2, blockingSnapshot: lifecycle.getSnapshot() }, true);
474
+ }
475
+ return;
476
+ }
477
+ const [update] = args;
478
+ const next = isFunction(update) ? update(current) : update;
479
+ if (!Object.is(next, current)) {
480
+ set({ ...next, blockingSnapshot: lifecycle.getSnapshot() });
481
+ }
482
+ }
483
+ api.setState = setWithLifecycle;
484
+ lifecycle.subscribe((blockingSnapshot) => {
485
+ api.setState({ blockingSnapshot });
486
+ });
487
+ return enhance(lifecycle)(setWithLifecycle, get, api);
488
+ };
489
+ }
490
+ var createUIBlockingActions = createLifecycleStateCreator(createActions);
491
+ function createUIBlockingActionsWithDevtools(options) {
492
+ return createLifecycleStateCreator((lifecycle) => devtools(createActions(lifecycle), options));
493
+ }
461
494
  var {
462
495
  Provider: BaseUIBlockingProvider,
463
496
  useContextStoreApi: useUIBlockingContext,
464
497
  useContextStore: baseUseUIBlockingStoreFromContext,
465
498
  useIsInsideProvider: useIsInsideUIBlockingProvider,
466
- useContextStoreOptional: useOptionalUIBlockingContext
467
- } = createStoreProvider(createUIBlockingActions, "UIBlocking");
499
+ useProviderStoreOptional: useOptionalUIBlockingContext
500
+ } = createStoreProvider(() => createUIBlockingActions, "UIBlocking");
468
501
  function useUIBlockingStoreFromContext(selector, equalityFn) {
469
502
  if (selector) {
470
503
  return baseUseUIBlockingStoreFromContext(selector, equalityFn);
@@ -476,11 +509,12 @@ function UIBlockingProvider({
476
509
  middlewares = []
477
510
  }) {
478
511
  const handleStoreInit = (store) => {
479
- middlewares.forEach((mw, index) => {
480
- store.getState().registerMiddleware(`provider-middleware-${index.toString()}`, mw);
512
+ const { observeBlockingEvents } = store.getState();
513
+ middlewares.forEach((middleware) => {
514
+ observeBlockingEvents(middleware);
481
515
  });
482
516
  };
483
- return /* @__PURE__ */ jsx(BaseUIBlockingProvider, { onStoreInit: handleStoreInit, children });
517
+ return /* @__PURE__ */ jsx(BaseUIBlockingProvider, { input: void 0, onStoreInit: handleStoreInit, children });
484
518
  }
485
519
 
486
520
  // src/store/uiBlockingStore.config.ts
@@ -491,9 +525,9 @@ var devtoolsConfig = {
491
525
 
492
526
  // src/store/uiBlockingStore.store.ts
493
527
  var storeBindings = createShallowStore(
494
- devtools(createUIBlockingActions, devtoolsConfig)
528
+ createUIBlockingActionsWithDevtools(devtoolsConfig)
495
529
  );
496
- var uiBlockingStoreApi = storeBindings.useStoreApi;
530
+ var uiBlockingStoreApi = storeBindings.store;
497
531
  function useUIBlockingStore(selector, equalityFn) {
498
532
  if (selector) {
499
533
  return storeBindings.useStore(selector, equalityFn);
@@ -502,16 +536,23 @@ function useUIBlockingStore(selector, equalityFn) {
502
536
  }
503
537
 
504
538
  // src/context/useResolvedStore.ts
505
- function useOptionalContextStore() {
539
+ var { useResolvedStoreApi: useToolkitStoreApi, useResolvedValue: useToolkitValue } = createResolvedStoreHooks(uiBlockingStoreApi, useOptionalUIBlockingContext);
540
+ function useResolvedStoreApi() {
541
+ return useToolkitStoreApi();
542
+ }
543
+ function useResolvedValue(selector, equalityFn) {
544
+ return useToolkitValue(selector, equalityFn);
545
+ }
546
+
547
+ // src/context/index.ts
548
+ function useOptionalContext() {
506
549
  return useOptionalUIBlockingContext();
507
550
  }
508
- function useResolvedStoreApi() {
509
- const contextStore = useOptionalContextStore();
510
- return contextStore ?? uiBlockingStoreApi;
551
+ function useResolvedStore() {
552
+ return useResolvedStoreApi();
511
553
  }
512
- function useResolvedValue(selector) {
513
- const store = useResolvedStoreApi();
514
- return useStore(store, useShallow(selector));
554
+ function useResolvedStoreWithSelector(selector) {
555
+ return useResolvedValue(selector);
515
556
  }
516
557
 
517
558
  // src/store/blockerId.utils.ts
@@ -519,10 +560,11 @@ var storeSequences = /* @__PURE__ */ new WeakMap();
519
560
  function allocateBlockerId(store, prefix) {
520
561
  let sequence = storeSequences.get(store) ?? 0;
521
562
  let blockerId;
563
+ const { blockingSnapshot } = store.getState();
522
564
  do {
523
565
  sequence += 1;
524
566
  blockerId = `${prefix}-${String(sequence)}`;
525
- } while (store.getState().activeBlockers.has(blockerId));
567
+ } while (blockingSnapshot.some(({ id }) => id === blockerId));
526
568
  storeSequences.set(store, sequence);
527
569
  return blockerId;
528
570
  }
@@ -568,24 +610,41 @@ function useAsyncAction(actionId, scope, options) {
568
610
  );
569
611
  return executeWithBlocking;
570
612
  }
571
- function createBlockerConfig(config) {
572
- return {
573
- scope: config.scope,
574
- reason: config.reason,
575
- priority: config.priority
576
- };
613
+ function useConfigRef(config) {
614
+ const configRef = useRef(config);
615
+ useEffect(() => {
616
+ configRef.current = config;
617
+ }, [config]);
618
+ return configRef;
577
619
  }
578
- function normalizeScopeSnapshot(scope) {
579
- if (scope === void 0) {
580
- return [DEFAULT_SCOPE];
620
+ var activeHookRegistrations = /* @__PURE__ */ new WeakMap();
621
+ function trackHookRegistration(store, blockerId) {
622
+ if (process.env.NODE_ENV === "production") {
623
+ return () => void 0;
581
624
  }
582
- if (typeof scope === "string") {
583
- return [scope];
625
+ const registrations = activeHookRegistrations.get(store) ?? /* @__PURE__ */ new Map();
626
+ const count = registrations.get(blockerId) ?? 0;
627
+ activeHookRegistrations.set(store, registrations);
628
+ registrations.set(blockerId, count + 1);
629
+ if (count > 0) {
630
+ console.warn(
631
+ `[react-action-guard] Multiple active useActionBlocker hooks registered ID "${blockerId}" in the same store. Use a unique ID per hook; shared IDs can overwrite configuration or remove another hook's blocker during cleanup.`
632
+ );
584
633
  }
585
- return scope;
634
+ return () => {
635
+ const remaining = (registrations.get(blockerId) ?? 1) - 1;
636
+ if (remaining > 0) {
637
+ registrations.set(blockerId, remaining);
638
+ } else {
639
+ registrations.delete(blockerId);
640
+ }
641
+ if (registrations.size === 0) {
642
+ activeHookRegistrations.delete(store);
643
+ }
644
+ };
586
645
  }
587
646
  function getScopeSnapshotKey(scope) {
588
- return JSON.stringify(normalizeScopeSnapshot(scope));
647
+ return JSON.stringify(normalizeScope(scope));
589
648
  }
590
649
  function toBlockerConfigSnapshot(config) {
591
650
  return [
@@ -601,74 +660,96 @@ function areBlockerConfigsEqual(first, second) {
601
660
  return shallow(toBlockerConfigSnapshot(first), toBlockerConfigSnapshot(second));
602
661
  }
603
662
 
604
- // src/hooks/useBlocker/useBlocker.ts
605
- function useBlocker(blockerId, config, isActive = true) {
606
- const { addBlocker, removeBlocker, updateBlocker } = useResolvedValue((state) => ({
607
- addBlocker: state.addBlocker,
608
- removeBlocker: state.removeBlocker,
609
- updateBlocker: state.updateBlocker
610
- }));
663
+ // src/hooks/useActionBlocker/useBlockerRegistration.ts
664
+ function useBlockerRegistration({
665
+ blockerId,
666
+ config,
667
+ endEpisodeOnTimeout = false
668
+ }) {
669
+ const store = useResolvedStoreApi();
611
670
  const lastConfigRef = useRef(null);
612
- const onTimeoutRef = useRef(config.onTimeout);
613
- onTimeoutRef.current = config.onTimeout;
614
- const handleTimeout = useCallback((id) => {
615
- onTimeoutRef.current?.(id);
616
- }, []);
617
- const storeConfig = useMemo(
618
- () => ({
619
- ...config,
620
- onTimeout: config.onTimeout ? handleTimeout : void 0
621
- }),
622
- [config, handleTimeout]
671
+ const releaseRef = useRef(null);
672
+ const expiredRef = useRef(false);
673
+ const configRef = useConfigRef(config);
674
+ const handleTimeout = useCallback(
675
+ (id) => {
676
+ expiredRef.current = endEpisodeOnTimeout;
677
+ configRef.current.onTimeout?.(id);
678
+ },
679
+ [configRef, endEpisodeOnTimeout]
623
680
  );
624
- useEffect(() => {
625
- if (!isActive || !blockerId) {
681
+ const storeConfig = useMemo(() => {
682
+ const shouldTrackTimeout = endEpisodeOnTimeout && isDefined(config.timeout);
683
+ const hasTimeoutCallback = isDefined(config.onTimeout);
684
+ let onTimeout;
685
+ if (shouldTrackTimeout || hasTimeoutCallback) {
686
+ onTimeout = handleTimeout;
687
+ }
688
+ return { ...config, onTimeout };
689
+ }, [config, handleTimeout, endEpisodeOnTimeout]);
690
+ const storeConfigRef = useConfigRef(storeConfig);
691
+ const activate = useCallback(() => {
692
+ if (!blockerId || !isNull(releaseRef.current)) {
626
693
  return;
627
694
  }
628
- addBlocker(blockerId, storeConfig);
629
- lastConfigRef.current = storeConfig;
630
- return () => {
631
- lastConfigRef.current = null;
632
- removeBlocker(blockerId);
633
- };
634
- }, [blockerId, isActive, addBlocker, removeBlocker]);
635
- useEffect(() => {
636
- if (!isActive || !blockerId) {
695
+ const { addBlocker } = store.getState();
696
+ expiredRef.current = false;
697
+ releaseRef.current = trackHookRegistration(store, blockerId);
698
+ lastConfigRef.current = storeConfigRef.current;
699
+ addBlocker(blockerId, storeConfigRef.current);
700
+ }, [blockerId, store, storeConfigRef]);
701
+ const deactivate = useCallback(() => {
702
+ if (isNull(releaseRef.current)) {
637
703
  return;
638
704
  }
639
- if (lastConfigRef.current !== null && areBlockerConfigsEqual(lastConfigRef.current, storeConfig)) {
705
+ const { removeBlocker } = store.getState();
706
+ releaseRef.current();
707
+ releaseRef.current = null;
708
+ lastConfigRef.current = null;
709
+ expiredRef.current = false;
710
+ removeBlocker(blockerId);
711
+ }, [blockerId, store]);
712
+ useEffect(() => deactivate, [deactivate]);
713
+ useEffect(() => {
714
+ if (isNull(lastConfigRef.current) || expiredRef.current || areBlockerConfigsEqual(lastConfigRef.current, storeConfig)) {
640
715
  return;
641
716
  }
642
- updateBlocker(blockerId, storeConfig);
717
+ const { replaceBlocker } = store.getState();
718
+ replaceBlocker(blockerId, storeConfig);
643
719
  lastConfigRef.current = storeConfig;
644
- }, [blockerId, storeConfig, isActive, updateBlocker]);
720
+ }, [blockerId, store, storeConfig]);
721
+ return { activate, deactivate };
645
722
  }
646
- function useBlockingInfo(scope = DEFAULT_SCOPE) {
647
- const store = useResolvedStoreApi();
648
- const activeBlockers = useStore(store, (state) => state.activeBlockers);
649
- return useMemo(() => {
650
- return store.getState().getBlockingInfo(scope);
651
- }, [activeBlockers, scope, store]);
652
- }
653
- function useConfigRef(config) {
654
- const configRef = useRef(config);
723
+
724
+ // src/hooks/useActionBlocker/useActionBlocker.ts
725
+ function useActionBlocker(blockerId, config, isActive = true) {
726
+ const { activate, deactivate } = useBlockerRegistration({ blockerId, config });
655
727
  useEffect(() => {
656
- configRef.current = config;
657
- }, [config]);
658
- return configRef;
728
+ if (!isActive) {
729
+ return;
730
+ }
731
+ activate();
732
+ return deactivate;
733
+ }, [isActive, activate, deactivate]);
734
+ }
735
+ var useBlocker = useActionBlocker;
736
+ function useBlockingInfo(scope = DEFAULT_SCOPE) {
737
+ const checkedScopes = normalizeScope(scope);
738
+ const selector = useMemo(() => {
739
+ return createBlockingInfoSelector(checkedScopes);
740
+ }, [checkedScopes]);
741
+ return useResolvedValue(selector, areBlockingInfosEqual);
659
742
  }
660
-
661
- // src/hooks/useConditionalBlocker/useConditionalBlocker.ts
662
743
  var DEFAULT_CHECK_INTERVAL = 1e3;
663
744
  function useConditionalBlocker(blockerId, config) {
664
- const { addBlocker, removeBlocker } = useResolvedValue((state) => ({
665
- addBlocker: state.addBlocker,
666
- removeBlocker: state.removeBlocker
667
- }));
668
- const intervalRef = useRef(null);
745
+ const { activate, deactivate } = useBlockerRegistration({
746
+ blockerId,
747
+ config,
748
+ endEpisodeOnTimeout: true
749
+ });
669
750
  const isBlockedRef = useRef(false);
670
751
  const configRef = useConfigRef(config);
671
- const checkInterval = config.checkInterval !== void 0 && config.checkInterval > 0 ? config.checkInterval : DEFAULT_CHECK_INTERVAL;
752
+ const checkInterval = !isUndefined(config.checkInterval) && config.checkInterval > 0 ? config.checkInterval : DEFAULT_CHECK_INTERVAL;
672
753
  const checkCondition = useCallback(() => {
673
754
  const currentConfig = configRef.current;
674
755
  const shouldBlock = currentConfig.condition(currentConfig.state);
@@ -676,31 +757,32 @@ function useConditionalBlocker(blockerId, config) {
676
757
  return;
677
758
  }
678
759
  if (shouldBlock) {
679
- addBlocker(blockerId, createBlockerConfig(currentConfig));
760
+ activate();
680
761
  } else {
681
- removeBlocker(blockerId);
762
+ deactivate();
682
763
  }
683
764
  isBlockedRef.current = shouldBlock;
684
- }, [blockerId, addBlocker, removeBlocker, configRef]);
765
+ }, [activate, deactivate, configRef]);
766
+ useEffect(
767
+ () => () => {
768
+ deactivate();
769
+ isBlockedRef.current = false;
770
+ },
771
+ [deactivate]
772
+ );
685
773
  useEffect(() => {
686
774
  checkCondition();
687
- intervalRef.current = setInterval(checkCondition, checkInterval);
775
+ const interval = setInterval(checkCondition, checkInterval);
688
776
  return () => {
689
- if (intervalRef.current) {
690
- clearInterval(intervalRef.current);
691
- intervalRef.current = null;
692
- }
693
- if (isBlockedRef.current) {
694
- removeBlocker(blockerId);
695
- isBlockedRef.current = false;
696
- }
777
+ clearInterval(interval);
697
778
  };
698
- }, [checkCondition, blockerId, removeBlocker, checkInterval]);
779
+ }, [checkCondition, checkInterval]);
699
780
  }
700
781
  function useConfirmableBlocker(blockerId, config) {
701
782
  const [isDialogOpen, setIsDialogOpen] = useState(false);
702
783
  const [isExecuting, setIsExecuting] = useState(false);
703
- useBlocker(
784
+ const executionRef = useRef(null);
785
+ useActionBlocker(
704
786
  blockerId,
705
787
  {
706
788
  ...config,
@@ -709,31 +791,48 @@ function useConfirmableBlocker(blockerId, config) {
709
791
  isDialogOpen || isExecuting
710
792
  );
711
793
  const execute = useCallback(() => {
794
+ if (executionRef.current) {
795
+ return;
796
+ }
712
797
  setIsDialogOpen(true);
713
798
  }, []);
714
799
  const onConfirm = useCallback(async () => {
800
+ if (executionRef.current) {
801
+ return executionRef.current;
802
+ }
715
803
  setIsDialogOpen(false);
716
804
  setIsExecuting(true);
717
- try {
718
- await config.onConfirm();
719
- } finally {
720
- setIsExecuting(false);
805
+ async function runConfirmation() {
806
+ try {
807
+ await config.onConfirm();
808
+ } finally {
809
+ executionRef.current = null;
810
+ setIsExecuting(false);
811
+ }
721
812
  }
813
+ const execution = Promise.resolve().then(runConfirmation);
814
+ executionRef.current = execution;
815
+ return execution;
722
816
  }, [config]);
723
817
  const onCancel = useCallback(() => {
818
+ if (executionRef.current) {
819
+ return;
820
+ }
724
821
  setIsDialogOpen(false);
725
822
  config.onCancel?.();
726
823
  }, [config]);
824
+ const { confirmTitle, confirmMessage, confirmButtonText, cancelButtonText } = config;
825
+ const confirmConfig = {
826
+ title: confirmTitle ?? "Confirm Action",
827
+ message: confirmMessage,
828
+ confirmText: confirmButtonText ?? "Confirm",
829
+ cancelText: cancelButtonText ?? "Cancel"
830
+ };
727
831
  return {
728
832
  execute,
729
833
  isDialogOpen,
730
834
  isExecuting,
731
- confirmConfig: {
732
- title: config.confirmTitle ?? "Confirm Action",
733
- message: config.confirmMessage,
734
- confirmText: config.confirmButtonText ?? "Confirm",
735
- cancelText: config.cancelButtonText ?? "Cancel"
736
- },
835
+ confirmConfig,
737
836
  onConfirm,
738
837
  onCancel
739
838
  };
@@ -743,54 +842,63 @@ function useConfirmableBlocker(blockerId, config) {
743
842
  function useIsBlocked(scope) {
744
843
  return useResolvedValue((state) => state.isBlocked(scope));
745
844
  }
746
-
747
- // src/hooks/useScheduledBlocker/useScheduledBlocker.utils.ts
748
845
  var MAX_TIMEOUT_MS = 2147483647;
749
846
  function parseDate(date) {
750
847
  return new Date(date).getTime();
751
848
  }
752
849
  function isValidTimestamp(timestamp) {
753
- return !Number.isNaN(timestamp);
850
+ return Number.isFinite(timestamp) && Math.abs(timestamp) <= 864e13;
754
851
  }
755
852
  function calculateEndTime(schedule, startTime) {
756
- if (schedule.duration) {
853
+ if (!isUndefined(schedule.duration)) {
757
854
  return startTime + schedule.duration;
758
855
  }
759
- if (schedule.end) {
856
+ if (!isUndefined(schedule.end)) {
760
857
  const endTime = parseDate(schedule.end);
761
858
  return isValidTimestamp(endTime) ? endTime : void 0;
762
859
  }
763
860
  return void 0;
764
861
  }
765
- function isSafeTimeout(delay) {
766
- return delay > 0 && delay <= MAX_TIMEOUT_MS;
767
- }
768
862
  function isInBlockingPeriod(startTime, endTime, now) {
769
863
  if (startTime > now) {
770
864
  return false;
771
865
  }
772
- if (!endTime) {
866
+ if (isUndefined(endTime)) {
773
867
  return true;
774
868
  }
775
869
  return endTime > now;
776
870
  }
777
- function isScheduleInPast(startTime, endTime, now) {
778
- if (!endTime) {
871
+ function isValidSchedule(schedule, startTime) {
872
+ const { duration, end } = schedule;
873
+ if (!isValidTimestamp(startTime)) {
779
874
  return false;
780
875
  }
781
- return endTime <= now && startTime <= now;
876
+ if (!isUndefined(end)) {
877
+ const suppliedEnd = parseDate(end);
878
+ if (!isValidTimestamp(suppliedEnd) || suppliedEnd < startTime) {
879
+ return false;
880
+ }
881
+ }
882
+ if (!isUndefined(duration)) {
883
+ if (!Number.isFinite(duration) || duration < 0) {
884
+ return false;
885
+ }
886
+ return isValidTimestamp(startTime + duration);
887
+ }
888
+ return true;
782
889
  }
783
890
 
784
891
  // src/hooks/useScheduledBlocker/useScheduledBlocker.ts
785
892
  function useScheduledBlocker(blockerId, config) {
786
- const { addBlocker, removeBlocker } = useResolvedValue((state) => ({
787
- addBlocker: state.addBlocker,
788
- removeBlocker: state.removeBlocker
789
- }));
893
+ const { activate, deactivate } = useBlockerRegistration({
894
+ blockerId,
895
+ config,
896
+ endEpisodeOnTimeout: true
897
+ });
790
898
  const timeoutsRef = useRef({});
791
899
  const configRef = useConfigRef(config);
792
900
  const scheduleStart = parseDate(config.schedule.start);
793
- const scheduleEnd = config.schedule.end === void 0 ? void 0 : parseDate(config.schedule.end);
901
+ const scheduleEnd = isUndefined(config.schedule.end) ? void 0 : parseDate(config.schedule.end);
794
902
  const scheduleDuration = config.schedule.duration;
795
903
  const cleanup = useCallback(() => {
796
904
  if (timeoutsRef.current.start) {
@@ -806,59 +914,69 @@ function useScheduledBlocker(blockerId, config) {
806
914
  const currentConfig = configRef.current;
807
915
  const startTime = parseDate(currentConfig.schedule.start);
808
916
  const now = Date.now();
809
- if (!isValidTimestamp(startTime)) {
917
+ if (!isValidSchedule(currentConfig.schedule, startTime)) {
918
+ if (process.env.NODE_ENV !== "production") {
919
+ console.warn("[react-action-guard] Invalid blocking schedule:", currentConfig.schedule);
920
+ }
921
+ return;
922
+ }
923
+ const endTime = calculateEndTime(currentConfig.schedule, startTime);
924
+ if (!isUndefined(endTime) && endTime <= startTime) {
810
925
  return;
811
926
  }
812
927
  const scheduleEnd2 = () => {
813
- const endTime = calculateEndTime(currentConfig.schedule, startTime);
814
- if (!endTime) {
928
+ if (isUndefined(endTime)) {
815
929
  return;
816
930
  }
817
931
  const remainingTime = endTime - Date.now();
818
932
  if (remainingTime > 0) {
819
- timeoutsRef.current.end = setTimeout(() => {
820
- configRef.current.onScheduleEnd?.();
821
- removeBlocker(blockerId);
822
- }, remainingTime);
933
+ timeoutsRef.current.end = setTimeout(scheduleEnd2, Math.min(remainingTime, MAX_TIMEOUT_MS));
934
+ return;
935
+ }
936
+ try {
937
+ configRef.current.onScheduleEnd?.();
938
+ } finally {
939
+ deactivate();
823
940
  }
824
941
  };
825
942
  const startBlocking = () => {
943
+ const now2 = Date.now();
944
+ const remainingTime = startTime - now2;
945
+ if (remainingTime > 0) {
946
+ timeoutsRef.current.start = setTimeout(
947
+ startBlocking,
948
+ Math.min(remainingTime, MAX_TIMEOUT_MS)
949
+ );
950
+ return;
951
+ }
952
+ if (!isInBlockingPeriod(startTime, endTime, now2)) {
953
+ return;
954
+ }
826
955
  const cfg = configRef.current;
827
- addBlocker(blockerId, createBlockerConfig(cfg));
828
- cfg.onScheduleStart?.();
956
+ activate();
829
957
  scheduleEnd2();
958
+ cfg.onScheduleStart?.();
830
959
  };
831
960
  if (startTime > now) {
832
961
  const delay = startTime - now;
833
- if (!isSafeTimeout(delay)) {
834
- return;
835
- }
836
- timeoutsRef.current.start = setTimeout(startBlocking, delay);
962
+ timeoutsRef.current.start = setTimeout(startBlocking, Math.min(delay, MAX_TIMEOUT_MS));
837
963
  } else {
838
- const endTime = calculateEndTime(currentConfig.schedule, startTime);
839
964
  if (isInBlockingPeriod(startTime, endTime, now)) {
840
- startBlocking();
841
- } else if (isScheduleInPast(startTime, endTime, now)) {
842
- return;
965
+ try {
966
+ startBlocking();
967
+ } catch (error) {
968
+ cleanup();
969
+ deactivate();
970
+ throw error;
971
+ }
843
972
  }
844
973
  }
845
974
  return () => {
846
975
  cleanup();
847
- removeBlocker(blockerId);
976
+ deactivate();
848
977
  };
849
- }, [
850
- blockerId,
851
- addBlocker,
852
- removeBlocker,
853
- cleanup,
854
- scheduleStart,
855
- scheduleEnd,
856
- scheduleDuration,
857
- configRef
858
- ]);
978
+ }, [activate, deactivate, cleanup, scheduleStart, scheduleEnd, scheduleDuration, configRef]);
859
979
  }
860
-
861
- // src/middleware/analyticsMiddleware.utils.ts
862
980
  function capitalizeFirst(str) {
863
981
  return str.charAt(0).toUpperCase() + str.slice(1);
864
982
  }
@@ -873,13 +991,13 @@ function buildEventData(context) {
873
991
  blocker_id: context.blockerId
874
992
  };
875
993
  const { scope, reason, priority } = context.config ?? {};
876
- if (scope !== void 0) {
877
- eventData.scope = Array.isArray(scope) ? scope.join(",") : scope;
994
+ if (!isUndefined(scope)) {
995
+ eventData.scope = isArray(scope) ? scope.join(",") : scope;
878
996
  }
879
- if (reason !== void 0) {
997
+ if (!isUndefined(reason)) {
880
998
  eventData.reason = reason;
881
999
  }
882
- if (priority !== void 0) {
1000
+ if (!isUndefined(priority)) {
883
1001
  eventData.priority = priority;
884
1002
  }
885
1003
  return eventData;
@@ -938,21 +1056,14 @@ function createAnalyticsMiddleware(config) {
938
1056
  }
939
1057
 
940
1058
  // src/middleware/configureMiddleware.ts
941
- var CONFIGURE_MIDDLEWARE_PREFIX = "middleware-";
1059
+ var configuredReleases = [];
942
1060
  function configureMiddleware(middlewares) {
943
- const store = uiBlockingStoreApi.getState();
944
- store.middlewares.forEach((_, name) => {
945
- if (name.startsWith(CONFIGURE_MIDDLEWARE_PREFIX)) {
946
- store.unregisterMiddleware(name);
947
- }
948
- });
949
- middlewares.forEach((middleware, index) => {
950
- const name = `${CONFIGURE_MIDDLEWARE_PREFIX}${index.toString()}`;
951
- store.registerMiddleware(name, middleware);
952
- });
1061
+ const { observeBlockingEvents } = uiBlockingStoreApi.getState();
1062
+ for (const release of configuredReleases) {
1063
+ release();
1064
+ }
1065
+ configuredReleases = middlewares.map(observeBlockingEvents);
953
1066
  }
954
-
955
- // src/middleware/loggerMiddleware.utils.ts
956
1067
  function getActionEmoji(action) {
957
1068
  switch (action) {
958
1069
  case "add":
@@ -980,7 +1091,7 @@ function extractConfigDetails(config) {
980
1091
  scope,
981
1092
  reason
982
1093
  };
983
- if (priority !== void 0) {
1094
+ if (!isUndefined(priority)) {
984
1095
  details.priority = priority;
985
1096
  }
986
1097
  return details;
@@ -988,19 +1099,19 @@ function extractConfigDetails(config) {
988
1099
  function formatLogData(context) {
989
1100
  const configDetails = extractConfigDetails(context.config);
990
1101
  const logData = {};
991
- if (context.scope !== void 0) {
1102
+ if (!isUndefined(context.scope)) {
992
1103
  logData.scope = context.scope;
993
1104
  }
994
- if (context.count !== void 0) {
1105
+ if (!isUndefined(context.count)) {
995
1106
  logData.count = context.count;
996
1107
  }
997
- if (Object.keys(configDetails).length > 0) {
1108
+ if (isNonEmptyArray(Object.keys(configDetails))) {
998
1109
  logData.config = configDetails;
999
1110
  }
1000
1111
  if (context.prevState) {
1001
1112
  logData.prevState = extractConfigDetails(context.prevState);
1002
1113
  }
1003
- if (Object.keys(logData).length === 0) {
1114
+ if (!isNonEmptyArray(Object.keys(logData))) {
1004
1115
  return configDetails;
1005
1116
  }
1006
1117
  return Object.keys(logData).length === 1 && logData.config ? configDetails : logData;
@@ -1015,43 +1126,78 @@ function loggerMiddleware(context) {
1015
1126
  }
1016
1127
 
1017
1128
  // src/middleware/performanceMiddleware.utils.ts
1018
- function handleAddAction(blockerId, timestamp, blockStartTimes) {
1019
- blockStartTimes.set(blockerId, timestamp);
1129
+ function finalizeTrackedBlocks(endedBlocks, timestamp, threshold, onSlowBlock) {
1130
+ for (const [id, tracked] of endedBlocks) {
1131
+ const duration = timestamp - tracked.timestamp;
1132
+ if (duration >= threshold) {
1133
+ try {
1134
+ onSlowBlock?.(id, duration);
1135
+ } catch {
1136
+ }
1137
+ }
1138
+ }
1020
1139
  }
1021
1140
 
1022
1141
  // src/middleware/performanceMiddleware.ts
1023
1142
  var DEFAULT_SLOW_BLOCK_THRESHOLD = 3e3;
1024
- var ACTION_ADD = "add";
1025
- var ACTION_REMOVE = "remove";
1026
- function handleRemoveAction(context, blockStartTimes, slowBlockThreshold, onSlowBlock) {
1027
- const startTime = blockStartTimes.get(context.blockerId);
1028
- if (startTime === void 0) {
1029
- return;
1030
- }
1031
- const duration = context.timestamp - startTime;
1032
- blockStartTimes.delete(context.blockerId);
1033
- if (duration >= slowBlockThreshold) {
1034
- onSlowBlock?.(context.blockerId, duration);
1035
- }
1036
- }
1037
1143
  function createPerformanceMiddleware(config = {}) {
1038
1144
  const { slowBlockThreshold = DEFAULT_SLOW_BLOCK_THRESHOLD, onSlowBlock } = config;
1039
- const blockStartTimes = /* @__PURE__ */ new Map();
1145
+ const trackedBlocks = /* @__PURE__ */ new Map();
1146
+ const registrations = /* @__PURE__ */ new WeakMap();
1040
1147
  return (context) => {
1041
- if (context.action === ACTION_ADD) {
1042
- handleAddAction(context.blockerId, context.timestamp, blockStartTimes);
1043
- } else if (context.action === ACTION_REMOVE) {
1044
- handleRemoveAction(context, blockStartTimes, slowBlockThreshold, onSlowBlock);
1148
+ const { action, blockerId, timestamp, config: blockerConfig, scope, registration } = context;
1149
+ if (action === "add") {
1150
+ const tracked = { timestamp, scope: normalizeScope(blockerConfig?.scope), registration };
1151
+ trackedBlocks.set(blockerId, tracked);
1152
+ if (isDefined(registration)) {
1153
+ registrations.set(registration, tracked);
1154
+ }
1155
+ } else if (action === "update") {
1156
+ const tracked = trackedBlocks.get(blockerId);
1157
+ if (isDefined(tracked) && isDefined(blockerConfig?.scope)) {
1158
+ tracked.scope = normalizeScope(blockerConfig.scope);
1159
+ }
1160
+ } else {
1161
+ const endedBlocks = /* @__PURE__ */ new Map();
1162
+ if (action === "remove" || action === "timeout") {
1163
+ let tracked = trackedBlocks.get(blockerId);
1164
+ if (isDefined(registration)) {
1165
+ tracked = registrations.get(registration);
1166
+ }
1167
+ if (isDefined(tracked)) {
1168
+ endedBlocks.set(blockerId, tracked);
1169
+ if (trackedBlocks.get(blockerId) === tracked) {
1170
+ trackedBlocks.delete(blockerId);
1171
+ }
1172
+ }
1173
+ } else {
1174
+ for (const [id, tracked] of trackedBlocks) {
1175
+ const cleared = action === "clear" || isDefined(scope) && scopeMatchesTarget(tracked.scope, scope);
1176
+ if (cleared) {
1177
+ endedBlocks.set(id, tracked);
1178
+ trackedBlocks.delete(id);
1179
+ }
1180
+ }
1181
+ }
1182
+ for (const tracked of endedBlocks.values()) {
1183
+ const { registration: trackedRegistration } = tracked;
1184
+ if (isDefined(trackedRegistration)) {
1185
+ registrations.delete(trackedRegistration);
1186
+ }
1187
+ }
1188
+ finalizeTrackedBlocks(endedBlocks, timestamp, slowBlockThreshold, onSlowBlock);
1045
1189
  }
1046
1190
  };
1047
1191
  }
1048
1192
 
1049
1193
  // src/createTypedHooks.ts
1050
1194
  function createTypedHooks() {
1195
+ const useTypedActionBlocker = (blockerId, config, isActive) => {
1196
+ useActionBlocker(blockerId, config, isActive);
1197
+ };
1051
1198
  return {
1052
- useBlocker: (blockerId, config, isActive) => {
1053
- useBlocker(blockerId, config, isActive);
1054
- },
1199
+ useActionBlocker: useTypedActionBlocker,
1200
+ useBlocker: useTypedActionBlocker,
1055
1201
  useIsBlocked: (scope) => {
1056
1202
  return useIsBlocked(scope);
1057
1203
  },
@@ -1062,17 +1208,6 @@ function createTypedHooks() {
1062
1208
  };
1063
1209
  }
1064
1210
 
1065
- // src/index.ts
1066
- function useOptionalContext() {
1067
- return useOptionalUIBlockingContext();
1068
- }
1069
- function useResolvedStore() {
1070
- return useResolvedStoreApi();
1071
- }
1072
- function useResolvedStoreWithSelector(selector) {
1073
- return useResolvedValue(selector);
1074
- }
1075
-
1076
- export { UIBlockingProvider, configureMiddleware, createAnalyticsMiddleware, createPerformanceMiddleware, createTypedHooks, loggerMiddleware, uiBlockingStoreApi, useAsyncAction, useBlocker, useBlockingInfo, useConditionalBlocker, useConfirmableBlocker, useIsBlocked, useIsInsideUIBlockingProvider, useOptionalContext, useOptionalUIBlockingContext, useResolvedStore, useResolvedStoreApi, useResolvedStoreWithSelector, useResolvedValue, useScheduledBlocker, useUIBlockingContext, useUIBlockingStore, useUIBlockingStoreFromContext };
1211
+ export { DEFAULT_SCOPE, UIBlockingProvider, configureMiddleware, createAnalyticsMiddleware, createBlockingLifecycle, createPerformanceMiddleware, createTypedHooks, loggerMiddleware, normalizeScope, resolveScope, scopeAffectsObservation, scopeMatchesTarget, uiBlockingStoreApi, useActionBlocker, useAsyncAction, useBlocker, useBlockingInfo, useConditionalBlocker, useConfirmableBlocker, useIsBlocked, useIsInsideUIBlockingProvider, useOptionalContext, useOptionalUIBlockingContext, useResolvedStore, useResolvedStoreApi, useResolvedStoreWithSelector, useResolvedValue, useScheduledBlocker, useUIBlockingContext, useUIBlockingStore, useUIBlockingStoreFromContext };
1077
1212
  //# sourceMappingURL=index.js.map
1078
1213
  //# sourceMappingURL=index.js.map