Skip to content

Commit 586fc85

Browse files
committed
Updated hooks and retry manager
1 parent aba4288 commit 586fc85

5 files changed

Lines changed: 294 additions & 234 deletions

File tree

src/hooks/README.md

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ useZendeskSwapArticleLinks({
2525
**Implementation Details:**
2626

2727
- Uses `unreadMessages` callback to detect when message content has been added to the DOM
28-
- Retries up to `DEFAULT_MAX_RETRIES` times if links aren't found initially
28+
- Uses coordinated retry system with operation type `'link-swapping'` to handle timing issues
2929
- Maps Zendesk article IDs to help page paths via `ARTICLE_LINK_MAP`. This map was manually created but will be automated in future iterations
3030

3131
---
@@ -87,7 +87,8 @@ useZendeskIframeStyles({
8787

8888
- Removes existing style elements with `data-zendesk-custom-styles` attribute before injecting
8989
- MutationObserver watches the `head` element for changes
90-
- Retries up to `DEFAULT_MAX_RETRIES` times if iframe isn't ready
90+
- Uses coordinated retry system with operation type `'style-injection'` to handle timing issues
91+
- Retries can be cancelled via `retryManager.cancelRetry('style-injection')` when widget becomes unavailable
9192

9293
---
9394

@@ -116,33 +117,146 @@ const isMobile = useMediaQuery('(max-width: 768px)');
116117

117118
---
118119

120+
## Retry Manager Utilities
121+
122+
The hooks use utilities from `@/utils/zendesk-retry-manager` to coordinate retry operations:
123+
124+
### `createRetryManager()`
125+
126+
Creates a new `RetryManager` instance. Each hook should create its own instance to track its retries:
127+
128+
```tsx
129+
const retryManagerRef = useRef(createRetryManager());
130+
```
131+
132+
### `scheduleCoordinatedRetry()`
133+
134+
Schedules a retry operation with automatic coordination and lifecycle management:
135+
136+
```tsx
137+
scheduleCoordinatedRetry(
138+
retryManager,
139+
'link-swapping', // Operation type
140+
() => {
141+
// Retry callback — return value is not used by the manager
142+
processLinks();
143+
},
144+
isMountedRef,
145+
retriesRemaining,
146+
delay,
147+
);
148+
```
149+
150+
This function:
151+
152+
- Returns `null` immediately if `retriesRemaining <= 0`
153+
- Creates a `shouldRetry` callback that checks mount state
154+
- Schedules the retry with coordination (cancels existing retries for the same operation type)
155+
- Returns a cleanup function to cancel the retry, or `null` if the retry was not scheduled
156+
157+
**Important:** The manager is a single-shot scheduler — each call schedules exactly one deferred callback. It does not auto-retry. The callback itself is responsible for calling `scheduleCoordinatedRetry` again with a decremented retry count if the operation still fails. This recursive pattern is how multi-attempt retry sequences are built.
158+
159+
### `markComponentMounted()`
160+
161+
Marks a component as mounted and notifies the retry manager:
162+
163+
```tsx
164+
markComponentMounted(isMountedRef, retryManager);
165+
```
166+
167+
This sets `isMountedRef.current = true` and calls `retryManager.setMounted()`.
168+
169+
### `markComponentUnmounted()`
170+
171+
Marks a component as unmounted and cancels all active retries:
172+
173+
```tsx
174+
markComponentUnmounted(isMountedRef, retryManager);
175+
```
176+
177+
This sets `isMountedRef.current = false` and calls `retryManager.setUnmounted()`, which cancels all active retries.
178+
179+
### Operation Types
180+
181+
Retries are categorized by operation type to enable coordination. Only one retry per operation type can be active at a time. Scheduling a new retry for an operation type will cancel any existing retry for that type.
182+
183+
- **`'style-injection'`** - Used by `useZendeskIframeStyles` when injecting CSS styles
184+
- **`'link-swapping'`** - Used by `useZendeskSwapArticleLinks` when swapping article links
185+
- **`'iframe-access'`** - Available for iframe access operations (not currently used)
186+
- **`'observer-setup'`** - Defined but not currently used; observer setup retries are handled internally by `setupZendeskObserver` via its `retryOnNotReady` option
187+
188+
---
189+
119190
## Common Patterns
120191

121192
### Mounted State Tracking
122193

123-
All Zendesk hooks use `isMountedRef` to prevent race conditions and memory leaks:
194+
All Zendesk hooks use `isMountedRef` to prevent race conditions and memory leaks. Hooks that use the retry manager (`useZendeskIframeStyles`, `useZendeskSwapArticleLinks`) also use the `markComponentMounted`/`markComponentUnmounted` helpers to keep the retry manager in sync. `useZendeskClickHandlers` manages `isMountedRef` directly since it does not use the retry manager. This solves several critical problems:
195+
196+
**Why Track Unmounting in a Single Page Application?**
197+
198+
In a typical single page application, the main page component shouldn't normally unmount. However, mount tracking is still essential because:
199+
200+
- **React Strict Mode**: In development, React Strict Mode intentionally mounts, unmounts, and remounts components to help detect side effects. Without mount tracking, retries scheduled during the first mount could execute after the remount, causing duplicate operations.
201+
202+
- **Hot Module Reloading (HMR)**: During development, HMR can cause components to remount when code changes, potentially leaving stale retries from the previous mount active.
203+
204+
- **Error Recovery**: If an error boundary catches an error and remounts the component, or if the Zendesk widget is reset (via the "Clear conversation" feature), the component may remount, leaving previous retries active.
205+
206+
- **Future-Proofing**: Even if the current architecture doesn't involve unmounting, future changes (route restructuring, component splitting, etc.) could introduce unmounting scenarios. Mount tracking ensures the code remains robust.
207+
208+
- **Widget Reset Scenarios**: When the Zendesk widget is reset (burn animation feature), the component state is reset but the component itself may not unmount. Mount tracking helps ensure retries from before the reset don't interfere with new operations.
209+
210+
**Problems Solved:**
211+
212+
1. **Race Conditions with Async Operations**: When a component unmounts (or resets), there may be pending timeouts, retries, or async operations (like accessing iframes) that could still execute. Without mount tracking, these operations could:
213+
- Access DOM elements that no longer exist (causing errors)
214+
- Update state on unmounted components (React warnings)
215+
- Execute callbacks that reference stale closures
216+
217+
2. **Memory Leaks**: Without proper cleanup, timeouts and retries could continue running indefinitely after unmount, holding references to components and preventing garbage collection.
218+
219+
3. **Stale Closures**: Callbacks scheduled via `setTimeout` or retry mechanisms might execute after the component has unmounted, accessing stale state or trying to update unmounted components.
220+
221+
4. **Multiple Retry Attempts**: Without coordination, multiple retry attempts could be scheduled and execute even after the component unmounts, wasting resources and potentially causing errors.
222+
223+
**Implementation:**
124224

125225
```tsx
126226
const isMountedRef = useRef(true);
227+
const retryManagerRef = useRef(createRetryManager());
127228

128229
useEffect(() => {
129-
isMountedRef.current = true;
230+
const retryManager = retryManagerRef.current;
231+
232+
// Mark component as mounted and notify retry manager
233+
markComponentMounted(isMountedRef, retryManager);
130234

131-
// Async operations check isMountedRef.current before executing
235+
// All async operations check isMountedRef.current before executing
236+
// Example: if (!isMountedRef.current) return;
132237

133238
return () => {
134-
isMountedRef.current = false;
239+
// Mark component as unmounted and cancel all retries
240+
markComponentUnmounted(isMountedRef, retryManager);
135241
};
136242
}, [dependencies]);
137243
```
138244

139-
### Retry Logic
245+
**How It Works:**
140246

141-
Hooks use retry mechanisms to handle timing issues where the iframe or DOM elements may not be ready:
247+
- Before any async operation (DOM access, retry callbacks, timeouts), hooks check `isMountedRef.current`
248+
- If the component has unmounted, operations return early without executing
249+
- The retry manager is notified of mount/unmount state and cancels all active retries on unmount
250+
- This prevents operations from executing after component cleanup, eliminating race conditions and memory leaks
142251

143-
- `processArticleLinks` retries if links aren't found
144-
- `injectStyles` retries if iframe isn't available
145-
- `setupZendeskObserver` retries if iframe isn't ready (when `retryOnNotReady: true`)
252+
### Coordinated Retry System
253+
254+
The retry manager coordinates retries across hooks to prevent race conditions by:
255+
256+
- Preventing conflicts: Only one retry per operation type can be active at a time
257+
- Tracking by operation type: Avoids duplicate work across hooks
258+
- Handling lifecycle: Automatically cancels retries when components unmount
259+
- Debouncing: Cancels existing retries when new ones are scheduled for the same operation type
146260

147261
### MutationObserver Usage
148262

@@ -151,3 +265,5 @@ Hooks use `setupZendeskObserver` utility to watch for DOM changes:
151265
- `useZendeskClickHandlers` - Re-attaches handlers on mutations
152266
- `useZendeskIframeStyles` - Re-injects styles when iframe reloads
153267
- `useZendeskSwapArticleLinks` - Not used (relies on `unreadMessages` callback)
268+
269+
**Note**: `setupZendeskObserver` has its own retry logic (via `retryOnNotReady` option) that is separate from the coordinated retry manager system. The retry manager handles retries for style injection and link swapping operations.

src/hooks/use-zendesk-click-handlers.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import {
66
ZENDESK_SEND_BUTTON_SELECTOR,
77
} from '@/constants/zendesk-selectors';
88
import { setupZendeskObserver } from '@/utils/zendesk-observer';
9+
import {
10+
markComponentMounted,
11+
markComponentUnmounted,
12+
} from '@/utils/zendesk-retry-manager';
913

1014
interface UseZendeskClickHandlersOptions {
1115
zendeskReady: boolean;
@@ -73,7 +77,7 @@ export function useZendeskClickHandlers({
7377
}
7478

7579
// Mark as mounted when effect runs
76-
isMountedRef.current = true;
80+
markComponentMounted(isMountedRef);
7781

7882
let observerCleanup: (() => void) | null = null;
7983

@@ -207,7 +211,7 @@ export function useZendeskClickHandlers({
207211

208212
return () => {
209213
// Mark as unmounted to prevent any async operations from continuing
210-
isMountedRef.current = false;
214+
markComponentUnmounted(isMountedRef);
211215

212216
// Clean up observer
213217
if (observerCleanup) {

0 commit comments

Comments
 (0)