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