Skip to content

Fix decouple preview from focus event so virtualized rows don't freeze it#509

Open
Ahmad-kadri wants to merge 6 commits intoealush:masterfrom
Ahmad-kadri:fix--decouple-preview-from-focus-event-so-virtualized-rows-don't-freeze-it
Open

Fix decouple preview from focus event so virtualized rows don't freeze it#509
Ahmad-kadri wants to merge 6 commits intoealush:masterfrom
Ahmad-kadri:fix--decouple-preview-from-focus-event-so-virtualized-rows-don't-freeze-it

Conversation

@Ahmad-kadri
Copy link
Copy Markdown
Contributor

@Ahmad-kadri Ahmad-kadri commented Apr 30, 2026

Bug
With row virtualization (most reliably triggered by Mac trackpad inertia scroll), hovering emojis can leave the preview frozen on a stale emoji. The last row is unaffected because it uses handlePartiallyVisibleElementFocus, which sets the preview synchronously and bypasses the focus event entirely.

Root cause
onMouseOver updates the preview indirectly: it calls focusElement(button), which queues button.focus() in a requestAnimationFrame; the resulting focus event then triggers onEnter, which calls setPreviewEmoji. If virtualization unmounts the button before the rAF fires, .focus() is a no-op on a detached node — no focus event, no preview update.

Fix
Set the preview synchronously from onMouseOver instead of relying on the focus event. Still call focusElement(button) for fully-visible emojis so existing behaviors are preserved.

PickerPreview

Ahmad-kadri and others added 6 commits April 13, 2026 10:46
Add the nonce to the PickerStyleTag
Avoids preview freezing when row virtualization unmounts the button
before focusElement's rAF fires (e.g. Mac trackpad inertia scroll).

Signed-off-by: Ahmad Kadri <ahmad.kadri@conceptboard.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

Someone is attempting to deploy a commit to the ealush's projects Team on Vercel.

A member of the Team first needs to authorize it.

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Decouple emoji preview from focus event for virtualized rows

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Set emoji preview synchronously on hover to prevent freezing
• Decouple preview update from focus event to handle virtualized rows
• Rename helper function for clarity on synchronous preview setting
• Improve focus handling for partially visible and fully visible emojis
Diagram
flowchart LR
  A["onMouseOver event"] --> B["setPreviewFromButton<br/>synchronously"]
  B --> C["Preview updates<br/>immediately"]
  A --> D["Check emoji<br/>visibility"]
  D --> E["Below fold:<br/>blur only"]
  D --> F["Fully visible:<br/>focus element"]
  E --> G["No preview freeze<br/>on virtualization"]
  F --> G
Loading

Grey Divider

File Changes

1. src/hooks/useEmojiPreviewEvents.ts 🐞 Bug fix +21/-10

Synchronous preview update and improved focus handling

• Refactored onMouseOver to set preview synchronously via setPreviewFromButton instead of
 relying on focus event
• Renamed handlePartiallyVisibleElementFocus to setPreviewFromButton to reflect its synchronous
 behavior
• Improved visibility check logic: blur previous focus for partially visible emojis, focus element
 for fully visible ones
• Added explanatory comments about virtualization and rAF timing issues

src/hooks/useEmojiPreviewEvents.ts


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Apr 30, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0)

Grey Divider


Action required

1. Preview cleared after blur 🐞 Bug ≡ Correctness
Description
In onMouseOver, the preview is set and then document.activeElement.blur() is called for
partially-below-fold emojis; the resulting captured blur event runs onLeave() which clears the
preview, so hovering a partially visible emoji can end with the preview immediately becoming null.
Code

src/hooks/useEmojiPreviewEvents.ts[R91-105]

+      // Set preview synchronously rather than relying on the focus listener:
+      // virtualized rows can unmount the button before focusElement's rAF fires
+      // (e.g. Mac trackpad inertia scroll), so button.focus() becomes a no-op
+      // and the preview would otherwise freeze.
+      setPreviewFromButton(button, setPreviewEmoji);

-        focusElement(button);
+      const belowFoldByPx = detectEmojyPartiallyBelowFold(button, bodyRef);
+      const buttonHeight = button.getBoundingClientRect().height;
+
+      if (belowFoldByPx < buttonHeight) {
+        // Partially below the fold: skip focus to avoid auto-scroll-into-view.
+        // Manually blur so the previous focus ring doesn't linger.
+        (document.activeElement as HTMLElement)?.blur?.();
+        return;
      }
