From cb5b1c15c9cae96c78585fefb5a616f80bde43cf Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre Dupuy Date: Wed, 1 Jul 2026 21:24:37 +0200 Subject: [PATCH] =?UTF-8?q?fix(search):=20stop=20lingering=20highlights=20?= =?UTF-8?q?on=20document=20switch=20and=201=E2=86=920=20clear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two distinct causes of stale search highlights: - reset() (called by loadFile after re-rendering) never resynced its internal query with the input field, so switching documents re-ran the previous term on the freshly rendered content — even when the field was empty. It now reads ui.input.value, the source of truth. - On the CSS Custom Highlight path (WKWebView), removing the last highlight from the registry does not always repaint the glyph, most visibly when the query goes from 1 char to 0. forceHighlightRepaint() nudges an imperceptible container repaint after clearing. Adds regression tests for both, including one through the real input-event + debounce path. --- src/search.test.ts | 51 ++++++++++++++++++++++++++++++++++++++++++++++ src/search.ts | 20 ++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/search.test.ts b/src/search.test.ts index e25025f..35294e5 100644 --- a/src/search.test.ts +++ b/src/search.test.ts @@ -201,6 +201,7 @@ describe("createSearchController", () => { it("re-evaluates after reset following DOM mutation", () => { const c = makeContainer("

foo

"); const ui = makeUI(); + ui.input.value = "foo"; const ctrl = createSearchController(c, ui); ctrl.setQuery("foo"); expect(ctrl.getState().total).toBe(1); @@ -209,6 +210,27 @@ describe("createSearchController", () => { expect(ctrl.getState().total).toBe(2); }); + it("reset() drops a stale query once the field is empty (document switch)", () => { + // Repro of the ghost-highlight bug: user searches in doc A, empties the + // field, then opens doc B. loadFile() calls reset(); it must not re-apply + // the old query to the freshly rendered document. + const c = makeContainer("

foo foo

"); + const ui = makeUI(); + ui.input.value = "foo"; + const ctrl = createSearchController(c, ui); + ctrl.setQuery("foo"); + expect(ctrl.getState().total).toBe(2); + + // User clears the field, then a new document is rendered. + ui.input.value = ""; + c.innerHTML = "

foo bar foo

"; + ctrl.reset(); + + expect(ctrl.getState().total).toBe(0); + expect(ui.counter.textContent).toBe(""); + expect(c.querySelectorAll("mark.mdv-search-hit")).toHaveLength(0); + }); + it("recomputes when options change", () => { const c = makeContainer("

cafe café

"); const ui = makeUI(); @@ -315,4 +337,33 @@ describe("createSearchController (CSS Custom Highlight API path)", () => { expect(reg.size).toBe(0); expect(ctrl.getState().total).toBe(0); }); + + it("clears highlights when a 1-char query is deleted to empty via input events", async () => { + vi.useFakeTimers(); + try { + const { reg, createSearchController: create } = + await loadWithCSSHighlights(); + const c = makeContainer("

eee

"); + const ui = makeUI(); + const ctrl = create(c, ui); + + // Type one character; the debounced input handler applies highlights. + ui.input.value = "e"; + ui.input.dispatchEvent(new Event("input")); + vi.advanceTimersByTime(100); + expect(reg.size).toBeGreaterThan(0); + expect(ctrl.getState().total).toBeGreaterThan(0); + + // Delete it back to empty via a normal keyboard edit (input event only). + ui.input.value = ""; + ui.input.dispatchEvent(new Event("input")); + vi.advanceTimersByTime(100); + + expect(reg.size).toBe(0); + expect(ctrl.getState().total).toBe(0); + expect(ui.counter.textContent).toBe(""); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/search.ts b/src/search.ts index a716db0..69afbec 100644 --- a/src/search.ts +++ b/src/search.ts @@ -343,11 +343,24 @@ export function createSearchController( return index; } + // WebKit (WKWebView) sometimes leaves the last CSS Custom Highlight glyph + // painted after it is removed from the registry — most visible when the final + // match is deleted (query goes 1 char → 0). Nudge an imperceptible repaint of + // the container so the stale paint is invalidated. No-op off the CSS path. + function forceHighlightRepaint(): void { + const style = container.style; + const prev = style.opacity; + style.opacity = prev === "0.999999" ? "0.999998" : "0.999999"; + void container.offsetHeight; + style.opacity = prev; + } + function clearHighlights(): void { if (supportsCSSHighlights) { const reg = (CSS as unknown as CSSWithHighlights).highlights; reg.delete(HIGHLIGHT_NAME); reg.delete(HIGHLIGHT_CURRENT_NAME); + forceHighlightRepaint(); } else { clearMarkFallback(container); } @@ -356,6 +369,9 @@ export function createSearchController( function applyHighlights(): void { if (supportsCSSHighlights) { applyCSSHighlights(ranges, currentIndex); + if (ranges.length === 0) { + forceHighlightRepaint(); + } return; } clearMarkFallback(container); @@ -468,6 +484,10 @@ export function createSearchController( index = null; ranges = []; currentIndex = -1; + // Re-sync to the live input: it is the source of truth. Loading a new + // document must not re-highlight a stale internal query (e.g. one the user + // already cleared from the field) on the freshly rendered content. + query = ui.input.value; clearHighlights(); if (query) { recompute();