fix: tab navigation not working in Fixed Layout due to event listener timing issue#41256
Conversation
WalkthroughRef handling in useWidgetFocus is refactored to use a dedicated attachEventListeners callback that conditionally binds click and keydown listeners when not in auto-layout mode. The ref setter initializes the element, attaches listeners immediately, and supports cleanup. Imports are updated. Public hook signature remains unchanged. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant Component as React Component
participant Hook as useWidgetFocus
participant Elem as DOM Element
participant Canvas as Canvas Widget
User->>Component: Render
Component->>Hook: Call useWidgetFocus()
Hook-->>Component: refCallback(instance)
Component->>Elem: Assign refCallback
Note over Hook,Elem: If auto-layout is enabled → skip attaching listeners
Elem->>Hook: refCallback(elem)
Hook->>Elem: attachEventListeners(click, keydown)
Note right of Elem: Cleanup removes listeners on unmount
User->>Elem: click
Elem->>Hook: click handler
Hook->>Canvas: focus()
User->>Elem: keydown (Tab)
Elem->>Hook: keydown handler
Hook->>Canvas: handleTab()
Estimated code review effort🎯 2 (Simple) | ⏱️ ~10 minutes Poem
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (3)
app/client/src/utils/hooks/useWidgetFocus/useWidgetFocus.tsx (3)
12-16: Detach listeners on ref change/unmount; wire up cleanupYou correctly attach immediately, but the returned cleanup from attachEventListeners isn’t used. This can leave stale listeners if the ref changes, and it won’t detach on unmount. Capture and invoke the cleanup.
Apply within setRef:
@@ const setRef = useCallback( (node: HTMLElement | null) => { if (node === null) { - ref.current = null; - return; + // Detach listeners on unmount + cleanupRef.current?.(); + cleanupRef.current = null; + ref.current = null; + return; } if (ref.current === node) { return; } - ref.current = node; - - // Attach event listeners immediately when ref is set - attachEventListeners(node); - - return ref; + // Detach from previous node (if any), then attach to the new node + cleanupRef.current?.(); + ref.current = node; + // Attach event listeners immediately when ref is set and keep cleanup + cleanupRef.current = attachEventListeners(node); }, [attachEventListeners], );Add cleanupRef near your other refs:
// place right after `const ref = useRef<HTMLElement | null>(null);` const cleanupRef = useRef<(() => void) | null>(null);Optional: if layout mode can toggle at runtime, ensure listeners reflect it:
import { useEffect } from "react"; useEffect(() => { if (!ref.current) return; cleanupRef.current?.(); cleanupRef.current = attachEventListeners(ref.current); return () => { cleanupRef.current?.(); cleanupRef.current = null; }; }, [attachEventListeners]);Also applies to: 34-41, 46-67
24-33: Type the native click event; drop eslint-disableUse the DOM MouseEvent type and remove the temporary rule disable.
- // TODO: Fix this the next time the file is edited - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handleClick = (event: any) => { + const handleClick = (event: MouseEvent) => { const target = event.target as HTMLElement; if (target.matches(CANVAS_WIDGET)) { target.focus(); } };
18-22: Honor layout toggles without re-mountattachEventListeners captures isAutoLayout at creation time. If layout switches at runtime, handlers won’t update. Either re-attach on change (via a tiny effect) or read a live ref inside the handlers.
Option A (re-attach, see effect in earlier comment).
Option B (live ref check):
const isAutoLayoutRef = useRef(isAutoLayout); useEffect(() => { isAutoLayoutRef.current = isAutoLayout; }, [isAutoLayout]); const handleKeyDown = (event: KeyboardEvent) => { if (isAutoLayoutRef.current) return; if (event.key === "Tab") handleTab(event); }; const handleClick = (event: MouseEvent) => { if (isAutoLayoutRef.current) return; const target = event.target as HTMLElement; if (target.matches(CANVAS_WIDGET)) target.focus(); };
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
app/client/src/utils/hooks/useWidgetFocus/useWidgetFocus.tsx(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
app/client/src/utils/hooks/useWidgetFocus/useWidgetFocus.tsx (1)
app/client/src/selectors/editorSelectors.tsx (1)
getIsAutoLayout(298-301)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
- GitHub Check: client-check-cyclic-deps / check-cyclic-dependencies
- GitHub Check: client-unit-tests / client-unit-tests
- GitHub Check: client-lint / client-lint
- GitHub Check: client-prettier / prettier-check
- GitHub Check: client-build / client-build
🔇 Additional comments (2)
app/client/src/utils/hooks/useWidgetFocus/useWidgetFocus.tsx (2)
61-63: Nice: immediate attachment fixes the timing raceAttaching in the ref callback eliminates the ref/effect race and restores controlled Tab order. Good call.
4-4: Import path is correct — getIsAutoLayout is exported from selectors/canvasSelectors.ts
- app/client/src/selectors/canvasSelectors.ts:10 exports
getIsAutoLayout.- app/client/src/selectors/editorSelectors.tsx:298 also exports
getIsAutoLayout— consider deduping if accidental.
| ref.current = node; | ||
| // TODO: Fix this the next time the file is edited | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const handleClick = (event: any) => { |
There was a problem hiding this comment.
Why do we have any here? Can this not be HTMLElement?
Problem
Tab navigation between input widgets was not working in Fixed Layout applications. Users reported that pressing the Tab key would not move focus to the next input widget in the expected order (top-to-bottom, left-to-right), instead following the browser's default DOM-based tab order.
This issues was raised by an Enterprise user here
Root Cause
The issue was caused by a timing problem in the
useWidgetFocushook:useEffecthook was running immediately when the component mountedref.current) was set later via the React ref callbackref.currentwasnullwhenuseEffectranSolution
Refactored the
useWidgetFocushook to attach event listeners immediately when the ref is set, rather than waiting for auseEffectthat runs too early:Before (Broken):
After (Fixed):
Why This Solution Works
useEffectand ref assignmentTesting
Files Changed
app/client/src/utils/hooks/useWidgetFocus/useWidgetFocus.tsx- Fixed event listener timingapp/client/src/utils/hooks/useWidgetFocus/handleTab.ts- Cleaned up (no functional changes)app/client/src/utils/hooks/useWidgetFocus/tabbable.ts- Cleaned up (no functional changes)Automation
/ok-to-test tags="@tag.Widget"
🔍 Cypress test results
Tip
🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
Workflow run: https://github.com/appsmithorg/appsmith/actions/runs/18034264649
Commit: ab9af84
Cypress dashboard.
Tags:
@tag.WidgetSpec:
Fri, 26 Sep 2025 11:09:55 UTC
Communication
Should the DevRel and Marketing teams inform users about this change?
Summary by CodeRabbit
Bug Fixes
Refactor