Skip to content

fix(muya): keep the link popover open when moving between links (#5313) - #5324

Merged
Jocs merged 4 commits into
marktext:developfrom
anandghegde:fix/5313-link-tooltip-stale-hide-timer
Sep 14, 2026
Merged

Jocs merged 4 commits into
marktext:developfrom
anandghegde:fix/5313-link-tooltip-stale-hide-timer

Conversation

@anandghegde

@anandghegde anandghegde commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #5313.

Root cause

linkMouseEvents.ts drives the popover with one event: hovering a link emits muya-link-tools with a reference, and leaving one emits it with reference: null. LinkTools.listen() turns the second form into a delayed hide:

else {
    if (this._hideTimer)
        clearTimeout(this._hideTimer);
    this._hideTimer = setTimeout(() => { this.hide(); }, 500);
}

The show branch never touched _hideTimer. Because moving the pointer from one element to another fires mouseout on the old one before mouseover on the new one, the sequence for two adjacent links is:

  1. mouseout on link A → { reference: null } → arms a 500ms hide
  2. mouseover on link B → { reference: B } → popover shown for B, stale timer still armed
  3. 500ms later → hide() → the popover closes while the pointer is on link B

The delay exists so the pointer can travel from the link to the popover without it closing. It was only ever cancelled by the mouseover handler on the popover container itself, so any path that re-enters a link first left the timer running.

That matches the report exactly: it is worst with consecutive anchor lines, because the pointer then crosses links almost continuously, so a stale hide is nearly always pending. The popover flickers and vanishes before it can be clicked, leaving Ctrl+Click as the only way to follow a link.

The fix

Cancel the pending hide when a reference arrives:

if (this._hideTimer) {
    clearTimeout(this._hideTimer);
    this._hideTimer = null;
}

Nine lines including the comment, in the one branch that was missing it. The delay itself is unchanged, so leaving a link and going nowhere still hides after the usual 500ms.

Verification

Two tests added to the existing linkTools.spec.ts, using its bootLinkTools() harness (real EventCenter, real LinkTools) with fake timers, asserting on BaseFloat's public status flag:

  • keeps the popover open when the pointer moves straight to another link — replays the exact three-step sequence above and advances 1000ms.
  • still hides when the pointer leaves a link and goes nowhere — guards the other direction, so the fix cannot degrade into "never hides".

Reverting just the index.ts change makes the first test fail and leaves the second passing:

- Expected   + Received

- true
+ false

 ❯ src/ui/linkTools/__tests__/linkTools.spec.ts:222:34
    expect(tools.status).toBe(true);

 Test Files  1 failed (1)
      Tests  1 failed | 7 passed (8)

With the fix, the full muya suite is green:

 Test Files  222 passed (222)
      Tests  1518 passed (1518)

eslint src/ui/linkTools and tsc --noEmit are both clean in packages/muya.

Notes

I could not reproduce on Windows 11 as reported — I verified on macOS. The mechanism is platform-independent (DOM event ordering plus a timer), and the reporter's "densely stacked anchors" detail is what makes a stale hide almost always pending, so I'm confident it is the same bug. If you would like it confirmed on Windows before merging, I'm happy to wait for someone with that setup.

I left the mouseOverHandler on the popover container alone; it clears the timer without nulling it, which is harmless since every read is guarded.

…text#5313)

Hovering a link emits `muya-link-tools` with a reference; leaving one emits
it with `reference: null`, which arms a 500ms hide. Moving the pointer from
one link to another fires `mouseout` on the old link before `mouseover` on
the new one, so that hide is still pending when the popover is shown for the
second link. Nothing cancelled it, so it fired 500ms later and closed the
popover while the pointer was sitting on a link.

With consecutive anchor lines the pointer crosses a link almost continuously,
so a stale hide is nearly always pending and the popover appears to flicker
and vanish before it can be clicked, leaving Ctrl+Click as the only way to
follow a link.

Cancel the pending hide when a reference arrives. Leaving a link and going
nowhere still hides after the usual delay.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01XWr9UtfXxdTdfHMydFh4V8
@anandghegde
anandghegde force-pushed the fix/5313-link-tooltip-stale-hide-timer branch from e00cedc to bdedf7b Compare September 14, 2026 04:45
Jocs and others added 3 commits September 14, 2026 14:07
…on fails

The marktext#5313 tests appended their stub anchors to document.body and removed
them after the assertions, so a failing expect leaked them into later
tests. Mount them inside the session's domNode, which the shared teardown
already removes, and move the fake-timer setup into beforeEach/afterEach.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FbzrQyyA4iG32wvK6j3ki6
The marktext#5313 regression test only checked `status === true`, which the first
link's popover had already set, so an implementation that cancelled the
stale hide but never showed the second link's popover still passed. Give
the second link its own linkInfo and assert that show() last ran for that
exact element and that the popover now holds the second link's info.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FbzrQyyA4iG32wvK6j3ki6
The fix comment restated the timer delay, so changing the delay would have
left it wrong, and the test's describe comment repeated the same event-order
explanation. Keep one short explanation at the cancel site and point the
test at it, per the commenting guidelines.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01FbzrQyyA4iG32wvK6j3ki6
@Jocs

Jocs commented Sep 14, 2026

Copy link
Copy Markdown
Member

Thanks @anandghegde. The root cause and the fix are both right.

Verified

Pushed on top (3 small commits)

  • 8f5dd06b: the stub anchors now live in the session's domNode. The shared teardown removes that node, so the anchors are cleaned up even when an assertion fails. Fake timers moved to beforeEach/afterEach.
  • e6aa202b: the regression test now also checks that the popover was shown again for the second link. show must last run with that exact element, and _linkInfo must be the second link's info. Before, the first link had already set status === true. A version that cancelled the hide but never showed the second popover still passed. toHaveBeenLastCalledWith is not enough here, because two empty <a> stubs are deep-equal.
  • a6a48523: the two comments are now one short explanation where the timer is cancelled, and it no longer hard-codes the delay.

Left out of this PR (the problem already existed)

The popover sits 5px below its link (offsetOptions.mainAxis: 5), but two links on consecutive lines are only about 4px apart. So when the pointer moves straight down toward the popover, it touches the next link first, and the popover moves under that link:

  • At 1px per frame, it hops down one line at a time until the last link.
  • At 3px per frame, it stops under link 2, so "jump" follows link 2's target.

With mainAxis: 0, every layout and speed I tried reached the popover of the link actually hovered. That changes the visual gap and needs a design decision, so it's left for a follow-up.

Merging, thanks!

@Jocs
Jocs merged commit 2164c58 into marktext:develop Sep 14, 2026
6 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] Anchor hover tooltip disappears instantly when anchor lines are consecutive, making click selection impossible

2 participants