diff --git a/packages/host/app/resources/search.ts b/packages/host/app/resources/search.ts index dba75ada5d..db8c73d210 100644 --- a/packages/host/app/resources/search.ts +++ b/packages/host/app/resources/search.ts @@ -40,6 +40,7 @@ export interface Args { isAutoSaved?: boolean; storeService?: StoreService; doWhileRefreshing?: (() => void) | undefined; + onLoad?: (load: Promise) => void; seed?: | { cards: T[]; @@ -94,7 +95,8 @@ export class SearchResource< } modify(_positional: never[], named: Args['named']) { - let { query, realms, isLive, doWhileRefreshing, seed, owner } = named; + let { query, realms, isLive, doWhileRefreshing, onLoad, seed, owner } = + named; setOwner(this, owner); // works around problem where lifetime parent is used as owner when they should be allowed to differ @@ -116,6 +118,7 @@ export class SearchResource< ); if (seed && !this.#seedApplied) { this.loaded = this.applySeed.perform(seed); + onLoad?.(this.loaded); this.#seedApplied = true; let hasQueryErrors = seed.queryErrors && seed.queryErrors.length > 0; if (seed.searchURL && !hasQueryErrors) { @@ -188,6 +191,7 @@ export class SearchResource< this.#previousQuery = query; this.#previousQueryString = queryString; this.loaded = this.search.perform(query); + onLoad?.(this.loaded); } get isLoading() { return this.search.isRunning; @@ -320,6 +324,7 @@ export function getSearch( opts?: { isLive?: boolean; doWhileRefreshing?: (() => void) | undefined; + onLoad?: (load: Promise) => void; seed?: | { cards: T[]; @@ -343,6 +348,7 @@ export function getSearch( isLive: opts?.isLive != null ? opts.isLive : false, // TODO refactor this out doWhileRefreshing: opts?.doWhileRefreshing, + onLoad: opts?.onLoad, seed: opts?.seed, owner, }, diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index 874f0defe6..e12130f361 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -420,13 +420,17 @@ export default class RenderRoute extends Route { // probably will be, so just optimistically including those let fields = cardApi.getFields(instance, { includeComputeds: true }); for (let [fieldName, field] of Object.entries(fields)) { - if (field?.isUsed) { + // Touch fields marked isUsed and query-backed fields (linksTo/linksToMany + // with a queryDefinition). Query-backed fields create a SearchResource + // whose async search must be tracked before store.loaded() is called so + // that prerendering waits for the search results. + if (field?.isUsed || field?.queryDefinition) { try { // accessing the field triggers the lazy loading of the linked field (instance as any)[fieldName]; } catch (error) { console.warn( - `Failed to touch field '${fieldName}' on ${instance.constructor.name} for isUsed=true:`, + `Failed to touch field '${fieldName}' on ${instance.constructor.name}:`, error, ); } diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 161bae71bd..e75459db0e 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -767,13 +767,15 @@ export default class StoreService extends Service implements StoreInterface { if (this.isRenderStore && opts) { opts.isLive = false; } - return getSearch( - parent, - getOwner(this)!, - getQuery, - getRealms, - opts, - ) as unknown as SearchResource; + return getSearch(parent, getOwner(this)!, getQuery, getRealms, { + ...opts, + onLoad: (load: Promise) => { + // Wrap with .catch() so that cancelled restartable tasks + // (which reject with TaskCancelation) don't surface as + // unhandled promise rejections inside trackLoad's .finally(). + this.store.trackLoad(load.catch(() => {})); + }, + }) as unknown as SearchResource; } getSearchDataResource( diff --git a/packages/realm-server/tests/server-endpoints/index-responses-test.ts b/packages/realm-server/tests/server-endpoints/index-responses-test.ts index f8e4a0f9f5..12a4413b1f 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -285,6 +285,78 @@ module(`server-endpoints/${basename(__filename)}`, function () { }, }, ); + + // Cards for testing that linksToMany query field search results + // are present in prerendered isolated HTML (i.e. store.loaded() + // waits for SearchResource tasks to settle). + writeFileSync( + join(context.testRealmDir, 'sample-item.gts'), + ` + import { contains, field, Component, CardDef } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + + export class SampleItem extends CardDef { + @field itemTitle = contains(StringField); + static embedded = class Embedded extends Component { + + }; + } + `, + ); + + writeFileSync( + join(context.testRealmDir, 'query-grid.gts'), + ` + import { field, linksToMany, Component, CardDef } from 'https://cardstack.com/base/card-api'; + import { SampleItem } from './sample-item.gts'; + + export class QueryGrid extends CardDef { + @field samples = linksToMany(() => SampleItem, { + query: { + page: { size: 10, number: 0 }, + }, + }); + static isolated = class Isolated extends Component { + + }; + } + `, + ); + + writeJSONSync(join(context.testRealmDir, 'sample-item-1.json'), { + data: { + type: 'card', + attributes: { itemTitle: 'Alpha Item' }, + meta: { + adoptsFrom: { + module: './sample-item.gts', + name: 'SampleItem', + }, + }, + }, + }); + + writeJSONSync(join(context.testRealmDir, 'sample-item-2.json'), { + data: { + type: 'card', + attributes: { itemTitle: 'Beta Item' }, + meta: { + adoptsFrom: { + module: './sample-item.gts', + name: 'SampleItem', + }, + }, + }, + }); + }, }); @@ -325,6 +397,78 @@ module(`server-endpoints/${basename(__filename)}`, function () { ); }); + test('serves isolated HTML with linksToMany query field search results', async function (assert) { + // Write query-grid-1 via the realm API AFTER the server starts. + // During from-scratch indexing, boxel_index (main table) is empty, + // so search returns no results for query-backed fields. By writing + // the card instance after initial indexing completes, the incremental + // re-index can find the sample items in boxel_index. + let writeResponse = await context.request2 + .post('/test/query-grid-1.json') + .set('Accept', 'application/vnd.card+source') + .send( + JSON.stringify({ + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: './query-grid.gts', + name: 'QueryGrid', + }, + }, + }, + }), + ); + + assert.strictEqual( + writeResponse.status, + 204, + 'query-grid-1 file write was accepted', + ); + + // Wait for the incremental re-index to produce isolated_html + await waitUntil( + async () => { + let rows = (await context.dbAdapter.execute( + `SELECT isolated_html FROM boxel_index + WHERE url = '${testRealm2URL.href}query-grid-1.json' + AND type = 'instance'`, + )) as { isolated_html: string | null }[]; + + return ( + rows.length > 0 && + rows[0].isolated_html != null && + rows[0].isolated_html.includes('data-test-query-grid') + ); + }, + { + timeout: 30000, + interval: 500, + timeoutMessage: + 'Timed out waiting for query-grid-1 isolated HTML to be indexed', + }, + ); + + let response = await context.request2 + .get('/test/query-grid-1') + .set('Accept', 'text/html'); + + assert.strictEqual(response.status, 200, 'serves HTML response'); + assert.ok( + response.text.includes('data-test-query-grid'), + 'query grid isolated HTML is present in the response', + ); + assert.ok( + response.text.includes('Alpha Item'), + 'first search result from linksToMany query is present in the isolated HTML', + ); + assert.ok( + response.text.includes('Beta Item'), + 'second search result from linksToMany query is present in the isolated HTML', + ); + }); + test('HTML response does not include boxel-ready class on body', async function (assert) { let response = await context.request2 .get('/test/isolated-test')