Skip to content

fix: stop link preview flicker on hover within nested anchors - #246

Open
P-CAmilE wants to merge 1 commit into
the-ora:mainfrom
P-CAmilE:fix/link-preview-flicker
Open

fix: stop link preview flicker on hover within nested anchors#246
P-CAmilE wants to merge 1 commit into
the-ora:mainfrom
P-CAmilE:fix/link-preview-flicker

Conversation

@P-CAmilE

Copy link
Copy Markdown

Problem

Hovering a link showed the bottom-left URL preview flickering. The preview would briefly disappear and reappear while the cursor moved within the same link (especially links with nested child elements like <img>/<span>), and would linger or clear inconsistently when leaving a link.

Root cause

The injected hover script in OraBrowserScripts.swift used mouseover/mouseout (which bubble) registered on document with capture, but the leave handler compared against event.currentTarget — which is always document — so document.contains(relatedTarget) was almost always true and provided no real signal for "still inside the same anchor". The clear path only fired when relatedTarget was null (leaving the window, entering an iframe, hitting native controls), so moving between nested children of the same <a> produced alternating mouseout-clear / mouseover-reset messages to the native side → @Published hoveredLinkURL toggled → LinkPreview flashed.

A secondary issue: BrowserWebContentView had no animation(value:) keyed to hoveredLinkURL, so the LinkPreview's .transition(.opacity) never engaged — appearance/disappearance was a hard cut, amplifying the visual flicker.

Fix

ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift (root cause)

Compare the from and to anchor via closest('a[href]') on both event.target and event.relatedTarget. Only post a linkHover message when the enclosing anchor actually changes:

  • Moving within the same link's children: from === to → no message → no flicker.
  • Crossing from one link to another: posts the new URL immediately.
  • Leaving a link to non-link content: posts empty string immediately (no longer waits for the next mouseover on a non-link area).
function currentAnchor(node) {
    return node && node.closest ? node.closest('a[href]') : null;
}
function onMouseOver(event) {
    const anchor = currentAnchor(event.target);
    postHover(anchor ? anchor.href : '');
}
function onMouseOut(event) {
    const from = currentAnchor(event.target);
    const to = currentAnchor(event.relatedTarget);
    if (from !== to) postHover(to ? to.href : '');
}

ora/Features/Browser/Views/BrowserWebContentView.swift (visual polish)

Add .animation(.easeOut(duration: 0.12), value: tab.hoveredLinkURL) to the BrowserPageView overlay chain so LinkPreview's existing .transition(.opacity) actually engages — fade in/out instead of a hard cut.

Verification

  • xcodebuild build -scheme ora -destination "platform=macOS" -project Ora.xcodeproj CODE_SIGNING_ALLOWED=NOBUILD SUCCEEDED
  • lefthook pre-commit (swiftformat + swiftlint) and pre-push (full build) hooks passed clean.
  • Manual: launched the unsigned Debug build, hovered links with nested children, confirmed the preview no longer flickers; cross-link transitions and link-leave clearing behave correctly.

Notes

  • No change to TabBrowserPageDelegate.swift — its hoveredLinkURL assignment is correct; equal values on @Published don't trigger re-renders.
  • Did not switch to mouseenter/mouseleave — they don't bubble, so they can't be delegated on document and would require per-anchor listeners (more code, more complexity for no gain).
  • No new files, no new abstractions.

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes link-preview flicker by correcting the mouseover/mouseout hover-detection logic in the injected JavaScript and wiring up SwiftUI's existing opacity transition with an animation modifier.

  • Root-cause fix (OraBrowserScripts.swift): replaces the broken event.currentTarget.contains(relatedTarget) guard with closest('a[href]') identity comparison on both event.target and event.relatedTarget, so moving between nested children of the same anchor no longer posts a clear-then-restore message pair to the native bridge.
  • Visual polish (BrowserWebContentView.swift): adds .animation(.easeOut(duration: 0.12), value: tab.hoveredLinkURL) so that LinkPreview's pre-existing .transition(.opacity) actually engages, producing a smooth fade instead of a hard cut on appear/disappear.

Confidence Score: 4/5

Safe to merge — both changes are narrowly scoped to hover-state signalling and do not touch navigation, data persistence, or security-sensitive paths.

The onMouseOut fix eliminates the flicker cleanly. The only remaining gap is that onMouseOver still posts a bridge message for every child-element transition within the same anchor (since it has no equivalent same-anchor guard), producing redundant IPC calls that the native side receives but discards visually. This is harmless today but is a minor inefficiency worth closing in a follow-up.

