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