Evidence
The hook registers a capture blur listener on bodyRef that calls onLeave, and onLeave clears
the preview (setPreviewEmoji(null)). The new flow sets the preview first and then blurs the active
element in the partially-visible branch, allowing the blur handler to clear the preview after it was
set.

src/hooks/useEmojiPreviewEvents.ts[40-44]
src/hooks/useEmojiPreviewEvents.ts[63-73]
src/hooks/useEmojiPreviewEvents.ts[80-109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Hovering a partially-below-fold emoji calls `setPreviewFromButton(...)` and then blurs the current active element. Because the hook also listens for `blur` (capture) and `onLeave()` clears the preview, the blur can clear the just-set preview, leaving the preview blank.

### Issue Context
This only occurs in the `belowFoldByPx < buttonHeight` branch (partially visible), where focus is intentionally skipped.

### Fix Focus Areas
- src/hooks/useEmojiPreviewEvents.ts[91-105]

### Suggested change
Move the blur earlier (before setting the preview) in the partially-visible path, or re-set the preview after blurring, e.g.
- compute belowFold first
- if partially visible: blur active element, then call `setPreviewFromButton(button, setPreviewEmoji)`, then `return`
- else: keep current behavior
This preserves the intent (clear focus ring) without clearing the new preview.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Leaking mouseover listeners 🐞 Bug ☼ Reliability
Description
useEmojiPreviewEvents adds a capturing mouseover listener but removes it without capture=true,
so it may not be removed; since the effect depends on unstable function references (recreated each
render), repeated re-runs can accumulate listeners and multiply preview updates over time.
Code

src/hooks/useEmojiPreviewEvents.ts[118]

  }, [BodyRef, allow, setPreviewEmoji, isMouseDisallowed, allowMouseMove]);
Evidence
The mouseover listener is registered with capture=true but the cleanup calls
removeEventListener without the capture flag, which won’t remove a capturing listener. The effect
dependency array includes isMouseDisallowed/allowMouseMove, which are returned as new function
instances each render, increasing the likelihood of effect re-runs and accumulating listeners if
cleanup is ineffective.

src/hooks/useEmojiPreviewEvents.ts[36-39]
src/hooks/useEmojiPreviewEvents.ts[111-117]
src/hooks/useEmojiPreviewEvents.ts[118-118]
src/hooks/useDisallowMouseMove.ts[13-25]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
A capturing `mouseover` listener is added but not removed with matching capture options, which can prevent cleanup. Combined with unstable function dependencies that can cause the effect to re-run on renders, this can accumulate listeners and amplify preview updates.

### Issue Context
`addEventListener('mouseover', ..., true)` must be paired with `removeEventListener('mouseover', ..., true)` (or an equivalent `{ capture: true }` options object).

### Fix Focus Areas
- src/hooks/useEmojiPreviewEvents.ts[36-39]
- src/hooks/useEmojiPreviewEvents.ts[111-117]
- src/hooks/useEmojiPreviewEvents.ts[118-118]
- src/hooks/useDisallowMouseMove.ts[13-25]

### Suggested change
1) Fix cleanup to match capture:
- `bodyRef?.removeEventListener('mouseover', onMouseOver, true);`

2) Reduce effect churn by stabilizing dependencies:
- Wrap `useIsMouseDisallowed()` / `useAllowMouseMove()` returned functions in `useCallback`, or
- Remove `allowMouseMove` from the dependency list in `useEmojiPreviewEvents` (it’s currently unused there), and ensure any remaining function deps are stable.
This prevents repeated re-binding and avoids listener accumulation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

📝 Walkthrough

Walkthrough

Modified the mouseover event handling in useEmojiPreviewEvents hook to synchronously set preview for valid emoji buttons. Behavior now adapts based on button viewport position: if partially below fold, element blur prevents focus ring; if fully visible, focus is maintained for keyboard navigation. Helper function renamed and blur responsibility reassigned.

Changes

Cohort / File(s) Summary
Emoji Preview Event Handler
src/hooks/useEmojiPreviewEvents.ts
Refactored mouseover event flow to synchronously set preview and conditionally manage focus/blur based on button viewport visibility; renamed helper to setPreviewFromButton and shifted blur responsibility from helper to event handler.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested labels

