fix(search): keep a trailing space when the trimmed q echo lands after the debounce

The #1432 guard only held while a debounce was still pending. Once it
fired, the router echo of our own trimmed q (replaceUrl navigation)
reset the one-way-bound search box to the trimmed term, deleting the
just-typed trailing space — typing "Bein Sports" collapsed into
"BeinSports".

Applied terms are now always trimmed at the apply choke point (URL sync
and portal stores only ever act on the trimmed form anyway), so the
echoed q compares directly, and the echo guard no longer requires a
pending debounce. Back/forward stays authoritative via the untouched
imperative-trigger check.

Residual part of #1338.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-23 09:00:52 +02:00
1 parent 72727a5dfa
commit 2af67f5dae
4 files changed
+65 -7

No files matched your search

+9
View File
@@ -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".
@@ -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`);
@@ -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() &&
@@ -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