diff --git a/.changes/search-trailing-space-typing.md b/.changes/search-trailing-space-typing.md new file mode 100644 index 000000000..005d8d24a --- /dev/null +++ b/.changes/search-trailing-space-typing.md @@ -0,0 +1,9 @@ +--- +type: fix +area: search +issues: [1338] +--- + +Typing a space in the portal search box and pausing briefly no longer deletes +the space — continuing to type "Bein Sports" no longer collapses into +"BeinSports". diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts index 67ba19b76..a6f25805a 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.spec.ts @@ -228,6 +228,47 @@ describe('WorkspaceShellSearchSyncService', () => { expect(service.searchQuery()).toBe('Beta Movie'); }); + it('keeps a trailing space when its own trimmed q echo lands after the debounce', () => { + // #1338 residual: type "Bein ", pause past the debounce. The applied + // term is trimmed, written to the URL as q=Bein, and the router echoes + // that navigation back with no debounce pending anymore. The echo must + // not snap the box back to "Bein" — typing on would yield "BeinSports". + service.onSearchInput('Bein '); + jest.advanceTimersByTime(SEARCH_INPUT_DEBOUNCE_MS); + + expect(service.appliedSearchQuery()).toBe('Bein'); + expect(service.searchQuery()).toBe('Bein '); + + TestBed.flushEffects(); + expect(router.navigateByUrl).toHaveBeenCalledWith( + `${DOWNLOADS_URL}?q=Bein`, + { replaceUrl: true } + ); + + navigateTo(`${DOWNLOADS_URL}?q=Bein`); + + expect(service.searchQuery()).toBe('Bein '); + + service.onSearchInput('Bein Sports'); + jest.advanceTimersByTime(SEARCH_INPUT_DEBOUNCE_MS); + + expect(service.appliedSearchQuery()).toBe('Bein Sports'); + expect(service.searchQuery()).toBe('Bein Sports'); + }); + + it('still adopts a history entry matching the applied term after typing settles', () => { + // The echo guard must stay scoped to app-initiated navigations even + // when no debounce is pending: back/forward re-applies exactly what + // the entry carries, dropping the uncommitted trailing space. + service.onSearchInput('Bein '); + jest.advanceTimersByTime(SEARCH_INPUT_DEBOUNCE_MS); + + navigateTo(`${DOWNLOADS_URL}?q=Bein`, 'popstate'); + + expect(service.searchQuery()).toBe('Bein'); + expect(service.appliedSearchQuery()).toBe('Bein'); + }); + it('syncs the search box from the url when nothing is being typed', () => { navigateTo(`${DOWNLOADS_URL}?q=Gamma`); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts index c75b48d1e..0a439c163 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts @@ -116,13 +116,17 @@ export class WorkspaceShellSearchSyncService { } /** - * Applies a term now. This supersedes a queued debounce — pressing Enter - * commits the trimmed term, and the keystroke that is still waiting must - * not reapply the untrimmed one behind it. + * Applies a term now, always in its trimmed form: the URL sync and the + * portal stores only ever act on the trimmed term, and an untrimmed + * applied term would make the router echo of our own `q` look like + * foreign search intent. The box (`searchQuery`) keeps what was actually + * typed, trailing whitespace included. This supersedes a queued debounce + * — pressing Enter commits the term, and the keystroke that is still + * waiting must not reapply the older one behind it. */ applySearchQuery(value: string): void { this.cancelPendingSearchApply(); - this.appliedSearchQuery.set(value); + this.appliedSearchQuery.set(value.trim()); } private syncSearchFromUrl(url: string): void { @@ -138,15 +142,17 @@ export class WorkspaceShellSearchSyncService { // either an unrelated query param the page wrote (a filter chip, a // refresh bump) or the router echoing back our own `q`. Syncing anyway // would cancel the pending debounce and reset the box to the applied - // term, silently eating everything typed since — including the whole - // word, when the first keystroke has not been applied yet. + // term, silently eating everything typed since — the whole word when + // the first keystroke has not been applied yet, or just-typed trailing + // whitespace once the debounce has fired ("Bein " snaps to "Bein" and + // typing on yields "BeinSports"). Applied terms are always trimmed, so + // the echoed `q` compares directly. // // The trigger check keeps that narrow: history is always authoritative, // so back/forward re-applies what the entry carries even mid-typing. // `lastSuccessfulNavigation` is set immediately before `NavigationEnd` // is emitted, so it describes the navigation being handled here. if ( - this.searchDebounceTimeoutId !== null && previousUrl !== null && getRoutePath(url) === getRoutePath(previousUrl) && nextTerm === this.appliedSearchQuery() && diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index e3f8cede4..6662d648f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -125,6 +125,7 @@ describe('WorkspaceShellFacade', () => { parseUrl: jest.Mock; createUrlTree: jest.Mock; isActive: jest.Mock; + lastSuccessfulNavigation: () => { trigger: string }; }; let playlistsService: { clearPortalRecentlyViewed: jest.Mock; @@ -196,6 +197,7 @@ describe('WorkspaceShellFacade', () => { parseUrl: jest.fn((url: string) => createParseUrl(url)), createUrlTree: jest.fn(), isActive: jest.fn(), + lastSuccessfulNavigation: () => ({ trigger: 'imperative' }), }; playlistsService = { clearPortalRecentlyViewed: jest