codex

Poem

🐰 Hopping through buttons, previews gleam bright,
Focus dances smoothly in viewport's sight,
Blur away gently when out of the view,
Emojis now flow like morning's fresh dew! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: decoupling the preview update from the focus event to prevent emoji preview from freezing during virtualized row scrolling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request updates useEmojiPreviewEvents.ts to handle emoji previews synchronously, addressing an issue where virtualized rows could unmount buttons before focus events occur. It also refines the handling of partially visible emojis by blurring the active element instead of focusing it to prevent automatic scrolling. A review comment suggests narrowing the blur operation to only target emoji buttons to avoid accidental focus loss on other UI components like the search input.

if (belowFoldByPx < buttonHeight) {
// Partially below the fold: skip focus to avoid auto-scroll-into-view.
// Manually blur so the previous focus ring doesn't linger.
(document.activeElement as HTMLElement)?.blur?.();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Blurring the active element unconditionally can cause unexpected focus loss on other UI components, such as the search input, if the mouse happens to hover over a partially visible emoji while the user is interacting with another part of the picker. It is safer to only blur if the currently focused element is an emoji button.

Suggested change
(document.activeElement as HTMLElement)?.blur?.();
buttonFromTarget(document.activeElement as HTMLElement)?.blur();

Copy link
Copy Markdown

@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 `@src/hooks/useEmojiPreviewEvents.ts`:
- Around line 87-109: Remove the manual blur in the partially-below-fold branch:
delete the (document.activeElement as HTMLElement)?.blur?.() call in the code
path that calls setPreviewFromButton and detectEmojyPartiallyBelowFold inside
useEmojiPreviewEvents so we don't trigger the global body blur listener; instead
just return without blurring so the synchronous preview update isn't undone.
Also update the onLeave handler to ignore blur events that have relatedTarget
=== null when those blurs originated from this "skip focus" path (i.e., ensure
onLeave doesn't unconditionally clear the preview on null relatedTarget), using
the same identifying logic around
setPreviewFromButton/detectEmojyPartiallyBelowFold/focusElement to detect the
case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 143103e4-0188-4927-a0a0-4d255c71ccdb

📥 Commits

Reviewing files that changed from the base of the PR and between d41d255 and d21d3f6.

📒 Files selected for processing (1)
  • src/hooks/useEmojiPreviewEvents.ts

Comment on lines +87 to 109
if (!button) {
return;
}

// Set preview synchronously rather than relying on the focus listener:
// virtualized rows can unmount the button before focusElement's rAF fires
// (e.g. Mac trackpad inertia scroll), so button.focus() becomes a no-op
// and the preview would otherwise freeze.
setPreviewFromButton(button, setPreviewEmoji);

focusElement(button);
const belowFoldByPx = detectEmojyPartiallyBelowFold(button, bodyRef);
const buttonHeight = button.getBoundingClientRect().height;

if (belowFoldByPx < buttonHeight) {
// Partially below the fold: skip focus to avoid auto-scroll-into-view.
// Manually blur so the previous focus ring doesn't linger.
(document.activeElement as HTMLElement)?.blur?.();
return;
}

// Fully visible: focus too, so keyboard navigation continues from here.
focusElement(button);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't blur document.activeElement here.

That blur fires the existing body-level blur listener, and onLeave will usually clear the preview again because relatedTarget is null. So the new synchronous hover update gets undone for partially visible rows.

Suggested fix
 function useEmojiPreviewEvents(
   allow: boolean,
   setPreviewEmoji: React.Dispatch<React.SetStateAction<PreviewEmoji>>,
 ) {
@@
   useEffect(() => {
     if (!allow) {
       return;
     }
     const bodyRef = BodyRef.current;
+    let suppressLeave = false;
@@
       if (belowFoldByPx < buttonHeight) {
         // Partially below the fold: skip focus to avoid auto-scroll-into-view.
         // Manually blur so the previous focus ring doesn't linger.
+        suppressLeave = true;
         (document.activeElement as HTMLElement)?.blur?.();
+        suppressLeave = false;
         return;
       }

and in onLeave:

     function onLeave(e?: FocusEvent | MouseEvent) {
+      if (suppressLeave) {
+        return;
+      }
       if (e) {
         const relatedTarget = e.relatedTarget as HTMLElement;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useEmojiPreviewEvents.ts` around lines 87 - 109, Remove the manual
blur in the partially-below-fold branch: delete the (document.activeElement as
HTMLElement)?.blur?.() call in the code path that calls setPreviewFromButton and
detectEmojyPartiallyBelowFold inside useEmojiPreviewEvents so we don't trigger
the global body blur listener; instead just return without blurring so the
synchronous preview update isn't undone. Also update the onLeave handler to
ignore blur events that have relatedTarget === null when those blurs originated
from this "skip focus" path (i.e., ensure onLeave doesn't unconditionally clear
the preview on null relatedTarget), using the same identifying logic around
setPreviewFromButton/detectEmojyPartiallyBelowFold/focusElement to detect the
case.

Comment on lines +91 to 105
// Set preview synchronously rather than relying on the focus listener:
// virtualized rows can unmount the button before focusElement's rAF fires
// (e.g. Mac trackpad inertia scroll), so button.focus() becomes a no-op
// and the preview would otherwise freeze.
setPreviewFromButton(button, setPreviewEmoji);

focusElement(button);
const belowFoldByPx = detectEmojyPartiallyBelowFold(button, bodyRef);
const buttonHeight = button.getBoundingClientRect().height;

if (belowFoldByPx < buttonHeight) {
// Partially below the fold: skip focus to avoid auto-scroll-into-view.
// Manually blur so the previous focus ring doesn't linger.
(document.activeElement as HTMLElement)?.blur?.();
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Preview cleared after blur 🐞 Bug ≡ Correctness

In onMouseOver, the preview is set and then document.activeElement.blur() is called for
partially-below-fold emojis; the resulting captured blur event runs onLeave() which clears the
preview, so hovering a partially visible emoji can end with the preview immediately becoming null.
Agent Prompt
### Issue description
Hovering a partially-below-fold emoji calls `setPreviewFromButton(...)` and then blurs the current active element. Because the hook also listens for `blur` (capture) and `onLeave()` clears the preview, the blur can clear the just-set preview, leaving the preview blank.

### Issue Context
This only occurs in the `belowFoldByPx < buttonHeight` branch (partially visible), where focus is intentionally skipped.

### Fix Focus Areas
- src/hooks/useEmojiPreviewEvents.ts[91-105]

### Suggested change
Move the blur earlier (before setting the preview) in the partially-visible path, or re-set the preview after blurring, e.g.
- compute belowFold first
- if partially visible: blur active element, then call `setPreviewFromButton(button, setPreviewEmoji)`, then `return`
- else: keep current behavior
This preserves the intent (clear focus ring) without clearing the new preview.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@@ -105,7 +118,7 @@ export function useEmojiPreviewEvents(
}, [BodyRef, allow, setPreviewEmoji, isMouseDisallowed, allowMouseMove]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Leaking mouseover listeners 🐞 Bug ☼ Reliability

useEmojiPreviewEvents adds a capturing mouseover listener but removes it without capture=true,
so it may not be removed; since the effect depends on unstable function references (recreated each
render), repeated re-runs can accumulate listeners and multiply preview updates over time.
Agent Prompt
### Issue description
A capturing `mouseover` listener is added but not removed with matching capture options, which can prevent cleanup. Combined with unstable function dependencies that can cause the effect to re-run on renders, this can accumulate listeners and amplify preview updates.

### Issue Context
`addEventListener('mouseover', ..., true)` must be paired with `removeEventListener('mouseover', ..., true)` (or an equivalent `{ capture: true }` options object).

### Fix Focus Areas
- src/hooks/useEmojiPreviewEvents.ts[36-39]
- src/hooks/useEmojiPreviewEvents.ts[111-117]
- src/hooks/useEmojiPreviewEvents.ts[118-118]
- src/hooks/useDisallowMouseMove.ts[13-25]

### Suggested change
1) Fix cleanup to match capture:
- `bodyRef?.removeEventListener('mouseover', onMouseOver, true);`

2) Reduce effect churn by stabilizing dependencies:
- Wrap `useIsMouseDisallowed()` / `useAllowMouseMove()` returned functions in `useCallback`, or
- Remove `allowMouseMove` from the dependency list in `useEmojiPreviewEvents` (it’s currently unused there), and ensure any remaining function deps are stable.
This prevents repeated re-binding and avoids listener accumulation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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.

1 participant