Skip to content

fix: retry giveFocus on next animation frame for newly created blocks#3100

Merged
sawka merged 3 commits intowavetermdev:mainfrom
majiayu000:fix/issue-2926-block-focus-on-create
Mar 24, 2026
Merged

fix: retry giveFocus on next animation frame for newly created blocks#3100
sawka merged 3 commits intowavetermdev:mainfrom
majiayu000:fix/issue-2926-block-focus-on-create

Conversation

@majiayu000
Copy link
Contributor

Fixes #2926

Problem

Keyboard scrolling (arrow keys) does not work in newly opened blocks until the user mouse-clicks. This is a race condition in BlockFull's focus handling.

When isFocused becomes true for a newly created block, setFocusTarget() calls viewModel.giveFocus(). But the view's DOM element (terminal, Monaco editor, webview) may not be mounted yet, so giveFocus() returns false and focus falls back to the hidden dummy <input> element — which cannot handle arrow key scrolling.

Fix

After falling back to the dummy focus element, schedule a requestAnimationFrame callback that retries viewModel.giveFocus(). This gives React one more frame to flush pending renders and mount the view's DOM, so focus transfers to the real element once it's ready.

Test Plan

  • Open a new terminal block — verify arrow keys scroll immediately without clicking
  • Open a file preview — verify arrow keys scroll the file content
  • Open a webview — verify keyboard scrolling works
  • Switch focus between blocks using keyboard shortcuts — verify scrolling works in each
  • npx tsc --noEmit passes clean

When a new block is created, giveFocus() may fail because the view's
DOM element isn't mounted yet. This causes focus to fall back to the
hidden dummy input, which cannot handle arrow key scrolling.

Retry giveFocus() via requestAnimationFrame after the fallback, giving
React a chance to flush pending renders before the retry.

Fixes wavetermdev#2926

Signed-off-by: majiayu000 <1835304752@qq.com>
@CLAassistant
Copy link

CLAassistant commented Mar 21, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 21, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f38b460f-a9fa-4c11-902e-9237fc4bd7a8

📥 Commits

Reviewing files that changed from the base of the PR and between 1d42fbc and c89126e.

📒 Files selected for processing (1)
  • frontend/app/block/block.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/app/block/block.tsx

Walkthrough

In frontend/app/block/block.tsx, BlockFull introduces pendingFocusRafRef and updates setFocusTarget to cancel any previously scheduled requestAnimationFrame before proceeding. After attempting immediate focus, it focuses the dummy input and schedules a new animation-frame callback that clears the ref and calls viewModel?.giveFocus?.() only if blockRef still contains the document's active element. A mount-time useEffect teardown was added to cancel any pending animation-frame callback on unmount.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change: retrying giveFocus on the next animation frame for newly created blocks, which directly addresses the focus race condition bug.
Description check ✅ Passed The description is well-related to the changeset, explaining the problem, the fix, and providing a test plan that aligns with the code changes shown in the raw summary.
Linked Issues check ✅ Passed The PR directly addresses issue #2926 by implementing the deferred focus retry mechanism using requestAnimationFrame, ensuring giveFocus is called after DOM elements are mounted, resolving the arrow-key scrolling issue in newly created blocks.
Out of Scope Changes check ✅ Passed All changes in block.tsx are narrowly scoped to fixing the focus race condition through requestAnimationFrame scheduling and cleanup, with no unrelated modifications to other functionality.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with 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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@majiayu000 majiayu000 marked this pull request as ready for review March 21, 2026 06:54
@kilo-code-bot
Copy link
Contributor

kilo-code-bot bot commented Mar 21, 2026

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (1 file)
  • frontend/app/block/block.tsx - The fix correctly addresses the race condition by scheduling a requestAnimationFrame callback to retry viewModel.giveFocus(). The code properly includes a guard check (blockRef.current?.contains(document.activeElement)) before calling giveFocus in the RAF callback, which prevents duplicate focus calls. The implementation also correctly cleans up pending RAF callbacks on component unmount and cancels any pending RAF before scheduling a new one.

Reviewed by minimax-m2.5-20260211 · 360,462 tokens

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
frontend/app/block/block.tsx (1)

229-231: Consider cancelling the requestAnimationFrame on cleanup or re-invocation.

The scheduled rAF callback is not cancelled if:

  1. The component unmounts before the frame fires
  2. setFocusTarget is called again before the previous frame executes

While the optional chaining prevents crashes, calling giveFocus() on a disposed viewModel or queuing multiple redundant callbacks is not ideal.

♻️ Suggested improvement using a ref to track and cancel pending rAF

Add a ref to track the animation frame ID at the component level (around line 144):

const pendingFocusRafRef = useRef<number | null>(null);

