From c22c5f2a14ce1280084127cb9e46c26bd6718791 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 15 Jun 2026 11:33:31 -0400 Subject: [PATCH] Unified search: expose the v2 surface on the card @context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card @context now carries `searchResultsComponent` — the v2 `` rendering surface — alongside the existing `prerenderedCardSearchComponent`, which is marked @deprecated. Card authors opt into the v2 surface; first-party card source migrates to it via the @context search codemod, after which the deprecated affordance is removed. The card-facing contract (`SearchResultsComponentSignature` plus its yield and entry view-model) lives in runtime-common so `CardContext` can type the member without importing host code; `HydrationMode` and `SearchEntryRendering` move there too and are re-exported from their host locations. The three root @context providers expose the host `` through the new member; the operator-mode providers inherit it via their context spread. The cards-facing `Store` interface stays instances-only (no `searchEntries`), already codified at the type level. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/card-api.gts | 12 + .../card-search/hydratable-card.gts | 10 +- .../components/card-search/search-results.gts | 34 +-- .../components/operator-mode/container.gts | 2 + packages/host/app/resources/search-entries.ts | 26 +- packages/host/app/templates/index.gts | 2 + packages/host/app/templates/render/html.gts | 2 + .../card-context-search-results-test.gts | 241 ++++++++++++++++++ packages/runtime-common/index.ts | 1 + .../search-results-component.ts | 87 +++++++ 10 files changed, 360 insertions(+), 57 deletions(-) create mode 100644 packages/host/tests/integration/components/card-context-search-results-test.gts create mode 100644 packages/runtime-common/search-results-component.ts diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index e871b03339f..ff0898ca0db 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -73,6 +73,7 @@ import { type getCardCollection, type Store, type PrerenderedCardComponentSignature, + type SearchResultsComponentSignature, type ErrorEntry, type Query, type QueryWithInterpolations, @@ -344,7 +345,18 @@ export interface CardContext { }; }; }>; + /** + * @deprecated Use {@link CardContext.searchResultsComponent} — the v2 + * `` surface. Retained (and still provided) during the + * migration window; first-party card source moves to the v2 surface via the + * `@context` search codemod, after which this is removed. + */ prerenderedCardSearchComponent: typeof GlimmerComponent; + // The v2 search rendering surface: renders the heterogeneous `search-entry` + // stream for a `search-entry`-rooted query — prerendered HTML inert (hydrated + // lazily) or a live card — so a card author renders results without ever + // branching on prerendered-vs-live. Supersedes `prerenderedCardSearchComponent`. + searchResultsComponent: typeof GlimmerComponent; getCard: getCard; getCards: getCards; getCardCollection: getCardCollection; diff --git a/packages/host/app/components/card-search/hydratable-card.gts b/packages/host/app/components/card-search/hydratable-card.gts index 3e7d8050e8e..5d703e0bed6 100644 --- a/packages/host/app/components/card-search/hydratable-card.gts +++ b/packages/host/app/components/card-search/hydratable-card.gts @@ -10,6 +10,7 @@ import { CardContextName, GetCardContextName, type Format, + type HydrationMode, type ResolvedCodeRef, type StoreReadType, type getCard, @@ -21,11 +22,10 @@ import type { BaseDef, CardContext } from 'https://cardstack.com/base/card-api'; import CardRenderer from '../card-renderer'; -// How an HTML-backed search result becomes a live, running card. `none` stays -// inert; `hover` / `click` / `touch` fetch the card on the matching gesture and -// swap the inert HTML for a live ``. The mode is a host-side UX -// choice and never travels on the wire. -export type HydrationMode = 'none' | 'hover' | 'click' | 'touch'; +// `HydrationMode` is the card-facing contract (it rides the v2 `@context` +// search surface), so it lives in runtime-common; re-exported here because this +// is where it's consumed and where call sites have long imported it. +export type { HydrationMode }; type CardComponentModifier = NonNullable; diff --git a/packages/host/app/components/card-search/search-results.gts b/packages/host/app/components/card-search/search-results.gts index 8ae7516a0d6..65d7f6b1c79 100644 --- a/packages/host/app/components/card-search/search-results.gts +++ b/packages/host/app/components/card-search/search-results.gts @@ -8,15 +8,14 @@ import { isValidPrerenderedHtmlFormat, logger as runtimeLogger, type CardResource, - type ErrorEntry, type FileMetaResource, type Format, type HtmlQuery, type PrerenderedHtmlFormat, type ResolvedCodeRef, type Saved, - type SearchEntryCollectionDocument, - type SearchEntryWireQuery, + type SearchResultsComponentSignature, + type SearchResultsYield, type StoreReadType, } from '@cardstack/runtime-common'; @@ -169,33 +168,6 @@ class RenderableSearchEntry { } } -// The block argument: the heterogeneous result stream plus its loading/meta/ -// error state. Mirrors the documented public API (`results.entries` / -// `.isLoading` / `.meta` / `.errors`). -export interface SearchResultsYield { - entries: RenderableSearchEntry[]; - isLoading: boolean; - meta: SearchEntryCollectionDocument['meta']; - errors: ErrorEntry[] | undefined; -} - -interface Signature { - Element: HTMLElement; - Args: { - // The `search-entry`-rooted v2 query. Re-issued live on invalidation; - // changing it re-runs the search. Undefined → idle (no results). - query: SearchEntryWireQuery | undefined; - // The hydration gesture for HTML-backed rows — a host-UX choice, never on - // the wire. A full live row ignores it. Defaults to `hover` (the fitted - // fast path); pass `none` to keep rows inert, `click`/`touch` to gate on - // those gestures. - mode?: HydrationMode; - }; - Blocks: { - default: [SearchResultsYield]; - }; -} - // The one v2 search component family. Consumes the heterogeneous `search-entry` // stream from `getSearchEntriesResource` and renders it transparently — // prerendered HTML inert (the fast path, hydrated lazily on interaction) or the @@ -204,7 +176,7 @@ interface Signature { // the default stream of `entry.component`s itself. Additive: it supersedes the // `prerendered-card-search` component and the live `SearchContent` tree as call // sites migrate. -export default class SearchResults extends Component { +export default class SearchResults extends Component { @service declare private store: StoreService; // Created once per component (the resource owns its own realm subscriptions diff --git a/packages/host/app/components/operator-mode/container.gts b/packages/host/app/components/operator-mode/container.gts index 410a8674576..67aa2d72a52 100644 --- a/packages/host/app/components/operator-mode/container.gts +++ b/packages/host/app/components/operator-mode/container.gts @@ -37,6 +37,7 @@ import type MessageService from '@cardstack/host/services/message-service'; import type { CardContext } from 'https://cardstack.com/base/card-api'; import CardChooserModal from '../card-chooser/modal'; +import SearchResults from '../card-search/search-results'; import PrerenderedCardSearch from '../prerendered-card-search'; import { Submodes } from '../submode-switcher'; @@ -108,6 +109,7 @@ export default class OperatorModeContainer extends Component { store: this.store, commandContext: this.commandContext, prerenderedCardSearchComponent: PrerenderedCardSearch, + searchResultsComponent: SearchResults, mode: 'operator', submode: this.operatorModeStateService.state?.submode, }; diff --git a/packages/host/app/resources/search-entries.ts b/packages/host/app/resources/search-entries.ts index 8f175c1d29e..4bd5c6a21aa 100644 --- a/packages/host/app/resources/search-entries.ts +++ b/packages/host/app/resources/search-entries.ts @@ -23,10 +23,9 @@ import { type ErrorEntry, type FileMetaResource, type HtmlResource, - type PrerenderedHtmlFormat, - type ResolvedCodeRef, type Saved, type SearchEntryCollectionDocument, + type SearchEntryRendering, type SearchEntryWireQuery, } from '@cardstack/runtime-common'; @@ -42,25 +41,10 @@ import type StoreService from '../services/store'; const waiter = buildWaiter('search-entries-resource:search-waiter'); -// One rendering of an entry: the wire's `html` resource flattened, with its -// `styles` references resolved to the stylesheets' hrefs (the stylesheets -// themselves are already imported through the loader by the time entries are -// exposed). `id` is the (card URL, format, renderType) composite — an opaque -// cache key; the readable rendering dimensions are the `format`/`renderType` -// fields. -export interface SearchEntryRendering { - id: string; - // Absent only on an error rendering with no last-known-good HTML. - html?: string; - cardType: string; - iconHtml?: string; - isError: boolean; - format: PrerenderedHtmlFormat; - // The type this rendering was rendered as. A file rendering carries none - // (files render natively). - renderType?: ResolvedCodeRef; - cssUrls: string[]; -} +// `SearchEntryRendering` is the card-facing rendering view-model (it rides the +// v2 `@context` search surface), so it lives in runtime-common; re-exported +// here because this resource builds it and call sites import it from here. +export type { SearchEntryRendering }; // One v2 search result, joined from the wire document: the `search-entry` // resource plus the `html` renderings and/or `item` serialization it diff --git a/packages/host/app/templates/index.gts b/packages/host/app/templates/index.gts index 935bfe32f9e..aaa949ad8a6 100644 --- a/packages/host/app/templates/index.gts +++ b/packages/host/app/templates/index.gts @@ -24,6 +24,7 @@ import { type getCard as GetCardType, } from '@cardstack/runtime-common'; +import SearchResults from '@cardstack/host/components/card-search/search-results'; import HostModeContent from '@cardstack/host/components/host-mode/content'; import OperatorModeContainer from '@cardstack/host/components/operator-mode/container'; @@ -148,6 +149,7 @@ export class IndexComponent extends Component store: this.store, commandContext: this.commandContext, prerenderedCardSearchComponent: PrerenderedCardSearch, + searchResultsComponent: SearchResults, mode: this.hostModeService.isActive ? 'host' : 'operator', submode: this.hostModeService.isActive ? 'host' diff --git a/packages/host/app/templates/render/html.gts b/packages/host/app/templates/render/html.gts index 619db45ab96..04f7f0c32c9 100644 --- a/packages/host/app/templates/render/html.gts +++ b/packages/host/app/templates/render/html.gts @@ -14,6 +14,7 @@ import { CardContextName, } from '@cardstack/runtime-common'; +import SearchResults from '@cardstack/host/components/card-search/search-results'; import PrerenderedCardSearch from '@cardstack/host/components/prerendered-card-search'; import { getCardCollection } from '@cardstack/host/resources/card-collection'; import { getCard } from '@cardstack/host/resources/card-resource'; @@ -56,6 +57,7 @@ class RenderHtmlTemplate extends Component { getCardCollection: this.getCardCollection, store: this.store, prerenderedCardSearchComponent: PrerenderedCardSearch, + searchResultsComponent: SearchResults, mode: 'host', submode: 'host', }; diff --git a/packages/host/tests/integration/components/card-context-search-results-test.gts b/packages/host/tests/integration/components/card-context-search-results-test.gts new file mode 100644 index 00000000000..d49ab3c6b64 --- /dev/null +++ b/packages/host/tests/integration/components/card-context-search-results-test.gts @@ -0,0 +1,241 @@ +import { + type RenderingTestContext, + render, + triggerEvent, + waitUntil, +} from '@ember/test-helpers'; + +import GlimmerComponent from '@glimmer/component'; + +import { getService } from '@universal-ember/test-support'; +import { provide } from 'ember-provide-consume-context'; + +import { module, test } from 'qunit'; + +import { + isCardInstance, + GetCardContextName, + type getCard as GetCardType, + type SearchEntryWireQuery, + type SearchResultsComponentSignature, +} from '@cardstack/runtime-common'; + +import SearchResults from '@cardstack/host/components/card-search/search-results'; +import PrerenderedCardSearch from '@cardstack/host/components/prerendered-card-search'; +import { getCardCollection } from '@cardstack/host/resources/card-collection'; +import { getCard } from '@cardstack/host/resources/card-resource'; +import type StoreService from '@cardstack/host/services/store'; + +import type { CardContext } from 'https://cardstack.com/base/card-api'; + +import { + setupIntegrationTestRealm, + setupLocalIndexing, + testRealmURL, + testRRI, +} from '../../helpers'; +import { + CardDef, + Component, + contains, + field, + StringField, + setupBaseRealm, +} from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +// The v2 search surface is the card-facing `@context.searchResultsComponent`, +// codified at the type level: the member exists, carries the v2 component +// contract, and the deprecated `prerenderedCardSearchComponent` rendering +// surface stays alongside it through the migration window. These fail the +// type-check (and so the suite) if that converged shape ever erodes. +type Assert = T; + +type CardContextExposesSearchResults = Assert< + CardContext['searchResultsComponent'] extends typeof GlimmerComponent + ? true + : false +>; + +type CardContextKeepsDeprecatedPrerendered = Assert< + 'prerenderedCardSearchComponent' extends keyof CardContext ? true : false +>; + +type CardContextKeepsInstancesSurface = Assert< + 'getCard' extends keyof CardContext + ? 'getCards' extends keyof CardContext + ? 'getCardCollection' extends keyof CardContext + ? true + : false + : false + : false +>; + +const bookRef = { module: testRRI('book'), name: 'Book' }; +const BOOK_1 = `${testRealmURL}books/1`; +const BOOK_2 = `${testRealmURL}books/2`; + +// Provides the full converged card `@context` the host hands a card — +// instances (`getCard` / `getCards` / `getCardCollection` / `store`) plus both +// rendering surfaces (the v2 `searchResultsComponent` and the deprecated +// `prerenderedCardSearchComponent`) — and yields it so a consumer template can +// render `` exactly as a card author would. +// `GetCardContextName` is provided too so the nested hydratable rows resolve +// their live instances, the way the route wires it. +class CardSearchContext extends GlimmerComponent<{ + Blocks: { default: [CardContext] }; +}> { + @provide(GetCardContextName) + get getCardFn() { + return getCard; + } + + get context(): CardContext { + let store = getService('store'); + return { + getCard: getCard as unknown as GetCardType, + getCards: store.getSearchResource.bind(store), + getCardCollection, + store, + prerenderedCardSearchComponent: PrerenderedCardSearch, + searchResultsComponent: SearchResults, + }; + } + + +} + +module( + 'Integration | Component | card @context searchResultsComponent', + function (hooks) { + let storeService: StoreService; + + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + autostart: true, + }); + + hooks.beforeEach(async function (this: RenderingTestContext) { + storeService = getService('store'); + + class Book extends CardDef { + static displayName = 'Book'; + @field title = contains(StringField); + static fitted = class Fitted extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + realmURL: testRealmURL, + contents: { + 'book.gts': { Book }, + 'books/1.json': new Book({ title: 'Mango' }), + 'books/2.json': new Book({ title: 'Van Gogh' }), + }, + }); + await getService('realm').login(testRealmURL); + }); + + test('a card renders the v2 search-entry stream via @context.searchResultsComponent', async function (assert) { + let query: SearchEntryWireQuery = { + filter: { 'item.on': bookRef }, + realms: [testRealmURL], + }; + await render( + , + ); + await waitUntil(() => + Boolean(document.querySelector('[data-test-search-result]')), + ); + + assert + .dom(`[data-test-search-result="${BOOK_1}"]`) + .exists('the first book renders through the @context surface'); + assert + .dom(`[data-test-search-result="${BOOK_2}"]`) + .exists('the second book renders through the @context surface'); + assert + .dom(`[data-test-hydratable-card="${BOOK_1}"][data-hydration="hover"]`) + .exists('an html-backed result starts inert with the hover gesture'); + assert.notOk( + isCardInstance(storeService.peek(BOOK_1)), + 'an html-only result is not deposited into the store', + ); + + await triggerEvent( + `[data-test-hydratable-card="${BOOK_1}"]`, + 'mouseenter', + ); + + assert + .dom( + `[data-test-hydratable-card="${BOOK_1}"][data-hydration="hydrated"]`, + ) + .exists('hovering hydrates the result into a live card'); + assert.ok( + isCardInstance(storeService.peek(BOOK_1)), + 'the hydration GET deposited the card into the store', + ); + }); + + test('the converged @context exposes the v2 + deprecated rendering surfaces and the instances surface', async function (assert) { + // The yielded `@context` carries both rendering surfaces and the + // instances surface at once — a card author reads them straight off + // `@context`. The compile-time witnesses below pin the same shape at the + // type level. + await render( + , + ); + + assert + .dom('[data-test-has-search-results]') + .exists('the v2 searchResultsComponent is exposed on @context'); + assert + .dom('[data-test-has-prerendered]') + .exists( + 'the deprecated prerenderedCardSearchComponent stays exposed alongside it', + ); + + // The instances surface (getCard / getCards / getCardCollection / + // store.search) coexisting on the same `@context` is pinned by the + // compile-time witnesses below — those members are functions, so a + // template `{{#if}}` truthiness check can't assert their presence. + let witnesses: [ + CardContextExposesSearchResults, + CardContextKeepsDeprecatedPrerendered, + CardContextKeepsInstancesSurface, + ] = [true, true, true]; + assert.deepEqual(witnesses, [true, true, true]); + }); + + hooks.afterEach(function () { + getService('network').virtualNetwork.removeRealmMapping('@test-prefix/'); + }); + }, +); diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 319a8ef54da..baafefcaf2b 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -1296,6 +1296,7 @@ export function isBrowserTestEnv() { } export * from './prerendered-card-search.ts'; +export * from './search-results-component.ts'; export { isBotTriggerEvent } from './bot-trigger.ts'; export { assertIsBotCommandFilter, diff --git a/packages/runtime-common/search-results-component.ts b/packages/runtime-common/search-results-component.ts new file mode 100644 index 00000000000..382ec947646 --- /dev/null +++ b/packages/runtime-common/search-results-component.ts @@ -0,0 +1,87 @@ +import type { ComponentLike } from '@glint/template'; + +import type { ResolvedCodeRef } from './code-ref.ts'; +import type { SearchEntryCollectionDocument } from './document-types.ts'; +import type { ErrorEntry } from './index.ts'; +import type { PrerenderedHtmlFormat } from './prerendered-html-format.ts'; +import type { + CardResource, + FileMetaResource, + Saved, +} from './resource-types.ts'; +import type { SearchEntryWireQuery } from './search-entry.ts'; + +// How an HTML-backed search result becomes a live, running card. `none` stays +// inert; `hover` / `click` / `touch` fetch the card on the matching gesture and +// swap the inert HTML for a live render. A host-side UX choice — it never +// travels on the wire. +export type HydrationMode = 'none' | 'hover' | 'click' | 'touch'; + +// One rendering of a search result: the wire's `html` resource flattened, with +// its `styles` references resolved to the stylesheets' hrefs. `id` is the +// (card URL, format, renderType) composite — an opaque cache key; the readable +// rendering dimensions are the `format` / `renderType` fields. +export interface SearchEntryRendering { + id: string; + // Absent only on an error rendering with no last-known-good HTML. + html?: string; + cardType: string; + iconHtml?: string; + isError: boolean; + format: PrerenderedHtmlFormat; + // The type this rendering was rendered as. A file rendering carries none + // (files render natively). + renderType?: ResolvedCodeRef; + cssUrls: string[]; +} + +// One v2 search result as a renderable view-model. `component` renders the +// result transparently — prerendered HTML inert (hydrated lazily) or a live +// card — so a consumer renders `` without ever branching on +// prerendered-vs-live. `html` / `item` are the raw branches, exposed for custom +// rendering. +export interface RenderableSearchEntryLike { + // The card/file identity URL. + id: string; + isError: boolean; + // The chosen prerendered rendering, when the result carries one. + html?: SearchEntryRendering; + // The raw live serialization branch (full or sparse), when present. + item?: CardResource | FileMetaResource; + // The ready-to-render component: renders `html` inert (hydrating lazily) or + // the `item` serialization live, owning the prerendered-vs-live split so the + // consumer never branches on it. + component: ComponentLike<{ Args: {}; Element: Element }>; +} + +// The block argument `` yields: the heterogeneous result stream +// plus its loading / meta / error state. +export interface SearchResultsYield { + entries: RenderableSearchEntryLike[]; + isLoading: boolean; + meta: SearchEntryCollectionDocument['meta']; + errors: ErrorEntry[] | undefined; +} + +// The card-facing contract for the v2 search component the host provides on +// `@context` (`@context.searchResultsComponent`). It consumes the heterogeneous +// `search-entry` stream for a `search-entry`-rooted query and renders it +// transparently — prerendered HTML inert (hydrated lazily) or the live +// serialization. Used with a block it yields a `results` object +// (`entries` / `isLoading` / `meta` / `errors`); used without one it renders +// the default stream of `entry.component`s itself. +export interface SearchResultsComponentSignature { + Element: HTMLElement; + Args: { + // The `search-entry`-rooted v2 query. Re-issued live on invalidation; + // changing it re-runs the search. Undefined → idle (no results). + query: SearchEntryWireQuery | undefined; + // The hydration gesture for HTML-backed rows — a host-UX choice, never on + // the wire. A full live row ignores it. Defaults to `hover`; pass `none` to + // keep rows inert, `click` / `touch` to gate on those gestures. + mode?: HydrationMode; + }; + Blocks: { + default: [SearchResultsYield]; + }; +}