From 4677447adb32894bcc45994ec4d45545fbf93c79 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 17 Jul 2026 14:21:20 +0700 Subject: [PATCH 1/4] Persist search sheet state across close/reopen Add an in-memory, session-scoped SearchSheetState service that holds the operator-mode search sheet's query, selected types/realms, sort, view, and pagination, plus a snapshot of the last results. The sheet's @mode-gated subtree is still destroyed on close (no background work while closed); on reopen it rehydrates from the service, redisplays the cached results snapshot immediately, and re-runs the query to refresh. Persistence is opt-in via a `@persist` flag passed only by the search sheet, so the card choosers keep their own ephemeral state. The service registers with the reset service, so logout/realm reset clears it; a page reload also clears it (in-memory, no localStorage). resetState now runs only on explicit Cancel/Escape (removed from plain close/blur and result-select), and reopening a persisted search opens straight to the results view. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../operator-mode/submode-layout.gts | 9 +- .../app/components/search-sheet/index.gts | 55 ++++++++--- .../app/components/search/panel-content.gts | 92 ++++++++++++++++++- packages/host/app/components/search/panel.gts | 14 ++- .../host/app/services/search-sheet-state.ts | 65 +++++++++++++ 5 files changed, 216 insertions(+), 19 deletions(-) create mode 100644 packages/host/app/services/search-sheet-state.ts diff --git a/packages/host/app/components/operator-mode/submode-layout.gts b/packages/host/app/components/operator-mode/submode-layout.gts index 59e102b70cc..060818f5c8e 100644 --- a/packages/host/app/components/operator-mode/submode-layout.gts +++ b/packages/host/app/components/operator-mode/submode-layout.gts @@ -51,6 +51,7 @@ import type AiAssistantPanelService from '../../services/ai-assistant-panel-serv import type MatrixService from '../../services/matrix-service'; import type OperatorModeStateService from '../../services/operator-mode-state-service'; import type RecentCardsService from '../../services/recent-cards-service'; +import type SearchSheetStateService from '../../services/search-sheet-state'; import type StoreService from '../../services/store'; import type { SearchSheetMode } from '../search-sheet'; import type { Submode } from '../submode-switcher'; @@ -137,6 +138,8 @@ export default class SubmodeLayout extends Component { @service declare private store: StoreService; @service declare private aiAssistantPanelService: AiAssistantPanelService; @service declare private recentCardsService: RecentCardsService; + @service('search-sheet-state') + declare private searchSheetState: SearchSheetStateService; private searchElement: HTMLElement | null = null; private suppressSearchClose = false; @@ -346,7 +349,11 @@ export default class SubmodeLayout extends Component { @action private openSearchSheetToPrompt() { if (this.searchSheetMode === SearchSheetModes.Closed) { - this.searchSheetMode = SearchSheetModes.SearchPrompt; + // Reopen straight to the results view when a search is persisted, so the + // restored results are shown immediately rather than the compact prompt. + this.searchSheetMode = this.searchSheetState.searchKey.trim() + ? SearchSheetModes.SearchResults + : SearchSheetModes.SearchPrompt; } this.searchElement?.focus(); diff --git a/packages/host/app/components/search-sheet/index.gts b/packages/host/app/components/search-sheet/index.gts index 6a4342ff841..fe2c6d28acb 100644 --- a/packages/host/app/components/search-sheet/index.gts +++ b/packages/host/app/components/search-sheet/index.gts @@ -4,7 +4,6 @@ import type Owner from '@ember/owner'; import { debounce } from '@ember/runloop'; import { service } from '@ember/service'; import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; import onClickOutside from 'ember-click-outside/modifiers/on-click-outside'; import { modifier } from 'ember-modifier'; @@ -27,6 +26,7 @@ import { } from '@cardstack/runtime-common'; import type RealmServerService from '@cardstack/host/services/realm-server'; +import type SearchSheetStateService from '@cardstack/host/services/search-sheet-state'; import { isURLSearchKey, resolveSearchKeyAsURL, @@ -35,6 +35,7 @@ import { import SearchPanel from '../search/panel'; import type StoreService from '../../services/store'; +import type { SortOption } from '../search/constants'; export const SearchSheetModes = { Closed: 'closed', @@ -87,11 +88,31 @@ const repositionDropdownsOnTransitionEnd = modifier((element: Element) => { const BASE_FILTER: Filter = { type: baseRef }; export default class SearchSheet extends Component { - @tracked private searchKey = ''; - @tracked private initialSelectedTypes: ResolvedCodeRef[] | undefined; - @service declare private realmServer: RealmServerService; @service declare private store: StoreService; + @service('search-sheet-state') + declare private searchSheetState: SearchSheetStateService; + + // The sheet's search state is held in the session-scoped service so it + // survives the close/reopen that unmounts this component's subtree. + private get searchKey() { + return this.searchSheetState.searchKey; + } + private set searchKey(value: string) { + this.searchSheetState.searchKey = value; + } + + private get initialSelectedTypes(): ResolvedCodeRef[] | undefined { + return this.searchSheetState.selectedTypes; + } + + private get initialSelectedRealms(): URL[] { + return this.searchSheetState.selectedRealms; + } + + private get initialActiveSort(): SortOption | undefined { + return this.searchSheetState.activeSort; + } constructor(owner: Owner, args: any) { super(owner, args); @@ -141,29 +162,27 @@ export default class SearchSheet extends Component { @action private onBlur() { + // A plain close/blur keeps the search so reopening restores it; only an + // explicit Cancel (or Escape) resets. this.args.onBlur(); - if (this.args.mode === SearchSheetModes.Closed) { - this.resetState(); - } } @action private handleCardSelect(selection: string | { realmURL: string }) { if (typeof selection !== 'string') { return; } - this.resetState(); + // Selecting a result keeps the search, so reopening returns to it. this.args.onCardSelect(selection); } @action private doExternallyTriggeredSearch(term: string, typeRef?: ResolvedCodeRef) { this.searchKey = term; - this.initialSelectedTypes = typeRef ? [typeRef] : undefined; + this.searchSheetState.selectedTypes = typeRef ? [typeRef] : undefined; } private resetState() { - this.searchKey = ''; - this.initialSelectedTypes = undefined; + this.searchSheetState.resetState(); } @action private debouncedSetSearchKey(searchKey: string) { @@ -176,14 +195,20 @@ export default class SearchSheet extends Component { this.args.onSearch?.(searchKey); } - @action private handleRealmChange(_selectedRealms: URL[]) { + @action private handleRealmChange(selectedRealms: URL[]) { + this.searchSheetState.selectedRealms = selectedRealms; this.args.onFilterChange?.(); } - @action private handleTypeChange(_selectedTypes: ResolvedCodeRef[]) { + @action private handleTypeChange(selectedTypes: ResolvedCodeRef[]) { + this.searchSheetState.selectedTypes = selectedTypes; this.args.onFilterChange?.(); } + @action private handleSortChange(option: SortOption) { + this.searchSheetState.activeSort = option; + } + @action private onSearchInputKeyDown(e: Event) { let kbEvent = e as KeyboardEvent; if (kbEvent.key === 'Escape') { @@ -268,8 +293,12 @@ export default class SearchSheet extends Component { @searchKey={{this.searchKey}} @baseFilter={{BASE_FILTER}} @initialSelectedTypes={{this.initialSelectedTypes}} + @initialSelectedRealms={{this.initialSelectedRealms}} + @initialActiveSort={{this.initialActiveSort}} + @persist={{true}} @onRealmChange={{this.handleRealmChange}} @onTypeChange={{this.handleTypeChange}} + @onSortChange={{this.handleSortChange}} as |Bar Content| > void; initialFocusedSection?: string | null; + // When true, view + pagination back onto the session-scoped + // search-sheet-state service, and the main results are snapshotted there so + // a reopen redisplays them immediately (the operator-mode search sheet opts + // in; the card choosers keep their own ephemeral state). + persist?: boolean; // When true, search-result tiles render the Adorn visual treatment // (teal hover type-label tab + teal selection chip). adorn?: boolean; @@ -157,9 +164,32 @@ export default class PanelContent extends Component { @service declare realmServer: RealmServerService; @service('recent-cards-service') declare private recentCardsService: RecentCards; + @service('search-sheet-state') + declare private searchSheetState: SearchSheetStateService; + + @tracked private _localActiveViewId = 'grid'; + #localPagination = new SectionPagination(this.args.initialFocusedSection); + + // View + pagination live in the persistence service when persisting so they + // survive the sheet's close/reopen; otherwise they are component-local. + get activeViewId() { + return this.args.persist + ? this.searchSheetState.activeViewId + : this._localActiveViewId; + } + set activeViewId(id: string) { + if (this.args.persist) { + this.searchSheetState.activeViewId = id; + } else { + this._localActiveViewId = id; + } + } - @tracked activeViewId = 'grid'; - pagination = new SectionPagination(this.args.initialFocusedSection); + get pagination(): SectionPagination { + return this.args.persist + ? this.searchSheetState.pagination + : this.#localPagination; + } @consume(GetCardContextName) declare private getCard: getCard; @@ -376,6 +406,55 @@ export default class PanelContent extends Component { this.args.onSortChange(option); } + // The results to render for the main search. While persisting, if a snapshot + // from a previous open exists and the recreated live resource hasn't produced + // rows yet, show the snapshot (flagged loading, so the "refreshing" indicator + // shows) — a single clean handoff to the live results once they land. + mainResultsFor = (live: SearchResultsYield): SearchResultsYield => { + if (!this.args.persist) { + return live; + } + let snapshot = this.searchSheetState.mainSnapshot; + if (snapshot && live.entries.length === 0 && live.isLoading) { + return { + entries: snapshot.entries, + meta: snapshot.meta, + isLoading: true, + errors: undefined, + }; + } + return live; + }; + + // Capture the live main results into the service whenever they settle, so a + // later reopen can redisplay them. Guarded on `!isLoading` so a transient + // empty loading state (e.g. the re-run on reopen) never clobbers the cache. + // `` yields a fresh `results` object every render, so this + // modifier re-runs on every render; the content check keeps it from writing + // an equal-but-new snapshot each time, which — since `mainResultsFor` reads + // `mainSnapshot` during render — would otherwise feed back into a re-render + // loop. + private captureMainSnapshot = modifier( + (_element: Element, [live]: [SearchResultsYield]) => { + if (live.isLoading) { + return; + } + let current = this.searchSheetState.mainSnapshot; + let unchanged = + current !== undefined && + current.entries.length === live.entries.length && + current.meta.page?.total === live.meta.page?.total && + current.entries.every((entry, i) => entry.id === live.entries[i].id); + if (unchanged) { + return; + } + this.searchSheetState.mainSnapshot = { + entries: [...live.entries], + meta: live.meta, + }; + }, + ); + diff --git a/packages/host/app/services/search-sheet-state.ts b/packages/host/app/services/search-sheet-state.ts new file mode 100644 index 00000000000..8b1bd245904 --- /dev/null +++ b/packages/host/app/services/search-sheet-state.ts @@ -0,0 +1,65 @@ +import type Owner from '@ember/owner'; +import Service, { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +import type { + EntryCollectionDocument, + RenderableSearchEntryLike, + ResolvedCodeRef, +} from '@cardstack/runtime-common'; + +import { SectionPagination } from '../utils/search/section-pagination'; + +import type ResetService from './reset'; +import type { SortOption } from '../components/search/constants'; + +// A snapshot of the last main-search results, retained across a sheet +// close/reopen so the sheet redisplays them immediately instead of flashing +// blank while its recreated search resource re-runs. The view-models wrap plain +// data + an inert HTML component, so they render safely after the resource that +// produced them is torn down. +export interface MainResultsSnapshot { + entries: RenderableSearchEntryLike[]; + meta: EntryCollectionDocument['meta']; +} + +// In-memory, session-scoped store for the operator-mode search sheet. Lets the +// query, filters, view, pagination, and last results survive a sheet +// close/reopen within a session — the sheet's `@mode`-gated subtree is still +// destroyed on close, so this service (not the components) holds the state to +// rehydrate from. Deliberately NOT persisted to localStorage: a page reload +// clears it. Cleared on logout/realm reset via the `reset` service, following +// `RecentCardsService`. Scoped to the sheet only — the card choosers never read +// or write it (they don't opt in via `@persist`). +export default class SearchSheetStateService extends Service { + @service declare private reset: ResetService; + + @tracked searchKey = ''; + @tracked selectedTypes: ResolvedCodeRef[] | undefined; + @tracked selectedRealms: URL[] = []; + @tracked activeSort: SortOption | undefined; + @tracked activeViewId = 'grid'; + @tracked pagination = new SectionPagination(); + @tracked mainSnapshot: MainResultsSnapshot | undefined; + + constructor(owner: Owner) { + super(owner); + this.reset.register(this); + } + + resetState() { + this.searchKey = ''; + this.selectedTypes = undefined; + this.selectedRealms = []; + this.activeSort = undefined; + this.activeViewId = 'grid'; + this.pagination = new SectionPagination(); + this.mainSnapshot = undefined; + } +} + +declare module '@ember/service' { + interface Registry { + 'search-sheet-state': SearchSheetStateService; + } +} From ee035476d77d87a0b75e0eff29681c6903d75ea6 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 20 Jul 2026 03:33:49 +0700 Subject: [PATCH 2/4] Fix backtracking assertions in persisted search sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search sheet's result snapshot and the search resource each wrote tracked state during the render that consumed it, tripping Glimmer's mutate-after-consume assertion and failing host tests as a global error. - Capture the main-results snapshot in a microtask instead of during the modifier's render, and key it to the current wire query so a reopen only redisplays results for the same search (never stale rows under a different term, and an idle sheet never clobbers a snapshot). Extract the snapshot-vs-live decision into a pure, unit-tested helper. - Start SearchEntriesResource's search a microtask after modify so the task's isRunning write lands outside the consuming render — the case that bites when a SearchResults mounts with a query already set (the sheet reopening restored, or a chooser rendering recents). Load tracking stays synchronous via a Deferred so prerender readiness is unaffected. - Update the reopen expectation in the interact-submode acceptance test to the persisted behavior, and add acceptance + unit coverage for persist, Cancel/Escape reset, empty-then-close, and service reset. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/search-sheet/index.gts | 3 + .../app/components/search/panel-content.gts | 101 +++++++++++------ packages/host/app/resources/search-entries.ts | 30 ++++- .../host/app/services/search-sheet-state.ts | 11 +- .../app/utils/search/main-results-snapshot.ts | 32 ++++++ .../acceptance/interact-submode-test.gts | 92 ++++++++++++++- .../tests/unit/main-results-snapshot-test.ts | 100 +++++++++++++++++ .../search-sheet-state-service-test.ts | 106 ++++++++++++++++++ 8 files changed, 436 insertions(+), 39 deletions(-) create mode 100644 packages/host/app/utils/search/main-results-snapshot.ts create mode 100644 packages/host/tests/unit/main-results-snapshot-test.ts create mode 100644 packages/host/tests/unit/services/search-sheet-state-service-test.ts diff --git a/packages/host/app/components/search-sheet/index.gts b/packages/host/app/components/search-sheet/index.gts index fe2c6d28acb..937501b8537 100644 --- a/packages/host/app/components/search-sheet/index.gts +++ b/packages/host/app/components/search-sheet/index.gts @@ -206,6 +206,9 @@ export default class SearchSheet extends Component { } @action private handleSortChange(option: SortOption) { + // Unlike realm/type changes, no `onFilterChange` here: the sort control only + // exists in the results view, so there's never a prompt→results expansion to + // trigger — just record the choice for persistence. this.searchSheetState.activeSort = option; } diff --git a/packages/host/app/components/search/panel-content.gts b/packages/host/app/components/search/panel-content.gts index 067367d7e0c..6abfecaee85 100644 --- a/packages/host/app/components/search/panel-content.gts +++ b/packages/host/app/components/search/panel-content.gts @@ -1,3 +1,4 @@ +import { isDestroyed, isDestroying } from '@ember/destroyable'; import { action } from '@ember/object'; import { service } from '@ember/service'; import Component from '@glimmer/component'; @@ -29,6 +30,7 @@ import type RealmServerService from '@cardstack/host/services/realm-server'; import type RecentCards from '@cardstack/host/services/recent-cards-service'; import type SearchSheetStateService from '@cardstack/host/services/search-sheet-state'; +import { resolveMainResults } from '@cardstack/host/utils/search/main-results-snapshot'; import { buildRecentsQuery, buildSearchQuery, @@ -255,6 +257,15 @@ export default class PanelContent extends Component { }); } + // A stable identity for the current main search — the serialized wire query. + // Undefined when the search is idle (empty key or a URL paste, both skipped). + // The snapshot is keyed by this so a reopen only redisplays results for the + // same search, and an idle sheet never captures (or clobbers) a snapshot. + private get mainQueryKey(): string | undefined { + let query = this.mainSearchQuery; + return query ? JSON.stringify(query) : undefined; + } + // In the mini card chooser every result row renders as the uniform CardDef // fitted tile instead of each card's own fitted template. Bind that // rendering through the wire filter's top-level `eq` htmlQuery — fitted @@ -406,52 +417,78 @@ export default class PanelContent extends Component { this.args.onSortChange(option); } - // The results to render for the main search. While persisting, if a snapshot - // from a previous open exists and the recreated live resource hasn't produced - // rows yet, show the snapshot (flagged loading, so the "refreshing" indicator - // shows) — a single clean handoff to the live results once they land. + // The results to render for the main search — the retained snapshot during the + // reopen handoff, otherwise the live results (see `resolveMainResults`). Only + // engaged while persisting; the card choosers always render live. mainResultsFor = (live: SearchResultsYield): SearchResultsYield => { if (!this.args.persist) { return live; } - let snapshot = this.searchSheetState.mainSnapshot; - if (snapshot && live.entries.length === 0 && live.isLoading) { - return { - entries: snapshot.entries, - meta: snapshot.meta, - isLoading: true, - errors: undefined, - }; - } - return live; + return resolveMainResults( + live, + this.searchSheetState.mainSnapshot, + this.mainQueryKey, + ); }; + // The settled live results awaiting capture (coalesced across renders), paired + // with the query key they belong to. Captured synchronously in the modifier so + // the pair is always consistent; written to the service in a microtask. + #pendingSnapshot: { live: SearchResultsYield; queryKey: string } | undefined; + #snapshotCaptureScheduled = false; + // Capture the live main results into the service whenever they settle, so a - // later reopen can redisplay them. Guarded on `!isLoading` so a transient - // empty loading state (e.g. the re-run on reopen) never clobbers the cache. - // `` yields a fresh `results` object every render, so this - // modifier re-runs on every render; the content check keeps it from writing - // an equal-but-new snapshot each time, which — since `mainResultsFor` reads - // `mainSnapshot` during render — would otherwise feed back into a re-render - // loop. + // later reopen can redisplay them. The write must NOT happen in the modifier's + // own render transaction: `mainResultsFor` reads `mainSnapshot` during render, + // so a synchronous write here trips Glimmer's mutate-after-consume assertion. + // Instead defer the read-compare-write into a microtask (the same escape hatch + // `SearchEntriesResource.modify` uses), where mutating tracked state is safe. + // Guards: skip while loading (a transient empty re-run must not clobber the + // cache) and skip an idle/empty-key query (no key → nothing to key a snapshot + // by, and capturing would overwrite a still-valid one). The content check + // keeps an equal-but-new snapshot from writing, so the deferred write + // converges instead of looping. private captureMainSnapshot = modifier( (_element: Element, [live]: [SearchResultsYield]) => { if (live.isLoading) { return; } - let current = this.searchSheetState.mainSnapshot; - let unchanged = - current !== undefined && - current.entries.length === live.entries.length && - current.meta.page?.total === live.meta.page?.total && - current.entries.every((entry, i) => entry.id === live.entries[i].id); - if (unchanged) { + let queryKey = this.mainQueryKey; + if (queryKey === undefined) { + return; + } + this.#pendingSnapshot = { live, queryKey }; + if (this.#snapshotCaptureScheduled) { return; } - this.searchSheetState.mainSnapshot = { - entries: [...live.entries], - meta: live.meta, - }; + this.#snapshotCaptureScheduled = true; + void Promise.resolve().then(() => { + this.#snapshotCaptureScheduled = false; + if (isDestroyed(this) || isDestroying(this)) { + return; + } + let pending = this.#pendingSnapshot; + this.#pendingSnapshot = undefined; + if (!pending) { + return; + } + let { live, queryKey } = pending; + let current = this.searchSheetState.mainSnapshot; + let unchanged = + current !== undefined && + current.queryKey === queryKey && + current.entries.length === live.entries.length && + current.meta.page?.total === live.meta.page?.total && + current.entries.every((entry, i) => entry.id === live.entries[i].id); + if (unchanged) { + return; + } + this.searchSheetState.mainSnapshot = { + queryKey, + entries: [...live.entries], + meta: live.meta, + }; + }); }, ); diff --git a/packages/host/app/resources/search-entries.ts b/packages/host/app/resources/search-entries.ts index c8439ba3c52..ae478ad93f6 100644 --- a/packages/host/app/resources/search-entries.ts +++ b/packages/host/app/resources/search-entries.ts @@ -1,4 +1,8 @@ -import { isDestroyed, registerDestructor } from '@ember/destroyable'; +import { + isDestroyed, + isDestroying, + registerDestructor, +} from '@ember/destroyable'; import { getOwner } from '@ember/owner'; import { service } from '@ember/service'; import { buildWaiter } from '@ember/test-waiters'; @@ -12,6 +16,7 @@ import { TrackedArray } from 'tracked-built-ins'; import { subscribeToRealm, + Deferred, htmlQueryRenderingSelection, isCardResource, isCssResource, @@ -318,7 +323,28 @@ export class SearchEntriesResource extends Resource { this.realmsNeedingRefresh.clear(); this.pendingSelectiveRefresh = undefined; this.hasCompletedFullRun = false; - this.#trackSearchLoad(this.search.perform()); + // Start the search out of the render that triggered this modify. A consumer + // reads the task's `isRunning` (through `isLoading`) during render, so a + // synchronous `perform()` here — which flips `isRunning` — would mutate a + // value already consumed in the same computation, tripping Glimmer's + // backtracking assertion. This bites specifically when a `` + // mounts with a query already set (e.g. the search sheet reopening with a + // restored query, or a chooser rendering recents), where the create + first + // `isRunning` read + `perform()` all land in one render. Register the load + // synchronously (via a Deferred) so a prerender still waits for the search, + // but defer the task start a microtask so its write lands after the render. + let loaded = new Deferred(); + this.#trackSearchLoad(loaded.promise); + void Promise.resolve().then(() => { + if (isDestroyed(this) || isDestroying(this)) { + loaded.fulfill(); + return; + } + this.search.perform().then( + () => loaded.fulfill(), + () => loaded.fulfill(), + ); + }); } get isLoading() { diff --git a/packages/host/app/services/search-sheet-state.ts b/packages/host/app/services/search-sheet-state.ts index 8b1bd245904..38c2c99a335 100644 --- a/packages/host/app/services/search-sheet-state.ts +++ b/packages/host/app/services/search-sheet-state.ts @@ -15,10 +15,15 @@ import type { SortOption } from '../components/search/constants'; // A snapshot of the last main-search results, retained across a sheet // close/reopen so the sheet redisplays them immediately instead of flashing -// blank while its recreated search resource re-runs. The view-models wrap plain -// data + an inert HTML component, so they render safely after the resource that -// produced them is torn down. +// blank while its recreated search resource re-runs. An inert HTML row is fully +// self-contained (its component renders a captured HTML string); an item-backed +// row resolves through the still-alive session store, so both render safely +// after the resource that produced them is torn down. `queryKey` is the +// serialized wire query the snapshot was captured under, so the sheet only +// redisplays it for the same search it belongs to (a different term never sees +// stale rows). export interface MainResultsSnapshot { + queryKey: string; entries: RenderableSearchEntryLike[]; meta: EntryCollectionDocument['meta']; } diff --git a/packages/host/app/utils/search/main-results-snapshot.ts b/packages/host/app/utils/search/main-results-snapshot.ts new file mode 100644 index 00000000000..0829b6cae93 --- /dev/null +++ b/packages/host/app/utils/search/main-results-snapshot.ts @@ -0,0 +1,32 @@ +import type { SearchResultsYield } from '@cardstack/runtime-common'; + +import type { MainResultsSnapshot } from '../../services/search-sheet-state'; + +// Decide what the main-search pane renders while persisting: the retained +// snapshot or the live results. Show the snapshot only when it belongs to the +// current search (`queryKey` match) AND the recreated live resource hasn't +// produced rows yet (empty + loading) — the reopen handoff. Flag it loading so +// the "refreshing" indicator shows; the live results take over as soon as they +// land. Every other case (no snapshot, a different search, live has rows, or the +// live search has settled) renders live. Pure so it can be unit-tested without a +// render. +export function resolveMainResults( + live: SearchResultsYield, + snapshot: MainResultsSnapshot | undefined, + currentQueryKey: string | undefined, +): SearchResultsYield { + if ( + snapshot && + snapshot.queryKey === currentQueryKey && + live.entries.length === 0 && + live.isLoading + ) { + return { + entries: snapshot.entries, + meta: snapshot.meta, + isLoading: true, + errors: undefined, + }; + } + return live; +} diff --git a/packages/host/tests/acceptance/interact-submode-test.gts b/packages/host/tests/acceptance/interact-submode-test.gts index 826f60ec9ac..e269eb94379 100644 --- a/packages/host/tests/acceptance/interact-submode-test.gts +++ b/packages/host/tests/acceptance/interact-submode-test.gts @@ -120,9 +120,11 @@ module('Acceptance | interact submode tests', function (hooks) { ); await click('[data-test-open-search-field]'); - assert.dom('[data-test-search-sheet]').hasClass('prompt'); + // The search persists across close/reopen, so reopening restores the + // results view (with the URL search) rather than a blank prompt. + assert.dom('[data-test-search-sheet]').hasClass('results'); - await click(`[data-test-search-result="${testRealmURL}person-entry"]`); + await click(`[data-test-card="${testRealmURL}person-entry"]`); assert .dom(`[data-test-stack-card="${testRealmURL}person-entry"]`) @@ -130,6 +132,92 @@ module('Acceptance | interact submode tests', function (hooks) { }); }); + module('search sheet persistence', function () { + test('restores the query, view, and results after a close/reopen', async function (assert) { + await visitOperatorMode({}); + + // Run a search and switch the results to the strip view. + await click('[data-test-open-search-field]'); + await fillIn('[data-test-search-field]', 'Mango'); + assert.dom('[data-test-search-sheet]').hasClass('results'); + assert + .dom(`[data-test-search-result="${testRealmURL}Pet/mango"]`) + .exists('the search found the card'); + + await click('[data-test-search-result-header] [aria-label="strip"]'); + assert + .dom('[data-test-search-result-header] [aria-label="strip"]') + .hasClass('is-selected', 'strip view is active'); + + // Close the sheet by clicking outside it (a plain close keeps the search). + await click('[data-test-submode-layout]'); + assert.dom('[data-test-search-sheet]').hasClass('closed'); + + // Reopening restores the results view, the query text, the view toggle, and + // the results (shown immediately from the retained snapshot). + await click('[data-test-open-search-field]'); + assert.dom('[data-test-search-sheet]').hasClass('results'); + assert.dom('[data-test-search-field]').hasValue('Mango'); + assert + .dom('[data-test-search-result-header] [aria-label="strip"]') + .hasClass('is-selected', 'strip view is restored'); + assert + .dom(`[data-test-search-result="${testRealmURL}Pet/mango"]`) + .exists('the results are restored'); + }); + + test('the Cancel button clears the persisted search', async function (assert) { + await visitOperatorMode({}); + + await click('[data-test-open-search-field]'); + await fillIn('[data-test-search-field]', 'Mango'); + assert.dom('[data-test-search-sheet]').hasClass('results'); + + await click('[data-test-search-sheet-cancel-button]'); + assert.dom('[data-test-search-sheet]').hasClass('closed'); + + await click('[data-test-open-search-field]'); + assert + .dom('[data-test-search-sheet]') + .hasClass('prompt', 'reopens to a blank prompt, not the prior results'); + assert.dom('[data-test-search-field]').hasValue(''); + }); + + test('Escape clears the persisted search', async function (assert) { + await visitOperatorMode({}); + + await click('[data-test-open-search-field]'); + await fillIn('[data-test-search-field]', 'Mango'); + assert.dom('[data-test-search-sheet]').hasClass('results'); + + await triggerKeyEvent('[data-test-search-field]', 'keydown', 'Escape'); + assert.dom('[data-test-search-sheet]').hasClass('closed'); + + await click('[data-test-open-search-field]'); + assert.dom('[data-test-search-sheet]').hasClass('prompt'); + assert.dom('[data-test-search-field]').hasValue(''); + }); + + test('emptying the query then closing reopens to a blank prompt', async function (assert) { + await visitOperatorMode({}); + + await click('[data-test-open-search-field]'); + await fillIn('[data-test-search-field]', 'Mango'); + assert.dom('[data-test-search-sheet]').hasClass('results'); + + // Clear the query, then close by clicking outside. + await fillIn('[data-test-search-field]', ''); + await click('[data-test-submode-layout]'); + assert.dom('[data-test-search-sheet]').hasClass('closed'); + + await click('[data-test-open-search-field]'); + assert + .dom('[data-test-search-sheet]') + .hasClass('prompt', 'an empty query reopens to the compact prompt'); + assert.dom('[data-test-search-field]').hasValue(''); + }); + }); + module('1 stack', function (_hooks) { test('restoring the stack from query param', async function (assert) { await visitOperatorMode({ diff --git a/packages/host/tests/unit/main-results-snapshot-test.ts b/packages/host/tests/unit/main-results-snapshot-test.ts new file mode 100644 index 00000000000..aee0aa3bcfb --- /dev/null +++ b/packages/host/tests/unit/main-results-snapshot-test.ts @@ -0,0 +1,100 @@ +import { module, test } from 'qunit'; + +import type { + RenderableSearchEntryLike, + SearchResultsYield, +} from '@cardstack/runtime-common'; + +import type { MainResultsSnapshot } from '@cardstack/host/services/search-sheet-state'; +import { resolveMainResults } from '@cardstack/host/utils/search/main-results-snapshot'; + +function entry(id: string): RenderableSearchEntryLike { + return { + id, + realmUrl: 'http://test-realm/test/', + name: id, + isError: false, + component: null as unknown as RenderableSearchEntryLike['component'], + }; +} + +function live(partial: Partial = {}): SearchResultsYield { + return { + entries: [], + isLoading: false, + meta: { page: { total: 0 } }, + errors: undefined, + ...partial, + }; +} + +function snapshot(queryKey: string): MainResultsSnapshot { + return { + queryKey, + entries: [entry('http://test-realm/test/Pet/mango')], + meta: { page: { total: 1 } }, + }; +} + +module('Unit | main-results-snapshot | resolveMainResults', function () { + test('shows the snapshot when its key matches and live is empty + loading', function (assert) { + let result = resolveMainResults( + live({ isLoading: true }), + snapshot('key-a'), + 'key-a', + ); + assert.strictEqual(result.entries.length, 1, 'snapshot entries are shown'); + assert.true(result.isLoading, 'flagged loading so the indicator shows'); + }); + + test('renders live when the snapshot belongs to a different search', function (assert) { + let result = resolveMainResults( + live({ isLoading: true }), + snapshot('key-a'), + 'key-b', + ); + assert.strictEqual(result.entries.length, 0, 'stale snapshot is not shown'); + }); + + test('renders live when the current search is idle (no key)', function (assert) { + let result = resolveMainResults( + live({ isLoading: true }), + snapshot('key-a'), + undefined, + ); + assert.strictEqual( + result.entries.length, + 0, + 'no snapshot for an idle sheet', + ); + }); + + test('renders live once the live search has settled', function (assert) { + let settled = live({ + isLoading: false, + entries: [entry('http://test-realm/test/Pet/vangogh')], + meta: { page: { total: 1 } }, + }); + let result = resolveMainResults(settled, snapshot('key-a'), 'key-a'); + assert.strictEqual(result, settled, 'live results take over'); + }); + + test('renders live when live already has rows (re-run keeps them)', function (assert) { + let withRows = live({ + isLoading: true, + entries: [entry('http://test-realm/test/Pet/vangogh')], + }); + let result = resolveMainResults(withRows, snapshot('key-a'), 'key-a'); + assert.strictEqual( + result, + withRows, + 'no snapshot substitution when live has rows', + ); + }); + + test('renders live when there is no snapshot', function (assert) { + let loading = live({ isLoading: true }); + let result = resolveMainResults(loading, undefined, 'key-a'); + assert.strictEqual(result, loading, 'nothing to substitute'); + }); +}); diff --git a/packages/host/tests/unit/services/search-sheet-state-service-test.ts b/packages/host/tests/unit/services/search-sheet-state-service-test.ts new file mode 100644 index 00000000000..46e3bc2f308 --- /dev/null +++ b/packages/host/tests/unit/services/search-sheet-state-service-test.ts @@ -0,0 +1,106 @@ +import { getService } from '@universal-ember/test-support'; +import { setupTest } from 'ember-qunit'; +import { module, test } from 'qunit'; + +import { + rri, + type ResolvedCodeRef, + type Sort, +} from '@cardstack/runtime-common'; + +import type { SortOption } from '@cardstack/host/components/search/constants'; +import type ResetService from '@cardstack/host/services/reset'; +import type SearchSheetStateService from '@cardstack/host/services/search-sheet-state'; + +const CODE_REF: ResolvedCodeRef = { + module: rri('http://test-realm/test/pet'), + name: 'Pet', +}; +const SORT: SortOption = { + displayName: 'A-Z', + sort: [{ by: 'cardTitle', direction: 'asc' }] as Sort, +}; + +function populate(service: SearchSheetStateService) { + service.searchKey = 'Mango'; + service.selectedTypes = [CODE_REF]; + service.selectedRealms = [new URL('http://test-realm/test/')]; + service.activeSort = SORT; + service.activeViewId = 'strip'; + service.pagination.focus('realm:http://test-realm/test/'); + service.mainSnapshot = { + queryKey: 'key', + entries: [], + meta: { page: { total: 0 } }, + }; +} + +function assertCleared( + assert: Assert, + service: SearchSheetStateService, + label: string, +) { + assert.strictEqual(service.searchKey, '', `${label}: searchKey cleared`); + assert.strictEqual( + service.selectedTypes, + undefined, + `${label}: selectedTypes cleared`, + ); + assert.deepEqual( + service.selectedRealms, + [], + `${label}: selectedRealms cleared`, + ); + assert.strictEqual( + service.activeSort, + undefined, + `${label}: activeSort cleared`, + ); + assert.strictEqual( + service.activeViewId, + 'grid', + `${label}: activeViewId back to default`, + ); + assert.strictEqual( + service.pagination.focusedSection, + null, + `${label}: pagination reset`, + ); + assert.strictEqual( + service.mainSnapshot, + undefined, + `${label}: mainSnapshot cleared`, + ); +} + +module('Unit | Service | search-sheet-state', function (hooks) { + setupTest(hooks); + + let service: SearchSheetStateService; + + hooks.beforeEach(function () { + service = getService('search-sheet-state'); + }); + + test('resetState() restores every field to its default', function (assert) { + populate(service); + service.resetState(); + assertCleared(assert, service, 'resetState'); + }); + + test('is cleared by the reset service (logout/realm reset)', function (assert) { + populate(service); + (getService('reset') as ResetService).resetAll(); + assertCleared(assert, service, 'resetAll'); + }); + + test('resetState() installs a fresh pagination instance', function (assert) { + let before = service.pagination; + service.resetState(); + assert.notStrictEqual( + service.pagination, + before, + 'a new SectionPagination replaces the old one', + ); + }); +}); From faf77a3a190aedfeeb5ba09e2084e1028e774874 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 20 Jul 2026 17:29:54 +0700 Subject: [PATCH 3/4] Seed search sheet results on reopen and persist scroll position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reopening the operator-mode search sheet with an unchanged query now seeds the freshly-mounted search resource from the retained snapshot and skips the fetch, so the prior results show immediately with no re-run and no "Searching…" flash. A one-shot guard keeps the seed from re-applying, so any query change still runs a fresh search. The results-list scroll offset is captured on scroll and restored on reopen (session-only). The seeded resource holds the rows directly, so the display-time resolveMainResults snapshot path is removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/search/panel-content.gts | 162 ++++++++------- .../app/components/search/search-results.gts | 68 +++++- .../resources/renderable-search-entries.ts | 11 +- packages/host/app/resources/search-entries.ts | 45 +++- .../host/app/services/search-sheet-state.ts | 24 ++- .../app/utils/search/main-results-snapshot.ts | 32 --- .../acceptance/interact-submode-test.gts | 98 ++++++++- .../resources/search-entries-test.gts | 193 +++++++++++++++++- .../tests/unit/main-results-snapshot-test.ts | 100 --------- .../search-sheet-state-service-test.ts | 6 + 10 files changed, 515 insertions(+), 224 deletions(-) delete mode 100644 packages/host/app/utils/search/main-results-snapshot.ts delete mode 100644 packages/host/tests/unit/main-results-snapshot-test.ts diff --git a/packages/host/app/components/search/panel-content.gts b/packages/host/app/components/search/panel-content.gts index 6abfecaee85..cf67c255f04 100644 --- a/packages/host/app/components/search/panel-content.gts +++ b/packages/host/app/components/search/panel-content.gts @@ -1,4 +1,3 @@ -import { isDestroyed, isDestroying } from '@ember/destroyable'; import { action } from '@ember/object'; import { service } from '@ember/service'; import Component from '@glimmer/component'; @@ -15,7 +14,6 @@ import { type getCard, type SearchEntryWireFilter, type SearchEntryWireQuery, - type SearchResultsYield, baseCardRef, GetCardContextName, internalKeyFor, @@ -29,8 +27,8 @@ import type NetworkService from '@cardstack/host/services/network'; import type RealmServerService from '@cardstack/host/services/realm-server'; import type RecentCards from '@cardstack/host/services/recent-cards-service'; import type SearchSheetStateService from '@cardstack/host/services/search-sheet-state'; +import type { MainResultsSnapshot } from '@cardstack/host/services/search-sheet-state'; -import { resolveMainResults } from '@cardstack/host/utils/search/main-results-snapshot'; import { buildRecentsQuery, buildSearchQuery, @@ -417,80 +415,96 @@ export default class PanelContent extends Component { this.args.onSortChange(option); } - // The results to render for the main search — the retained snapshot during the - // reopen handoff, otherwise the live results (see `resolveMainResults`). Only - // engaged while persisting; the card choosers always render live. - mainResultsFor = (live: SearchResultsYield): SearchResultsYield => { + // The snapshot to seed the fresh main-search resource with on reopen, so it + // adopts the prior rows and skips its fetch — no re-run, no "Searching…" + // flash. Only offered while persisting, and only when a captured snapshot + // belongs to the current query and actually has rows; the resource re-checks + // the query match before adopting it. A changed term/filter/sort has a + // different `mainQueryKey`, so the seed no longer matches and the search runs + // normally. + get mainSearchSeed(): MainResultsSnapshot | undefined { if (!this.args.persist) { - return live; + return undefined; } - return resolveMainResults( - live, - this.searchSheetState.mainSnapshot, - this.mainQueryKey, - ); + let snapshot = this.searchSheetState.mainSnapshot; + if ( + snapshot === undefined || + snapshot.entries.length === 0 || + snapshot.queryKey !== this.mainQueryKey + ) { + return undefined; + } + return snapshot; + } + + // Persist a freshly-settled main-search snapshot for a later reopen. Skips a + // write that matches the stored one (same query, same rows) so a live re-run + // that changed nothing doesn't churn the seed identity. + persistMainSnapshot = (snapshot: MainResultsSnapshot) => { + let current = this.searchSheetState.mainSnapshot; + let unchanged = + current !== undefined && + current.queryKey === snapshot.queryKey && + current.entries.length === snapshot.entries.length && + current.meta.page?.total === snapshot.meta.page?.total && + current.entries.every((entry, i) => entry.id === snapshot.entries[i].id); + if (unchanged) { + return; + } + this.searchSheetState.mainSnapshot = snapshot; }; - // The settled live results awaiting capture (coalesced across renders), paired - // with the query key they belong to. Captured synchronously in the modifier so - // the pair is always consistent; written to the service in a microtask. - #pendingSnapshot: { live: SearchResultsYield; queryKey: string } | undefined; - #snapshotCaptureScheduled = false; - - // Capture the live main results into the service whenever they settle, so a - // later reopen can redisplay them. The write must NOT happen in the modifier's - // own render transaction: `mainResultsFor` reads `mainSnapshot` during render, - // so a synchronous write here trips Glimmer's mutate-after-consume assertion. - // Instead defer the read-compare-write into a microtask (the same escape hatch - // `SearchEntriesResource.modify` uses), where mutating tracked state is safe. - // Guards: skip while loading (a transient empty re-run must not clobber the - // cache) and skip an idle/empty-key query (no key → nothing to key a snapshot - // by, and capturing would overwrite a still-valid one). The content check - // keeps an equal-but-new snapshot from writing, so the deferred write - // converges instead of looping. - private captureMainSnapshot = modifier( - (_element: Element, [live]: [SearchResultsYield]) => { - if (live.isLoading) { - return; + // Restore and track the results-list scroll offset across a sheet + // close/reopen (only while persisting; the choosers keep their own scroll). + // + // Capture records the live offset on every scroll event, so the service + // always holds the current position. We deliberately don't capture on + // teardown: by the time the modifier's destructor runs the element is + // detached and reports `scrollTop === 0`, which would clobber the good value. + // + // Restore is a per-frame retry rather than a single rAF: the seeded rows land + // a microtask + render after mount, so on the first frame the list is usually + // not yet tall enough to accept the offset (setting it would clamp to 0). + // Re-apply each frame until it sticks (or the content settles shorter than + // the offset), then stop. The saved offset is read inside the frame, not in + // the modifier body, so recording new positions never re-runs this modifier. + // Runs AFTER `ScrollToFocusedSection` (placed earlier on the element), so a + // restored focused section can't leave the list scrolled to the top. + private persistScroll = modifier((element: HTMLElement) => { + if (!this.args.persist) { + return undefined; + } + let rafId: number | undefined; + let target: number | undefined; + let attempts = 0; + let restore = () => { + if (target === undefined) { + target = this.searchSheetState.resultsScrollTop; } - let queryKey = this.mainQueryKey; - if (queryKey === undefined) { + if (target <= 0) { return; } - this.#pendingSnapshot = { live, queryKey }; - if (this.#snapshotCaptureScheduled) { - return; + element.scrollTop = target; + attempts += 1; + if (Math.abs(element.scrollTop - target) > 1 && attempts < 30) { + // eslint-disable-next-line @cardstack/boxel/no-raf-for-state -- awaiting post-seed layout + rafId = requestAnimationFrame(restore); } - this.#snapshotCaptureScheduled = true; - void Promise.resolve().then(() => { - this.#snapshotCaptureScheduled = false; - if (isDestroyed(this) || isDestroying(this)) { - return; - } - let pending = this.#pendingSnapshot; - this.#pendingSnapshot = undefined; - if (!pending) { - return; - } - let { live, queryKey } = pending; - let current = this.searchSheetState.mainSnapshot; - let unchanged = - current !== undefined && - current.queryKey === queryKey && - current.entries.length === live.entries.length && - current.meta.page?.total === live.meta.page?.total && - current.entries.every((entry, i) => entry.id === live.entries[i].id); - if (unchanged) { - return; - } - this.searchSheetState.mainSnapshot = { - queryKey, - entries: [...live.entries], - meta: live.meta, - }; - }); - }, - ); + }; + // eslint-disable-next-line @cardstack/boxel/no-raf-for-state -- restore needs post-layout scroll height + rafId = requestAnimationFrame(restore); + + let onScroll = () => { + this.searchSheetState.resultsScrollTop = element.scrollTop; + }; + element.addEventListener('scroll', onScroll, { passive: true }); + return () => { + if (rafId !== undefined) { + cancelAnimationFrame(rafId); + } + element.removeEventListener('scroll', onScroll); + }; + });