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();