Warn when an encrypted search runs before the index has finished building (#34001)

* Warn when an encrypted search runs before the index has finished building

When a search runs in an encrypted room while the local Seshat index is still
crawling not-yet-indexed history, results can silently come back partial.
SearchWarning now subscribes to the event index's changedCheckpoint progress and
shows a polite (role=status) notice while the crawl is in progress, clearing
automatically the moment indexing finishes.

* Scope the partial-index warning to the room being searched

The warning was driven by `currentRoom() !== null`, which is a global signal:
it is non-null while the crawler has any outstanding checkpoint for any room.
Searching a fully-crawled room while an unrelated room was still being crawled
therefore claimed the results may be incomplete when they were not.

Drive it from `crawlingRooms()` instead, which reports the rooms with
outstanding checkpoints by id, and pass the search scope and room id in from
RoomSearchAuxPanel: a room-scoped search now asks only about that room, while
an all-rooms search still reacts to any outstanding checkpoint. Using room ids
throughout also avoids `currentRoom()` returning null, and so under-reporting a
crawl, when the js-sdk does not know the room at the head of the queue.

The `changedCheckpoint` payload only carries the globally-current room and
cannot answer a per-room question, so the handler re-reads the checkpoint set.

* Also warn when the searched room has not been indexed at all

The crawl set cannot see a room that has no checkpoint: before the initial
checkpoints have been seeded, such a room is absent from it and looks identical
to one that has been fully crawled. That is the case issue #32253 describes, so
ask isRoomIndexed() as well, and warn when the index holds no events for the
room being searched.

Only ask it while the crawler still has work outstanding. That is what the
warning claims, and the index has no event for its contents changing --
changedCheckpoint fires on checkpoint transitions only, and an idle crawler is
silent -- so a warning raised once the crawler had drained would never be
re-evaluated and would stick.

Rename the hook to useIsIndexIncomplete, as it no longer answers the narrower
question of whether a crawl is in progress. Re-seed it from the checkpoint set
on each scope or room change so that the previous search's answer is not left
on screen while the lookup is in flight, but not on each checkpoint change,
which would blink an already-earned warning off and on again.

Also reword the crawlingRooms() doc, which described the set as the rooms being
crawled when it holds every queued checkpoint too.

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
hayyaksi
2026-07-17 16:20:30 +00:00
committed by GitHub
co-authored by Michael Telatynski
parent d0a70fb1da
commit 13aa6fcc46
5 changed files with 489 additions and 7 deletions
@@ -6,10 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import React, { type JSX, type ReactNode, useCallback, useEffect, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import EventIndexPeg from "../../../indexing/EventIndexPeg";
import type EventIndex from "../../../indexing/EventIndex";
import { SearchScope } from "../../../Searching";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import dis from "../../../dispatcher/dispatcher";
@@ -26,11 +28,131 @@ interface IProps {
isRoomEncrypted?: boolean;
kind: WarningKind;
showLogo?: boolean;
/** The scope of the search being warned about; only meaningful for {@link WarningKind.Search}. */
scope?: SearchScope;
/** The room being searched. Mirrors `SearchInfo.roomId`: `undefined` when searching all rooms. */
roomId?: string;
}
export default function SearchWarning({ isRoomEncrypted, kind, showLogo = true }: IProps): JSX.Element {
/**
* Track whether the index is still missing history that is relevant to the given search.
*
* A room-scoped search is incomplete if the crawler still holds a checkpoint for the room
* ({@link EventIndex.crawlingRooms}, which covers both the checkpoint being crawled right now and
* those still queued behind it), or if the index holds no events for it at all
* ({@link EventIndex.isRoomIndexed}) — which is how a room looks before its checkpoint has been
* seeded, and is the case the checkpoint set alone cannot see.
*
* The second question is only asked while the crawler still has work outstanding, for two reasons.
* It is what the warning claims ("your search index is still being built"), and the index has no
* event for its contents changing — `changedCheckpoint` fires only on checkpoint transitions, and
* an idle crawler is silent — so a warning raised once the crawler has drained would never be
* re-evaluated and would stick.
*
* Neither signal proves completeness: `isRoomIndexed` reports only that the index holds *some*
* events for a room, not all of them, and a room has no checkpoint if it never had a
* back-pagination token to crawl from or if its checkpoint was dropped because the server rejected
* the request. So this under-warns rather than over-warns.
*
* An all-rooms search cannot ask the per-room question, so it uses the checkpoint set alone.
*
* The `changedCheckpoint` payload carries only the globally-current room and so cannot answer a
* per-room question: we re-read the index on each event rather than trust it.
*
* @param index The event index to observe, or `null` if there is no index.
* @param scope The scope of the search, if this warning is being rendered for one.
* @param roomId The room being searched, or `undefined` when searching all rooms.
* @returns `true` while the index is known to be missing history for the search, `false` otherwise.
*/
function useIsIndexIncomplete(index: EventIndex | null, scope?: SearchScope, roomId?: string): boolean {
const readCheckpoints = useCallback((): { relevant: boolean; anyOutstanding: boolean } => {
if (!index) return { relevant: false, anyOutstanding: false };
const { crawlingRooms } = index.crawlingRooms();
// Fall back to the global check when we don't know which room is being searched: the room
// id may still be undefined while a room alias is being resolved.
const roomScoped = scope === SearchScope.Room && roomId !== undefined;
return {
relevant: roomScoped ? crawlingRooms.has(roomId) : crawlingRooms.size > 0,
anyOutstanding: crawlingRooms.size > 0,
};
}, [index, scope, roomId]);
// The checkpoint half of the answer is known synchronously, so seed from it rather than
// rendering an unwarned search for a room we already know is being crawled.
const [incomplete, setIncomplete] = useState<boolean>(() => readCheckpoints().relevant);
useEffect(() => {
if (!index) {
setIncomplete(false);
return;
}
// Guards against a slow isRoomIndexed() response overwriting a newer one, or landing after
// the scope changed or the component unmounted.
let generation = 0;
// Answer this scope and room from the checkpoint set up front, so that the previous
// search's result is not left on screen while the first lookup below is in flight. Doing
// this per effect run rather than per event matters: a checkpoint change is not a new
// question, and resetting on one would blink an already-earned warning off and on again.
setIncomplete(readCheckpoints().relevant);
const update = async (): Promise<void> => {
const current = ++generation;
const { relevant, anyOutstanding } = readCheckpoints();
if (relevant) {
setIncomplete(true);
return;
}
if (!anyOutstanding || scope !== SearchScope.Room || roomId === undefined) {
setIncomplete(false);
return;
}
// Nothing is queued for this room yet, but the index may hold nothing for it at all.
// `undefined` means there is no index manager to ask, which is not evidence either way.
const indexed = await index.isRoomIndexed(roomId);
if (current === generation) setIncomplete(indexed === false);
};
const onChangedCheckpoint = (): void => {
void update();
};
// Re-sync in case the crawl state changed between the initial render and the subscription.
onChangedCheckpoint();
index.on("changedCheckpoint", onChangedCheckpoint);
return () => {
generation++;
index.removeListener("changedCheckpoint", onChangedCheckpoint);
};
}, [index, scope, roomId, readCheckpoints]);
return incomplete;
}
export default function SearchWarning({ isRoomEncrypted, kind, showLogo = true, scope, roomId }: IProps): JSX.Element {
const eventIndex = EventIndexPeg.get();
const indexIncomplete = useIsIndexIncomplete(eventIndex, scope, roomId);
if (!isRoomEncrypted) return <></>;
if (EventIndexPeg.get()) return <></>;
if (eventIndex) {
// The index is still missing history for this search, so it may silently return partial
// results (#32253). Warn the user.
if (indexIncomplete && kind === WarningKind.Search) {
// This warning appears dynamically while a search panel is already open (the crawler
// finishes draining mid-session), so mark it as a polite live region for screen readers.
return (
<div className="mx_SearchWarning" role="status">
<span>{_t("seshat|warning_kind_search_partial")}</span>
</div>
);
}
return <></>;
}
if (EventIndexPeg.error) {
return (
@@ -45,7 +45,13 @@ const RoomSearchAuxPanel: React.FC<Props> = ({ searchInfo, isRoomEncrypted, onSe
) : (
<InlineSpinner />
)}
<SearchWarning kind={WarningKind.Search} isRoomEncrypted={isRoomEncrypted} showLogo={false} />
<SearchWarning
kind={WarningKind.Search}
isRoomEncrypted={isRoomEncrypted}
showLogo={false}
scope={scope}
roomId={searchInfo?.roomId}
/>
</div>
</div>
<div className="mx_RoomSearchAuxPanel_buttons">
+2 -1
View File
@@ -2434,7 +2434,8 @@
"warning_kind_files": "This version of %(brand)s does not support viewing some encrypted files",
"warning_kind_files_app": "Use the <a>Desktop app</a> to see all encrypted files",
"warning_kind_search": "This version of %(brand)s does not support searching encrypted messages",
"warning_kind_search_app": "Use the <a>Desktop app</a> to search encrypted messages"
"warning_kind_search_app": "Use the <a>Desktop app</a> to search encrypted messages",
"warning_kind_search_partial": "Results may be incomplete because your search index is still being built."
},
"setting": {
"help_about": {
+4 -1
View File
@@ -992,7 +992,10 @@ export default class EventIndex extends EventEmitter {
}
public crawlingRooms(): {
/** The rooms that we are currently crawling. */
/**
* The rooms with an outstanding crawler checkpoint: the one being crawled right now, and
* those still queued behind it.
*/
crawlingRooms: Set<string>;
/** All the encrypted rooms known by the MatrixClient. */
@@ -1,4 +1,5 @@
/*
Copyright 2026 hayaksi1
Copyright 2024 New Vector Ltd.
Copyright 2024 The Matrix.org Foundation C.I.C.
@@ -6,15 +7,76 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { render } from "jest-matrix-react";
import { act, render } from "jest-matrix-react";
import React from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import SdkConfig from "../../../../../src/SdkConfig";
import SearchWarning, { WarningKind } from "../../../../../src/components/views/elements/SearchWarning";
import EventIndexPeg from "../../../../../src/indexing/EventIndexPeg";
import { type default as EventIndex } from "../../../../../src/indexing/EventIndex";
import { SearchScope } from "../../../../../src/Searching";
const SEARCHED_ROOM = "!searched:example.org";
const OTHER_ROOM = "!other:example.org";
const PARTIAL_WARNING = "Results may be incomplete because your search index is still being built.";
/**
* A minimal fake EventIndex exposing only the surface that SearchWarning consumes:
* `crawlingRooms()` (the rooms with outstanding crawler checkpoints), `isRoomIndexed()` (whether
* the index holds any events for a room) and the `changedCheckpoint` emitter.
*/
class FakeEventIndex {
private listeners = new Map<string, Set<(...args: any[]) => void>>();
/**
* @param crawling Rooms with an outstanding crawler checkpoint.
* @param indexed Rooms the index holds events for. Defaults to `undefined`, meaning
* `isRoomIndexed` resolves `undefined`, as it does when there is no index manager.
*/
public constructor(
private crawling: string[] = [],
private indexed?: string[],
) {}
public crawlingRooms(): { crawlingRooms: Set<string>; totalRooms: Set<string> } {
return { crawlingRooms: new Set(this.crawling), totalRooms: new Set(this.crawling) };
}
public async isRoomIndexed(roomId: string): Promise<boolean | undefined> {
return this.indexed === undefined ? undefined : this.indexed.includes(roomId);
}
public on(event: string, listener: (...args: any[]) => void): void {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(listener);
}
public removeListener(event: string, listener: (...args: any[]) => void): void {
this.listeners.get(event)?.delete(listener);
}
/**
* Test helper: simulate the crawler advancing (or finishing, when `crawling` is empty).
*
* The real index emits only the globally-current room, which cannot answer a per-room
* question, so `currentRoom` is passed through purely to prove consumers ignore it.
*/
public emitChangedCheckpoint(crawling: string[], currentRoom: Room | null = null): void {
this.crawling = crawling;
this.listeners.get("changedCheckpoint")?.forEach((listener) => listener(currentRoom));
}
}
describe("<SearchWarning />", () => {
afterEach(() => {
EventIndexPeg.index = null;
EventIndexPeg.error = undefined;
});
describe("with desktop builds available", () => {
beforeEach(() => {
EventIndexPeg.index = null;
SdkConfig.put({
brand: "Element",
desktop_builds: {
@@ -42,4 +104,292 @@ describe("<SearchWarning />", () => {
expect(asFragment()).toMatchSnapshot();
});
});
describe("with the event index present", () => {
const setIndex = (index: FakeEventIndex): void => {
EventIndexPeg.index = index as unknown as EventIndex;
};
/** Let the pending isRoomIndexed() lookup settle and React re-render off the back of it. */
const settle = async (): Promise<void> => {
await act(async () => {});
};
it("warns a room-scoped search while the searched room is still being crawled", async () => {
setIndex(new FakeEventIndex([SEARCHED_ROOM], []));
const { queryByText, queryByRole } = render(
<SearchWarning
isRoomEncrypted={true}
kind={WarningKind.Search}
scope={SearchScope.Room}
roomId={SEARCHED_ROOM}
/>,
);
await settle();
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
// The notice appears dynamically while the panel is open, so it must be a live region (#32253).
expect(queryByRole("status")).toBeInTheDocument();
});
it("does not warn a room-scoped search when only an unrelated room is still being crawled", async () => {
setIndex(new FakeEventIndex([OTHER_ROOM], [SEARCHED_ROOM]));
const { container } = render(
<SearchWarning
isRoomEncrypted={true}
kind={WarningKind.Search}
scope={SearchScope.Room}
roomId={SEARCHED_ROOM}
/>,
);
await settle();
expect(container).toBeEmptyDOMElement();
});
it("warns a room-scoped search when the index holds no events for the searched room", async () => {
// Nothing is queued for the searched room, but it has never been indexed: this is how a
// room looks before its checkpoint has been seeded, and the crawl set alone cannot see
// it. The crawler is still working through another room, so indexing is still under way.
setIndex(new FakeEventIndex([OTHER_ROOM], []));
const { queryByText } = render(
<SearchWarning
isRoomEncrypted={true}
kind={WarningKind.Search}
scope={SearchScope.Room}
roomId={SEARCHED_ROOM}
/>,
);
await settle();
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
});
it("does not warn about an unindexed room once the crawler has drained", async () => {
// The index holds nothing for the room, but the crawler has no work left, so nothing
// more is coming: the warning would never clear itself, and its claim that the index is
// "still being built" would be untrue.
setIndex(new FakeEventIndex([], []));
const { container } = render(
<SearchWarning
isRoomEncrypted={true}
kind={WarningKind.Search}
scope={SearchScope.Room}
roomId={SEARCHED_ROOM}
/>,
);
await settle();
expect(container).toBeEmptyDOMElement();
});
it("keeps an earned warning up while an unrelated checkpoint change is re-checked", async () => {
// The warning here is earned asynchronously: the room is not queued, but the index
// holds nothing for it. An unrelated crawler transition must not blink it off while
// the fresh lookup is in flight.
const index = new FakeEventIndex([OTHER_ROOM], []);
setIndex(index);
const { queryByText } = render(
<SearchWarning
isRoomEncrypted={true}
kind={WarningKind.Search}
scope={SearchScope.Room}
roomId={SEARCHED_ROOM}
/>,
);
await settle();
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
// The crawler moves on to a third room; nothing about the searched room changed.
act(() => {
index.emitChangedCheckpoint([OTHER_ROOM, "!third:example.org"]);
});
// Asserted before the lookup settles: the warning must not have been dropped.
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
await settle();
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
});
it("treats an unanswerable isRoomIndexed as no evidence for a room-scoped search", async () => {
setIndex(new FakeEventIndex([OTHER_ROOM], undefined));
const { container } = render(
<SearchWarning
isRoomEncrypted={true}
kind={WarningKind.Search}
scope={SearchScope.Room}
roomId={SEARCHED_ROOM}
/>,
);
await settle();
expect(container).toBeEmptyDOMElement();
});
it("does not carry a warning from a previously searched room across a scope change", async () => {
// The crawler is working through OTHER_ROOM, so an all-rooms search warns. Switching to
// a room-scoped search of a fully-indexed room must not leave that warning on screen
// while the isRoomIndexed lookup for the new room is in flight.
setIndex(new FakeEventIndex([OTHER_ROOM], [SEARCHED_ROOM]));
const { queryByText, rerender } = render(
<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} scope={SearchScope.All} />,
);
await settle();
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
rerender(
<SearchWarning
isRoomEncrypted={true}
kind={WarningKind.Search}
scope={SearchScope.Room}
roomId={SEARCHED_ROOM}
/>,
);
// Asserted before settling: the stale answer must be gone as soon as the effect for
// the new scope has run, rather than lingering for the isRoomIndexed round-trip.
expect(queryByText(PARTIAL_WARNING)).not.toBeInTheDocument();
});
it("warns an all-rooms search while any room is still being crawled", async () => {
setIndex(new FakeEventIndex([OTHER_ROOM], []));
const { queryByText } = render(
<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} scope={SearchScope.All} />,
);
await settle();
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
});
it("falls back to the global check when the searched room is not yet known", async () => {
// RoomView can render before a room alias has resolved to an id.
setIndex(new FakeEventIndex([OTHER_ROOM], []));
const { queryByText } = render(
<SearchWarning
isRoomEncrypted={true}
kind={WarningKind.Search}
scope={SearchScope.Room}
roomId={undefined}
/>,
);
await settle();
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
});
it("renders nothing once the index has finished crawling", async () => {
setIndex(new FakeEventIndex([], []));
const { container } = render(
<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} scope={SearchScope.All} />,
);
await settle();
expect(container).toBeEmptyDOMElement();
});
it("does not warn for the Files kind even while crawling", async () => {
setIndex(new FakeEventIndex([SEARCHED_ROOM], []));
const { container } = render(<SearchWarning isRoomEncrypted={true} kind={WarningKind.Files} />);
await settle();
expect(container).toBeEmptyDOMElement();
});
it("clears the warning when the searched room's checkpoint drains", async () => {
const index = new FakeEventIndex([SEARCHED_ROOM], [SEARCHED_ROOM]);
setIndex(index);
const { queryByText } = render(
<SearchWarning
isRoomEncrypted={true}
kind={WarningKind.Search}
scope={SearchScope.Room}
roomId={SEARCHED_ROOM}
/>,
);
await settle();
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
await act(async () => {
index.emitChangedCheckpoint([]);
});
expect(queryByText(PARTIAL_WARNING)).not.toBeInTheDocument();
});
it("keeps warning when the crawler moves to another room but the searched room is still queued", async () => {
const index = new FakeEventIndex([SEARCHED_ROOM, OTHER_ROOM], [SEARCHED_ROOM]);
setIndex(index);
const { queryByText } = render(
<SearchWarning
isRoomEncrypted={true}
kind={WarningKind.Search}
scope={SearchScope.Room}
roomId={SEARCHED_ROOM}
/>,
);
await settle();
// The event payload names a different room; the searched room is still outstanding, so
// the warning must survive. This fails if the handler trusts the payload.
await act(async () => {
index.emitChangedCheckpoint([SEARCHED_ROOM], { roomId: OTHER_ROOM } as Room);
});
expect(queryByText(PARTIAL_WARNING)).toBeInTheDocument();
});
it("renders nothing when the room is not encrypted even while crawling", async () => {
setIndex(new FakeEventIndex([SEARCHED_ROOM], []));
const { container } = render(
<SearchWarning
isRoomEncrypted={false}
kind={WarningKind.Search}
scope={SearchScope.Room}
roomId={SEARCHED_ROOM}
/>,
);
expect(container).toBeEmptyDOMElement();
});
});
describe("with no event index (web build)", () => {
beforeEach(() => {
EventIndexPeg.index = null;
EventIndexPeg.error = undefined;
SdkConfig.put({
brand: "Element",
desktop_builds: {
available: true,
logo: "https://logo",
url: "https://url",
},
});
});
it("still renders the desktop/enable-search warning", () => {
const { container } = render(<SearchWarning isRoomEncrypted={true} kind={WarningKind.Search} />);
expect(container.querySelector(".mx_SearchWarning")).toBeInTheDocument();
expect(container.textContent).toContain("to search encrypted messages");
// It is the desktop/enable-search affordance, not the partial-index notice.
expect(container.textContent).not.toContain("your search index is still being built");
});
});
});