@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/README.md CHANGED
@@ -4,18 +4,14 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@okyrychenko-dev/react-action-guard.svg)](https://www.npmjs.com/package/@okyrychenko-dev/react-action-guard)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- > Coordinate UI blocking across async actions and competing interactions in React
7
+ A save can affect an editor and a navigation control owned by different components.
8
+ Core lets the operation publish scopes and reasons, so both consumers react without receiving
9
+ its pending state as props. Unrelated controls remain available.
8
10
 
9
- `react-action-guard` helps you prevent duplicate submits, conflicting actions, and stale UI interactions without scattering local `isLoading` flags across components.
10
-
11
- ## Why Use It
12
-
13
- - Wrap async work with `useAsyncAction` and get blocker cleanup automatically
14
- - Coordinate blocking by `scope` across unrelated components
15
- - Inspect current blockers with `useIsBlocked` and `useBlockingInfo`
16
- - Isolate state per provider for SSR, tests, and micro-frontends
17
- - Extend lifecycle events with optional middleware
18
- - Add advanced hooks for scheduled, confirmable, or conditional flows when needed
11
+ For one local loading button, React state or mutation observation is usually sufficient.
12
+ Use shared scopes when independent features need a common availability policy. React Context
13
+ can also coordinate a small workflow; this library supplies blocker registration, scope matching,
14
+ ordered reasons and lifecycle cleanup rather than requiring you to build that model yourself.
19
15
 
20
16
  ## Installation
21
17
 
@@ -32,1095 +28,193 @@ This package requires the following peer dependencies:
32
28
  - [React](https://react.dev/) ^18.0.0 || ^19.0.0
33
29
  - [Zustand](https://zustand-demo.pmnd.rs/) ^5.0.0 - State management library
34
30
 
35
- ## 1.0 Migration
36
-
37
- `1.0.0` removes public re-exports of internal `react-zustand-toolkit` helpers from the root package.
38
-
39
- If you previously imported `createShallowStore`, `createStoreToolkit`, `createStoreProvider`, or `createResolvedStoreHooks` from `@okyrychenko-dev/react-action-guard`, import them directly from `@okyrychenko-dev/react-zustand-toolkit` instead.
40
-
41
31
  ## Quick Start
42
32
 
43
- ```jsx
44
- import { useAsyncAction, useIsBlocked } from "@okyrychenko-dev/react-action-guard";
45
-
46
- function SaveButton() {
47
- const isSaving = useIsBlocked("form");
48
- const runSave = useAsyncAction("save-profile", "form");
49
-
50
- const handleClick = async () => {
51
- await runSave(async () => {
52
- await api.saveProfile();
53
- });
54
- };
55
-
56
- return <button onClick={handleClick} disabled={isSaving}>Save</button>;
57
- }
58
- ```
59
-
60
- ## Core Concepts
61
-
62
- - `scope` lets you coordinate blocking across components like `"form"`, `"navigation"`, or `"checkout"`
63
- - `useAsyncAction` is the fastest path for async workflows
64
- - `useBlocker` is the lower-level hook when you already have your own boolean state
65
- - `UIBlockingProvider` gives you isolated state instead of the default global store
66
-
67
- ## Core Use Cases
68
-
69
- ### Prevent duplicate async actions
70
-
71
- Use `useAsyncAction` when a button, mutation, or workflow should block while work is in flight.
72
-
73
- ### Coordinate multiple components
74
-
75
- Use shared scopes when one component starts work and another component should react by disabling UI or showing blocker details.
76
-
77
- ### Isolate blocking domains
78
-
79
- Use `UIBlockingProvider` when each request, test, or micro-frontend should get its own store instance.
80
-
81
- ## Documentation
82
-
83
- 📚 **Interactive Storybook Documentation** - Run locally to explore live examples and detailed guides for all hooks
84
-
85
- To run Storybook locally:
86
-
87
- ```bash
88
- npm run storybook
89
- ```
90
-
91
- ## API Reference
92
-
93
- ### Hooks
94
-
95
- #### Core hooks
96
-
97
- Start with these first:
98
-
99
- - `useAsyncAction`
100
- - `useBlocker`
101
- - `useIsBlocked`
102
- - `useBlockingInfo`
103
-
104
- #### `useBlocker(blockerId, config, isActive?)`
33
+ One producer, two independent consumers, one real Provider. Paste this into a React application's
34
+ `App.tsx`; the simulated save takes 1.2 seconds:
105
35
 
106
- Automatically adds a blocker when the component mounts and removes it on unmount.
107
-
108
- **Parameters:**
109
-
110
- - `blockerId: string` - Unique identifier for the blocker
111
- - `config: BlockerConfig` - Configuration object
112
- - `scope?: string | string[]` - Scope(s) to block (default: "global")
113
- - `reason?: string` - Reason for blocking (for debugging)
114
- - `priority?: number` - Priority level (higher = more important)
115
- - `timeout?: number` - Auto-remove after N milliseconds
116
- - `onTimeout?: (blockerId: string) => void` - Callback when auto-removed
117
- - `isActive?: boolean` - Whether the blocker is active (default: true)
118
-
119
- When `isActive` is `true`, changing `config` updates the existing blocker via `updateBlocker` without requiring unmount/remount.
120
-
121
- **Example:**
122
-
123
- ```jsx
124
- function MyComponent() {
125
- useBlocker("my-blocker", {
126
- scope: "form",
127
- reason: "Form is saving",
128
- priority: 10,
129
- });
130
-
131
- return <div>Content</div>;
132
- }
133
-
134
- // With timeout - auto-removes after 30 seconds
135
- function SaveButton() {
136
- const [isSaving, setIsSaving] = useState(false);
36
+ ```tsx
37
+ import {
38
+ UIBlockingProvider,
39
+ useAsyncAction,
40
+ useBlockingInfo,
41
+ useIsBlocked,
42
+ } from "@okyrychenko-dev/react-action-guard";
43
+ import { useState, type ReactElement } from "react";
137
44
 
138
- useBlocker("save-operation", {
139
- scope: "form",
140
- reason: "Saving...",
141
- timeout: 30000,
142
- onTimeout: (id) => {
143
- console.warn(`Operation ${id} timed out`);
144
- showNotification("Operation timed out");
145
- setIsSaving(false);
146
- },
147
- }, isSaving);
45
+ function SaveButton(): ReactElement {
46
+ const runSave = useAsyncAction("save-profile", ["profile", "navigation"]);
47
+ const blocked = useIsBlocked("profile");
48
+ const [status, setStatus] = useState("Ready");
148
49
 
149
- const handleSave = async () => {
150
- setIsSaving(true);
50
+ async function save(): Promise<void> {
151
51
  try {
152
- await saveData();
153
- } finally {
154
- setIsSaving(false);
52
+ await runSave(() => new Promise<void>((resolve) => setTimeout(resolve, 1200)));
53
+ setStatus("Saved");
54
+ } catch {
55
+ setStatus("Save failed; try again");
155
56
  }
156
- };
157
-
158
- return <button onClick={handleSave} disabled={isSaving}>Save</button>;
159
- }
160
- ```
161
-
162
- #### `useIsBlocked(scope)`
163
-
164
- Checks if a specific scope (or scopes) is currently blocked.
165
-
166
- **Parameters:**
167
-
168
- - `scope?: string | string[]` - Scope(s) to check (default: "global")
169
-
170
- **Returns:** `boolean` - Whether the scope is blocked
171
-
172
- **Example:**
173
-
174
- ```jsx
175
- function SubmitButton() {
176
- const isFormBlocked = useIsBlocked("form");
177
-
178
- return <button disabled={isFormBlocked}>Submit</button>;
179
- }
180
- ```
181
-
182
- #### `useBlockingInfo(scope)`
183
-
184
- Gets detailed information about all active blockers for a specific scope.
185
-
186
- **Parameters:**
187
-
188
- - `scope?: string` - Scope to get blocking information for (default: "global")
189
-
190
- **Returns:** `ReadonlyArray<BlockerInfo>` - Array of blocker information objects, sorted by priority (highest first)
191
-
192
- **BlockerInfo:**
193
-
194
- - `id: string` - Unique identifier of the blocker
195
- - `reason: string` - Reason for blocking (defaults to "Unknown")
196
- - `priority: number` - Priority level (higher = higher priority, minimum value is 0)
197
- - `scope: string | string[]` - Scope(s) being blocked
198
- - `timestamp: number` - When the blocker was added (milliseconds since epoch)
199
- - `timeout?: number` - Optional timeout duration in milliseconds
200
- - `onTimeout?: (blockerId: string) => void` - Optional callback when timeout expires
201
-
202
- **Example:**
203
-
204
- ```jsx
205
- function CheckoutButton() {
206
- const blockers = useBlockingInfo("checkout");
207
-
208
- if (blockers.length > 0) {
209
- const topBlocker = blockers[0]; // Highest priority blocker
210
- return (
211
- <Tooltip content={`Blocked: ${topBlocker.reason}`}>
212
- <Button disabled>Checkout ({blockers.length} blockers)</Button>
213
- </Tooltip>
214
- );
215
57
  }
216
58
 
217
- return <Button>Checkout</Button>;
218
- }
219
- ```
220
-
221
- #### `useAsyncAction(actionId, scope, options)`
222
-
223
- Wraps an async function with automatic blocking/unblocking.
224
-
225
- **Parameters:**
226
-
227
- - `actionId: string` - Identifier for the action
228
- - `scope?: string | string[]` - Scope(s) to block during execution
229
- - `options?: UseAsyncActionOptions` - Optional configuration
230
- - `timeout?: number` - Auto-remove blocker after N milliseconds
231
- - `onTimeout?: (blockerId: string) => void` - Callback when timed out
232
-
233
- **Returns:** `(asyncFn: () => Promise<T>) => Promise<T>` - Function wrapper
234
-
235
- Each execution receives a unique blocker ID within the resolved store, including concurrent
236
- executions from different hook instances. Separate `UIBlockingProvider` stores allocate IDs
237
- independently.
238
-
239
- **Example:**
240
-
241
- ```jsx
242
- function MyComponent() {
243
- const executeWithBlocking = useAsyncAction("save-data", "form");
244
-
245
- const handleSave = async () => {
246
- await executeWithBlocking(async () => {
247
- await saveData();
248
- });
249
- };
250
-
251
- return <button onClick={handleSave}>Save</button>;
252
- }
253
-
254
- // With timeout - prevents infinite blocking if operation hangs
255
- function ApiComponent() {
256
- const execute = useAsyncAction("api-call", "global", {
257
- timeout: 60000, // 1 minute timeout
258
- onTimeout: (id) => {
259
- showError("Request timed out. Please try again.");
260
- },
261
- });
262
-
263
- const fetchData = () => execute(async () => {
264
- const response = await fetch("/api/data");
265
- return response.json();
266
- });
267
-
268
- return <button onClick={fetchData}>Fetch Data</button>;
269
- }
270
- ```
271
-
272
- #### Advanced hooks
273
-
274
- These hooks are useful, but they are not the main onboarding path for most apps:
275
-
276
- - `useConfirmableBlocker` for confirmation flows
277
- - `useScheduledBlocker` for time-window blocking
278
- - `useConditionalBlocker` for polling-based conditional synchronization
279
-
280
- #### `useConfirmableBlocker(blockerId, config)`
281
-
282
- Creates a confirmable action with UI blocking while the dialog is open or the action is running.
283
- Use it for advanced confirmation flows after the core hooks are already a good fit.
284
-
285
- **Parameters:**
286
-
287
- - `blockerId: string` - Unique identifier for the blocker
288
- - `config: ConfirmableBlockerConfig` - Configuration object
289
- - `confirmMessage: string` - Message to show in confirmation dialog
290
- - `confirmTitle?: string` - Dialog title (default: "Confirm Action")
291
- - `confirmButtonText?: string` - Confirm button label (default: "Confirm")
292
- - `cancelButtonText?: string` - Cancel button label (default: "Cancel")
293
- - `onConfirm: () => void | Promise<void>` - Callback when user confirms
294
- - `onCancel?: () => void` - Callback when user cancels
295
- - Plus all `BlockerConfig` properties (scope, reason, priority, timeout)
296
-
297
- **Returns:**
298
-
299
- - `execute: () => void` - Opens the confirmation dialog
300
- - `isDialogOpen: boolean` - Whether the dialog is open
301
- - `isExecuting: boolean` - Whether the confirm action is running
302
- - `confirmConfig: { title, message, confirmText, cancelText }` - UI-ready dialog config
303
- - `onConfirm: () => Promise<void>` - Confirm handler to wire to your dialog
304
- - `onCancel: () => void` - Cancel handler to wire to your dialog
305
-
306
- **Example:**
307
-
308
- ```jsx
309
- function UnsavedChangesGuard({ discardChanges }) {
310
- const {
311
- execute,
312
- isDialogOpen,
313
- isExecuting,
314
- confirmConfig,
315
- onConfirm,
316
- onCancel,
317
- } = useConfirmableBlocker("unsaved-changes", {
318
- scope: "navigation",
319
- reason: "Unsaved changes",
320
- confirmMessage: "You have unsaved changes. Are you sure you want to leave?",
321
- onConfirm: async () => {
322
- await discardChanges();
323
- },
324
- });
325
-
326
59
  return (
327
60
  <>
328
- <button onClick={execute}>Leave</button>
329
- {isDialogOpen && (
330
- <ConfirmDialog
331
- title={confirmConfig.title}
332
- message={confirmConfig.message}
333
- confirmText={confirmConfig.confirmText}
334
- cancelText={confirmConfig.cancelText}
335
- onConfirm={onConfirm}
336
- onCancel={onCancel}
337
- />
338
- )}
339
- {isExecuting && <LoadingOverlay message="Processing..." />}
61
+ <button
62
+ disabled={blocked}
63
+ onClick={() => {
64
+ void save();
65
+ }}
66
+ >
67
+ Save profile
68
+ </button>
69
+ <p role="status">{status}</p>
340
70
  </>
341
71
  );
342
72
  }
343
- ```
344
-
345
- #### `useScheduledBlocker(blockerId, config)`
346
-
347
- Blocks UI during a scheduled time period or maintenance window.
348
- Use it when blocking is driven by time windows rather than user-triggered async work.
349
-
350
- **Parameters:**
351
-
352
- - `blockerId: string` - Unique identifier for the blocker
353
- - `config: ScheduledBlockerConfig`
354
- - `schedule: BlockingSchedule`
355
- - `start: string | Date | number` - Start time (ISO string, Date, or timestamp)
356
- - `end?: string | Date | number` - End time (optional)
357
- - `duration?: number` - Duration in milliseconds (takes precedence over end)
358
- - `onScheduleStart?: () => void` - Callback when blocking starts
359
- - `onScheduleEnd?: () => void` - Callback when blocking ends
360
- - Plus all `BlockerConfig` properties (scope, reason, priority)
361
-
362
- Changing `schedule.start`, `schedule.end`, or `schedule.duration` on rerender cancels the previous
363
- timers and schedules the blocker using the latest values.
364
-
365
- **Example:**
366
-
367
- ```jsx
368
- function MaintenanceWindow() {
369
- useScheduledBlocker("maintenance", {
370
- scope: "global",
371
- reason: "Scheduled maintenance",
372
- priority: 1000,
373
- schedule: {
374
- start: "2024-01-15T02:00:00Z",
375
- duration: 3600000, // 1 hour in milliseconds
376
- },
377
- onScheduleStart: () => {
378
- console.log("Maintenance started");
379
- },
380
- onScheduleEnd: () => {
381
- console.log("Maintenance completed");
382
- },
383
- });
384
-
385
- return <div>App content</div>;
386
- }
387
- ```
388
-
389
- #### `useConditionalBlocker(blockerId, config)`
390
-
391
- Periodically checks a condition and blocks/unblocks based on the result.
392
- Use this when polling is an acceptable tradeoff and the condition is not naturally event-driven.
393
-
394
- **Parameters:**
395
-
396
- - `blockerId: string` - Unique identifier for the blocker
397
- - `config: ConditionalBlockerConfig<TState>`
398
- - `scope: string | string[]` - Required scope(s) to block
399
- - `condition: (state?: TState) => boolean` - Function that determines if blocking should be active
400
- - `checkInterval?: number` - How often to check the condition in ms (default: 1000; non-positive values use the default)
401
- - `state?: TState` - Optional state to pass to the condition function
402
- - Plus all other `BlockerConfig` properties (reason, priority)
403
-
404
- Changing `checkInterval` on rerender restarts condition checks using the latest interval.
405
-
406
- **Example:**
407
-
408
- ```jsx
409
- function NetworkStatusBlocker() {
410
- const [isOnline, setIsOnline] = useState(navigator.onLine);
411
-
412
- useEffect(() => {
413
- const handleOnline = () => setIsOnline(true);
414
- const handleOffline = () => setIsOnline(false);
415
-
416
- window.addEventListener("online", handleOnline);
417
- window.addEventListener("offline", handleOffline);
418
-
419
- return () => {
420
- window.removeEventListener("online", handleOnline);
421
- window.removeEventListener("offline", handleOffline);
422
- };
423
- }, []);
424
-
425
- useConditionalBlocker("network-check", {
426
- scope: ["form", "navigation"],
427
- reason: "No network connection",
428
- priority: 100,
429
- condition: () => !isOnline,
430
- checkInterval: 2000,
431
- });
432
-
433
- return <div>App content</div>;
434
- }
435
- ```
436
-
437
- ### Provider (Optional)
438
-
439
- #### `UIBlockingProvider`
440
-
441
- Provides an isolated store instance for SSR, testing, or micro-frontends. Without the provider, hooks use a global store.
442
-
443
- **Props:**
444
-
445
- - `children: ReactNode` - Child components
446
- - `middlewares?: Middleware[]` - Initial middlewares to register
447
-
448
- **Example:**
449
-
450
- ```jsx
451
- import { UIBlockingProvider } from "@okyrychenko-dev/react-action-guard";
452
-
453
- // SSR - each request gets isolated state
454
- function App() {
455
- return (
456
- <UIBlockingProvider>
457
- <MyApp />
458
- </UIBlockingProvider>
459
- );
460
- }
461
73
 
462
- // Testing - isolated state per test, no cleanup needed
463
- function renderWithProvider(ui) {
464
- return render(
465
- <UIBlockingProvider>{ui}</UIBlockingProvider>
466
- );
467
- }
74
+ function ProfileEditor(): ReactElement {
75
+ const blockers = useBlockingInfo("profile");
468
76
 
469
- // Micro-frontends - each app has its own blocking state
470
- function MicroFrontend() {
471
77
  return (
472
- <UIBlockingProvider>
473
- <MicroApp />
474
- </UIBlockingProvider>
475
- );
476
- }
477
- ```
478
-
479
- #### Context Hooks
480
-
481
- - `useUIBlockingContext()` - Get store API from context (throws if outside provider)
482
- - `useOptionalUIBlockingContext()` - Get store API from context or `null` outside provider
483
- - `useIsInsideUIBlockingProvider()` - Check if inside a provider
484
- - `useUIBlockingStoreFromContext(selector)` - Select state from context store
485
- - `useResolvedStoreApi()` - Resolve to provider store API or global store API
486
- - `useResolvedValue(selector)` - Resolve to provider/global store and select with shallow comparison
487
-
488
- Legacy aliases remain available for backward compatibility:
489
- - `useOptionalContext()` -> `useOptionalUIBlockingContext()`
490
- - `useResolvedStore()` -> `useResolvedStoreApi()`
491
- - `useResolvedStoreWithSelector()` -> `useResolvedValue()`
492
-
493
- ### Store
494
-
495
- #### `useUIBlockingStore`
496
-
497
- Direct access to the Zustand store for advanced use cases (requires a selector).
498
-
499
- **Methods:**
500
-
501
- - `addBlocker(id, config)` - Manually add a blocker (re-adding with the same ID replaces config)
502
- - `updateBlocker(id, config)` - Update blocker metadata (timeout restarts if new `timeout` value provided)
503
- - `removeBlocker(id)` - Manually remove a blocker
504
- - `isBlocked(scope)` - Check if scope is blocked
505
- - `getBlockingInfo(scope)` - Get detailed blocking information
506
- - `clearAllBlockers()` - Remove all blockers (emits `"clear"` middleware event)
507
- - `clearBlockersForScope(scope)` - Remove blockers for specific scope (emits `"clear_scope"` middleware event)
508
- - `registerMiddleware(name, middleware)` - Register middleware manually
509
- - `unregisterMiddleware(name)` - Unregister middleware manually
510
-
511
- **Note about `updateBlocker` and timeouts:**
512
- - If you pass a **new** `timeout` value, the timer will be **restarted**
513
- - If you **don't** pass `timeout`, the existing timer continues unchanged
514
- - Set `timeout: 0` to **clear** an existing timeout
515
- - `addBlocker` always resets/creates a new timeout timer when called with the same ID
516
-
517
- **Example:**
518
-
519
- ```jsx
520
- import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
521
-
522
- function AdvancedComponent() {
523
- const { addBlocker, updateBlocker, removeBlocker } = useUIBlockingStore((state) => ({
524
- addBlocker: state.addBlocker,
525
- updateBlocker: state.updateBlocker,
526
- removeBlocker: state.removeBlocker,
527
- }));
528
-
529
- const startBlocking = () => {
530
- addBlocker("custom-blocker", {
531
- scope: ["form", "navigation"],
532
- reason: "Critical operation in progress",
533
- priority: 100,
534
- });
535
- };
536
-
537
- const updatePriority = () => {
538
- // Update metadata - timeout continues if not changed
539
- updateBlocker("custom-blocker", { priority: 200 });
540
- };
541
-
542
- const extendTimeout = () => {
543
- // Update timeout - timer restarts with new value
544
- updateBlocker("custom-blocker", { timeout: 60000 });
545
- };
546
-
547
- const stopBlocking = () => {
548
- removeBlocker("custom-blocker");
549
- };
550
-
551
- return (
552
- <div>
553
- <button onClick={startBlocking}>Start</button>
554
- <button onClick={updatePriority}>Increase Priority</button>
555
- <button onClick={stopBlocking}>Stop</button>
556
- </div>
557
- );
558
- }
559
- ```
560
-
561
- For non-hook contexts (tests, utilities, event handlers), use `uiBlockingStoreApi`:
562
-
563
- ```jsx
564
- import { uiBlockingStoreApi } from "@okyrychenko-dev/react-action-guard";
565
-
566
- uiBlockingStoreApi.getState().addBlocker("server-call", {
567
- scope: "global",
568
- reason: "Server call running",
569
- });
570
- ```
571
-
572
- ## Middleware System
573
-
574
- Middleware is optional. Use it when you need analytics, logging, or performance visibility around blocker lifecycle events.
575
-
576
- Most applications can start without middleware and add it later if blocker observability becomes important.
577
-
578
- ### Middleware Actions
579
-
580
- Middleware receives events for the following actions:
581
-
582
- - `"add"` - Blocker was added
583
- - `"update"` - Blocker metadata was updated
584
- - `"remove"` - Blocker was removed
585
- - `"timeout"` - Blocker was auto-removed due to timeout
586
- - `"clear"` - All blockers were cleared (includes `count` field)
587
- - `"clear_scope"` - Blockers for specific scope were cleared (includes `scope` and `count` fields)
588
-
589
- **MiddlewareContext type:**
590
- ```typescript
591
- {
592
- action: "add" | "update" | "remove" | "timeout" | "clear" | "clear_scope";
593
- blockerId: string;
594
- config?: BlockerConfig;
595
- timestamp: number;
596
- prevState?: BlockerConfig; // Available for "update" and "remove"
597
- scope?: string; // Available for "clear_scope"
598
- count?: number; // Available for "clear" and "clear_scope"
599
- }
600
- ```
601
-
602
- ### Built-in Middleware
603
-
604
- These are extensions around the blocker lifecycle, not the primary onboarding path for the library.
605
-
606
- #### Analytics Middleware
607
-
608
- Track blocker events with your analytics provider (Google Analytics, Mixpanel, Amplitude, or custom).
609
-
610
- ```jsx
611
- import {
612
- configureMiddleware,
613
- createAnalyticsMiddleware,
614
- } from "@okyrychenko-dev/react-action-guard";
615
-
616
- // Google Analytics
617
- configureMiddleware([createAnalyticsMiddleware({ provider: "ga" })]);
618
-
619
- // Mixpanel
620
- configureMiddleware([createAnalyticsMiddleware({ provider: "mixpanel" })]);
621
-
622
- // Amplitude
623
- configureMiddleware([createAnalyticsMiddleware({ provider: "amplitude" })]);
624
-
625
- // Custom analytics
626
- configureMiddleware([
627
- createAnalyticsMiddleware({
628
- track: (event, data) => {
629
- myAnalytics.track(event, data);
630
- },
631
- }),
632
- ]);
633
- ```
634
-
635
- #### Logger Middleware
636
-
637
- Log blocker lifecycle events to the console for debugging.
638
- The middleware is not disabled automatically in production; register it conditionally when needed.
639
-
640
- ```jsx
641
- import { configureMiddleware, loggerMiddleware } from "@okyrychenko-dev/react-action-guard";
642
-
643
- configureMiddleware([loggerMiddleware]);
644
- ```
645
-
646
- #### Performance Middleware
647
-
648
- Monitor blocker performance and detect slow operations.
649
-
650
- ```jsx
651
- import {
652
- configureMiddleware,
653
- createPerformanceMiddleware,
654
- } from "@okyrychenko-dev/react-action-guard";
655
-
656
- configureMiddleware([
657
- createPerformanceMiddleware({
658
- onSlowBlock: (blockerId, duration) => {
659
- console.warn(`Blocker ${blockerId} was active for ${duration}ms`);
660
- },
661
- slowBlockThreshold: 5000, // 5 seconds
662
- }),
663
- ]);
664
- ```
665
-
666
- Note: `configureMiddleware` registers middleware on the global store. If you use `UIBlockingProvider`, register middleware via the provider's `middlewares` prop instead.
667
- Each `configureMiddleware(...)` call replaces previously configured global middlewares (`middleware-*`) and keeps provider-level middlewares untouched.
668
-
669
- Analytics middleware is SSR-safe: in non-browser environments it becomes a no-op for `ga`, `mixpanel`, and `amplitude` providers.
670
-
671
- ### Custom Middleware
672
-
673
- Create your own middleware to handle blocker events:
674
-
675
- ```jsx
676
- import { configureMiddleware } from "@okyrychenko-dev/react-action-guard";
677
-
678
- const myCustomMiddleware = (context) => {
679
- const { action, blockerId, config, timestamp, scope, count } = context;
680
-
681
- if (action === "add") {
682
- console.log(`Blocker added: ${blockerId}`, config);
683
- } else if (action === "update") {
684
- console.log(`Blocker updated: ${blockerId}`, config);
685
- } else if (action === "remove") {
686
- console.log(`Blocker removed: ${blockerId}`);
687
- } else if (action === "timeout") {
688
- console.log(`Blocker timed out: ${blockerId}`);
689
- } else if (action === "clear") {
690
- console.log(`All blockers cleared (${count} total)`);
691
- } else if (action === "clear_scope") {
692
- console.log(`Cleared ${count} blocker(s) for scope: ${scope}`);
693
- }
694
- };
695
-
696
- configureMiddleware([myCustomMiddleware]);
697
- ```
698
-
699
- ### Combining Middleware
700
-
701
- You can combine multiple middleware when you need broader observability:
702
-
703
- ```jsx
704
- import {
705
- configureMiddleware,
706
- createAnalyticsMiddleware,
707
- loggerMiddleware,
708
- createPerformanceMiddleware,
709
- } from "@okyrychenko-dev/react-action-guard";
710
-
711
- configureMiddleware([
712
- loggerMiddleware,
713
- createAnalyticsMiddleware({ provider: "ga" }),
714
- createPerformanceMiddleware({
715
- slowBlockThreshold: 3000,
716
- onSlowBlock: (blockerId, duration) => {
717
- // Send to error tracking service
718
- errorTracker.captureMessage(`Slow blocker: ${blockerId}`, {
719
- duration,
720
- });
721
- },
722
- }),
723
- ]);
724
- ```
725
-
726
- ## Tree Shaking
727
-
728
- The library is fully tree-shakeable. Import only the features you need to keep your bundle size small:
729
-
730
- ```jsx
731
- // Only imports the hook you need
732
- import { useBlocker } from "@okyrychenko-dev/react-action-guard";
733
-
734
- // Middleware is not included unless you import it
735
- import {
736
- configureMiddleware,
737
- createAnalyticsMiddleware,
738
- } from "@okyrychenko-dev/react-action-guard";
739
- ```
740
-
741
- The package is configured with `"sideEffects": false`, allowing modern bundlers (Webpack, Rollup, Vite) to eliminate unused code automatically.
742
-
743
- ## TypeScript
744
-
745
- The package is written in TypeScript and includes full type definitions.
746
-
747
- ```typescript
748
- import type {
749
- // Core types
750
- BlockerConfig,
751
- BlockerInfo,
752
- UIBlockingStore,
753
- UIBlockingStoreState,
754
-
755
- // Hook types
756
- ConfirmableBlockerConfig,
757
- ConfirmDialogConfig,
758
- UseConfirmableBlockerReturn,
759
- ScheduledBlockerConfig,
760
- ConditionalBlockerConfig,
761
- BlockingSchedule,
762
- UseAsyncActionOptions,
763
-
764
- // Middleware types
765
- Middleware,
766
- MiddlewareContext,
767
- AnalyticsConfig,
768
- AnalyticsProvider,
769
- PerformanceConfig,
770
-
771
- // Type-safe scopes
772
- BlockerConfigTyped,
773
- DefaultScopes,
774
- ScopeValue,
775
- } from "@okyrychenko-dev/react-action-guard";
776
- ```
777
-
778
- ## Type-Safe Scopes
779
-
780
- Create typed versions of the hooks to prevent scope typos at compile time:
781
-
782
- ```typescript
783
- import { createTypedHooks } from "@okyrychenko-dev/react-action-guard";
784
-
785
- type AppScopes = "global" | "form" | "navigation" | "checkout";
786
-
787
- const { useBlocker, useIsBlocked, useAsyncAction, useBlockingInfo } =
788
- createTypedHooks<AppScopes>();
789
-
790
- useBlocker("save", { scope: "form" }); // OK
791
- useBlocker("save", { scope: "typo" }); // Type error
792
- ```
793
-
794
- ## Use Cases
795
-
796
- The first four examples below are the most representative starting points.
797
-
798
- ### Loading States
799
-
800
- ```jsx
801
- function DataLoader() {
802
- const [isLoading, setIsLoading] = useState(false);
803
-
804
- useBlocker(
805
- "data-loader",
806
- {
807
- scope: "content",
808
- reason: "Loading data",
809
- },
810
- isLoading
811
- );
812
-
813
- // ... rest of component
814
- }
815
- ```
816
-
817
- ### Form Submission with Analytics
818
-
819
- ```jsx
820
- import { useAsyncAction, useIsBlocked } from "@okyrychenko-dev/react-action-guard";
821
-
822
- function UserForm() {
823
- const executeWithBlocking = useAsyncAction("submit-form", "form");
824
- const isBlocked = useIsBlocked("form");
825
-
826
- const handleSubmit = async (data) => {
827
- await executeWithBlocking(async () => {
828
- await submitForm(data);
829
- });
830
- };
831
-
832
- return (
833
- <form onSubmit={handleSubmit}>
834
- <input disabled={isBlocked} />
835
- <button disabled={isBlocked}>Submit</button>
836
- </form>
78
+ <>
79
+ <label>
80
+ Display name <input disabled={blockers.length > 0} />
81
+ </label>
82
+ <p role="status">{blockers[0]?.reason ?? "Editing available"}</p>
83
+ </>
837
84
  );
838
85
  }
839
- ```
840
86
 
841
- ### Advanced: Unsaved Changes Protection
842
-
843
- ```jsx
844
- import { useConfirmableBlocker } from "@okyrychenko-dev/react-action-guard";
845
-
846
- function FormWithUnsavedWarning() {
847
- const [formData, setFormData] = useState({});
848
- const [hasChanges, setHasChanges] = useState(false);
849
-
850
- const {
851
- execute,
852
- isDialogOpen,
853
- confirmConfig,
854
- onConfirm,
855
- onCancel,
856
- } = useConfirmableBlocker("unsaved-form", {
857
- scope: "navigation",
858
- reason: "Unsaved form data",
859
- priority: 100,
860
- confirmMessage: "You have unsaved changes. Discard them?",
861
- onConfirm: () => {
862
- setFormData({});
863
- setHasChanges(false);
864
- },
865
- });
87
+ function NavigationControl(): ReactElement {
88
+ const blockers = useBlockingInfo("navigation");
89
+ const [page, setPage] = useState("Profile");
866
90
 
867
91
  return (
868
- <form>
869
- <input
870
- onChange={(e) => {
871
- setFormData({ ...formData, name: e.target.value });
872
- setHasChanges(true);
873
- }}
874
- />
875
- <button type="button" onClick={execute}>Cancel</button>
876
- {isDialogOpen && (
877
- <ConfirmDialog
878
- title={confirmConfig.title}
879
- message={confirmConfig.message}
880
- confirmText={confirmConfig.confirmText}
881
- cancelText={confirmConfig.cancelText}
882
- onConfirm={onConfirm}
883
- onCancel={onCancel}
884
- />
885
- )}
886
- </form>
92
+ <>
93
+ <button disabled={blockers.length > 0} onClick={() => setPage("Dashboard")}>
94
+ Open dashboard
95
+ </button>
96
+ <p role="status">{blockers[0]?.reason ?? "Navigation available"}</p>
97
+ <p>{page}</p>
98
+ </>
887
99
  );
888
100
  }
889
- ```
890
-
891
- ### Global Loading Overlay
892
-
893
- ```jsx
894
- function App() {
895
- const isGloballyBlocked = useIsBlocked("global");
896
101
 
102
+ export default function App(): ReactElement {
897
103
  return (
898
- <div>
899
- {isGloballyBlocked && <LoadingOverlay />}
900
- <YourApp />
901
- </div>
104
+ <UIBlockingProvider>
105
+ <SaveButton />
106
+ <ProfileEditor />
107
+ <NavigationControl />
108
+ <button onClick={() => alert("Help remains available")}>Help</button>
109
+ </UIBlockingProvider>
902
110
  );
903
111
  }
904
112
  ```
905
113
 
906
- ### Multi-Step Process with Priority
114
+ The save publishes both explicit scopes. Each consumer reads only its own scope and displays
115
+ `Executing save-profile` while protected. Names match literally: `profile.name` does not inherit
116
+ `profile`. A `global` blocker affects ordinary scopes; omitted scopes default to `global`.
907
117
 
908
- ```jsx
909
- function MultiStepWizard() {
910
- const [step, setStep] = useState(1);
118
+ `useAsyncAction` releases each execution's blocker after success or failure. If its producer
119
+ unmounts, pending work stays protected until settlement; unmount does not cancel the operation.
120
+ Every call still executes. Applications own repeat-submit exclusion, cancellation and backend
121
+ permissions. The navigation control above changes local UI; it does not intercept browser navigation.
911
122
 
912
- // Higher priority for payment step
913
- useBlocker(
914
- "payment-step",
915
- {
916
- scope: ["navigation", "form"],
917
- reason: "Processing payment",
918
- priority: 100,
919
- },
920
- step === 3
921
- );
123
+ Prefer `UIBlockingProvider` for explicit ownership and SSR, using fresh state per request.
124
+ Without a Provider, hooks use a shared global fallback that does not isolate server requests.
125
+ For existing boolean state, register it with `useActionBlocker` instead.
922
126
 
923
- // Lower priority for other steps
924
- useBlocker(
925
- "wizard-step",
926
- {
927
- scope: "navigation",
928
- reason: "Wizard in progress",
929
- priority: 50,
930
- },
931
- step < 3
932
- );
127
+ ## Run the complete example
933
128
 
934
- return <div>Step {step}</div>;
935
- }
936
- ```
129
+ The [core-only example](https://github.com/okyrychenko-dev/react-action-guard/tree/main/examples/core-coordination)
130
+ adds success/failure, retry, producer detachment and unrelated help. From a repository checkout:
937
131
 
938
- ### Advanced: Scheduled Maintenance Window
939
-
940
- ```jsx
941
- import { useScheduledBlocker } from "@okyrychenko-dev/react-action-guard";
942
-
943
- function App() {
944
- useScheduledBlocker("weekly-maintenance", {
945
- scope: "global",
946
- reason: "Weekly system maintenance",
947
- priority: 500,
948
- schedule: {
949
- start: new Date("2024-01-21T03:00:00Z"),
950
- duration: 1800000, // 30 minutes
951
- },
952
- onScheduleStart: () => {
953
- showNotification("System maintenance in progress");
954
- },
955
- onScheduleEnd: () => {
956
- showNotification("Maintenance completed");
957
- window.location.reload();
958
- },
959
- });
960
-
961
- return <YourApp />;
962
- }
132
+ ```bash
133
+ pnpm install --frozen-lockfile
134
+ pnpm --filter @okyrychenko-dev/react-action-guard run build
135
+ pnpm --filter react-action-guard-core-example run dev
963
136
  ```
964
137
 
965
- ### Advanced: Dynamic Blocking Based on State
138
+ ## Next steps
966
139
 
967
- ```jsx
968
- import { useConditionalBlocker } from "@okyrychenko-dev/react-action-guard";
140
+ - [Canonical documentation and local site instructions](https://github.com/okyrychenko-dev/react-action-guard/tree/main/packages/docs): concepts, Provider ownership, examples and API reference.
141
+ - [UI controls](https://github.com/okyrychenko-dev/react-action-guard/tree/main/packages/ui): richer control state and accessible reason relationships.
142
+ - [Router integration](https://github.com/okyrychenko-dev/react-action-guard/tree/main/packages/router): browser navigation protection with adapter-specific limits.
143
+ - [Enterprise showcase and local run instructions](https://github.com/okyrychenko-dev/react-action-guard/tree/main/examples/enterprise-demo): checkout coordination, isolated sessions and Query integration.
969
144
 
970
- function StorageQuotaGuard() {
971
- const [storageUsed, setStorageUsed] = useState(0);
972
- const STORAGE_LIMIT = 1000000; // 1MB
973
-
974
- useConditionalBlocker("storage-limit", {
975
- scope: ["upload", "save"],
976
- reason: "Storage quota exceeded",
977
- priority: 200,
978
- condition: () => storageUsed > STORAGE_LIMIT,
979
- state: storageUsed,
980
- checkInterval: 5000, // Check every 5 seconds
981
- });
145
+ ## Core Concepts
982
146
 
983
- return (
984
- <div>
985
- <p>
986
- Storage used: {storageUsed} / {STORAGE_LIMIT} bytes
987
- </p>
988
- <UploadButton />
989
- </div>
990
- );
991
- }
992
- ```
147
+ - `scope` lets you coordinate blocking across components like `"form"`, `"navigation"`, or `"checkout"`
148
+ - `useAsyncAction` is the fastest path for async workflows
149
+ - `useActionBlocker` is the lower-level hook when you already have your own boolean state
150
+ - `UIBlockingProvider` gives you isolated state instead of the default global store
993
151
 
994
- ### Clearing Blockers on Navigation
152
+ ### Not React Router's `useBlocker`
995
153
 
996
- Clear blockers when navigating away or on specific events:
154
+ `useActionBlocker` coordinates shared UI blocking state. It does not intercept browser navigation like React Router's [`useBlocker`](https://reactrouter.com/api/hooks/useBlocker).
997
155
 
998
- ```jsx
999
- import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
1000
- import { useEffect } from "react";
156
+ The original `useBlocker` export remains available as a deprecated alias for backward compatibility. Prefer `useActionBlocker` in new code, especially when the same application uses React Router.
1001
157
 
1002
- function CheckoutPage() {
1003
- const { clearBlockersForScope, clearAllBlockers } = useUIBlockingStore(
1004
- (state) => ({
1005
- clearBlockersForScope: state.clearBlockersForScope,
1006
- clearAllBlockers: state.clearAllBlockers,
1007
- })
1008
- );
158
+ ## Core Use Cases
1009
159
 
1010
- // Clear checkout-specific blockers when leaving the page
1011
- useEffect(() => {
1012
- return () => {
1013
- // Clean up checkout blockers on unmount
1014
- clearBlockersForScope("checkout");
1015
- };
1016
- }, [clearBlockersForScope]);
160
+ ### Track async actions
1017
161
 
1018
- const handleCancelOrder = () => {
1019
- // Clear all blockers when user explicitly cancels
1020
- clearAllBlockers();
1021
- navigate("/");
1022
- };
162
+ Use `useAsyncAction` when related controls should reflect work in flight. It tracks concurrent calls rather than excluding them.
1023
163
 
1024
- return (
1025
- <div>
1026
- <CheckoutForm />
1027
- <button onClick={handleCancelOrder}>Cancel Order</button>
1028
- </div>
1029
- );
1030
- }
1031
- ```
1032
-
1033
- ### Managing Session Timeouts with Dynamic Updates
164
+ ### Coordinate multiple components
1034
165
 
1035
- Extend or update blocker timeouts dynamically:
166
+ Use shared scopes when one component starts work and another component should react by disabling UI or showing blocker details.
1036
167
 
1037
- ```jsx
1038
- import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
168
+ ### Isolate blocking domains
1039
169
 
1040
- function SessionManager() {
1041
- const { addBlocker, updateBlocker, removeBlocker } = useUIBlockingStore(
1042
- (state) => ({
1043
- addBlocker: state.addBlocker,
1044
- updateBlocker: state.updateBlocker,
1045
- removeBlocker: state.removeBlocker,
1046
- })
1047
- );
170
+ Recommend `UIBlockingProvider` for production ownership and SSR, with a fresh store for each request, test, or micro-frontend. The global fallback is shared convenience state, not request isolation.
1048
171
 
1049
- const startSession = () => {
1050
- addBlocker("session-timeout", {
1051
- scope: "global",
1052
- reason: "Session expiring soon",
1053
- priority: 50,
1054
- timeout: 300000, // 5 minutes
1055
- onTimeout: () => {
1056
- logout();
1057
- showNotification("Session expired");
1058
- },
1059
- });
1060
- };
172
+ ## API Reference
1061
173
 
1062
- const extendSession = () => {
1063
- // Extend timeout - timer restarts with new value
1064
- updateBlocker("session-timeout", {
1065
- timeout: 600000, // 10 minutes
1066
- reason: "Session extended",
1067
- });
1068
- };
174
+ Continue in the [canonical Core reference and recipes](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard/reference.md)
175
+ for hook options, specialized helpers, provider/context APIs, store actions, middleware,
176
+ typed scopes, cancellation/exclusion and advanced workflow examples. All detailed material
177
+ previously expanded here is retained there.
1069
178
 
1070
- const endSession = () => {
1071
- removeBlocker("session-timeout");
1072
- logout();
1073
- };
1074
-
1075
- return (
1076
- <div>
1077
- <button onClick={startSession}>Start Session</button>
1078
- <button onClick={extendSession}>Extend Session</button>
1079
- <button onClick={endSession}>Logout</button>
1080
- </div>
1081
- );
1082
- }
1083
- ```
179
+ Production applications should use `UIBlockingProvider` for explicit ownership. Global fallback
180
+ is shared convenience state and does not isolate SSR requests. Tracking does not prevent
181
+ concurrent work or cancel operations; applications own exclusion and idempotency.
1084
182
 
1085
183
  ## Development
1086
184
 
1087
- ```bash
1088
- # Install dependencies
1089
- npm install
1090
-
1091
- # Run tests
1092
- npm run test
1093
-
1094
- # Run tests with coverage
1095
- npm run test:coverage
1096
-
1097
- # Build the package
1098
- npm run build
185
+ This package lives in the [react-action-guard monorepo](https://github.com/okyrychenko-dev/react-action-guard)
186
+ (pnpm workspaces). From the monorepo root:
1099
187
 
1100
- # Type checking
1101
- npm run typecheck
1102
-
1103
- # Lint code
1104
- npm run lint
1105
-
1106
- # Fix lint errors
1107
- npm run lint:fix
188
+ ```bash
189
+ # Install dependencies for all packages
190
+ pnpm install
1108
191
 
1109
- # Format code
1110
- npm run format
192
+ # Run this package's scripts with --filter
193
+ pnpm --filter @okyrychenko-dev/react-action-guard run test:run
194
+ pnpm --filter @okyrychenko-dev/react-action-guard run test:coverage
195
+ pnpm --filter @okyrychenko-dev/react-action-guard run build
196
+ pnpm --filter @okyrychenko-dev/react-action-guard run typecheck
197
+ pnpm --filter @okyrychenko-dev/react-action-guard run lint
198
+ pnpm --filter @okyrychenko-dev/react-action-guard run lint:fix
199
+ pnpm --filter @okyrychenko-dev/react-action-guard run format
200
+ pnpm --filter @okyrychenko-dev/react-action-guard run dev
1111
201
 
1112
- # Watch mode for development
1113
- npm run dev
202
+ # Or cd into the package and run scripts directly
203
+ cd packages/core
204
+ pnpm run test:run
1114
205
  ```
1115
206
 
1116
207
  ## Contributing
1117
208
 
1118
- Contributions are welcome! Please ensure:
209
+ Contributions are welcome! Please ensure, before opening a PR:
1119
210
 
1120
- 1. All tests pass (`npm run test`)
1121
- 2. Code is properly typed (`npm run typecheck`)
1122
- 3. Linting passes (`npm run lint`)
1123
- 4. Code is formatted (`npm run format`)
211
+ 1. All tests pass (`pnpm run test`)
212
+ 2. Code is properly typed (`pnpm run typecheck`)
213
+ 3. Linting passes (`pnpm run lint`)
214
+ 4. Code is formatted (`pnpm run format`)
215
+ 5. If the change affects this package's public behavior, add a changeset: `pnpm changeset`
216
+ (see the [Releasing](https://github.com/okyrychenko-dev/react-action-guard#releasing) section
217
+ of the monorepo README)
1124
218
 
1125
219
  ## Changelog
1126
220
 
@@ -1129,3 +223,44 @@ See [CHANGELOG.md](./CHANGELOG.md) for a detailed list of changes in each versio
1129
223
  ## License
1130
224
 
1131
225
  MIT © Oleksii Kyrychenko
226
+
227
+ ## Canonical guides and stabilized Core
228
+
229
+ Follow [concepts](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/concepts.md),
230
+ [workflow guides](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/guides/workflows.md),
231
+ [UI integration](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard-ui/index.md),
232
+ [Router integrations](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard-router/index.md),
233
+ and [advanced ownership](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/advanced/ownership.md).
234
+ The [Core contract](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard/contract.md)
235
+ and [migration guide](https://github.com/okyrychenko-dev/react-action-guard/blob/main/packages/docs/src/packages/react-action-guard/migration.md)
236
+ describe current source and planned stabilization, rather than asserting registry publication.
237
+
238
+ ## Scheduled windows
239
+
240
+ Scheduled windows are half-open `[start, end)`. Omit both `duration` and `end` to
241
+ request indefinite blocking. A zero duration or equal start/end is empty and emits
242
+ neither start nor end notifications; an epoch-zero end is a real bound.
243
+
244
+ A supplied duration takes precedence over a supplied end, but every supplied field
245
+ is validated. Negative or nonfinite duration, invalid dates, end before start, and
246
+ calculated ends outside the JavaScript Date range cancel the schedule and warn in
247
+ development. Invalid or empty reconfiguration cancels timers and releases only the
248
+ hook-owned registration without emitting completion. Keep registration IDs unique
249
+ among owners in the same store. Long finite starts and ends use bounded timer chunks;
250
+ a window already elapsed when its start callback runs never activates.
251
+
252
+ ### Performance lifetime diagnostics
253
+
254
+ `createPerformanceMiddleware` measures each registration from its add event to removal,
255
+ timeout or bulk clear. Updates preserve the original start and refresh scope metadata;
256
+ a replacement add starts a new lifetime. `onSlowBlock` runs once for each ended lifetime
257
+ meeting the threshold, including every affected registration in a bulk clear. Callback
258
+ failures are isolated. Targeted clears leave global and unrelated registrations tracked.
259
+ These durations measure blocker lifetimes, not operation success or execution time.
260
+
261
+ Lifecycle events for individual registrations include an optional opaque `registration`
262
+ identity, stable across updates and renewed on add. Diagnostics use this identity to
263
+ attribute a timeout to the expired registration even when `onTimeout` has already added
264
+ a replacement with the same ID. Event ordering is unchanged. Identity is meaningful
265
+ by reference within the live observation stream, not after serialization; manually
266
+ constructed events without it retain ID-based tracking.