fix(search): make the trimmed-applied-term invariant structural and update the shell contract doc

Review follow-ups on the echo-guard widening:

- setSearchState now trims too, so URL-sourced terms (deep links with
  ?q=Bein%20, actor/discover prefills passing raw provider titles) cannot
  put an untrimmed term into appliedSearchQuery — previously that path
  failed the echo guard's equality check, snapped the box, and dispatched
  the portal search twice. Regression spec added.
- docs/architecture/workspace-shell.md item 8 updated: the applied-term
  echo is now always ignored, not only while input is still debouncing.
- The facade spec's router mock exposes a mutable navigation trigger so
  facade-level tests can exercise the popstate branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-23 09:15:30 +02:00
1 parent 2af67f5dae
commit 935200c4b8
4 files changed
+48 -9

No files matched your search

@@ -269,6 +269,24 @@ describe('WorkspaceShellSearchSyncService', () => {
expect(service.appliedSearchQuery()).toBe('Bein');
});
it('adopts an externally crafted q with edge whitespace in trimmed form', () => {
// A deep link ?q=Bein%20 must not put an untrimmed term into the
// applied signal: the URL-sync effect rewrites the URL trimmed, and
// an untrimmed applied term would fail the echo guard's equality
// check — snapping the box and dispatching the portal search twice.
navigateTo(`${DOWNLOADS_URL}?q=Bein%20`);
expect(service.searchQuery()).toBe('Bein');
expect(service.appliedSearchQuery()).toBe('Bein');
// The trimmed rewrite's echo is our own navigation — skipped.
TestBed.flushEffects();
navigateTo(`${DOWNLOADS_URL}?q=Bein`);
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`);
@@ -109,10 +109,20 @@ export class WorkspaceShellSearchSyncService {
this.syncSearchFromUrl(this.routeState.currentUrl());
}
/**
* Adopts a term from outside the input box (URL `q`, command palette) into
* both signals. The trim keeps the applied-terms-are-always-trimmed
* invariant structural for URL-sourced values too: an externally crafted
* `?q=Bein%20` must not put an untrimmed term into `appliedSearchQuery`,
* or the URL-sync effect's trimmed rewrite would fail the echo guard's
* equality check and snap the box — the same eaten-keystroke cycle the
* guard exists to prevent — while the portal stores dispatch twice.
*/
setSearchState(value: string): void {
this.cancelPendingSearchApply();
this.searchQuery.set(value);
this.appliedSearchQuery.set(value);
const trimmed = value.trim();
this.searchQuery.set(trimmed);
this.appliedSearchQuery.set(trimmed);
}
/**
@@ -117,6 +117,10 @@ describe('WorkspaceShellFacade', () => {
let playerCommands: {
ensureEmbeddedMpvSupportLoaded: jest.Mock;
};
// Mirrors Navigation.trigger: 'imperative' is the app navigating,
// 'popstate' is the user moving through browser history. Mutable so a
// test can exercise the history-authoritative branch of the search sync.
let navigationTrigger: 'imperative' | 'popstate';
let router: {
url: string;
events: ReturnType<typeof of>;
@@ -161,6 +165,7 @@ describe('WorkspaceShellFacade', () => {
};
beforeEach(() => {
navigationTrigger = 'imperative';
showDashboardSignal = signal(true);
runtime = {
isElectron: true,
@@ -197,7 +202,7 @@ describe('WorkspaceShellFacade', () => {
parseUrl: jest.fn((url: string) => createParseUrl(url)),
createUrlTree: jest.fn(),
isActive: jest.fn(),
lastSuccessfulNavigation: () => ({ trigger: 'imperative' }),
lastSuccessfulNavigation: () => ({ trigger: navigationTrigger }),
};
playlistsService = {
clearPortalRecentlyViewed: jest