Then update setFocusTarget to cancel any pending frame before scheduling a new one:

 const setFocusTarget = useCallback(() => {
+    if (pendingFocusRafRef.current != null) {
+        cancelAnimationFrame(pendingFocusRafRef.current);
+        pendingFocusRafRef.current = null;
+    }
     const ok = viewModel?.giveFocus?.();
     if (ok) {
         return;
     }
     focusElemRef.current?.focus({ preventScroll: true });
-    requestAnimationFrame(() => {
+    pendingFocusRafRef.current = requestAnimationFrame(() => {
+        pendingFocusRafRef.current = null;
         viewModel?.giveFocus?.();
     });
 }, [viewModel]);

And add cleanup on unmount (e.g., in a useEffect):

useEffect(() => {
    return () => {
        if (pendingFocusRafRef.current != null) {
            cancelAnimationFrame(pendingFocusRafRef.current);
        }
    };
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/block/block.tsx` around lines 229 - 231, The
requestAnimationFrame scheduled in setFocusTarget calls viewModel?.giveFocus()
but is never cancelled, so add a component-level ref (e.g., pendingFocusRafRef)
to store the RAF id, cancel any existing RAF via
cancelAnimationFrame(pendingFocusRafRef.current) before scheduling a new one in
setFocusTarget, assign the new id to pendingFocusRafRef.current, and add a
cleanup useEffect that cancels the pending RAF on unmount; reference the
existing requestAnimationFrame call and viewModel?.giveFocus() when making these
changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@frontend/app/block/block.tsx`:
- Around line 229-231: The requestAnimationFrame scheduled in setFocusTarget
calls viewModel?.giveFocus() but is never cancelled, so add a component-level
ref (e.g., pendingFocusRafRef) to store the RAF id, cancel any existing RAF via
cancelAnimationFrame(pendingFocusRafRef.current) before scheduling a new one in
setFocusTarget, assign the new id to pendingFocusRafRef.current, and add a
cleanup useEffect that cancels the pending RAF on unmount; reference the
existing requestAnimationFrame call and viewModel?.giveFocus() when making these
changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b3006749-8cfc-4710-9b33-13e3d345d387

📥 Commits

Reviewing files that changed from the base of the PR and between 8fc4dc3 and f90d576.

📒 Files selected for processing (1)
  • frontend/app/block/block.tsx

Address CodeRabbit review feedback on PR wavetermdev#3100: track the
requestAnimationFrame ID via a ref and cancel it before scheduling
a new one or when the component unmounts.

Signed-off-by: majiayu000 <1835304752@qq.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/app/block/block.tsx`:
- Around line 232-246: The retry RAF currently calls viewModel?.giveFocus?()
unconditionally, causing duplicate subscriptions; update setFocusTarget so the
RAF callback first checks whether focus is already on the block (e.g. check
focusElemRef.current and document.activeElement or a viewModel method) and only
calls viewModel?.giveFocus?() if focus is still not within focusElemRef.current;
keep the immediate giveFocus call as-is, still clear pendingFocusRafRef, and
ensure pendingFocusRafRef is nulled whether or not the retry runs to avoid
dangling refs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 8695f4ac-9ef8-4d6b-b103-7d8164d0241f

📥 Commits

Reviewing files that changed from the base of the PR and between f90d576 and 1d42fbc.

📒 Files selected for processing (1)
  • frontend/app/block/block.tsx

Comment on lines 232 to 246
const setFocusTarget = useCallback(() => {
if (pendingFocusRafRef.current != null) {
cancelAnimationFrame(pendingFocusRafRef.current);
pendingFocusRafRef.current = null;
}
const ok = viewModel?.giveFocus?.();
if (ok) {
return;
}
focusElemRef.current?.focus({ preventScroll: true });
pendingFocusRafRef.current = requestAnimationFrame(() => {
pendingFocusRafRef.current = null;
viewModel?.giveFocus?.();
});
}, [viewModel]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find all giveFocus implementations to assess side-effect patterns

# Search for giveFocus method definitions
ast-grep --pattern $'giveFocus($_) {
  $$$
}'

# Also search for giveFocus with no params
ast-grep --pattern $'giveFocus() {
  $$$
}'

Repository: wavetermdev/waveterm

Length of output: 46


🏁 Script executed:

# First, find the block.tsx file and examine the code around lines 232-246
find . -name "block.tsx" -path "*/frontend/*" | head -5

Repository: wavetermdev/waveterm

Length of output: 94


🏁 Script executed:

# Search for giveFocus implementations using rg
rg "giveFocus\s*\(" --type ts --type tsx -A 5 -B 1 | head -100

Repository: wavetermdev/waveterm

Length of output: 91


🏁 Script executed:

# Also search for giveFocus method definitions more broadly
rg "giveFocus\s*[:\(]" --type ts --type tsx | head -50

Repository: wavetermdev/waveterm

Length of output: 91


🏁 Script executed:

# Check file size first
wc -l ./frontend/app/block/block.tsx

Repository: wavetermdev/waveterm

Length of output: 98


🏁 Script executed:

# Read the specific lines from block.tsx
sed -n '232,246p' ./frontend/app/block/block.tsx

Repository: wavetermdev/waveterm

Length of output: 619


🏁 Script executed:

# Search for giveFocus without type filters
rg "giveFocus\s*\(" -A 5 -B 1 | head -150

Repository: wavetermdev/waveterm

Length of output: 8613


🏁 Script executed:

# Also find webview.tsx file
find . -name "webview.tsx" | head -5

Repository: wavetermdev/waveterm

Length of output: 103


🏁 Script executed:

# Find the giveFocus method in webview.tsx and get more context
rg "giveFocus" ./frontend/app/view/webview/webview.tsx -A 30 -B 2

Repository: wavetermdev/waveterm

Length of output: 1248


🏁 Script executed:

# Check the webview.tsx file size first
wc -l ./frontend/app/view/webview/webview.tsx

Repository: wavetermdev/waveterm

Length of output: 108


🏁 Script executed:

# Read lines around 504-527 as mentioned in the review
sed -n '500,540p' ./frontend/app/view/webview/webview.tsx

Repository: wavetermdev/waveterm

Length of output: 1439


🏁 Script executed:

# Search for focusedBlockId function
rg "focusedBlockId" --type-list

Repository: wavetermdev/waveterm

Length of output: 453


🏁 Script executed:

# Search more broadly for focusedBlockId
rg "focusedBlockId" -A 3 -B 1 | head -50

Repository: wavetermdev/waveterm

Length of output: 3132


🏁 Script executed:

# Check the setFocusTarget context in block.tsx - get more lines to see available variables
sed -n '200,250p' ./frontend/app/block/block.tsx

Repository: wavetermdev/waveterm

Length of output: 2003


🏁 Script executed:

# Search for nodeModel usage in block.tsx
rg "nodeModel" ./frontend/app/block/block.tsx -B 1 -A 1 | head -30

Repository: wavetermdev/waveterm

Length of output: 1182


Double giveFocus() invocation creates duplicate subscriptions in webview.

The retry logic calls giveFocus() twice: immediately (line 237) and in the RAF callback (line 244). The webview implementation has problematic side effects when ctrlShiftState is true—it sets up a globalStore.sub() subscription and returns false. If the RAF callback fires while ctrlShiftState is still true, a second subscription is created. Both subscriptions respond to the state change, causing duplicate unsubscribe calls, double focus() invocations, and potential memory leaks.

Guard the RAF callback to skip the retry if focus is already on the block:

Suggested guard to prevent redundant retry
         focusElemRef.current?.focus({ preventScroll: true });
         pendingFocusRafRef.current = requestAnimationFrame(() => {
             pendingFocusRafRef.current = null;
+            if (focusedBlockId() === nodeModel.blockId) {
+                return;
+            }
             viewModel?.giveFocus?.();
         });
-    }, [viewModel]);
+    }, [viewModel, nodeModel.blockId]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/app/block/block.tsx` around lines 232 - 246, The retry RAF currently
calls viewModel?.giveFocus?() unconditionally, causing duplicate subscriptions;
update setFocusTarget so the RAF callback first checks whether focus is already
on the block (e.g. check focusElemRef.current and document.activeElement or a
viewModel method) and only calls viewModel?.giveFocus?() if focus is still not
within focusElemRef.current; keep the immediate giveFocus call as-is, still
clear pendingFocusRafRef, and ensure pendingFocusRafRef is nulled whether or not
the retry runs to avoid dangling refs.

Skip the requestAnimationFrame retry of giveFocus() if focus has
already moved outside the block, preventing unnecessary calls when
the user has clicked elsewhere between scheduling and execution.

Signed-off-by: majiayu000 <1835304752@qq.com>
PhenixStar added a commit to PhenixStar/waveterm that referenced this pull request Mar 24, 2026
Cherry-pick from wavetermdev#3100.
Fixes keyboard scrolling not working in newly opened blocks until
mouse click — race condition where giveFocus() is called before
the view's DOM element is mounted.
@sawka
Copy link
Member

sawka commented Mar 24, 2026

@majiayu000 thanks for the submission. lgtm! coderabbit often gets over eager about flagging things that aren't actually bugs.

@sawka sawka merged commit 9ed86e9 into wavetermdev:main Mar 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Cannot scroll file content when opening or focus changing

3 participants