Fix highlight bug

What does this MR do and why?

Fixes #565353 (closed) — browser Ctrl+F (⌘+F) highlight disappears immediately after the browser scrolls to a match in the blob viewer.

Root cause

The blob viewer renders file content in 70-line chunks. Each chunk initially renders as plain {{ rawContent }} text so that browser find can locate text across the whole file. When a chunk becomes visible — including when the browser scrolls to it as a result of a find match — GlIntersectionObserver fires handleChunkAppear, setting hasAppeared = true. This causes shouldHighlight to become true, which triggers a v-if swap from {{ rawContent }} to v-safe-html="highlightedContent". The v-safe-html directive calls el.textContent = '' then el.appendChild(sanitize(...)), destroying the text node the browser's find was anchored to. The browser loses its match.

There is no JavaScript API to detect that the browser scrolled to a chunk because of a find match rather than user scrolling — window.getSelection() is empty during native find in all tested browsers. Any timing-based deferral approach either fires too early (bug persists) or introduces a visible rendering delay on every chunk scroll.

Solution — two-layer chunk rendering

Each chunk now renders two layers inside the <pre>:

Raw text layer (on top, z-index: 1):

  • Rendered with v-once — created once, never mutated by Vue
  • color: transparent — invisible to the user, but the text node exists in the DOM
  • Browser find anchors here permanently — the anchor can never be broken
  • Pointer events on this layer are forwarded to the highlighted layer below via forwardEventToHighlight

Syntax-highlighted layer (underneath, position: absolute):

  • Still gated by v-if="shouldHighlight" — the lazy rendering performance model is fully preserved, off-screen chunks remain lightweight
  • Marked inert — excluded from browser find entirely, preventing duplicate matches
  • May be freely added, removed, and re-rendered without affecting the find anchor

The inert attribute is the load-bearing piece: without it both layers would carry the same findable text and find would show 2× duplicate matches per search term. aria-hidden was considered but does not affect browser find — only display: none, visibility: hidden, and inert exclude content from find, and only inert keeps the layer visible to the user.

Code navigation

Because the highlighted layer is inert, pointer events on hljs spans (used for code navigation hover popovers and click handlers) would be dead. A forwarding bridge in forwardEventToHighlight handles this:

  1. Temporarily removes inert from the highlighted layer
  2. Uses document.elementsFromPoint to find the hljs span at the exact pointer coordinates
  3. Dispatches a synthetic MouseEvent on that span — it bubbles normally so document-level delegation still fires
  4. Re-adds inert immediately

This is wired to click, mouseover, and mouseout on the raw layer.

Screen recording

Before After

Closes #565353 (closed)

How to set up and validate locally

  1. Open a large file in the blob viewer, e.g. https://gitlab.com/gitlab-org/gitlab-services/design.gitlab.com/-/blob/main/packages/gitlab-ui/src/tokens/build/css/tokens.css
  2. Scroll to the middle of the page, copy a value from a line beyond the first 70 lines
  3. Scroll back to the top and refresh the page
  4. Press Ctrl+F (⌘+F), paste the value
  5. Observe that the browser scrolls to the match and the highlight persists
  6. Verify that code navigation (hover popovers, click-to-definition) still works on syntax-highlighted spans

MR acceptance checklist

Related to #565353 (closed)

Edited by Joseph Fletcher

Merge request reports

Loading