Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ describe("createSearchController", () => {
it("re-evaluates after reset following DOM mutation", () => {
const c = makeContainer("<p>foo</p>");
const ui = makeUI();
ui.input.value = "foo";
const ctrl = createSearchController(c, ui);
ctrl.setQuery("foo");
expect(ctrl.getState().total).toBe(1);
Expand All @@ -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("<p>foo foo</p>");
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 = "<p>foo bar foo</p>";
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("<p>cafe café</p>");
const ui = makeUI();
Expand Down Expand Up @@ -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("<p>eee</p>");
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();
}
});
});
20 changes: 20 additions & 0 deletions src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -356,6 +369,9 @@ export function createSearchController(
function applyHighlights(): void {
if (supportsCSSHighlights) {
applyCSSHighlights(ranges, currentIndex);
if (ranges.length === 0) {
forceHighlightRepaint();
}
return;
}
clearMarkFallback(container);
Expand Down Expand Up @@ -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();
Expand Down
Loading