Skip to content
Merged
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
19 changes: 17 additions & 2 deletions packages/base/query-field-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,23 @@ export function ensureQueryFieldSearchResource(
let seedSearchURL = fieldState?.seedSearchURL;
let args = () => resolveQueryAndRealm(instance, field, fieldDefinition);

// Inside a prerender the parent doc's `relationships.{field}.data` is
// the authoritative cardinality for this field — the indexer just
// wrote it. A live re-query would fire a `_federated-search`
// round-trip per field per loaded card to re-validate what the
// parent doc already serialized. With N query-backed `linksToMany`
// fields fanning out across M loaded cards that cascade is O(N*M)
// extra fetches. It is also an internal-inconsistency vector: if
// the live re-query returns a different set than the parent doc's
// serialized relationships, the rendered HTML iterates a different
// set than the parent doc describes. `isLive: false` in prerender
// keeps the SearchResource resolved from the seed and exits; the
// SPA path is unchanged.
let inPrerender = Boolean((globalThis as any).__boxelRenderContext);
let isLive = !inPrerender;

log.info(
`ensureQueryFieldSearchResource: creating resource; field=${field.name}; isLive=${true}; seedRecord=${seedRecords?.length ?? 0} realms derivation starting`,
`ensureQueryFieldSearchResource: creating resource; field=${field.name}; isLive=${isLive}; seedRecord=${seedRecords?.length ?? 0} realms derivation starting`,
);
searchResource = store.getSearchResource(
instance,
Expand All @@ -124,7 +139,7 @@ export function ensureQueryFieldSearchResource(
return realm ? [realm] : undefined;
},
{
isLive: true,
isLive,
dependencyTracking: trackingContext,
seed: seedRecords
? {
Expand Down
30 changes: 30 additions & 0 deletions packages/host/app/resources/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,36 @@ export class SearchResource<
this.#log.info(
`apply seed for search resource (one-time); count=${seed.cards.length}; searchURL=${seed.searchURL}`,
);
// Non-live callers can treat the seed as authoritative ONLY when
// it actually carries content. Two signals say "authoritative":
// - seed.cards.length > 0: the parent serialized resolved
// instances in this document; we have the answer.
// - seed.searchURL is set: query-field capture in
// `query-field-support.ts::captureQueryFieldSeedData` only
// populates `seedSearchURL` when the relationship is fully
// resolved (its `shouldTreatEmptySeedAsUnresolved` branch
// leaves it `null` for empty seeds that need a fallback
// search). A non-null URL means the IDs are known.
// An empty seed with no searchURL is the explicit "unresolved,
// please run the client-side fallback query" signal — let it
// fall through to perform() even in non-live mode.
let seedIsAuthoritative =
seed.cards.length > 0 || Boolean(seed.searchURL);
if (!isLive && seedIsAuthoritative) {
// The parent document already serialized the relationship set
// we are resolving, so a re-query would only re-derive the
// same data and (in prerender) burn a `_federated-search`
// round-trip per field per loaded card. Skip the search and
// also bypass the query/realm equality check below so a
// signature drift between the parent doc's `links.search` and
// the recomputed query doesn't sneak a fetch back in.
this.#previousRealms = realms;
this.#previousQuery = query;
this.#previousQueryString = buildQueryParamValue(
normalizeQueryForSignature(query),
);
return;
Comment thread
habdelra marked this conversation as resolved.
}
}

if (
Expand Down
226 changes: 226 additions & 0 deletions packages/host/tests/integration/resources/search-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1137,4 +1137,230 @@ module(`Integration | search resource`, function (hooks) {
);
});
});