No files require special attention; OraBrowserScripts.swift carries the one minor redundancy noted above.

Important Files Changed

Filename Overview
ora/Core/BrowserEngine/Scripts/OraBrowserScripts.swift Replaces the broken mouseover/mouseout handlers with anchor-aware logic using closest('a[href]') to compare the entering and leaving anchors, preventing spurious bridge posts when the cursor moves within nested children of the same link. Minor: onMouseOver still posts for every child transition even when the resolved anchor is unchanged, generating redundant bridge messages.
ora/Features/Browser/Views/BrowserWebContentView.swift Adds .animation(.easeOut(duration: 0.12), value: tab.hoveredLinkURL) to engage the existing LinkPreview opacity transition; one-line, low-risk change.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant JS as JS (injected script)
    participant Bridge as WKScriptMessageHandler
    participant Tab as Tab.hoveredLinkURL (@Published)
    participant UI as LinkPreview (SwiftUI)

    Note over JS,UI: Before fix – cursor moves img → span inside same anchor
    JS->>Bridge: mouseout → postHover("")
    Bridge->>Tab: "hoveredLinkURL = """
    Tab->>UI: hide LinkPreview (hard cut)
    JS->>Bridge: mouseover → postHover("https://…")
    Bridge->>Tab: "hoveredLinkURL = "https://…""
    Tab->>UI: show LinkPreview (hard cut) → FLICKER

    Note over JS,UI: After fix – cursor moves img → span inside same anchor
    JS->>JS: "onMouseOut: from === to (same anchor) → skip"
    JS->>Bridge: onMouseOver → postHover("https://…") [same URL]
    Bridge->>Tab: "hoveredLinkURL = "https://…" (no visual change)"
    Tab->>UI: animation(value:) sees no value change → stable

    Note over JS,UI: After fix – cursor leaves anchor to non-link
    JS->>Bridge: "onMouseOut: from=anchorX, to=null → postHover("")"
    Bridge->>Tab: "hoveredLinkURL = """
    Tab->>UI: .easeOut(0.12) fade-out via .transition(.opacity)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant JS as JS (injected script)
    participant Bridge as WKScriptMessageHandler
    participant Tab as Tab.hoveredLinkURL (@Published)
    participant UI as LinkPreview (SwiftUI)

    Note over JS,UI: Before fix – cursor moves img → span inside same anchor
    JS->>Bridge: mouseout → postHover("")
    Bridge->>Tab: "hoveredLinkURL = """
    Tab->>UI: hide LinkPreview (hard cut)
    JS->>Bridge: mouseover → postHover("https://…")
    Bridge->>Tab: "hoveredLinkURL = "https://…""
    Tab->>UI: show LinkPreview (hard cut) → FLICKER

    Note over JS,UI: After fix – cursor moves img → span inside same anchor
    JS->>JS: "onMouseOut: from === to (same anchor) → skip"
    JS->>Bridge: onMouseOver → postHover("https://…") [same URL]
    Bridge->>Tab: "hoveredLinkURL = "https://…" (no visual change)"
    Tab->>UI: animation(value:) sees no value change → stable

    Note over JS,UI: After fix – cursor leaves anchor to non-link
    JS->>Bridge: "onMouseOut: from=anchorX, to=null → postHover("")"
    Bridge->>Tab: "hoveredLinkURL = """
    Tab->>UI: .easeOut(0.12) fade-out via .transition(.opacity)
Loading

Reviews (1): Last reviewed commit: "fix: stop link preview flicker on hover ..." | Re-trigger Greptile

Comment on lines 100 to 103
function onMouseOver(event) {
const anchor = event.target.closest && event.target.closest('a[href]');
const anchor = currentAnchor(event.target);
postHover(anchor ? anchor.href : '');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 onMouseOver still posts redundant bridge messages within the same anchor

onMouseOut now correctly suppresses posts when the anchor stays the same, but onMouseOver unconditionally posts for every child element transition. Moving the cursor across <a href="/foo"><img/><span>text</span></a> produces a mouseover on <img> and then on <span>, both resolving to the same anchor and both issuing a postHover("/foo"). Each call reaches the native side, assigns hoveredLinkURL to an equal string, and triggers objectWillChange — the visual flicker is gone (thanks to the onMouseOut fix), but the redundant IPC can be eliminated by tracking the last-posted anchor reference in JS and early-returning when it hasn't changed.

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