mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
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:
9 files changed
+345
-29
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
|
||||
+193
-3
@@ -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() {
|
||||
|
||||
+8
-1
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user