diff --git a/.changes/search-single-debounce.md b/.changes/search-single-debounce.md new file mode 100644 index 000000000..1b00e5520 --- /dev/null +++ b/.changes/search-single-debounce.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5a2e1418..b5a581c80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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() diff --git a/apps/electron-backend-e2e/src/journeys/search-journey-app.ts b/apps/electron-backend-e2e/src/journeys/search-journey-app.ts index 7d76b54fe..b57418091 100644 --- a/apps/electron-backend-e2e/src/journeys/search-journey-app.ts +++ b/apps/electron-backend-e2e/src/journeys/search-journey-app.ts @@ -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; diff --git a/apps/electron-backend-e2e/src/performance/search-journey-record.ts b/apps/electron-backend-e2e/src/performance/search-journey-record.ts index 556f378e8..0e941379c 100644 --- a/apps/electron-backend-e2e/src/performance/search-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/search-journey-record.ts @@ -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. */ diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index b83dbfc00..fac0b08be 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -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::`) 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. diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts index 5cd30c85a..6ede59898 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts @@ -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([]); 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>; + 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(); + const newer = createDeferred(); + 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']); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts index 23299dfe5..a2c6b0e5f 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts @@ -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() { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts index c1e38a4c3..30923b92a 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts @@ -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) diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index 382d39762..ee8841485 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -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" + } } } }