@okyrychenko-dev/react-action-guard 1.0.4 → 2.0.0

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