perf(search): one 300 ms debounce between typing and the global search query (#1899)

* perf(search): one 300 ms debounce between typing and the global search query

Typing in the header search box waited twice before a query ran: 350 ms in
the workspace shell (SEARCH_INPUT_DEBOUNCE_MS), then another 300 ms in
SearchResultsComponent. On J4 (large catalog, "system") the query and first
page take 30-40 ms; about 650 ms of the ~690 ms from the last key to settled
results was waiting.

The results component now searches as soon as the applied term reaches the
store, and the shell debounce is the only one, lowered to 300 ms: above the
gap between keys of steady typing, so a word typed in one go still queries
once. Superseded queries are dropped by the existing request versions. The
component's route sync reads the store term untracked, so a term the shell
applied before writing q is not pushed back to the stale q and re-queried.

J4 locally (3 runs per build): lastKeystrokeToSettledMs P50 692-695 ms ->
336-337 ms, ipcCallsPerSearch 1 and sqlStatementsPerSearch 2 unchanged. At
400 ms per key both builds run five queries; only typing with 300-350 ms
gaps queries per key where it did not before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(journeys): enforce J4 ipcCallsPerSearch 1 and sqlStatementsPerSearch 2

Both counters were identical in all 816 J4 iterations of the 136 CI journey
jobs since #1816 and in all 18 iterations of three runner runs of this PR
(run 38165392046, attempts 1-3). They guard the single debounce: a search
that queries per keystroke or twice for one term raises them. Guard-only
note, since the wall-clock gain came without moving them. Added to the
Performance journeys --only list; the direction check reports 2 added,
nothing weakened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(search): run a prefilled in-portal search again once the catalog has loaded

Review follow-up on #1899 (Greptile P1). Without the results component's
300 ms timer, an in-portal search applied while the portal's catalog is still
loading (a reload or deep link with q) runs against the incomplete catalog,
and nothing re-ran it afterwards. The old timer only helped when loading
finished within 300 ms. The component now runs the current in-portal search
once more when nothing of the catalog is loading or importing any more, the
readiness the Discover route already uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(search): one search effect for a new term and the loaded catalog

Review follow-up on #1899 (Greptile). With separate effects, a term and the
end of catalog loading arriving in the same flush each ran executeSearch for
the same term: the request version dropped the first answer, but the
catalog scan or desktop search still ran twice. One effect now tracks both
triggers and searches once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 08:04:48 +02:00
1 parent f60d797011
commit 70e771ff27
9 files changed
+345 -29

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: perf
area: search
---
Search results appear about twice as fast after you stop typing: the search now waits once for typing to pause instead of twice.
+2
View File
@@ -353,6 +353,8 @@ jobs:
--only open-source/renderer.layoutShiftScore
--only playback/renderer.httpRequestsToPlaying
--only playback/renderer.layoutShiftScore
--only search/renderer.ipcCallsPerSearch
--only search/renderer.sqlStatementsPerSearch
- name: Upload journey summaries
if: always()
@@ -60,7 +60,7 @@ export const SEARCH_JOURNEY_PORTAL_NAME = 'Journey search portal';
* matches 170 series titles, more than the first page of 100.
*/
export const SEARCH_JOURNEY_QUERY = 'system';
/** Well below the shell's 350 ms input debounce, like steady typing. */
/** Well below the shell's 300 ms input debounce, like steady typing. */
export const SEARCH_JOURNEY_KEY_DELAY_MS = 100;
const QUIET_MS = 1_000;
const QUIET_POLL_MS = 100;
@@ -34,7 +34,7 @@ export const SEARCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
export const SEARCH_JOURNEY_QUERY_METHOD = 'dbGlobalSearch';
/**
* Longest accepted gap between two keydowns. Below the shell's 350 ms input
* Longest accepted gap between two keydowns. Below the shell's 300 ms input
* debounce, so a late key on a busy machine cannot let an intermediate term
* run a query that steady typing would not.
*/
+68 -12
View File
@@ -927,11 +927,13 @@ put behind the request ledger: global search reads only the local database.
**What typing does.** The header search box
(`app-workspace-shell-header .search-field input[type="search"]`) applies
its term after `SEARCH_INPUT_DEBOUNCE_MS` (350 ms,
its term after `SEARCH_INPUT_DEBOUNCE_MS` (300 ms,
`WorkspaceShellSearchSyncService`) and writes it to the URL as `q`. On
`/workspace/search` (`app.routes.ts`, Electron only) `SearchResultsComponent`
adopts `q` and runs `executeSearch` after its own 300 ms debounce, which
calls the `dbGlobalSearch` bridge method once with a limit of 101.
runs `executeSearch` as soon as the applied term reaches the store, with no
debounce of its own, which calls the `dbGlobalSearch` bridge method once
with a limit of 101. A newer term supersedes a query still in flight: its
result is dropped by request version.
**Start.** After J1 has ended, the test clicks the rail's **Global search**
link and focuses the header search box (not measured), installs the IPC
@@ -941,7 +943,7 @@ been quiet for 1 s: no DOM mutation, no new or pending bridge call, and an
unchanged `main.sqlStatements` total (30 s timeout, which fails the
iteration). J1's capture is detached. The test then types the query with one
`keyboard.type` call per character on a fixed schedule, 100 ms apart from
the first key (well below the 350 ms debounce, as steady typing would be),
the first key (well below the 300 ms debounce, as steady typing would be),
and samples the main process just before each key. The probe's
capture-phase `keydown` listener on `window` stamps every key in the search
box before the app sees it; the first one starts the journey and sends
@@ -950,7 +952,7 @@ rejects an iteration whose DOM mutations, bridge calls or SQL statements
moved between the quiet snapshot and the first key.
**End.** "No DOM mutation for 200 ms after the last keystroke" alone would
end inside the 650 ms of debounce, before any query ran: nothing in the DOM
end inside the 300 ms of debounce, before any query ran: nothing in the DOM
changes while the term waits. So after the sixth key the probe waits for
the results of the final term to be shown (the path ends with
`/workspace/search`, the URL `q` is the query, no `.loading-state` is
@@ -975,7 +977,7 @@ window closed until its results replace the old ones.
`evidence.finalQuery` keeps its term, result length and duration. The
record also rejects an iteration in which two keydowns were more than
250 ms apart (`typing-cadence`; the gaps are `evidence.keyIntervalsMs`).
At that point a late key on a busy machine could let the 350 ms debounce
At that point a late key on a busy machine could let the 300 ms debounce
apply an intermediate term, which steady typing does not.
A settle that times out fails the iteration with what the probe and the
@@ -1017,9 +1019,10 @@ counter: global search does not touch the network.
| `firstKeystrokeToFirstResultMs.p50/.p90` | First mutation batch with a visible result card (for any term) minus the first keydown. |
| `lastKeystrokeToSettledMs.p50/.p90` | Settled moment (the last mutation batch before the 200 ms quiet window) minus the last keydown. The quiet window itself is not included. |
Both are taken in the renderer. With the current debounces both include
the 650 ms the term waits (350 ms in the shell, then 300 ms in the results
component); the first also includes the 500 ms of typing.
Both are taken in the renderer. Both include the 300 ms the term waits in
the shell; the first also includes the 500 ms of typing. Until the single
debounce below, the wait was 650 ms (350 ms in the shell, then 300 ms in the
results component).
### First measurement
@@ -1045,6 +1048,46 @@ debounces (350 ms in the shell, then 300 ms in the results component). The
query itself, from the bridge call to the rendered first page, takes the
remaining 30-40 ms.
### One debounce
The results component's own 300 ms timer is gone: the shell debounce is
now the only one, and global search runs as soon as the shell applies the
term. `SEARCH_INPUT_DEBOUNCE_MS` went from 350 to 300 ms. It has to stay
above the gap between keys of steady typing, or a word typed in one go
queries once per key; 300 ms covers an average typist (about 200-250 ms
between keys) with some margin, while the query and first page take only
30-40 ms of the remaining time. The route sync in the component reads the
store term untracked, so a term the shell applied before it writes `q` is
no longer pushed back to the previous `q` and queried again.
Local, macOS, 2026-10-11, three J4 runs per build (five measured
iterations each), the same machine, master `633fde3e0` against this
change:
| Measurement | Two debounces | One debounce |
| ------------------------------------ | -------------------- | -------------------- |
| `lastKeystrokeToSettledMs` P50 / P90 | 692-695 / 695-697 ms | 336-337 / 337-339 ms |
| `firstKeystrokeToFirstResultMs` P50 | 1,190 ms | 832-834 ms |
| `renderer.ipcCallsPerSearch` | 1 | 1 |
| `renderer.sqlStatementsPerSearch` | 2 | 2 |
| `renderer.domMutationsToResults` | 402 | 392 |
Per keystroke (query calls / SQL / DOM mutations / CD ticks) at 100 ms per
key, keys 1-5 are unchanged at `0/0/0/1`; key 6 is `1/2/402/9` before and
`1/2/392/7` after.
Slower typing was measured with a scratch change to the journey (key
interval and cadence guard raised, quiet window 1 s; not committed). At
400 ms per key both builds run one query per key from the second
character, five in all with 2 statements each: the shell debounce already
expired between keys before this change, and the results timer only
delayed each query. Before, the queries trail the keys by 650 ms (keys
`y s t e m` read `0 1 1 1 2`); after, each follows its own key. At 325 ms
per key the two builds differ: before, one query in 17 of 20 iterations
(two or four when a gap stretched past 350 ms); after, five. Typing with
gaps between 300 and 350 ms is the only range where this change adds
queries; each is a 2-statement query whose stale result is dropped.
About one launch in sixty showed the empty-results view: the single
`dbGlobalSearch` call went out with the same arguments (`system`, all three
types, hidden categories included, limit 101) and the main process answered
@@ -1069,8 +1112,8 @@ persisted status (`xtream-import-status:<id>:<type>`) reads `completed` and
the type has rows, unless the portal has no categories of that type. Rows
are required because a refresh deletes the catalog without resetting the
status. `xtream-import-wait.e2e.ts` holds the account check past the overlay
window and fails without it. No J4 baseline exists yet; J4 counters join the
ratchet once three runner runs agree.
window and fails without it. Two J4 counters are enforced since #1899
(see [Enforced journey counters](#enforced-journey-counters)).
## `renderer.initialBytes`
@@ -1337,6 +1380,14 @@ changes) and the wall-clock entries did not move. Under
`prefers-reduced-motion` there is no slide, so a user with that preference
still sees the jump that the journey no longer measures.
The two J4 entries, `search/renderer.ipcCallsPerSearch` 1 and
`search/renderer.sqlStatementsPerSearch` 2 (#1899, `evidenceRun`
38165392046), were identical in all 816 J4 iterations of the 136 CI journey
jobs since #1816 and in all 18 iterations of three runs of #1899. They guard
the debounce: a search that queried per keystroke, or ran a second query for
the same term, raises them. Like the entries in the table they carry the
guard-only note: #1899 halved the J4 wall clock without moving either.
Being deterministic is not the same as being validated. Principle 3 of the
plan promotes a counter to a guardrail once a PR has shown that lowering it
lowered the journey's wall-clock. None of these counters has that evidence
@@ -1372,7 +1423,12 @@ Not enforced, with the reason:
- J3 `renderer.ipcSerialDepthToPlaying` (4 on the runner, 3 on a Mac):
added after these runs and measured in one runner run so far; a
candidate once `master` runs agree.
- J4: not measured yet.
- J4 `renderer.domMutationsToResults` (402 on `master`, 392 since #1899),
`renderer.ipcSerialDepthToResults` (1) and `renderer.layoutShiftScore`
(0): identical in the three #1899 runs; candidates once `master` runs
agree.
- J4 `renderer.cdTicksToResults` (11 to 13) and `renderer.longTasks`: see
the general reasons above.
Runner counters differ from a Mac (the Linux-only `getWindowState` call, for
one), so take every journey baseline value from the runner only.
@@ -1,7 +1,7 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { BehaviorSubject, of } from 'rxjs';
import { DatabaseService } from '@iptvnator/services';
import { SearchResultsComponent } from './search-results.component';
import {
@@ -54,6 +54,9 @@ class MockXtreamStore {
readonly searchFilters = signal(DEFAULT_SEARCH_FILTERS);
readonly searchResults = signal<XtreamContentItem[]>([]);
readonly isSearching = signal(false);
readonly isLoadingContent = signal(false);
readonly isLoadingCategories = signal(false);
readonly isImporting = signal(false);
setSearchTerm = jest.fn((term: string) => {
this.searchTerm.set(term);
@@ -377,7 +380,7 @@ describe('SearchResultsComponent global search', () => {
it('refuses to append pages while an edited query is still debouncing', async () => {
// Regression: the auto-fill can request more results right after the
// reset identity changes, i.e. inside the 300ms search debounce. An
// reset identity changes, before the search effect has run. An
// append with the edited term would fetch a page of the NEW query at
// the OLD offset and interleave it into the old query's results.
const firstPage = Array.from({ length: 101 }, (_, index) =>
@@ -402,7 +405,7 @@ describe('SearchResultsComponent global search', () => {
databaseService.globalSearchContent.mockClear();
// The user edits the query; the fresh offset-zero search has not run
// yet (debounce pending), so appending must be refused entirely.
// yet (effects not flushed), so appending must be refused entirely.
store.searchTerm.set('matrix reloaded');
await component.loadMoreGlobalResults();
@@ -506,3 +509,190 @@ describe('SearchResultsComponent in-portal result window', () => {
}
});
});
describe('SearchResultsComponent search trigger', () => {
let queryParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let globalSearchContent: jest.Mock;
function setUp(initialQuery: string): void {
queryParams = new BehaviorSubject(
convertToParamMap({ q: initialQuery })
);
globalSearchContent = jest.fn().mockResolvedValue([]);
TestBed.configureTestingModule({
providers: [
{ provide: XtreamStore, useClass: MockXtreamStore },
{ provide: Router, useValue: { navigate: jest.fn() } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { isGlobalSearch: true },
queryParamMap: queryParams.value,
},
queryParamMap: queryParams.asObservable(),
},
},
{
provide: DatabaseService,
useValue: { globalSearchContent },
},
],
});
}
function searchedTerms(): string[] {
return globalSearchContent.mock.calls.map(([term]) => term as string);
}
afterEach(() => {
jest.useRealTimers();
});
it('queries an applied term at once, without a second debounce', () => {
// The shell already debounces typing; a second timer here only added
// 300 ms to every search.
jest.useFakeTimers();
setUp('');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
store.setSearchTerm('system');
TestBed.flushEffects();
expect(searchedTerms()).toEqual(['system']);
expect(jest.getTimerCount()).toBe(0);
});
it('queries only the route term when the store still holds an older one', () => {
setUp('system');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.searchTerm.set('matrix');
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
expect(searchedTerms()).toEqual(['system']);
});
it('keeps a term applied ahead of the URL instead of re-querying the stale q', () => {
// The shell sets the store term first and writes `q` afterwards; the
// route sync must not snap the store back to the previous `q`.
setUp('syst');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
globalSearchContent.mockClear();
store.setSearchTerm('system');
TestBed.flushEffects();
queryParams.next(convertToParamMap({ q: 'system' }));
TestBed.flushEffects();
expect(searchedTerms()).toEqual(['system']);
expect(store.searchTerm()).toBe('system');
});
it('ignores an older query that resolves after a newer term', async () => {
setUp('');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
const older = createDeferred<GlobalSearchResult[]>();
const newer = createDeferred<GlobalSearchResult[]>();
globalSearchContent
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
store.setSearchTerm('syst');
TestBed.flushEffects();
store.setSearchTerm('system');
TestBed.flushEffects();
newer.resolve([
createSearchItem({ title: 'System' }) as GlobalSearchResult,
]);
await newer.promise;
older.resolve([
createSearchItem({ title: 'Syst' }) as GlobalSearchResult,
]);
await older.promise;
await Promise.resolve();
expect(searchedTerms()).toEqual(['syst', 'system']);
expect(store.searchResults().map((item) => item.title)).toEqual([
'System',
]);
});
});
describe('SearchResultsComponent in-portal search and catalog loading', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: XtreamStore, useClass: MockXtreamStore },
{ provide: Router, useValue: { navigate: jest.fn() } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {},
queryParamMap: convertToParamMap({ q: 'matrix' }),
},
queryParamMap: of(convertToParamMap({ q: 'matrix' })),
},
},
{
provide: DatabaseService,
useValue: {
globalSearchContent: jest.fn().mockResolvedValue([]),
},
},
],
});
});
function searchedTerms(store: MockXtreamStore): string[] {
return store.searchContent.mock.calls.map(
([params]) => (params as { term: string }).term
);
}
it('runs a prefilled search again once the catalog has loaded', () => {
// The in-portal search reads the loaded catalog, so a term applied
// while it loads would otherwise stay without results.
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.isLoadingContent.set(true);
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix']);
store.isLoadingContent.set(false);
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix', 'matrix']);
});
it('searches once when a new term and the loaded catalog arrive together', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.isLoadingContent.set(true);
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
store.searchContent.mockClear();
store.setSearchTerm('matrix reloaded');
store.isLoadingContent.set(false);
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix reloaded']);
});
it('searches once when the catalog is already loaded', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix']);
});
});
@@ -7,6 +7,7 @@ import {
inject,
linkedSignal,
signal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -121,6 +122,14 @@ export class SearchResultsComponent implements AfterViewInit {
readonly isGlobalSearch =
this.activatedRoute.snapshot.data?.['isGlobalSearch'] === true;
/** Nothing of the portal's catalog is loading or importing any more. */
private readonly isCatalogReady = computed(
() =>
!this.xtreamStore.isLoadingContent() &&
!this.xtreamStore.isLoadingCategories() &&
!this.xtreamStore.isImporting()
);
/** Whether to group global search results by playlist */
readonly groupByPlaylist = signal(
localStorage.getItem(SearchResultsComponent.GROUP_BY_STORAGE_KEY) !==
@@ -249,24 +258,48 @@ export class SearchResultsComponent implements AfterViewInit {
}
}
effect((onCleanup) => {
const term = this.searchTerm();
if (term.length >= this.minSearchLength) {
const timeout = setTimeout(() => this.executeSearch(), 300);
onCleanup(() => clearTimeout(timeout));
} else if (term.length < this.minSearchLength) {
this.clearResultsOnly();
}
});
// Created first so a route `q` reaches the store before the search
// effect's first run: a term left in the store by an earlier visit
// must not be queried on the way to the route's term.
effect(() => {
const queryTerm = this.routeSearchTerm();
if (queryTerm === this.searchTerm()) {
if (queryTerm === untracked(this.searchTerm)) {
return;
}
this.xtreamStore.setSearchTerm(queryTerm);
});
// One effect for both triggers, so a term and the end of catalog
// loading arriving in the same flush run one search, not two.
// - A new term runs at once: typed terms arrive already debounced by
// the workspace shell (SEARCH_INPUT_DEBOUNCE_MS), and a term from
// the URL or a filter change is a single deliberate change. A newer
// term supersedes an older query still in flight (request versions).
// - A prefilled in-portal search (a reload or deep link with `q`) can
// run while the portal's catalog is still loading, and the in-portal
// search reads that catalog: it runs again once loading has finished.
let previousTerm: string | undefined;
let wasCatalogReady: boolean | undefined;
effect(() => {
const term = this.searchTerm();
const ready = this.isCatalogReady();
const termChanged = term !== previousTerm;
const becameReady =
wasCatalogReady === false && ready && !this.isGlobalSearch;
previousTerm = term;
wasCatalogReady = ready;
if (!termChanged && !becameReady) {
return;
}
untracked(() => {
if (term.length >= this.minSearchLength) {
this.executeSearch();
} else if (termChanged) {
this.clearResultsOnly();
}
});
});
}
ngAfterViewInit() {
@@ -1,6 +1,13 @@
import { PortalRailSection } from '@iptvnator/portal/shared/util';
export const SEARCH_INPUT_DEBOUNCE_MS = 350;
/**
* The one debounce between a keystroke in the header search box and the
* applied term; pages that search on the applied term (global search
* included) run at once. 300 ms sits above the inter-key gap of steady
* typing, so a word typed in one go is applied once.
* See docs/architecture/performance-journeys.md (J4).
*/
export const SEARCH_INPUT_DEBOUNCE_MS = 300;
/**
* How long a purely local Xtream load (reading the saved catalog from SQLite)
+22
View File
@@ -112,6 +112,28 @@
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37184230956",
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04"
}
},
"search": {
"renderer.ipcCallsPerSearch": {
"value": 1,
"unit": "calls",
"slack": 0,
"note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)",
"updatedAt": "2026-10-12",
"evidencePr": 1899,
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38165392046",
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 18 measured iterations of 3 runs of #1899 (attempts 1-3), and in all 816 J4 iterations of the 136 CI journey jobs since #1816"
},
"renderer.sqlStatementsPerSearch": {
"value": 2,
"unit": "statements",
"slack": 0,
"note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)",
"updatedAt": "2026-10-12",
"evidencePr": 1899,
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38165392046",
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 18 measured iterations of 3 runs of #1899 (attempts 1-3), and in all 816 J4 iterations of the 136 CI journey jobs since #1816"
}
}
}
}