From 5f99c52a35622e9256abc8523cc18c17abe79444 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 9 Jul 2026 14:02:40 -0400 Subject: [PATCH 1/6] Selectively refresh live search members on prerender_html events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prerender_html event can't change a structured query's membership — only its members' renderings. So instead of re-querying the whole search on every such event, the entry-model live search now refreshes just the members the event carries newer HTML for, each through a conditional card+html GET keyed on the composite validator the member holds: a 304 keeps the current rendering (and the member's object identity, so a hydrated row stays live), a 200 swaps in the fresh entry. Full-text (matches) queries still re-run in full, since their membership is built from markdown. Paginated queries, composite/sparse rendering selections, and any member that can't be refreshed in isolation fall back to the coarse re-run, so nothing regresses. The instances live search makes the same distinction: it skips a prerender_html re-run for a structured query (instances carry no prerendered HTML and their membership is final after the index pass) and keeps re-running for a matches query. - runtime-common: filterHasMatches / wireFilterHasMatches, a single-leaf htmlQuery -> (format, renderType) helper, and an isEntrySingleDocument guard. - store: fetchCardEntry issues the conditional single-instance card+html GET. - search-entries resource: carries each member's index/html generations, reconstructs the composite validator, and performs the per-member refresh. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/resources/search-entries.ts | 327 +++++++++- packages/host/app/resources/search.ts | 21 +- packages/host/app/services/store.ts | 82 +++ .../resources/search-entries-test.gts | 574 ++++++++++++++++-- packages/runtime-common/document-types.ts | 28 + packages/runtime-common/index.ts | 1 + packages/runtime-common/query.ts | 25 + packages/runtime-common/search-entry.ts | 52 +- 8 files changed, 1052 insertions(+), 58 deletions(-) diff --git a/packages/host/app/resources/search-entries.ts b/packages/host/app/resources/search-entries.ts index bd3b751fe2..c11125cc90 100644 --- a/packages/host/app/resources/search-entries.ts +++ b/packages/host/app/resources/search-entries.ts @@ -12,6 +12,7 @@ import { TrackedArray } from 'tracked-built-ins'; import { subscribeToRealm, + htmlQueryRenderingSelection, isCardResource, isCssResource, isFileMetaResource, @@ -20,6 +21,7 @@ import { logger as runtimeLogger, resourceIdentity, rri, + wireFilterHasMatches, RealmPaths, type CardResource, type ErrorEntry, @@ -28,12 +30,16 @@ import { type IconResource, type ResolvedCodeRef, type Saved, + type StoreReadType, type EntryCollectionDocument, type SearchEntryRendering, type SearchEntryWireQuery, } from '@cardstack/runtime-common'; -import type { RealmEventContent } from 'https://cardstack.com/base/matrix-event'; +import type { + RealmEventContent, + PrerenderHtmlEventContent, +} from 'https://cardstack.com/base/matrix-event'; import { knownFileMetaUrls } from '../lib/known-file-meta-urls'; import { normalizeRealms } from '../lib/realm-utils'; @@ -72,6 +78,14 @@ export interface SearchEntry { iconHtml?: string; displayName?: string; codeRef?: ResolvedCodeRef; + // The two generations the row was served at (from response `meta`), + // independent channels: the index-data generation and the generation the + // chosen rendering was produced at (absent when the row carries no + // rendering). The selective refresh reads them to decide whether a + // `prerender_html` event carries newer HTML for this member and to + // reconstruct the composite validator it sends as `If-None-Match`. + indexGeneration?: number; + htmlGeneration?: number; } interface Args { @@ -101,6 +115,17 @@ export class SearchEntriesResource extends Resource { // never consumes it, which is what would create a tracking hazard. private realmsNeedingRefresh = new Set(); + // The coalesced prerender_html invalidations queued for a selective + // per-member refresh: the union of invalidated URLs and the max generation + // seen across the events. Set only for structured, refreshable queries (a + // prerender_html event can't change their membership, so the visible members + // need only their HTML refreshed, not a whole-search re-query). An index + // event supersedes it — the search task consumes or folds it, never drops + // it silently. Untracked, like `realmsNeedingRefresh`. + private pendingSelectiveRefresh: + | { generation: number; urls: Set } + | undefined; + // A partial refresh splices fetched-realm rows into the standing result // set, so it is only sound once a full run has populated that set. Until // then every run fetches all realms (an event arriving during the initial @@ -124,6 +149,7 @@ export class SearchEntriesResource extends Resource { } this.subscriptions = []; this.realmsNeedingRefresh.clear(); + this.pendingSelectiveRefresh = undefined; }); } @@ -180,6 +206,7 @@ export class SearchEntriesResource extends Resource { this.#previousQuery = undefined; this.#previousRealms = undefined; this.realmsNeedingRefresh.clear(); + this.pendingSelectiveRefresh = undefined; this.hasCompletedFullRun = false; this.search.cancelAll(); for (let subscription of this.subscriptions) { @@ -227,17 +254,36 @@ export class SearchEntriesResource extends Resource { if (this.#previousQuery === undefined) { return; } - // Two coarse "anything moved in a subscribed realm" triggers - // re-run the search: an incremental index event (the search doc - // changed) and a prerender_html event (fresh HTML / corrected - // full-text membership landed on its own channel after the - // index pass). + // Two triggers re-run a subscribed search: an incremental index + // event (the search doc — and so membership — changed) and a + // prerender_html event (fresh HTML / corrected full-text membership + // landed on its own channel after the index pass). let isIncrementalIndex = event.eventName === 'index' && (!('indexType' in event) || event.indexType === 'incremental'); if (!isIncrementalIndex && event.eventName !== 'prerender_html') { return; } + + // A prerender_html event can't change a structured query's + // membership — only its members' renderings. So for a structured, + // refreshable query, refresh just the members the event carries + // newer HTML for, through a conditional card+html GET, instead of + // re-querying the whole realm. Full-text (matches) queries, + // paginated queries, and composite/sparse selections can't refresh + // in isolation and fall through to the coarse re-run. + if ( + event.eventName === 'prerender_html' && + this.#canSelectivelyRefresh() + ) { + this.#log.info( + `prerender_html event on ${realm}; scheduling selective per-member refresh`, + ); + this.#recordSelectiveRefresh(event); + this.#trackSearchLoad(this.search.perform()); + return; + } + this.#log.info( `${event.eventName === 'prerender_html' ? 'prerender_html' : 'incremental index'} event on ${realm}; scheduling partial refresh`, ); @@ -258,6 +304,7 @@ export class SearchEntriesResource extends Resource { this.#previousRealms = realms; // A query/realm change owns the whole result set again. this.realmsNeedingRefresh.clear(); + this.pendingSelectiveRefresh = undefined; this.hasCompletedFullRun = false; this.#trackSearchLoad(this.search.perform()); } @@ -285,6 +332,31 @@ export class SearchEntriesResource extends Resource { } let token = waiter.beginAsync(); try { + // A prerender_html event queued a selective per-member refresh. Consume + // it: perform the targeted card+html GETs when no index event is also + // pending (an index event changes membership and supersedes it) and the + // query still supports it; otherwise fold the queued realms into the + // coarse re-run so the update is never dropped. + let selective = this.pendingSelectiveRefresh; + this.pendingSelectiveRefresh = undefined; + if (selective) { + if ( + this.realmsNeedingRefresh.size === 0 && + this.#canSelectivelyRefresh() + ) { + let handled = await this.#performSelectiveRefresh(selective, query); + if (handled) { + return; + } + // A member couldn't be refreshed in isolation (a GET failed or the + // response was malformed) — fall through to a coarse re-run over + // the realms the queued invalidations spanned. + this.#foldSelectiveRealmsIntoRefresh(selective); + } else { + this.#foldSelectiveRealmsIntoRefresh(selective); + } + } + // A paginated query never takes the realm-scoped path: with the held // rows page-limited, no whole-set total can be reconstituted from a // realm-subset fetch — the full re-run keeps `meta` server-accurate. @@ -369,6 +441,185 @@ export class SearchEntriesResource extends Resource { } }); + // Whether the current query supports a selective per-member refresh on a + // prerender_html event, rather than a whole-search re-query. Requires a + // standing result set to splice into, and a query whose members can be + // refreshed one URL at a time through the card+html GET: not paginated (the + // held rows are page-limited, so no splice), not full-text (matches + // membership can change), and with a rendering selection / fieldset the GET + // can spell as query params. When false the prerender_html event falls + // through to the coarse re-run — the CS-11763 behavior — so nothing regresses. + #canSelectivelyRefresh(): boolean { + let query = this.#previousQuery; + if (query === undefined || !this.hasCompletedFullRun) { + return false; + } + if (query.page !== undefined) { + return false; + } + if (wireFilterHasMatches(query.filter)) { + return false; + } + // A composite htmlQuery has no ?format=/?renderType= spelling. + if ( + this._meta.htmlQuery !== undefined && + htmlQueryRenderingSelection(this._meta.htmlQuery) === undefined + ) { + return false; + } + // The GET's ?fields= serves only html / item / html,item; a sparse + // item. selection has no query-string spelling. + return this.#fieldsetIsRefreshable(query.fields?.entry); + } + + #fieldsetIsRefreshable(fields: string[] | undefined): boolean { + return ( + fields === undefined || + fields.every((field) => field === 'html' || field === 'item') + ); + } + + // Whether the html branch is in play for the fieldset — the default + // resolution (rendering, falling back to item) or an explicit `html`. When + // it isn't (an item-only fieldset), a member with no rendering is not a + // refresh candidate: a prerender_html event brings it nothing. + #htmlBranchInPlay(fields: string[] | undefined): boolean { + return fields === undefined || fields.includes('html'); + } + + // Merge a prerender_html event into the queued selective refresh: the union + // of invalidated URLs and the max generation across events (a member is a + // candidate when any event carried HTML newer than the one it holds; the GET + // returns the newest rendering regardless). + #recordSelectiveRefresh(event: PrerenderHtmlEventContent): void { + let urls = event.invalidations ?? []; + if (this.pendingSelectiveRefresh === undefined) { + this.pendingSelectiveRefresh = { + generation: event.generation, + urls: new Set(urls), + }; + } else { + this.pendingSelectiveRefresh.generation = Math.max( + this.pendingSelectiveRefresh.generation, + event.generation, + ); + for (let url of urls) { + this.pendingSelectiveRefresh.urls.add(url); + } + } + } + + // When a selective refresh can't run (superseded by an index event, or a + // member GET failed), fold the realms the queued invalidations spanned into + // the coarse re-run so their prerender_html update still lands. A different + // realm's index event must not swallow this realm's HTML update. + #foldSelectiveRealmsIntoRefresh(selective: { urls: Set }): void { + for (let member of this._entries) { + if (memberMatchesUrls(member, selective.urls)) { + this.realmsNeedingRefresh.add(member.realmUrl); + } + } + } + + // Refresh only the members a prerender_html event carries newer HTML for, + // each through a conditional card+html GET keyed on the composite validator + // the member holds. A `304` keeps the current rendering (identity preserved, + // so a hydrated row stays live); a `200` swaps in the fresh entry. Membership + // is stable across a prerender_html event, so this never adds or drops a row + // and leaves `meta.page.total` untouched. Nothing is written to the store — + // a member a consumer has hydrated is owned by the store's reactive reload. + // Returns false to request a coarse fallback when a member can't be refreshed + // in isolation. + async #performSelectiveRefresh( + selective: { generation: number; urls: Set }, + query: SearchEntryWireQuery, + ): Promise { + let selection = htmlQueryRenderingSelection(this._meta.htmlQuery); + let htmlInPlay = this.#htmlBranchInPlay(query.fields?.entry); + let fieldsParam = this.#fieldsParam(query.fields?.entry); + + let candidates = this._entries.filter((member) => { + if (!memberMatchesUrls(member, selective.urls)) { + return false; + } + if (member.htmlGeneration !== undefined) { + // A member with a rendering refreshes only when the event carries a + // newer one than it holds. + return member.htmlGeneration < selective.generation; + } + // No rendering yet: refresh (an upgrade opportunity) only when the html + // branch is in play. + return htmlInPlay; + }); + if (candidates.length === 0) { + // The common case for an unrelated event: nothing in the visible set + // moved, so do no work at all. + return true; + } + + let replacements = new Map(); + for (let member of candidates) { + let result; + try { + result = await this.runtimeStore.fetchCardEntry(member.id, { + kind: memberKind(member), + format: selection?.format, + renderType: selection?.renderType, + fields: fieldsParam, + ifNoneMatch: memberValidator(member), + }); + } catch (err) { + // A restart (a newer event) cancels this task mid-GET — let it + // propagate rather than treating it as a failed refresh. + if (didCancel(err)) { + throw err; + } + this.#log.warn( + `selective refresh GET failed for ${member.id}; falling back to a full re-run`, + err, + ); + return false; + } + if (result.notModified) { + continue; + } + // Reuse the collection builders: wrap the single doc so the refreshed + // member is shaped exactly as the search would have returned it. + let collectionDoc: EntryCollectionDocument = { + data: [result.doc.data], + included: result.doc.included, + meta: { page: { total: 1 } }, + }; + await this.loadStylesheets(collectionDoc); + let [refreshed] = this.buildEntries(collectionDoc); + if (refreshed === undefined) { + return false; + } + replacements.set(member.id, refreshed); + } + + if (replacements.size > 0) { + // Splice in place: every unchanged member keeps its object identity, so + // only the members whose rendering actually advanced re-render. + let next = this._entries.map( + (member) => replacements.get(member.id) ?? member, + ); + this._entries.splice(0, this._entries.length, ...next); + } + // `realmsNeedingRefresh` is deliberately left as-is: a selective refresh + // only runs when it was empty, and any index event arriving mid-refresh + // restarts this task (so its realm survives for the re-run). + this._errors = undefined; + return true; + } + + // The ?fields= value mirroring the query's entry fieldset (validated + // refreshable by `#fieldsetIsRefreshable`); undefined for the default + // resolution. + #fieldsParam(fields: string[] | undefined): string | undefined { + return fields === undefined ? undefined : fields.join(','); + } + // The `css` resources base64-embed their whole stylesheet in the href; the // loader import is what registers each scoped stylesheet with the document, // so entries are paint-ready when exposed. @@ -419,10 +670,15 @@ export class SearchEntriesResource extends Resource { }; return doc.data.map((entry) => { - let renderings = (entry.relationships.html?.data ?? []) + let htmlResources = (entry.relationships.html?.data ?? []) .map((ref) => htmlById.get(ref.id)) - .filter(Boolean) - .map((html) => buildRendering(html!, cssHrefById)); + .filter(Boolean) as HtmlResource[]; + let renderings = htmlResources.map((html) => + buildRendering(html, cssHrefById), + ); + // The chosen rendering's generation (all of a row's renderings share the + // per-row `prerendered_html.generation`, so the first is authoritative). + let htmlGeneration = htmlResources[0]?.meta?.generation; let itemRef = entry.relationships.item?.data; let item = itemRef ? itemsByIdentity.get(resourceIdentity(itemRef.type, itemRef.id)) @@ -455,6 +711,10 @@ export class SearchEntriesResource extends Resource { codeRef: icon.codeRef, } : {}), + ...(entry.meta?.generation !== undefined + ? { indexGeneration: entry.meta.generation } + : {}), + ...(htmlGeneration !== undefined ? { htmlGeneration } : {}), }; }); } @@ -478,6 +738,55 @@ function buildRendering( }; } +// Strip a trailing `.json` so an entry's id (the bare card URL) matches a +// prerender_html invalidation (an instance's `.json` file URL); a file's id +// and invalidation are both bare, so stripping is a no-op there. Comparing the +// normalized forms matches either shape without knowing which a member is. +function stripJsonSuffix(url: string): string { + return url.replace(/\.json$/, ''); +} + +function memberMatchesUrls(member: SearchEntry, urls: Set): boolean { + let normalized = stripJsonSuffix(member.id); + for (let url of urls) { + if (stripJsonSuffix(url) === normalized) { + return true; + } + } + return false; +} + +// `card` vs `file-meta` for the card+html GET's Accept header — the same +// classification `RenderableSearchEntry.type` makes: a file is an `item` of +// type `file-meta`, or an html-backed row whose rendering carries no render +// type (files render natively; card renderings always name one). +function memberKind(member: SearchEntry): StoreReadType { + if (member.item) { + return member.item.type === 'file-meta' ? 'file-meta' : 'card'; + } + if (member.html.length > 0 && !member.html[0].renderType) { + return 'file-meta'; + } + return 'card'; +} + +// The composite validator the member holds, in the `card+html` GET's ETag +// spelling — `":"` (see +// `buildEntryHtmlEtag`). For a member that holds a rendering this matches the +// server's pure-html ETag exactly, so an unchanged rendering returns `304`. A +// member with no rendering carries `none`, and the server folds a realm-info +// hash into an item-bearing response's validator, so it never matches — that's +// intended: a no-rendering member always refetches, which is exactly the +// upgrade a prerender_html event should pick up. +function memberValidator(member: SearchEntry): string { + let indexSegment = member.indexGeneration ?? 0; + let htmlSegment = + member.htmlGeneration !== undefined + ? String(member.htmlGeneration) + : 'none'; + return `"${indexSegment}:${htmlSegment}"`; +} + // The one host live-search resource: issues the `entry` wire query // through `StoreService.searchEntries`, subscribes to each searched realm, // and re-runs on incremental index events with a per-realm partial refresh. diff --git a/packages/host/app/resources/search.ts b/packages/host/app/resources/search.ts index ad7d5d42da..ed9cdbdc20 100644 --- a/packages/host/app/resources/search.ts +++ b/packages/host/app/resources/search.ts @@ -24,6 +24,7 @@ import type { } from '@cardstack/runtime-common'; import { subscribeToRealm, + filterHasMatches, isFileDefInstance, isFileDefCodeRef, isClientEvaluable, @@ -423,13 +424,25 @@ export class SearchResource< if (this.#previousQuery === undefined) { return; } - // Re-run on incremental index events (the search doc changed) - // and on prerender_html events (fresh HTML / corrected - // full-text membership landed on its own channel). let isIncrementalIndex = event.eventName === 'index' && (!('indexType' in event) || event.indexType === 'incremental'); - if (!isIncrementalIndex && event.eventName !== 'prerender_html') { + let isPrerenderHtml = event.eventName === 'prerender_html'; + if (!isIncrementalIndex && !isPrerenderHtml) { + return; + } + // An incremental index event (the search doc — and so membership — + // changed) always re-runs. A prerender_html event only matters to a + // full-text (matches) query: its membership is built from + // `markdown`, which lands on the prerender-html channel. These + // results are instances, which carry no prerendered HTML, so a + // structured query's members and their fields are already final + // after the index pass — re-running on its prerender_html event + // would fetch an identical result set. + if ( + isPrerenderHtml && + !filterHasMatches(this.#previousQuery.filter) + ) { return; } this.trackStoreLoad( diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index e368fe9f0a..4d90124093 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -77,6 +77,10 @@ import { type CardResource, type SearchEntryResults, type SearchEntryWireQuery, + type EntrySingleDocument, + isEntrySingleDocument, + type PrerenderedHtmlFormat, + type ResolvedCodeRef, type RealmIdentifier, type RealmResourceIdentifier, type Saved, @@ -1339,6 +1343,84 @@ export default class StoreService extends Service implements StoreInterface { return json; } + // Conditional single-instance card+html GET: fetch one `entry` sourced by + // URL (the single-instance counterpart of `_search`), with the rendering + // selection spelled as query params and the client's held composite + // validator as `If-None-Match`. A `304` means the client's rendering is + // current; a `200` returns the fresh entry (with an `item` fallback when no + // rendering exists). The live-search selective refresh uses this to bring one + // member's HTML up to date without re-querying the whole search. Nothing is + // hydrated into the store. + async fetchCardEntry( + url: string, + opts: { + kind: StoreReadType; + format?: PrerenderedHtmlFormat; + renderType?: ResolvedCodeRef; + // `html` | `item` | `html,item`; omit for the default resolution (the + // selected rendering, falling back to `item` where none matched). + fields?: string; + ifNoneMatch?: string; + }, + ): Promise< + { notModified: true } | { notModified: false; doc: EntrySingleDocument } + > { + let requestURL = new URL(url); + if (opts.format) { + requestURL.searchParams.set('format', opts.format); + } + if (opts.renderType) { + requestURL.searchParams.set( + 'renderType', + `${opts.renderType.module}/${opts.renderType.name}`, + ); + } + if (opts.fields) { + requestURL.searchParams.set('fields', opts.fields); + } + let headers: Record = { + Accept: + opts.kind === 'file-meta' + ? SupportedMimeType.FileMetaHtml + : SupportedMimeType.CardHtml, + ...duringPrerenderHeaders(), + ...consumingRealmHeader(), + ...jobIdHeader(), + ...jobPriorityHeader(), + ...loggingCorrelationIdHeader(), + }; + if (opts.ifNoneMatch) { + headers['If-None-Match'] = opts.ifNoneMatch; + } + let response = await this.network.authedFetch(requestURL.href, { + method: 'GET', + headers, + }); + if (response.status === 304) { + return { notModified: true }; + } + if (!response.ok) { + let responseText = await response.text(); + let err = new Error( + `status: ${response.status} - ${response.statusText}. ${responseText}`, + ) as any; + err.status = response.status; + err.responseText = responseText; + err.responseHeaders = response.headers; + throw err; + } + // The response content-type is the negotiated `application/vnd.card+html` + // (not `+json`), but the body is a JSON:API document — parse the text. + let json = JSON.parse(await response.text()); + if (!isEntrySingleDocument(json)) { + throw new Error( + `The card+html response was not a valid entry single document: + ${JSON.stringify(json, null, 2)}`, + ); + } + return { notModified: false, doc: json }; + } + getSearchResource( parent: object, getQuery: () => Query | undefined, diff --git a/packages/host/tests/integration/resources/search-entries-test.gts b/packages/host/tests/integration/resources/search-entries-test.gts index e8b7bf4c53..9fb7de2b4e 100644 --- a/packages/host/tests/integration/resources/search-entries-test.gts +++ b/packages/host/tests/integration/resources/search-entries-test.gts @@ -13,6 +13,7 @@ import { CssResourceType, HtmlResourceType, EntryResourceType, + type EntrySingleDocument, type Loader, type Realm, type SearchEntryResults, @@ -140,6 +141,78 @@ module('Integration | search-entries resource', function (hooks) { }); } + // A book result carrying the generations the selective refresh reads. `htmlGen` + // omitted → an empty-html (item-fallback) row. + interface BookEntrySpec { + id: string; + indexGen: number; + htmlGen?: number; + html?: string; + } + + function fittedHtmlId(id: string) { + return htmlResourceId({ url: id, format: 'fitted', renderType: bookRef }); + } + + function entryResourceFor( + spec: BookEntrySpec, + ): SearchEntryResults['data'][number] { + return { + type: EntryResourceType, + id: spec.id, + relationships: { + html: { + data: + spec.htmlGen !== undefined + ? [{ type: HtmlResourceType, id: fittedHtmlId(spec.id) }] + : [], + }, + }, + meta: { generation: spec.indexGen }, + }; + } + + function htmlResourceFor( + spec: BookEntrySpec, + ): NonNullable[number] { + return { + type: HtmlResourceType, + id: fittedHtmlId(spec.id), + attributes: { + html: spec.html ?? '
book
', + cardType: 'Book', + format: 'fitted', + renderType: bookRef, + }, + // No scoped CSS, so `loadStylesheets` imports nothing. + relationships: { styles: { data: [] } }, + meta: { generation: spec.htmlGen! }, + }; + } + + // A `_federated-search` collection document with the default htmlQuery echoed, + // as the server returns for `{ 'item.on': Book }`. + function entryCollectionDoc(specs: BookEntrySpec[]): SearchEntryResults { + return { + data: specs.map(entryResourceFor), + included: specs + .filter((spec) => spec.htmlGen !== undefined) + .map(htmlResourceFor), + meta: { + page: { total: specs.length }, + htmlQuery: { eq: { format: 'fitted', renderType: bookRef } }, + }, + }; + } + + // The single-instance card+html GET's response for one book. + function entrySingleDoc(spec: BookEntrySpec): EntrySingleDocument { + return { + data: entryResourceFor(spec), + included: spec.htmlGen !== undefined ? [htmlResourceFor(spec)] : [], + }; + } + test('exposes entries joined from the wire document (default fieldset: html renderings)', async function (assert) { let search = getResourceForTest(storeService, () => ({ named: { @@ -356,56 +429,469 @@ module('Integration | search-entries resource', function (hooks) { ); }); - test('re-runs the search on a prerender_html event; other realm events leave it alone', async function (assert) { - let fetchCount = 0; - let originalSearchEntries = storeService.searchEntries.bind(storeService); - storeService.searchEntries = async (...args) => { - fetchCount++; - return originalSearchEntries(...args); - }; - try { - let search = getResourceForTest(storeService, () => ({ - named: { - query: { - filter: { 'item.on': bookRef }, - realms: [testRealmURL], - }, - }, - })); - await search.loaded; - let baseline = fetchCount; - - // The realm-server broadcasts prerender_html when fresh HTML lands on - // its own channel after the index pass; the in-browser test realm - // renders fused and never emits one, so inject it synthetically. + // A prerender_html event can't change a structured query's membership, so a + // live search refreshes only the invalidated members' HTML through a + // conditional card+html GET — it never re-queries the whole search. + module('prerender_html selective per-member refresh', function () { + function relayPrerenderHtml(invalidations: string[], generation: number) { getService('message-service').relayRealmEvent({ eventName: 'prerender_html', realmURL: testRealmURL, - generation: 2, - invalidations: [`${testRealmURL}books/1.json`], + generation, + invalidations, }); - await waitUntil(() => fetchCount > baseline, { timeout: 10_000 }); - await settled(); - assert.ok( - fetchCount > baseline, - 'a prerender_html event re-runs the search', - ); + } - let afterPrerender = fetchCount; - getService('message-service').relayRealmEvent({ - eventName: 'index', - indexType: 'full', - realmURL: testRealmURL, + test('refreshes only the invalidated member via a card+html GET; no full re-query', async function (assert) { + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango v1
', + }, + { + id: `${testRealmURL}books/2`, + indexGen: 1, + htmlGen: 1, + html: '
Van Gogh v1
', + }, + ]); + }; + + let getCalls: { url: string; ifNoneMatch?: string; format?: string }[] = + []; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async (url: string, opts: any) => { + getCalls.push({ + url, + ifNoneMatch: opts.ifNoneMatch, + format: opts.format, + }); + return { + notModified: false, + doc: entrySingleDoc({ + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 2, + html: '
Mango v2
', + }), + }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + assert.strictEqual(search.entries.length, 2); + let book2Before = search.entries.find( + (entry) => entry.id === `${testRealmURL}books/2`, + ); + let baseline = searchCount; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => getCalls.length > 0, { timeout: 10_000 }); + await settled(); + + assert.strictEqual( + searchCount, + baseline, + 'no whole-search re-query is issued', + ); + assert.strictEqual(getCalls.length, 1, 'exactly one card+html GET'); + assert.strictEqual(getCalls[0].url, `${testRealmURL}books/1`); + assert.strictEqual( + getCalls[0].ifNoneMatch, + '"1:1"', + 'the held composite validator (index:html) is sent as If-None-Match', + ); + assert.strictEqual(getCalls[0].format, 'fitted'); + + assert.strictEqual( + search.entries[0].html[0].html, + '
Mango v2
', + 'the invalidated member swaps in the fresh HTML', + ); + assert.strictEqual( + search.entries.find((entry) => entry.id === `${testRealmURL}books/2`), + book2Before, + 'the uninvalidated member keeps its identity', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('a 304 keeps the current rendering and the member identity', async function (assert) { + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango v1
', + }, + ]); + + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return { notModified: true }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let before = search.entries[0]; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => getCount > 0, { timeout: 10_000 }); + await settled(); + + assert.strictEqual(getCount, 1, 'the conditional GET was issued'); + assert.strictEqual( + search.entries[0], + before, + 'a 304 leaves the member object untouched (a hydrated row stays live)', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('a member already at or above the event generation is not fetched', async function (assert) { + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 5, + htmlGen: 5, + html: '
Mango
', + }, + ]); + + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return { notModified: true }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let before = search.entries[0]; + + // The event's generation (5) is not newer than the member's held HTML + // generation (5), so there is nothing to refresh. + relayPrerenderHtml([`${testRealmURL}books/1.json`], 5); + await settled(); + + assert.strictEqual(getCount, 0, 'no GET is issued for a fresh member'); + assert.strictEqual( + search.entries[0], + before, + 'the member is untouched', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('an event whose URLs are not in the visible set does no work', async function (assert) { + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + ]); + + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return { notModified: true }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let before = search.entries[0]; + + relayPrerenderHtml([`${testRealmURL}books/999.json`], 9); + await settled(); + + assert.strictEqual(getCount, 0, 'no GET for an unrelated invalidation'); + assert.strictEqual(search.entries[0], before); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('an empty-html member upgrades when its rendering lands', async function (assert) { + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + // No rendering yet (empty html array), so no held html generation. + entryCollectionDoc([{ id: `${testRealmURL}books/1`, indexGen: 2 }]); + + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + let capturedIfNoneMatch: string | undefined; + storeService.fetchCardEntry = (async (_url: string, opts: any) => { + capturedIfNoneMatch = opts.ifNoneMatch; + return { + notModified: false, + doc: entrySingleDoc({ + id: `${testRealmURL}books/1`, + indexGen: 2, + htmlGen: 2, + html: '
Mango
', + }), + }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + assert.deepEqual(search.entries[0].html, [], 'no rendering initially'); + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => search.entries[0]?.html.length === 1, { + timeout: 10_000, + }); + + assert.strictEqual(search.entries[0].html[0].html, '
Mango
'); + assert.strictEqual( + capturedIfNoneMatch, + '"2:none"', + 'a member with no rendering sends the none-html validator', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('a full-text (matches) query re-runs in full instead of refreshing members', async function (assert) { + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + ]); + }; + + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return { notModified: true }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { + filter: { 'item.on': bookRef, matches: 'mango' }, + realms: [testRealmURL], + }, + }, + })); + await search.loaded; + let baseline = searchCount; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => searchCount > baseline, { timeout: 10_000 }); + await settled(); + + assert.ok( + searchCount > baseline, + 'a matches query re-runs the whole search (membership can change)', + ); + assert.strictEqual( + getCount, + 0, + 'no per-member GET for a matches query', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('a failed member GET falls back to a full re-run', async function (assert) { + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + ]); + }; + + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + throw new Error('boom'); + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let baseline = searchCount; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => searchCount > baseline, { timeout: 10_000 }); + await settled(); + + assert.ok( + searchCount > baseline, + 'a member the GET could not refresh triggers a coarse re-run', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('a non-incremental index event does not re-run the search', async function (assert) { + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + ]); + }; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let baseline = searchCount; + + getService('message-service').relayRealmEvent({ + eventName: 'index', + indexType: 'full', + realmURL: testRealmURL, + }); + await settled(); + + assert.strictEqual( + searchCount, + baseline, + 'a full (non-incremental) index event leaves the search alone', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + } + }); + + // Exercises the real card+html GET (not stubbed) against the in-browser + // realm: the validator the client reconstructs from a member's held + // generations must match the server's composite ETag, or the 304 that keeps + // a rendering live would never fire. + test('the real card+html GET round-trips and its ETag matches the reconstructed validator', async function (assert) { + let url = `${testRealmURL}books/1`; + let first = await storeService.fetchCardEntry(url, { + kind: 'card', + format: 'fitted', }); - await settled(); - assert.strictEqual( - fetchCount, - afterPrerender, - 'a non-incremental index event does not re-run the search', - ); - } finally { - storeService.searchEntries = originalSearchEntries; - } + assert.false(first.notModified, 'the first GET returns the entry'); + if (!first.notModified) { + let entry = first.doc.data; + assert.strictEqual(entry.id, url); + let htmlRef = entry.relationships.html?.data?.[0]; + assert.ok(htmlRef, 'the entry carries a rendering'); + let htmlResource = first.doc.included?.find( + (resource) => resource.id === htmlRef!.id, + ); + assert.ok(htmlResource, 'the rendering rides in included'); + + // Reconstruct the composite validator the selective refresh sends, + // from the generations the response carries — the same shape + // `memberValidator` builds. + let indexGeneration = ( + entry.meta as { generation?: number } | undefined + )?.generation; + let htmlGeneration = ( + htmlResource as { meta?: { generation?: number } } | undefined + )?.meta?.generation; + let validator = `"${indexGeneration ?? 0}:${htmlGeneration ?? 'none'}"`; + + let second = await storeService.fetchCardEntry(url, { + kind: 'card', + format: 'fitted', + ifNoneMatch: validator, + }); + assert.true( + second.notModified, + 'the reconstructed validator matches the server ETag, so the GET 304s', + ); + } + }); }); test('an entry with an empty html array upgrades when its rendering lands on a later event', async function (assert) { diff --git a/packages/runtime-common/document-types.ts b/packages/runtime-common/document-types.ts index d76c021fb3..04a95a9e74 100644 --- a/packages/runtime-common/document-types.ts +++ b/packages/runtime-common/document-types.ts @@ -160,6 +160,34 @@ export function isEntryCollectionDocument( return data.every((resource) => isEntryResource(resource)); } +export function isEntrySingleDocument(doc: any): doc is EntrySingleDocument { + if (typeof doc !== 'object' || doc == null) { + return false; + } + if (!('data' in doc)) { + return false; + } + // Same untrusted-JSON discipline as the collection guard: a + // present-but-malformed `included` must fail rather than throw downstream. + if ('included' in doc && doc.included !== undefined) { + let { included } = doc; + if (!Array.isArray(included)) { + return false; + } + for (let resource of included) { + if ( + typeof resource !== 'object' || + resource == null || + typeof resource.type !== 'string' || + typeof resource.id !== 'string' + ) { + return false; + } + } + } + return isEntryResource(doc.data); +} + export type CardTypeSummaryKind = 'instance' | 'file'; // JSON:API representation of one entry from `realm_meta.value`. Clients diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 9d1f6cf483..18e634fe12 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -942,6 +942,7 @@ export { isSingleFileMetaDocument, isFileMetaCollectionDocument, isEntryCollectionDocument, + isEntrySingleDocument, isCardDocumentString, } from './document-types.ts'; export { diff --git a/packages/runtime-common/query.ts b/packages/runtime-common/query.ts index 9ed7529b21..902ea6998c 100644 --- a/packages/runtime-common/query.ts +++ b/packages/runtime-common/query.ts @@ -187,6 +187,31 @@ export function isMatchesFilter(filter: Filter): filter is MatchesFilter { return (filter as MatchesFilter).matches !== undefined; } +// Whether a full-text `matches` predicate appears anywhere in the filter tree. +// A `matches` predicate reads `markdown`, which is produced on the deferred +// prerender-html channel, so a query carrying one has full-text-dependent +// membership that a `prerender_html` event can change. A live search uses this +// to decide whether a `prerender_html` event needs a full re-run (matches) or +// only a targeted rendering refresh (structured). +export function filterHasMatches(filter: Filter | undefined): boolean { + if (!filter) { + return false; + } + if (isMatchesFilter(filter)) { + return true; + } + if (isAnyFilter(filter)) { + return filter.any.some(filterHasMatches); + } + if (isEveryFilter(filter)) { + return filter.every.some(filterHasMatches); + } + if (isNotFilter(filter)) { + return filterHasMatches(filter.not); + } + return false; +} + // True when a filter path's leaf is a card/file reference (`id` / `url`). Such // leaves get canonical-RRI tolerance in `in` filters — a registered-prefix // value also matches its equivalent real-URL / virtual-alias spellings — while diff --git a/packages/runtime-common/search-entry.ts b/packages/runtime-common/search-entry.ts index 344f58a925..0f0ec0e689 100644 --- a/packages/runtime-common/search-entry.ts +++ b/packages/runtime-common/search-entry.ts @@ -40,7 +40,7 @@ import { PRERENDERED_HTML_FORMATS, type PrerenderedHtmlFormat, } from './prerendered-html-format.ts'; -import { isCodeRef } from './card-document-shape.ts'; +import { isCodeRef, isResolvedCodeRef } from './card-document-shape.ts'; import { generalSortFields } from './index-query-engine.ts'; import { ensureTrailingSlash } from './paths.ts'; import { parseUsedRenderType } from './search-resource-helpers.ts'; @@ -738,6 +738,56 @@ export function fieldsetFromParam( }; } +// The (format, renderType) a single-leaf htmlQuery selects — the pair the +// card+html GET spells as `?format=` / `?renderType=`. Returns `undefined` for +// a composite htmlQuery (`every`/`any`/`not`) or an `eq` leaf whose renderType +// isn't a resolved `/` ref, since neither has a query-string +// spelling — a caller wanting to refresh one member's rendering in isolation +// must fall back when this is `undefined`. The inverse of `htmlQueryFromParams` +// for the leaf case (`format` defaults to `fitted`, matching that helper and +// `DEFAULT_HTML_QUERY`). +export function htmlQueryRenderingSelection( + htmlQuery: HtmlQuery | undefined, +): { format: PrerenderedHtmlFormat; renderType?: ResolvedCodeRef } | undefined { + if (!htmlQuery || !('eq' in htmlQuery)) { + return undefined; + } + let { format, renderType } = htmlQuery.eq; + if (renderType !== undefined && !isResolvedCodeRef(renderType)) { + return undefined; + } + return { + format: format ?? DEFAULT_HTML_QUERY.eq.format, + ...(renderType !== undefined ? { renderType } : {}), + }; +} + +// Whether a full-text `matches` predicate appears anywhere in the entry wire +// filter tree — the wire-grammar counterpart of `filterHasMatches`. A live +// search reads this to route a `prerender_html` event: a query whose membership +// depends on `markdown` (matches) needs a full re-run, a structured one only a +// targeted rendering refresh. +export function wireFilterHasMatches( + filter: SearchEntryWireFilter | undefined, +): boolean { + if (!filter) { + return false; + } + if (filter.matches !== undefined) { + return true; + } + if (filter.any) { + return filter.any.some(wireFilterHasMatches); + } + if (filter.every) { + return filter.every.some(wireFilterHasMatches); + } + if (filter.not) { + return wireFilterHasMatches(filter.not); + } + return false; +} + // --------------------------------------------------------------------------- // The wire grammar — what a client sends to `_search` / // `_federated-search`. `SearchEntryWireQuery` is the entry-rooted From da4a83995c592ffebeb6d82e3f13d4b05839c304 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 9 Jul 2026 14:21:26 -0400 Subject: [PATCH 2/6] Keep queued HTML invalidations across a live-search task restart The search task consumed pendingSelectiveRefresh at task start, so a restart that cancelled a selective refresh mid-GET (an event on another realm) lost the queued invalidations with the cancelled run, and the stale member never refreshed. The queue is now consumed only at the point its content is applied or folded into the coarse re-run, so a cancelled run leaves it for the replacement run. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/resources/search-entries.ts | 24 +++--- .../resources/search-entries-test.gts | 75 +++++++++++++++++++ 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/packages/host/app/resources/search-entries.ts b/packages/host/app/resources/search-entries.ts index c11125cc90..8ad9326ccf 100644 --- a/packages/host/app/resources/search-entries.ts +++ b/packages/host/app/resources/search-entries.ts @@ -332,13 +332,16 @@ export class SearchEntriesResource extends Resource { } let token = waiter.beginAsync(); try { - // A prerender_html event queued a selective per-member refresh. Consume - // it: perform the targeted card+html GETs when no index event is also - // pending (an index event changes membership and supersedes it) and the - // query still supports it; otherwise fold the queued realms into the - // coarse re-run so the update is never dropped. + // A prerender_html event queued a selective per-member refresh. Perform + // the targeted card+html GETs when no index event is also pending (an + // index event changes membership and supersedes it) and the query still + // supports it; otherwise fold the queued invalidations into the coarse + // re-run. The queue is consumed only at the point its content is + // applied or folded — never at task start — so a restart that cancels + // this run mid-GET (any later realm event re-performs this restartable + // task) leaves it queued for the replacement run instead of dropping + // it. let selective = this.pendingSelectiveRefresh; - this.pendingSelectiveRefresh = undefined; if (selective) { if ( this.realmsNeedingRefresh.size === 0 && @@ -346,15 +349,18 @@ export class SearchEntriesResource extends Resource { ) { let handled = await this.#performSelectiveRefresh(selective, query); if (handled) { + // Completed without a restart, so the queued set is exactly what + // was just applied — an event arriving mid-run would have + // restarted this task before this line. + this.pendingSelectiveRefresh = undefined; return; } // A member couldn't be refreshed in isolation (a GET failed or the // response was malformed) — fall through to a coarse re-run over // the realms the queued invalidations spanned. - this.#foldSelectiveRealmsIntoRefresh(selective); - } else { - this.#foldSelectiveRealmsIntoRefresh(selective); } + this.pendingSelectiveRefresh = undefined; + this.#foldSelectiveRealmsIntoRefresh(selective); } // A paginated query never takes the realm-scoped path: with the held diff --git a/packages/host/tests/integration/resources/search-entries-test.gts b/packages/host/tests/integration/resources/search-entries-test.gts index 9fb7de2b4e..5293285461 100644 --- a/packages/host/tests/integration/resources/search-entries-test.gts +++ b/packages/host/tests/integration/resources/search-entries-test.gts @@ -808,6 +808,81 @@ module('Integration | search-entries resource', function (hooks) { } }); + test('an index event on another realm mid-refresh does not drop the HTML update', async function (assert) { + let bookAId = `${testRealmURL}books/1`; + let bookBId = `${testRealm2URL}books/other`; + let staleDoc = entryCollectionDoc([ + { id: bookAId, indexGen: 1, htmlGen: 1, html: '
Mango v1
' }, + { id: bookBId, indexGen: 1, htmlGen: 1, html: '
Paper
' }, + ]); + let freshDoc = entryCollectionDoc([ + { id: bookAId, indexGen: 1, htmlGen: 2, html: '
Mango v2
' }, + { id: bookBId, indexGen: 1, htmlGen: 1, html: '
Paper
' }, + ]); + let fetchedRealms: string[][] = []; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async (_query, realms) => { + fetchedRealms.push([...(realms ?? [])].sort()); + return fetchedRealms.length === 1 ? staleDoc : freshDoc; + }; + + // The member GET never resolves, holding the selective refresh mid-GET + // so the index event's restart lands while it is in flight. + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return new Promise(() => {}); + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { + filter: { 'item.on': bookRef }, + realms: [testRealmURL, testRealm2URL], + }, + }, + })); + await search.loaded; + assert.strictEqual(search.entries.length, 2); + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => getCount > 0, { timeout: 10_000 }); + + // An incremental index event on the OTHER realm restarts the task + // while the member GET is in flight. The queued HTML invalidation + // must fold into the coarse re-run — not die with the cancelled run. + getService('message-service').relayRealmEvent({ + eventName: 'index', + indexType: 'incremental', + invalidations: [], + realmURL: testRealm2URL, + }); + await waitUntil( + () => + search.entries.find((e) => e.id === bookAId)?.htmlGeneration === 2, + { timeout: 10_000 }, + ); + await settled(); + + assert.deepEqual( + fetchedRealms[fetchedRealms.length - 1], + [testRealmURL, testRealm2URL].sort(), + "the coarse re-run covers the prerender event's realm too", + ); + assert.strictEqual( + search.entries.find((e) => e.id === bookAId)?.html[0]?.html, + '
Mango v2
', + 'the invalidated member picked up the fresh HTML through the fold', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + test('a non-incremental index event does not re-run the search', async function (assert) { let searchCount = 0; let originalSearchEntries = storeService.searchEntries.bind(storeService); From b979d8fc9846d6102521fb92267fd46de86e64b7 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 9 Jul 2026 15:44:44 -0400 Subject: [PATCH 3/6] Address review findings on the selective live-search refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The instances live search keeps re-running on prerender_html events: its query mode excludes rows with an effective error, and a render error lands on the prerendered_html channel at-or-above the row's index generation, so membership can flip with no index event announcing it. (filterHasMatches, now unused, is removed.) - An item-only entry fieldset takes the coarse re-run for the same reason — without the html branch the search runs the live-search projection, whose membership excludes render-errored rows. - The selective refresh is computed by a pure helper and applied in the task body, whose awaits are cancellation points — a run cancelled by a restart can no longer splice a superseded result into the entries. The helper also stops issuing GETs once a newer run supersedes it. - Queued invalidations track the highest generation per URL instead of one max across events; generations are per-realm counters and are never compared across realms. - An equal-content 200 keeps the member's object identity, matching the coarse merge paths. - htmlQueryRenderingSelection returns undefined for a format-less eq leaf (format unconstrained has no single ?format= spelling) and validates its input structurally — malformed wire data reads as "no selection" instead of throwing. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/resources/search-entries.ts | 199 +++++++++--------- packages/host/app/resources/search.ts | 26 +-- .../resources/search-entries-test.gts | 135 ++++++++++++ .../realm-server/tests/search-entry-test.ts | 90 ++++++++ packages/runtime-common/query.ts | 25 --- packages/runtime-common/search-entry.ts | 41 +++- 6 files changed, 368 insertions(+), 148 deletions(-) diff --git a/packages/host/app/resources/search-entries.ts b/packages/host/app/resources/search-entries.ts index 8ad9326ccf..5897373bc0 100644 --- a/packages/host/app/resources/search-entries.ts +++ b/packages/host/app/resources/search-entries.ts @@ -116,15 +116,22 @@ export class SearchEntriesResource extends Resource { private realmsNeedingRefresh = new Set(); // The coalesced prerender_html invalidations queued for a selective - // per-member refresh: the union of invalidated URLs and the max generation - // seen across the events. Set only for structured, refreshable queries (a - // prerender_html event can't change their membership, so the visible members - // need only their HTML refreshed, not a whole-search re-query). An index - // event supersedes it — the search task consumes or folds it, never drops - // it silently. Untracked, like `realmsNeedingRefresh`. - private pendingSelectiveRefresh: - | { generation: number; urls: Set } - | undefined; + // per-member refresh: invalidated URL → the highest generation an event + // carried for it (generations are per-realm counters, so they are never + // compared across URLs from different realms). Set only for structured, + // refreshable queries (a prerender_html event can't change their + // membership, so the visible members need only their HTML refreshed, not a + // whole-search re-query). An index event supersedes it — the search task + // consumes or folds it, never drops it silently. Untracked, like + // `realmsNeedingRefresh`. + private pendingSelectiveRefresh: Map | undefined; + + // Bumped by every search-task run (and on teardown) so an in-flight + // `#computeSelectiveRefresh` from a superseded run stops issuing GETs: the + // task body's awaits are cancellable, but a plain async method keeps + // executing after its caller was cancelled, and without this check it + // would keep fetching members the replacement run already owns. + #refreshEpoch = 0; // A partial refresh splices fetched-realm rows into the standing result // set, so it is only sound once a full run has populated that set. Until @@ -150,6 +157,7 @@ export class SearchEntriesResource extends Resource { this.subscriptions = []; this.realmsNeedingRefresh.clear(); this.pendingSelectiveRefresh = undefined; + this.#refreshEpoch++; }); } @@ -331,6 +339,7 @@ export class SearchEntriesResource extends Resource { return; } let token = waiter.beginAsync(); + this.#refreshEpoch++; try { // A prerender_html event queued a selective per-member refresh. Perform // the targeted card+html GETs when no index event is also pending (an @@ -340,18 +349,36 @@ export class SearchEntriesResource extends Resource { // applied or folded — never at task start — so a restart that cancels // this run mid-GET (any later realm event re-performs this restartable // task) leaves it queued for the replacement run instead of dropping - // it. + // it. The compute is pure and the mutations happen here in the task + // body, whose awaits are cancellation points — a cancelled run can + // never splice its (now superseded) result into the entries. let selective = this.pendingSelectiveRefresh; if (selective) { if ( this.realmsNeedingRefresh.size === 0 && this.#canSelectivelyRefresh() ) { - let handled = await this.#performSelectiveRefresh(selective, query); - if (handled) { - // Completed without a restart, so the queued set is exactly what - // was just applied — an event arriving mid-run would have - // restarted this task before this line. + let refresh = await this.#computeSelectiveRefresh(selective, query); + if (refresh !== 'fallback') { + if (refresh.size > 0) { + // Splice in place, preserving object identity for every member + // that didn't actually change (including an equal-content 200), + // so unchanged rows never re-render or lose hydration. + let next = this._entries.map((member) => { + let replacement = refresh.get(member.id); + return replacement !== undefined && + !isEqual(member, replacement) + ? replacement + : member; + }); + this._entries.splice(0, this._entries.length, ...next); + this._errors = undefined; + } + // Membership is stable across a prerender_html event, so the + // standing set, order, and `meta` all remain correct. The queued + // invalidations are exactly what was just applied — an event + // arriving mid-run would have restarted this task before this + // line. this.pendingSelectiveRefresh = undefined; return; } @@ -478,40 +505,29 @@ export class SearchEntriesResource extends Resource { return this.#fieldsetIsRefreshable(query.fields?.entry); } + // Refreshable = the html branch is in play (the default resolution or an + // explicit `html`) and any other selection is `item`. An item-only fieldset + // is NOT refreshable even though the GET could spell it: without the html + // branch the search runs the live-search projection, which excludes rows + // with an effective error — and a render error lands on the + // prerendered_html channel, so such a query's membership can flip on the + // very event being routed. It takes the coarse re-run instead. #fieldsetIsRefreshable(fields: string[] | undefined): boolean { return ( fields === undefined || - fields.every((field) => field === 'html' || field === 'item') + (fields.includes('html') && + fields.every((field) => field === 'html' || field === 'item')) ); } - // Whether the html branch is in play for the fieldset — the default - // resolution (rendering, falling back to item) or an explicit `html`. When - // it isn't (an item-only fieldset), a member with no rendering is not a - // refresh candidate: a prerender_html event brings it nothing. - #htmlBranchInPlay(fields: string[] | undefined): boolean { - return fields === undefined || fields.includes('html'); - } - - // Merge a prerender_html event into the queued selective refresh: the union - // of invalidated URLs and the max generation across events (a member is a - // candidate when any event carried HTML newer than the one it holds; the GET - // returns the newest rendering regardless). + // Merge a prerender_html event into the queued selective refresh, taking + // the max generation per URL (a member is a candidate when an event carried + // HTML newer than the one it holds; the GET returns the newest rendering + // regardless). #recordSelectiveRefresh(event: PrerenderHtmlEventContent): void { - let urls = event.invalidations ?? []; - if (this.pendingSelectiveRefresh === undefined) { - this.pendingSelectiveRefresh = { - generation: event.generation, - urls: new Set(urls), - }; - } else { - this.pendingSelectiveRefresh.generation = Math.max( - this.pendingSelectiveRefresh.generation, - event.generation, - ); - for (let url of urls) { - this.pendingSelectiveRefresh.urls.add(url); - } + let pending = (this.pendingSelectiveRefresh ??= new Map()); + for (let url of event.invalidations ?? []) { + pending.set(url, Math.max(pending.get(url) ?? 0, event.generation)); } } @@ -519,52 +535,55 @@ export class SearchEntriesResource extends Resource { // member GET failed), fold the realms the queued invalidations spanned into // the coarse re-run so their prerender_html update still lands. A different // realm's index event must not swallow this realm's HTML update. - #foldSelectiveRealmsIntoRefresh(selective: { urls: Set }): void { + #foldSelectiveRealmsIntoRefresh(invalidations: Map): void { for (let member of this._entries) { - if (memberMatchesUrls(member, selective.urls)) { + if (invalidationGenerationFor(member, invalidations) !== undefined) { this.realmsNeedingRefresh.add(member.realmUrl); } } } - // Refresh only the members a prerender_html event carries newer HTML for, - // each through a conditional card+html GET keyed on the composite validator - // the member holds. A `304` keeps the current rendering (identity preserved, - // so a hydrated row stays live); a `200` swaps in the fresh entry. Membership - // is stable across a prerender_html event, so this never adds or drops a row - // and leaves `meta.page.total` untouched. Nothing is written to the store — - // a member a consumer has hydrated is owned by the store's reactive reload. - // Returns false to request a coarse fallback when a member can't be refreshed - // in isolation. - async #performSelectiveRefresh( - selective: { generation: number; urls: Set }, + // Compute the replacements for the members a prerender_html event carries + // newer HTML for, each through a conditional card+html GET keyed on the + // composite validator the member holds: a `304` keeps the current rendering + // (the member is skipped, so its identity — and any hydration — survives), + // a `200` yields the fresh entry. Pure with respect to the resource's + // result state: the task body applies the returned map, so a run cancelled + // mid-GET can never splice a superseded result (this method keeps executing + // after its caller is cancelled — a plain async method has no cancellation + // points — which is also why it re-checks `#refreshEpoch` between GETs). + // Nothing is written to the store — a member a consumer has hydrated is + // owned by the store's reactive reload. Returns 'fallback' to request a + // coarse re-run when a member can't be refreshed in isolation. + async #computeSelectiveRefresh( + invalidations: Map, query: SearchEntryWireQuery, - ): Promise { + ): Promise | 'fallback'> { + let epoch = this.#refreshEpoch; let selection = htmlQueryRenderingSelection(this._meta.htmlQuery); - let htmlInPlay = this.#htmlBranchInPlay(query.fields?.entry); let fieldsParam = this.#fieldsParam(query.fields?.entry); + // A member with a rendering is a candidate only when an event names it at + // a newer generation than it holds; one with no rendering yet is always a + // candidate (the upgrade opportunity this event may be announcing). let candidates = this._entries.filter((member) => { - if (!memberMatchesUrls(member, selective.urls)) { + let eventGeneration = invalidationGenerationFor(member, invalidations); + if (eventGeneration === undefined) { return false; } - if (member.htmlGeneration !== undefined) { - // A member with a rendering refreshes only when the event carries a - // newer one than it holds. - return member.htmlGeneration < selective.generation; - } - // No rendering yet: refresh (an upgrade opportunity) only when the html - // branch is in play. - return htmlInPlay; + return ( + member.htmlGeneration === undefined || + member.htmlGeneration < eventGeneration + ); }); - if (candidates.length === 0) { - // The common case for an unrelated event: nothing in the visible set - // moved, so do no work at all. - return true; - } let replacements = new Map(); for (let member of candidates) { + if (epoch !== this.#refreshEpoch || isDestroyed(this)) { + // A newer run (or teardown) superseded this one: stop fetching. The + // returned value is discarded — the cancelled caller never resumes. + return replacements; + } let result; try { result = await this.runtimeStore.fetchCardEntry(member.id, { @@ -575,16 +594,11 @@ export class SearchEntriesResource extends Resource { ifNoneMatch: memberValidator(member), }); } catch (err) { - // A restart (a newer event) cancels this task mid-GET — let it - // propagate rather than treating it as a failed refresh. - if (didCancel(err)) { - throw err; - } this.#log.warn( `selective refresh GET failed for ${member.id}; falling back to a full re-run`, err, ); - return false; + return 'fallback'; } if (result.notModified) { continue; @@ -599,24 +613,11 @@ export class SearchEntriesResource extends Resource { await this.loadStylesheets(collectionDoc); let [refreshed] = this.buildEntries(collectionDoc); if (refreshed === undefined) { - return false; + return 'fallback'; } replacements.set(member.id, refreshed); } - - if (replacements.size > 0) { - // Splice in place: every unchanged member keeps its object identity, so - // only the members whose rendering actually advanced re-render. - let next = this._entries.map( - (member) => replacements.get(member.id) ?? member, - ); - this._entries.splice(0, this._entries.length, ...next); - } - // `realmsNeedingRefresh` is deliberately left as-is: a selective refresh - // only runs when it was empty, and any index event arriving mid-refresh - // restarts this task (so its realm survives for the re-run). - this._errors = undefined; - return true; + return replacements; } // The ?fields= value mirroring the query's entry fieldset (validated @@ -752,14 +753,20 @@ function stripJsonSuffix(url: string): string { return url.replace(/\.json$/, ''); } -function memberMatchesUrls(member: SearchEntry, urls: Set): boolean { +// The highest generation among the queued invalidation URLs that name this +// member, or undefined when none do. +function invalidationGenerationFor( + member: SearchEntry, + invalidations: Map, +): number | undefined { let normalized = stripJsonSuffix(member.id); - for (let url of urls) { + let result: number | undefined; + for (let [url, generation] of invalidations) { if (stripJsonSuffix(url) === normalized) { - return true; + result = result === undefined ? generation : Math.max(result, generation); } } - return false; + return result; } // `card` vs `file-meta` for the card+html GET's Accept header — the same diff --git a/packages/host/app/resources/search.ts b/packages/host/app/resources/search.ts index ed9cdbdc20..f3372b6127 100644 --- a/packages/host/app/resources/search.ts +++ b/packages/host/app/resources/search.ts @@ -24,7 +24,6 @@ import type { } from '@cardstack/runtime-common'; import { subscribeToRealm, - filterHasMatches, isFileDefInstance, isFileDefCodeRef, isClientEvaluable, @@ -424,25 +423,18 @@ export class SearchResource< if (this.#previousQuery === undefined) { return; } + // Re-run on incremental index events (the search doc changed) + // and on prerender_html events. The latter matter even to + // structured queries: this search excludes rows with an + // effective error, and a render error lands on the + // prerendered_html channel at-or-above the row's index + // generation — so membership can flip on a prerender_html event + // with no index event announcing it. (Full-text `matches` + // membership rides that channel too, via `markdown`.) let isIncrementalIndex = event.eventName === 'index' && (!('indexType' in event) || event.indexType === 'incremental'); - let isPrerenderHtml = event.eventName === 'prerender_html'; - if (!isIncrementalIndex && !isPrerenderHtml) { - return; - } - // An incremental index event (the search doc — and so membership — - // changed) always re-runs. A prerender_html event only matters to a - // full-text (matches) query: its membership is built from - // `markdown`, which lands on the prerender-html channel. These - // results are instances, which carry no prerendered HTML, so a - // structured query's members and their fields are already final - // after the index pass — re-running on its prerender_html event - // would fetch an identical result set. - if ( - isPrerenderHtml && - !filterHasMatches(this.#previousQuery.filter) - ) { + if (!isIncrementalIndex && event.eventName !== 'prerender_html') { return; } this.trackStoreLoad( diff --git a/packages/host/tests/integration/resources/search-entries-test.gts b/packages/host/tests/integration/resources/search-entries-test.gts index 5293285461..7ff2be0aa6 100644 --- a/packages/host/tests/integration/resources/search-entries-test.gts +++ b/packages/host/tests/integration/resources/search-entries-test.gts @@ -808,6 +808,141 @@ module('Integration | search-entries resource', function (hooks) { } }); + test('an item-only fieldset takes the coarse re-run — render errors can flip its membership', async function (assert) { + // Without the html branch the search runs the live-search projection, + // which excludes rows with an effective error — and a render error + // lands on the prerendered_html channel. So a prerender_html event can + // change this query's membership and a per-member refresh would miss + // that. The doc content is irrelevant to the routing decision under + // test. + let itemOnlyDoc: SearchEntryResults = { + data: [ + { + type: EntryResourceType, + id: `${testRealmURL}books/1`, + relationships: {}, + meta: { generation: 1 }, + }, + ], + meta: { page: { total: 1 } }, + }; + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return itemOnlyDoc; + }; + let getCount = 0; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return { notModified: true }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { + filter: { 'item.on': bookRef }, + fields: { entry: ['item'] }, + realms: [testRealmURL], + }, + }, + })); + await search.loaded; + let baseline = searchCount; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => searchCount > baseline, { timeout: 10_000 }); + await settled(); + + assert.ok( + searchCount > baseline, + 'an item-only query re-runs the whole search on a prerender_html event', + ); + assert.strictEqual(getCount, 0, 'no per-member GET is attempted'); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + + test('coalesced events refresh the union of their members, each judged at its own generation', async function (assert) { + let book1 = `${testRealmURL}books/1`; + let book2 = `${testRealmURL}books/2`; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + entryCollectionDoc([ + { id: book1, indexGen: 1, htmlGen: 1, html: '
Mango v1
' }, + { + id: book2, + indexGen: 1, + htmlGen: 1, + html: '
Van Gogh v1
', + }, + ]); + + // The first GET (event 1's member) never resolves, holding the run + // mid-GET so event 2 restarts it; the replacement run must cover both + // events' members. + let getUrls: string[] = []; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async (url: string) => { + getUrls.push(url); + if (getUrls.length === 1) { + return new Promise(() => {}); + } + return { + notModified: false, + doc: entrySingleDoc({ + id: url, + indexGen: 1, + htmlGen: url === book2 ? 3 : 2, + html: `
${url === book2 ? 'Van Gogh' : 'Mango'} v2
`, + }), + }; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => getUrls.length === 1, { timeout: 10_000 }); + relayPrerenderHtml([`${testRealmURL}books/2.json`], 3); + await waitUntil( + () => + search.entries.find((e) => e.id === book1)?.htmlGeneration === 2 && + search.entries.find((e) => e.id === book2)?.htmlGeneration === 3, + { timeout: 10_000 }, + ); + await settled(); + + assert.deepEqual( + getUrls.slice(1).sort(), + [book1, book2], + "the replacement run refreshes both events' members", + ); + assert.strictEqual( + search.entries.find((e) => e.id === book1)?.html[0]?.html, + '
Mango v2
', + ); + assert.strictEqual( + search.entries.find((e) => e.id === book2)?.html[0]?.html, + '
Van Gogh v2
', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + test('an index event on another realm mid-refresh does not drop the HTML update', async function (assert) { let bookAId = `${testRealmURL}books/1`; let bookBId = `${testRealm2URL}books/other`; diff --git a/packages/realm-server/tests/search-entry-test.ts b/packages/realm-server/tests/search-entry-test.ts index 9445321043..138eb30937 100644 --- a/packages/realm-server/tests/search-entry-test.ts +++ b/packages/realm-server/tests/search-entry-test.ts @@ -8,6 +8,7 @@ import { buildSparseItemResource, fieldsetFromParam, htmlQueryFromParams, + htmlQueryRenderingSelection, htmlResourceId, cssResourceId, htmlQueryHasRenderTypePredicate, @@ -20,6 +21,7 @@ import { parseSearchEntryQueryFromPayload, resolveHtmlQuery, searchEntryWireQueryFromQuery, + wireFilterHasMatches, SearchRequestError, DEFAULT_HTML_QUERY, rri, @@ -420,6 +422,94 @@ module(basename(import.meta.filename), function () { }); }); + // The (format, renderType) a single-leaf htmlQuery selects — what the + // live-search selective refresh spells as the card+html GET's query params. + module('htmlQuery rendering selection', function () { + test('a single eq leaf yields its format and renderType', function (assert) { + assert.deepEqual( + htmlQueryRenderingSelection({ + eq: { format: 'embedded', renderType: authorRef }, + }), + { format: 'embedded', renderType: authorRef }, + ); + assert.deepEqual( + htmlQueryRenderingSelection({ eq: { format: 'fitted' } }), + { format: 'fitted' }, + ); + }); + + test('a format-less eq leaf has no selection — it leaves format unconstrained (one rendering per format), which one ?format= cannot spell', function (assert) { + assert.strictEqual( + htmlQueryRenderingSelection({ eq: { renderType: authorRef } }), + undefined, + ); + }); + + test('an absent htmlQuery and composite htmlQueries have no selection', function (assert) { + assert.strictEqual(htmlQueryRenderingSelection(undefined), undefined); + assert.strictEqual( + htmlQueryRenderingSelection({ + any: [{ eq: { format: 'fitted' } }, { eq: { format: 'embedded' } }], + }), + undefined, + ); + assert.strictEqual( + htmlQueryRenderingSelection({ not: { eq: { format: 'atom' } } }), + undefined, + ); + }); + + test('a malformed value reads as no selection instead of throwing', function (assert) { + // The input can come straight off a wire document's meta.htmlQuery, + // which the document guard does not validate. + for (let malformed of [ + 'fitted', + 42, + null, + { eq: null }, + { eq: 'fitted' }, + { eq: { format: 'nonsense' } }, + { eq: { format: 'fitted', renderType: 'not-a-code-ref' } }, + ]) { + assert.strictEqual( + htmlQueryRenderingSelection(malformed as any), + undefined, + `${JSON.stringify(malformed)} → undefined`, + ); + } + }); + }); + + // Whether a full-text `matches` predicate appears anywhere in an entry wire + // filter — what routes a prerender_html event to a full re-run vs a + // selective refresh. + module('wire filter matches detection', function () { + test('detects matches at the top level and nested in connectives', function (assert) { + assert.true(wireFilterHasMatches({ matches: 'pigeon' })); + assert.true( + wireFilterHasMatches({ + every: [ + { 'item.on': authorRef }, + { any: [{ not: { matches: 'pigeon' } }] }, + ], + }), + ); + }); + + test('structured filters carry no matches', function (assert) { + assert.false(wireFilterHasMatches(undefined)); + assert.false(wireFilterHasMatches({ 'item.on': authorRef })); + assert.false( + wireFilterHasMatches({ + every: [ + { eq: { 'item.status': 'ready' } }, + { not: { contains: { 'item.title': 'x' } } }, + ], + }), + ); + }); + }); + // The legacy-Query → wire-grammar translation is the parser's inverse; // round-tripping a translated query through the parser must recover the // original itemQuery exactly. diff --git a/packages/runtime-common/query.ts b/packages/runtime-common/query.ts index 902ea6998c..9ed7529b21 100644 --- a/packages/runtime-common/query.ts +++ b/packages/runtime-common/query.ts @@ -187,31 +187,6 @@ export function isMatchesFilter(filter: Filter): filter is MatchesFilter { return (filter as MatchesFilter).matches !== undefined; } -// Whether a full-text `matches` predicate appears anywhere in the filter tree. -// A `matches` predicate reads `markdown`, which is produced on the deferred -// prerender-html channel, so a query carrying one has full-text-dependent -// membership that a `prerender_html` event can change. A live search uses this -// to decide whether a `prerender_html` event needs a full re-run (matches) or -// only a targeted rendering refresh (structured). -export function filterHasMatches(filter: Filter | undefined): boolean { - if (!filter) { - return false; - } - if (isMatchesFilter(filter)) { - return true; - } - if (isAnyFilter(filter)) { - return filter.any.some(filterHasMatches); - } - if (isEveryFilter(filter)) { - return filter.every.some(filterHasMatches); - } - if (isNotFilter(filter)) { - return filterHasMatches(filter.not); - } - return false; -} - // True when a filter path's leaf is a card/file reference (`id` / `url`). Such // leaves get canonical-RRI tolerance in `in` filters — a registered-prefix // value also matches its equivalent real-URL / virtual-alias spellings — while diff --git a/packages/runtime-common/search-entry.ts b/packages/runtime-common/search-entry.ts index 0f0ec0e689..101702eb06 100644 --- a/packages/runtime-common/search-entry.ts +++ b/packages/runtime-common/search-entry.ts @@ -739,25 +739,46 @@ export function fieldsetFromParam( } // The (format, renderType) a single-leaf htmlQuery selects — the pair the -// card+html GET spells as `?format=` / `?renderType=`. Returns `undefined` for -// a composite htmlQuery (`every`/`any`/`not`) or an `eq` leaf whose renderType -// isn't a resolved `/` ref, since neither has a query-string -// spelling — a caller wanting to refresh one member's rendering in isolation -// must fall back when this is `undefined`. The inverse of `htmlQueryFromParams` -// for the leaf case (`format` defaults to `fitted`, matching that helper and -// `DEFAULT_HTML_QUERY`). +// card+html GET spells as `?format=` / `?renderType=`. Returns `undefined` +// whenever the htmlQuery has no query-string spelling, and the caller must +// fall back to a full search instead of refreshing one member in isolation: +// a composite htmlQuery (`every`/`any`/`not`), an `eq` leaf with no `format` +// (that leaves format UNCONSTRAINED — the row carries a rendering per format, +// which one GET's single `?format=` cannot reproduce), or an `eq` leaf whose +// renderType isn't a resolved `/` ref. export function htmlQueryRenderingSelection( htmlQuery: HtmlQuery | undefined, ): { format: PrerenderedHtmlFormat; renderType?: ResolvedCodeRef } | undefined { - if (!htmlQuery || !('eq' in htmlQuery)) { + // Structural guards, not just types: the input can come straight off a wire + // document's `meta.htmlQuery`, whose shape the document guard does not + // validate. A malformed value must read as "no single-leaf selection" (the + // caller falls back to a full re-run) rather than throw. + if ( + typeof htmlQuery !== 'object' || + htmlQuery == null || + !('eq' in htmlQuery) || + typeof htmlQuery.eq !== 'object' || + htmlQuery.eq == null + ) { return undefined; } let { format, renderType } = htmlQuery.eq; - if (renderType !== undefined && !isResolvedCodeRef(renderType)) { + if (!isValidPrerenderedHtmlFormat(format)) { + return undefined; + } + if ( + renderType !== undefined && + // `isResolvedCodeRef` assumes an object (it probes with `in`), so the + // structural check must come first for the same malformed-wire reason as + // above. + (typeof renderType !== 'object' || + renderType == null || + !isResolvedCodeRef(renderType)) + ) { return undefined; } return { - format: format ?? DEFAULT_HTML_QUERY.eq.format, + format, ...(renderType !== undefined ? { renderType } : {}), }; } From 5943391dae95418d1be5fb7d418b4338b36d3cb8 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 9 Jul 2026 16:06:53 -0400 Subject: [PATCH 4/6] Drop a ticket reference from a code comment Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/resources/search-entries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/app/resources/search-entries.ts b/packages/host/app/resources/search-entries.ts index 5897373bc0..ae6733b4ca 100644 --- a/packages/host/app/resources/search-entries.ts +++ b/packages/host/app/resources/search-entries.ts @@ -481,7 +481,7 @@ export class SearchEntriesResource extends Resource { // held rows are page-limited, so no splice), not full-text (matches // membership can change), and with a rendering selection / fieldset the GET // can spell as query params. When false the prerender_html event falls - // through to the coarse re-run — the CS-11763 behavior — so nothing regresses. + // through to the coarse re-run, which handles every case. #canSelectivelyRefresh(): boolean { let query = this.#previousQuery; if (query === undefined || !this.hasCompletedFullRun) { From f38fc1489cce2f6d2357b1e3794ee8af46f1193f Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 9 Jul 2026 16:16:40 -0400 Subject: [PATCH 5/6] Explain the two rows a prerender_html invalidation URL names Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/resources/search-entries.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/host/app/resources/search-entries.ts b/packages/host/app/resources/search-entries.ts index ae6733b4ca..0be3b5ef39 100644 --- a/packages/host/app/resources/search-entries.ts +++ b/packages/host/app/resources/search-entries.ts @@ -745,10 +745,15 @@ function buildRendering( }; } -// Strip a trailing `.json` so an entry's id (the bare card URL) matches a -// prerender_html invalidation (an instance's `.json` file URL); a file's id -// and invalidation are both bare, so stripping is a no-op there. Comparing the -// normalized forms matches either shape without knowing which a member is. +// A prerender_html invalidation names the underlying file (`books/1.json`), +// which can back TWO result rows: the card instance (id `books/1` — instance +// ids never carry the extension) and the file-meta row (id `books/1.json` — +// every file gets a file entry, card JSON included). Stripping a trailing +// `.json` from both sides lets the one invalidation reach both rows without +// knowing which kind a member is: the instance row needs the invalidation +// normalized, and the file row then needs its own id normalized the same way +// to keep matching. For every other file kind (`notes.md`, `book.gts`) the +// strip is a no-op and the comparison is exact. function stripJsonSuffix(url: string): string { return url.replace(/\.json$/, ''); } From 8223d0b1ebc7ecc38553d22864d12f68a97f3257 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 9 Jul 2026 17:04:52 -0400 Subject: [PATCH 6/6] Harden the selective refresh against mid-pipeline failures and stamp churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Any failure in the member-refresh pipeline — the GET, the stylesheet imports, or the rebuild — now falls back to the coarse re-run; previously a stylesheet-import rejection escaped the task and the member stayed stale with nothing surfaced. - Generation stamps no longer break row identity: a row whose visible content is unchanged keeps its object (and its hydration) across every merge path, adopting the fresh stamps in place. The invalidation fan-out re-indexes dependents whose content is often byte-identical, so without this every edit remounted every dependent row. - Clearing the query mid-refresh stops the remaining member GETs, same as teardown. - A prerender_html event with no usable generation takes the coarse re-run. - The test fixture echoes the true default htmlQuery (format only), and the stylesheet-failure test pins that its loader stub actually intercepted — the loader service replaces its loader instance on reset, so a stub installed on a stale instance silently no-ops. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/resources/search-entries.ts | 130 ++++++--- .../resources/search-entries-test.gts | 248 +++++++++++++++++- 2 files changed, 333 insertions(+), 45 deletions(-) diff --git a/packages/host/app/resources/search-entries.ts b/packages/host/app/resources/search-entries.ts index 0be3b5ef39..b5eb1a981f 100644 --- a/packages/host/app/resources/search-entries.ts +++ b/packages/host/app/resources/search-entries.ts @@ -215,6 +215,9 @@ export class SearchEntriesResource extends Resource { this.#previousRealms = undefined; this.realmsNeedingRefresh.clear(); this.pendingSelectiveRefresh = undefined; + // An in-flight selective refresh must stop issuing GETs for the + // cleared query, same as on teardown. + this.#refreshEpoch++; this.hasCompletedFullRun = false; this.search.cancelAll(); for (let subscription of this.subscriptions) { @@ -278,10 +281,12 @@ export class SearchEntriesResource extends Resource { // refreshable query, refresh just the members the event carries // newer HTML for, through a conditional card+html GET, instead of // re-querying the whole realm. Full-text (matches) queries, - // paginated queries, and composite/sparse selections can't refresh + // paginated queries, composite/sparse selections, and a malformed + // event (no usable generation to judge staleness by) can't refresh // in isolation and fall through to the coarse re-run. if ( event.eventName === 'prerender_html' && + typeof event.generation === 'number' && this.#canSelectivelyRefresh() ) { this.#log.info( @@ -362,14 +367,14 @@ export class SearchEntriesResource extends Resource { if (refresh !== 'fallback') { if (refresh.size > 0) { // Splice in place, preserving object identity for every member - // that didn't actually change (including an equal-content 200), - // so unchanged rows never re-render or lose hydration. + // whose visible content didn't change (a 200 can advance only + // the stamps), so unchanged rows never re-render or lose + // hydration. let next = this._entries.map((member) => { let replacement = refresh.get(member.id); - return replacement !== undefined && - !isEqual(member, replacement) - ? replacement - : member; + return replacement === undefined + ? member + : adoptFresh(member, replacement); }); this._entries.splice(0, this._entries.length, ...next); this._errors = undefined; @@ -422,7 +427,7 @@ export class SearchEntriesResource extends Resource { } let replacement = freshById.get(entry.id); if (replacement !== undefined) { - merged.push(isEqual(entry, replacement) ? entry : replacement); + merged.push(adoptFresh(entry, replacement)); freshById.delete(entry.id); } } @@ -442,12 +447,9 @@ export class SearchEntriesResource extends Resource { let previousById = new Map( this._entries.map((entry) => [entry.id, entry]), ); - let next = fresh.map((entry) => { - let previous = previousById.get(entry.id); - return previous !== undefined && isEqual(previous, entry) - ? previous - : entry; - }); + let next = fresh.map((entry) => + adoptFresh(previousById.get(entry.id), entry), + ); this._entries.splice(0, this._entries.length, ...next); this._meta = doc.meta; this.hasCompletedFullRun = true; @@ -566,6 +568,12 @@ export class SearchEntriesResource extends Resource { // A member with a rendering is a candidate only when an event names it at // a newer generation than it holds; one with no rendering yet is always a // candidate (the upgrade opportunity this event may be announcing). + // Content that changes WITHOUT a generation advance (a dual-read fallback + // row whose real HTML lands at the generation the fallback already + // reported, or a generation reused after a failed commit) is invisible to + // this gate — and equally to the composite validator, which would `304` + // such a member anyway — so those heal on the next generation advance + // rather than here. let candidates = this._entries.filter((member) => { let eventGeneration = invalidationGenerationFor(member, invalidations); if (eventGeneration === undefined) { @@ -584,38 +592,40 @@ export class SearchEntriesResource extends Resource { // returned value is discarded — the cancelled caller never resumes. return replacements; } - let result; + // The whole per-member pipeline falls back on any failure — the GET, + // the stylesheet imports, and the rebuild alike — so a member that + // can't be refreshed in isolation always reaches the coarse re-run. try { - result = await this.runtimeStore.fetchCardEntry(member.id, { + let result = await this.runtimeStore.fetchCardEntry(member.id, { kind: memberKind(member), format: selection?.format, renderType: selection?.renderType, fields: fieldsParam, ifNoneMatch: memberValidator(member), }); + if (result.notModified) { + continue; + } + // Reuse the collection builders: wrap the single doc so the refreshed + // member is shaped exactly as the search would have returned it. + let collectionDoc: EntryCollectionDocument = { + data: [result.doc.data], + included: result.doc.included, + meta: { page: { total: 1 } }, + }; + await this.loadStylesheets(collectionDoc); + let [refreshed] = this.buildEntries(collectionDoc); + if (refreshed === undefined) { + return 'fallback'; + } + replacements.set(member.id, refreshed); } catch (err) { this.#log.warn( - `selective refresh GET failed for ${member.id}; falling back to a full re-run`, + `selective refresh failed for ${member.id}; falling back to a full re-run`, err, ); return 'fallback'; } - if (result.notModified) { - continue; - } - // Reuse the collection builders: wrap the single doc so the refreshed - // member is shaped exactly as the search would have returned it. - let collectionDoc: EntryCollectionDocument = { - data: [result.doc.data], - included: result.doc.included, - meta: { page: { total: 1 } }, - }; - await this.loadStylesheets(collectionDoc); - let [refreshed] = this.buildEntries(collectionDoc); - if (refreshed === undefined) { - return 'fallback'; - } - replacements.set(member.id, refreshed); } return replacements; } @@ -745,6 +755,48 @@ function buildRendering( }; } +// Render-stability identity. The generation stamps are freshness metadata the +// refresh machinery reads — no template consumes them — so a row whose +// visible content is unchanged keeps its object identity even when a +// re-index bumped its stamps: the fresh stamps are copied onto the retained +// object instead (plain untracked fields, so the mutation re-renders +// nothing). Without this, the invalidation fan-out — which re-indexes +// dependents whose content is often byte-identical — would remount every +// dependent row (and drop its hydration) on every edit. +function adoptFresh( + previous: SearchEntry | undefined, + fresh: SearchEntry, +): SearchEntry { + if (previous === undefined || !contentEquals(previous, fresh)) { + return fresh; + } + if (fresh.indexGeneration !== undefined) { + previous.indexGeneration = fresh.indexGeneration; + } else { + delete previous.indexGeneration; + } + if (fresh.htmlGeneration !== undefined) { + previous.htmlGeneration = fresh.htmlGeneration; + } else { + delete previous.htmlGeneration; + } + return previous; +} + +function contentEquals(a: SearchEntry, b: SearchEntry): boolean { + let { + indexGeneration: _aIndexGeneration, + htmlGeneration: _aHtmlGeneration, + ...aContent + } = a; + let { + indexGeneration: _bIndexGeneration, + htmlGeneration: _bHtmlGeneration, + ...bContent + } = b; + return isEqual(aContent, bContent); +} + // A prerender_html invalidation names the underlying file (`books/1.json`), // which can back TWO result rows: the card instance (id `books/1` — instance // ids never carry the extension) and the file-meta row (id `books/1.json` — @@ -791,11 +843,13 @@ function memberKind(member: SearchEntry): StoreReadType { // The composite validator the member holds, in the `card+html` GET's ETag // spelling — `":"` (see // `buildEntryHtmlEtag`). For a member that holds a rendering this matches the -// server's pure-html ETag exactly, so an unchanged rendering returns `304`. A -// member with no rendering carries `none`, and the server folds a realm-info -// hash into an item-bearing response's validator, so it never matches — that's -// intended: a no-rendering member always refetches, which is exactly the -// upgrade a prerender_html event should pick up. +// server's pure-html ETag exactly, so an unchanged rendering returns `304`. +// The server folds a realm-info hash — which the client can't reconstruct — +// into an ITEM-bearing response's validator, so those never match: a member +// with no rendering (the item fallback) always refetches, which is exactly +// the upgrade a prerender_html event should pick up, and an `html,item` +// fieldset's members refetch on every qualifying event (`adoptFresh` keeps +// their row identity when the content comes back unchanged). function memberValidator(member: SearchEntry): string { let indexSegment = member.indexGeneration ?? 0; let htmlSegment = diff --git a/packages/host/tests/integration/resources/search-entries-test.gts b/packages/host/tests/integration/resources/search-entries-test.gts index 7ff2be0aa6..d8256e6ac6 100644 --- a/packages/host/tests/integration/resources/search-entries-test.gts +++ b/packages/host/tests/integration/resources/search-entries-test.gts @@ -142,12 +142,14 @@ module('Integration | search-entries resource', function (hooks) { } // A book result carrying the generations the selective refresh reads. `htmlGen` - // omitted → an empty-html (item-fallback) row. + // omitted → an empty-html (item-fallback) row. `cssHref` links a scoped + // stylesheet, so `loadStylesheets` has something to import. interface BookEntrySpec { id: string; indexGen: number; htmlGen?: number; html?: string; + cssHref?: string; } function fittedHtmlId(id: string) { @@ -184,14 +186,32 @@ module('Integration | search-entries resource', function (hooks) { format: 'fitted', renderType: bookRef, }, - // No scoped CSS, so `loadStylesheets` imports nothing. - relationships: { styles: { data: [] } }, + relationships: { + styles: { + data: spec.cssHref + ? [{ type: CssResourceType, id: `css-${spec.id}` }] + : [], + }, + }, meta: { generation: spec.htmlGen! }, }; } - // A `_federated-search` collection document with the default htmlQuery echoed, - // as the server returns for `{ 'item.on': Book }`. + function cssResourcesFor( + specs: BookEntrySpec[], + ): NonNullable { + return specs + .filter((spec) => spec.cssHref !== undefined) + .map((spec) => ({ + type: CssResourceType, + id: `css-${spec.id}`, + attributes: { href: spec.cssHref! }, + })); + } + + // A `_federated-search` collection document echoing the default htmlQuery + // (`{ eq: { format: 'fitted' } }` — no renderType), as the server returns + // for `{ 'item.on': Book }` with no htmlQuery bound. function entryCollectionDoc(specs: BookEntrySpec[]): SearchEntryResults { return { data: specs.map(entryResourceFor), @@ -200,7 +220,7 @@ module('Integration | search-entries resource', function (hooks) { .map(htmlResourceFor), meta: { page: { total: specs.length }, - htmlQuery: { eq: { format: 'fitted', renderType: bookRef } }, + htmlQuery: { eq: { format: 'fitted' } }, }, }; } @@ -209,7 +229,10 @@ module('Integration | search-entries resource', function (hooks) { function entrySingleDoc(spec: BookEntrySpec): EntrySingleDocument { return { data: entryResourceFor(spec), - included: spec.htmlGen !== undefined ? [htmlResourceFor(spec)] : [], + included: + spec.htmlGen !== undefined + ? [htmlResourceFor(spec), ...cssResourcesFor([spec])] + : [], }; } @@ -1018,6 +1041,217 @@ module('Integration | search-entries resource', function (hooks) { } }); + test('a stylesheet-import failure during a member refresh falls back to a full re-run', async function (assert) { + let cssHref = `${testRealmURL}book.gts.deadbeef.glimmer-scoped.css`; + let searchCount = 0; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => { + searchCount++; + return entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango v1
', + }, + ]); + }; + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => ({ + notModified: false, + doc: entrySingleDoc({ + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 2, + html: '
Mango v2
', + cssHref, + }), + })) as typeof storeService.fetchCardEntry; + // Stubbed on the loader current at event time: `loaderService.loader` + // is a field the service REPLACES on reset/clone, so an instance + // captured earlier (e.g. in beforeEach) can be stale by now. + let importCalls: string[] = []; + let stubbedLoader: Loader | undefined; + let originalImport: Loader['import'] | undefined; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let baseline = searchCount; + + stubbedLoader = loaderService.loader; + originalImport = stubbedLoader.import.bind(stubbedLoader); + stubbedLoader.import = (async (url: string) => { + importCalls.push(url); + if (url === cssHref) { + throw new Error('stylesheet fetch failed'); + } + return originalImport!(url); + }) as Loader['import']; + + relayPrerenderHtml([`${testRealmURL}books/1.json`], 2); + await waitUntil(() => searchCount > baseline, { timeout: 10_000 }); + await settled(); + + assert.true( + importCalls.includes(cssHref), + 'the member refresh actually attempted the stylesheet import', + ); + assert.ok( + searchCount > baseline, + 'the failed stylesheet import falls back to the coarse re-run', + ); + assert.strictEqual( + search.entries[0]?.htmlGeneration, + 1, + "the failed member's refresh was not applied", + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + if (stubbedLoader && originalImport) { + stubbedLoader.import = originalImport; + } + } + }); + + test('a row whose only change is its generation stamps keeps identity and adopts the stamps', async function (assert) { + // The invalidation fan-out re-indexes dependents whose content is often + // byte-identical — only the stamps move. Those rows must not remount + // (identity preserved) but must carry the fresh stamps, or later events + // would judge staleness against outdated generations. + let docs = [ + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + ]), + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 2, + htmlGen: 2, + html: '
Mango
', + }, + ]), + ]; + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => docs.shift() ?? docs[0]; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + let before = search.entries[0]; + assert.strictEqual(before.htmlGeneration, 1); + + getService('message-service').relayRealmEvent({ + eventName: 'index', + indexType: 'incremental', + invalidations: [`${testRealmURL}books/1.json`], + realmURL: testRealmURL, + }); + await waitUntil(() => search.entries[0]?.htmlGeneration === 2, { + timeout: 10_000, + }); + await settled(); + + assert.strictEqual( + search.entries[0], + before, + 'the content-unchanged row keeps its object identity', + ); + assert.strictEqual(before.indexGeneration, 2, 'the stamps are adopted'); + } finally { + storeService.searchEntries = originalSearchEntries; + } + }); + + test('clearing the query mid-refresh stops the remaining member GETs', async function (assert) { + let originalSearchEntries = storeService.searchEntries.bind(storeService); + storeService.searchEntries = async () => + entryCollectionDoc([ + { + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 1, + html: '
Mango
', + }, + { + id: `${testRealmURL}books/2`, + indexGen: 1, + htmlGen: 1, + html: '
Van Gogh
', + }, + ]); + + // The first GET is held open; once the query is cleared and the GET + // resolves, the (uncancellable) helper must not proceed to the second + // member. + let getCount = 0; + let releaseFirstGet: (value: { + notModified: false; + doc: EntrySingleDocument; + }) => void; + let firstGet = new Promise<{ + notModified: false; + doc: EntrySingleDocument; + }>((resolve) => (releaseFirstGet = resolve)); + let originalFetchCardEntry = + storeService.fetchCardEntry.bind(storeService); + storeService.fetchCardEntry = (async () => { + getCount++; + return firstGet; + }) as typeof storeService.fetchCardEntry; + + try { + let search = getResourceForTest(storeService, () => ({ + named: { + query: { filter: { 'item.on': bookRef }, realms: [testRealmURL] }, + }, + })); + await search.loaded; + + relayPrerenderHtml( + [`${testRealmURL}books/1.json`, `${testRealmURL}books/2.json`], + 2, + ); + await waitUntil(() => getCount === 1, { timeout: 10_000 }); + + search.modify([], { query: undefined }); + releaseFirstGet!({ + notModified: false, + doc: entrySingleDoc({ + id: `${testRealmURL}books/1`, + indexGen: 1, + htmlGen: 2, + html: '
Mango v2
', + }), + }); + await settled(); + + assert.strictEqual( + getCount, + 1, + 'no further member GET fires after the query is cleared', + ); + } finally { + storeService.searchEntries = originalSearchEntries; + storeService.fetchCardEntry = originalFetchCardEntry; + } + }); + test('a non-incremental index event does not re-run the search', async function (assert) { let searchCount = 0; let originalSearchEntries = storeService.searchEntries.bind(storeService);