module(
`non-live SearchResource with seed (prerender query-field path)`,
function (innerHooks) {
let releaseFetch: Deferred<void>;
let fetchCalls: number;
let restoreFetch: (() => void) | undefined;

innerHooks.beforeEach(function () {
releaseFetch = new Deferred<void>();
fetchCalls = 0;
let realmServer = getService('realm-server') as RealmServerService;
let original = realmServer.maybeAuthedFetchForRealms.bind(realmServer);
realmServer.maybeAuthedFetchForRealms = (async (url, ...args) => {
let isSearch =
typeof url === 'string' && url.includes('_federated-search');
if (isSearch) {
fetchCalls++;
await releaseFetch.promise;
}
return await original(url, ...args);
}) as RealmServerService['maybeAuthedFetchForRealms'];
restoreFetch = () => {
realmServer.maybeAuthedFetchForRealms = original;
};
});

innerHooks.afterEach(function () {
try {
releaseFetch.fulfill();
} catch {
// already settled
}
restoreFetch?.();
restoreFetch = undefined;
(
globalThis as unknown as { __boxelRenderContext?: boolean }
).__boxelRenderContext = undefined;
storeService.clearInFlightSearch();
});

let bookQuery: Query = {
filter: {
on: {
module: testRRI('book'),
name: 'Book',
},
eq: { 'author.lastName': 'Abdel-Rahman' },
},
};

// Build a seed by first running a normal search outside the
// prerender — this gives us real CardDef instances from the
// store that match the query. The seed represents what the
// parent doc's `relationships.{field}.data` resolved to during
// serialize.
async function buildSeed() {
// Release any parked fetches so this prep call resolves; the
// tests reset `fetchCalls` after this returns so the seed
// search doesn't count against the in-test fetch budget.
releaseFetch.fulfill();
let result = await storeService.search(bookQuery, [testRealmURL]);
let url = `${testRealmURL}_federated-search?${new URLSearchParams({
query: JSON.stringify(bookQuery),
}).toString()}`;
return {
cards: result as any[],
searchURL: url,
};
}

Comment thread
habdelra marked this conversation as resolved.
test(`seed-only resolve: no fetch fires when isLive=false and a seed is present (prerender path)`, async function (assert) {
let { cards, searchURL } = await buildSeed();
// Reset the fetch counter — the seed prep above used a live
// search outside the prerender gate.
fetchCalls = 0;

(
globalThis as unknown as { __boxelRenderContext?: boolean }
).__boxelRenderContext = true;

let search = getSearchResourceForTest(loaderService, () => ({
named: {
query: bookQuery,
realms: [testRealmURL],
isLive: false,
isAutoSaved: false,
storeService,
seed: {
cards,
searchURL,
realms: [testRealmURL],
},
owner: this.owner,
},
}));
await search.loaded;
await settled();

assert.strictEqual(
fetchCalls,
0,
'seed-only resolve: no _federated-search fetch fires',
);
assert.strictEqual(
search.instances.length,
cards.length,
'resource exposes seed cards',
);
assert.deepEqual(
search.instances.map((i) => i.id),
cards.map((c) => c.id),
'seed cards are returned in order',
);
});

test(`live path with the same seed still fetches (live-SPA behavior is preserved)`, async function (assert) {
let { cards, searchURL } = await buildSeed();
fetchCalls = 0;

// __boxelRenderContext intentionally unset — live SPA path.
let search = getSearchResourceForTest(loaderService, () => ({
named: {
query: bookQuery,
realms: [testRealmURL],
isLive: true,
isAutoSaved: false,
storeService,
seed: {
cards,
searchURL,
realms: [testRealmURL],
},
owner: this.owner,
},
}));
await search.loaded;
await settled();

// Today the live path with a matching seed.searchURL happens
// to short-circuit via the previousQueryString equality check
// in SearchResource. The contract we care about for this
// ticket is the opposite case (non-live + seed must NOT
// fetch), so we only assert that live + seed produces the
// correct result set. Whether or not the equality-skip path
// saves a fetch here is an implementation detail of
// SearchResource that's orthogonal to this change.
assert.strictEqual(
search.instances.length,
cards.length,
'live path with seed still resolves to the correct set',
);
});

test(`non-live with no seed still fetches (other non-live callers are unaffected)`, async function (assert) {
releaseFetch.fulfill();
// __boxelRenderContext intentionally unset.

let search = getSearchResourceForTest(loaderService, () => ({
named: {
query: bookQuery,
realms: [testRealmURL],
isLive: false,
isAutoSaved: false,
storeService,
// no seed
owner: this.owner,
},
}));
await search.loaded;

assert.ok(
fetchCalls >= 1,
'non-live + no-seed callers still hit the network',
);
assert.strictEqual(
search.instances.length,
2,
'returns the books matching the query',
);
});

test(`empty unresolved seed still falls back to a fetch in prerender (cards=[], no searchURL)`, async function (assert) {
// This is the captureQueryFieldSeedData "unresolved" shape:
// - seedRecords resolved to [] because no nested instances
// landed inline on the parent search result.
// - seedSearchURL was nulled out via
// `shouldTreatEmptySeedAsUnresolved`.
// Result must still run the client-side fallback query —
// otherwise relationship items that should have appeared in
// the rendered HTML go missing.
releaseFetch.fulfill();
(
globalThis as unknown as { __boxelRenderContext?: boolean }
).__boxelRenderContext = true;

let search = getSearchResourceForTest(loaderService, () => ({
named: {
query: bookQuery,
realms: [testRealmURL],
isLive: false,
isAutoSaved: false,
storeService,
seed: {
cards: [],
// searchURL intentionally omitted — the "unresolved"
// signal from query-field-support.
realms: [testRealmURL],
},
owner: this.owner,
},
}));
await search.loaded;

assert.ok(
fetchCalls >= 1,
'empty unresolved seed in prerender falls back to fetch',
);
assert.strictEqual(
search.instances.length,
2,
'fallback fetch returns the books matching the query',
);
});
},
);
});
Loading