chore(eslint): add cleanup for setTimeout/setInterval/addEventListener in useEffect (#1468)#1643
chore(eslint): add cleanup for setTimeout/setInterval/addEventListener in useEffect (#1468)#1643steilerDev wants to merge 1 commit into
Conversation
steilerDev
left a comment
There was a problem hiding this comment.
[ux-designer]
Design Review — PR #1643 chore(eslint): add cleanup for setTimeout/setInterval/addEventListener in useEffect
VERDICT: APPROVED ✅
(Self-approval blocked by GitHub — using --comment per instructions.)
Scope Verification
The diff touches exactly 2 TypeScript files (LinkedDocumentsSection.tsx, InvoiceBudgetLinesSection.tsx) with zero CSS/styling changes. No design tokens, CSS Modules, JSX structure, layout, color, spacing, or dark mode values are modified. This is a pure logic refactor.
Focus Behaviour Analysis
All four changed useEffect hooks use the identical pattern:
const timer = setTimeout(() => {
ref.current?.focus();
}, 0);
return () => clearTimeout(timer);Focus still lands on the correct element in all cases:
| Effect | Trigger | Focus target | Behaviour preserved? |
|---|---|---|---|
LinkedDocumentsSection picker modal |
showPicker becomes true |
pickerModalRef.current |
✅ Yes |
LinkedDocumentsSection unlink dialog |
unlinkTarget becomes truthy |
cancelButtonRef.current |
✅ Yes |
InvoiceBudgetLinesSection picker modal |
showPicker becomes true |
pickerModalRef.current |
✅ Yes |
InvoiceBudgetLinesSection create form |
pickerState.showCreateForm becomes true |
#budget-description |
✅ Yes |
The setTimeout(fn, 0) defers focus until after the browser has painted the newly rendered DOM — unchanged from before. The cleanup only runs when the component unmounts or the dependency flips back, i.e. the timer is cancelled before it fires, not after. In normal usage (state flips to true and the component stays mounted), the cleanup function is never called while the timer is still pending, so focus management is functionally identical to the pre-patch code.
Rapid Open/Close Regression Risk
Worst-case sequence: user opens picker → immediately closes it before the 0ms timer fires.
- Before patch: Timer fires after close;
ref.currentmay point to an unmounted or hidden element;focus()is a no-op on a non-displayed element — harmless but slightly wasteful. - After patch: Cleanup fires on state flip back to
false,clearTimeoutcancels the timer before it runs — strictly better. No focus theft, no residual side-effect.
The PR author's investigation note (audited all setTimeout/addEventListener calls in useEffect) is correct — the remaining setTimeout calls in closePicker and handleDocumentSelect are inside useCallback callbacks, not directly inside useEffect, so they are not subject to the ESLint rule and do not require cleanup.
Design System Checklist
- Token adherence — No CSS/token changes; N/A
- Visual consistency — No rendering changes; layout/appearance identical
- Dark mode — No color values touched; N/A
- Responsive / touch targets — Unchanged; focus targets retain existing touch target compliance
- Accessibility — Focus management behaviour preserved;
clearTimeoutin cleanup prevents unwanted focus theft on rapid state changes — a net accessibility improvement - Shared pattern usage — No new components; N/A
- Component reuse — N/A
- Animation/transition — No animation code changed
No visual, dark mode, responsive, or accessibility regressions identified.
|
[frontend-developer] APPROVED Frontend Engineering Review: APPROVED ✅This PR adds proper cleanup for Changes VerifiedLinkedDocumentsSection.tsx (2 effects):
InvoiceBudgetLinesSection.tsx (2 effects):
Completeness Audit (Spot-Check)Sampled other components to verify the audit claim:
The author's audit claim is accurate. These are the problematic patterns, and the fixes align with established patterns in the codebase. Code Quality Assessment
NoteSeveral Verdict: Ready to merge once conflicts are resolved. |
steilerDev
left a comment
There was a problem hiding this comment.
[product-architect]
VERDICT: APPROVED (posted as comment — GitHub blocks self-approval)
Architecture review — no concerns.
Pattern consistency audit ✅
Spot-checked the author's claim that "only these 4 useEffect hooks were missing cleanup". Ran a static audit across all production .ts/.tsx files in client/src/ looking for useEffect blocks containing setTimeout/setInterval/addEventListener without a corresponding return () => clear*/removeEventListener cleanup. Zero other instances found. Audit confirmed.
In particular, the other setTimeout calls in the two touched files (LinkedDocumentsSection.tsx lines 124, 182, 192, 211, 218; InvoiceBudgetLinesSection.tsx lines 171, 355, 444, 871, 1049) are all inside useCallback event handlers — not useEffect — so they are correctly out of scope for this PR (fire-and-forget focus restoration after a user action).
The cleanup pattern used (const timer = setTimeout(...); return () => clearTimeout(timer);) matches the idiomatic pattern already used throughout the codebase (e.g. ToastContext.tsx, Tooltip.tsx, CalendarView.tsx, ref-based debouncers in pages).
Other dimensions
- API contract / schema: N/A (client-only refactor)
- Wiki/ADR: No implications — pure code hygiene, no architectural decision involved
- Scope: Minimal and surgical (+8/-4), addresses exactly the issue
- Tests: Behavior unchanged (zero-delay timers complete before any plausible unmount); no test changes needed
Ignoring the merge conflict per orchestrator instruction.
steilerDev
left a comment
There was a problem hiding this comment.
[dev-team-lead]
VERDICT: APPROVED
Code Review: PR #1643 — clearTimeout cleanup in focus-management useEffect hooks
Summary
The fix correctly addresses all 4 @eslint-react/web-api-no-leaked-timeout warnings. Both modified files are clean.
Pattern Assessment ✅
All 4 changes follow the idiomatic React cleanup pattern — assigning setTimeout to a named const timer, then returning () => clearTimeout(timer). Cleanup is correctly placed inside the conditional branch (only registered when the timer is created, so no spurious clearTimeout(undefined) call on the else path).
Audit Completeness ✅
Spot-checked all other setTimeout/setInterval/addEventListener usages across the codebase. All remaining instances fall into one of these safe categories:
useCallback/ async handlers — e.g.,closePicker,handleLinkDocument,handleUnlinkin both files — not subject to@eslint-react/web-api-no-leaked-timeout- Ref-based debounce patterns with
clearTimeoutguards — e.g.,DiaryPage,DocumentBrowser,useColumnPreferences,BudgetOverviewPage— already havereturn () => clearTimeoutin their effects - Cleanup-only
useEffect— e.g.,InvoiceDepositsSection.tsxusesrevertErrorTimerRefwith a dedicated cleanup effect addEventListenerwith pairedremoveEventListener— alluseEffectblocks in both modified files have symmetric add/remove (verified programmatically)
The author's claim that only these 4 effects lacked cleanup is confirmed correct.
Functional Equivalence ✅
Zero-delay setTimeout(..., 0) focus timers typically fire before any meaningful unmount. The cleanup is defensive and technically correct; it adds no functional risk.
No New ESLint Warnings ✅
The fix eliminates the 4 flagged warnings. The const timer = ... variable is used immediately and does not trigger unused-variable linting warnings.
✅ Approved. Ready to rebase onto beta once #1640/#1642 land.
|
[orchestrator] Consolidated
Status
|
Summary
Fixes
@eslint-react/web-api-no-leaked-timeoutwarnings by adding proper cleanup in alluseEffecthooks that createsetTimeoutcalls without returning a cleanup function.Changes
client/src/components/documents/LinkedDocumentsSection.tsxshowPickereffect): Capture thesetTimeoutreturn value andclearTimeoutit in the cleanupunlinkTargeteffect): Same patternclient/src/pages/InvoiceDetailPage/InvoiceBudgetLinesSection.tsxshowPickereffect): Same pattern as abovepickerState.showCreateFormeffect): Same patternInvestigation Notes
I audited every
setTimeout,setInterval,addEventListenercall in the codebase that appeared inside auseEffect. The vast majority were already correctly cleaned up:setTimeoutin event handlers / callbacks (not inuseEffect) — not flagged by the rule, already safeSearchPicker,WorkItemSelector,BudgetOverviewPage, etc.) — all have a dedicated unmountuseEffectthat callsclearTimeoutaddEventListenercalls — all 20+ instances already return a correspondingremoveEventListenercleanupThe 4 genuine violations were all zero-delay focus management
setTimeouts (delay: 0) used to defer focus into newly-rendered modal/picker elements. These are a well-known React pattern but need the cleanup return to avoid the rule warning and to be technically correct (the timer would fire even if the component unmounts before the next microtask tick).Fixes #1468
Part of #1455