Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/host/app/resources/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface Args<T extends CardDef | FileDef = CardDef> {
isAutoSaved?: boolean;
storeService?: StoreService;
doWhileRefreshing?: (() => void) | undefined;
onLoad?: (load: Promise<unknown>) => void;
seed?:
| {
cards: T[];
Expand Down Expand Up @@ -94,7 +95,8 @@ export class SearchResource<
}

modify(_positional: never[], named: Args<T>['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

Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -320,6 +324,7 @@ export function getSearch<T extends CardDef | FileDef = CardDef>(
opts?: {
isLive?: boolean;
doWhileRefreshing?: (() => void) | undefined;
onLoad?: (load: Promise<unknown>) => void;
seed?:
| {
cards: T[];
Expand All @@ -343,6 +348,7 @@ export function getSearch<T extends CardDef | FileDef = CardDef>(
isLive: opts?.isLive != null ? opts.isLive : false,
// TODO refactor this out
doWhileRefreshing: opts?.doWhileRefreshing,
onLoad: opts?.onLoad,
seed: opts?.seed,
owner,
},
Expand Down
8 changes: 6 additions & 2 deletions packages/host/app/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,13 +420,17 @@ export default class RenderRoute extends Route<Model> {
// 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,
);
}
Expand Down
16 changes: 9 additions & 7 deletions packages/host/app/services/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,13 +767,15 @@ export default class StoreService extends Service implements StoreInterface {
if (this.isRenderStore && opts) {
opts.isLive = false;
}
return getSearch<T>(
parent,
getOwner(this)!,
getQuery,
getRealms,
opts,
) as unknown as SearchResource<T>;
return getSearch<T>(parent, getOwner(this)!, getQuery, getRealms, {
...opts,
onLoad: (load: Promise<unknown>) => {
// 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<T>;
}

getSearchDataResource(
Expand Down
144 changes: 144 additions & 0 deletions packages/realm-server/tests/server-endpoints/index-responses-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof this> {
<template>
<span data-test-sample-item>{{@model.itemTitle}}</span>
</template>
};
}
`,
);

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<typeof this> {
<template>
<div data-test-query-grid>
{{#each @model.samples as |sample|}}
<div data-test-grid-item>{{sample.itemTitle}}</div>
{{/each}}
</div>
</template>
};
}
`,
);

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',
},
},
},
});

},
});

Expand Down Expand Up @@ -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')
Expand Down
Loading