From 2dff1d32fdf41c83e4ccf64cb05274bbd7bd68a7 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 17 Feb 2026 12:29:06 -0600 Subject: [PATCH 01/27] test: Add failing test for theme-based favicon/webclip in default head format Add test verifying that the default head template emits and tags when a card has a BrandGuide linked as cardInfo.theme with a socialMediaProfileIcon. CS-9828 Co-Authored-By: Claude Opus 4.6 --- .../server-endpoints/index-responses-test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) 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..caca4f270b 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,53 @@ module(`server-endpoints/${basename(__filename)}`, function () { }, }, ); + + // Cards for testing default head template with cardInfo.theme + writeJSONSync( + join(context.testRealmDir, 'test-brand-guide.json'), + { + data: { + type: 'card', + attributes: { + markUsage: { + socialMediaProfileIcon: + 'https://example.com/brand-icon.png', + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/brand-guide', + name: 'BrandGuide', + }, + }, + }, + }, + ); + + writeJSONSync( + join(context.testRealmDir, 'card-with-theme.json'), + { + data: { + type: 'card', + attributes: { + firstName: 'Themed Card', + }, + relationships: { + 'cardInfo.theme': { + links: { + self: './test-brand-guide', + }, + }, + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + }, + ); }, }); @@ -543,6 +590,32 @@ module(`server-endpoints/${basename(__filename)}`, function () { ); }); + test('default head template includes favicon and apple-touch-icon from cardInfo.theme', async function (assert) { + let response = await context.request2 + .get('/test/card-with-theme') + .set('Accept', 'text/html'); + + assert.strictEqual(response.status, 200, 'serves HTML response'); + + let headMatch = response.text.match( + /data-boxel-head-start[^>]*>([\s\S]*?)data-boxel-head-end/, + ); + let headContent = headMatch?.[1] ?? ''; + + assert.ok( + headContent.includes( + ' Date: Tue, 17 Feb 2026 12:30:47 -0600 Subject: [PATCH 02/27] base: Emit favicon and apple-touch-icon from cardInfo.theme in default head format When a card has a BrandGuide (or other Theme subclass) linked as cardInfo.theme, the default head template now emits and tags using the theme's cardThumbnailURL, which for BrandGuide falls back to markUsage.socialMediaProfileIcon. CS-9828 Co-Authored-By: Claude Opus 4.6 --- packages/base/default-templates/head.gts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/base/default-templates/head.gts b/packages/base/default-templates/head.gts index 6770d3bc44..bd545d0f3b 100644 --- a/packages/base/default-templates/head.gts +++ b/packages/base/default-templates/head.gts @@ -21,6 +21,10 @@ export default class DefaultHeadTemplate extends GlimmerComponent<{ return this.args.model?.cardThumbnailURL; } + get themeIcon(): string | undefined { + return this.args.model?.cardInfo?.theme?.cardThumbnailURL; + } + } From 08bfad49d481517eae2ffa276e0b1fb8b126ad91 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 17 Feb 2026 12:42:13 -0600 Subject: [PATCH 03/27] Add lint autofixes --- .../server-endpoints/index-responses-test.ts | 63 +++++++++---------- 1 file changed, 28 insertions(+), 35 deletions(-) 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 caca4f270b..734dbed51e 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -287,51 +287,44 @@ module(`server-endpoints/${basename(__filename)}`, function () { ); // Cards for testing default head template with cardInfo.theme - writeJSONSync( - join(context.testRealmDir, 'test-brand-guide.json'), - { - data: { - type: 'card', - attributes: { - markUsage: { - socialMediaProfileIcon: - 'https://example.com/brand-icon.png', - }, + writeJSONSync(join(context.testRealmDir, 'test-brand-guide.json'), { + data: { + type: 'card', + attributes: { + markUsage: { + socialMediaProfileIcon: 'https://example.com/brand-icon.png', }, - meta: { - adoptsFrom: { - module: 'https://cardstack.com/base/brand-guide', - name: 'BrandGuide', - }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/brand-guide', + name: 'BrandGuide', }, }, }, - ); + }); - writeJSONSync( - join(context.testRealmDir, 'card-with-theme.json'), - { - data: { - type: 'card', - attributes: { - firstName: 'Themed Card', - }, - relationships: { - 'cardInfo.theme': { - links: { - self: './test-brand-guide', - }, + writeJSONSync(join(context.testRealmDir, 'card-with-theme.json'), { + data: { + type: 'card', + attributes: { + firstName: 'Themed Card', + }, + relationships: { + 'cardInfo.theme': { + links: { + self: './test-brand-guide', }, }, - meta: { - adoptsFrom: { - module: './person.gts', - name: 'Person', - }, + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', }, }, }, - ); + }); }, }); From 2c8c66fe4959dd693d17bbf6b53eadc2a5a8fb77 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 17 Feb 2026 16:41:40 -0600 Subject: [PATCH 04/27] Fix test --- .../tests/server-endpoints/index-responses-test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 734dbed51e..27991f8a26 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -287,18 +287,18 @@ module(`server-endpoints/${basename(__filename)}`, function () { ); // Cards for testing default head template with cardInfo.theme - writeJSONSync(join(context.testRealmDir, 'test-brand-guide.json'), { + writeJSONSync(join(context.testRealmDir, 'test-theme.json'), { data: { type: 'card', attributes: { - markUsage: { - socialMediaProfileIcon: 'https://example.com/brand-icon.png', + cardInfo: { + cardThumbnailURL: 'https://example.com/brand-icon.png', }, }, meta: { adoptsFrom: { - module: 'https://cardstack.com/base/brand-guide', - name: 'BrandGuide', + module: 'https://cardstack.com/base/card-api', + name: 'Theme', }, }, }, @@ -313,7 +313,7 @@ module(`server-endpoints/${basename(__filename)}`, function () { relationships: { 'cardInfo.theme': { links: { - self: './test-brand-guide', + self: './test-theme', }, }, }, From 73ed8da450611156470dd9c8cebd2102eec87881 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 17 Feb 2026 16:41:56 -0600 Subject: [PATCH 05/27] Add temporary fallbacks --- packages/base/default-templates/head.gts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/base/default-templates/head.gts b/packages/base/default-templates/head.gts index bd545d0f3b..69e04973e4 100644 --- a/packages/base/default-templates/head.gts +++ b/packages/base/default-templates/head.gts @@ -50,6 +50,16 @@ export default class DefaultHeadTemplate extends GlimmerComponent<{ {{#if this.themeIcon}} + {{else}} + {{! FIXME these are hardcoded to staging }} + + {{/if}} From aa371fac16d0e29d0864a01b707f322e82eee5e5 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 17 Feb 2026 16:42:21 -0600 Subject: [PATCH 06/27] Remove hardcoded icons for now --- packages/host/app/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/host/app/index.html b/packages/host/app/index.html index 570dafe505..408bf3f66c 100644 --- a/packages/host/app/index.html +++ b/packages/host/app/index.html @@ -10,8 +10,8 @@ - - + Date: Thu, 19 Feb 2026 07:59:15 -0600 Subject: [PATCH 07/27] Fix linksTo inside contains: propagate store to child FieldDef instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a `contains` or `containsMany` field returns a FieldDef instance, the parent's store was not propagated to it. This meant that nested `linksTo` fields (e.g., `cardInfo.theme`) couldn't properly track their lazy loads — `getStore()` returned a throwaway FallbackCardStore instead of the real store. The two-pass render's `waitForLinkedData` never saw these loads, so linked data was never resolved. Fix: propagate the store from parent to child in Contains/ContainsMany getters, matching the existing `propagateRealmContext` pattern. Also clean up debug logging in the theme icon test. Co-Authored-By: Claude Opus 4.6 --- packages/base/card-api.gts | 18 ++++++++++++++++++ .../server-endpoints/index-responses-test.ts | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index fad0e9cebe..930752d938 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -530,6 +530,16 @@ class ContainsMany implements Field< } let results = getter(instance, this); propagateRealmContext(results, instance); + // Propagate the store from the parent instance to child FieldDef values + // so that nested linksTo fields can properly track their loads + let parentStore = stores.get(instance); + if (parentStore && Array.isArray(results)) { + for (let item of results) { + if (isCardOrField(item) && !stores.has(item)) { + stores.set(item, parentStore); + } + } + } return results; } @@ -836,6 +846,14 @@ class Contains implements Field { } let value = getter(instance, this); propagateRealmContext(value, instance); + // Propagate the store from the parent instance to child FieldDef values + // so that nested linksTo fields can properly track their loads + if (isCardOrField(value)) { + let parentStore = stores.get(instance); + if (parentStore && !stores.has(value)) { + stores.set(value, parentStore); + } + } return value; } 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 27991f8a26..c0ce5b041f 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -599,13 +599,13 @@ module(`server-endpoints/${basename(__filename)}`, function () { headContent.includes( ' Date: Thu, 19 Feb 2026 11:00:28 -0600 Subject: [PATCH 08/27] Add diagnostic meta tag to debug theme icon resolution in CI Temporary debug output to understand why cardInfo.theme doesn't resolve during head prerendering. The debug-card-info meta tag will show whether theme is undefined, null, or has a URL. Co-Authored-By: Claude Opus 4.6 --- packages/base/default-templates/head.gts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/base/default-templates/head.gts b/packages/base/default-templates/head.gts index 69e04973e4..724d7822cc 100644 --- a/packages/base/default-templates/head.gts +++ b/packages/base/default-templates/head.gts @@ -25,6 +25,16 @@ export default class DefaultHeadTemplate extends GlimmerComponent<{ return this.args.model?.cardInfo?.theme?.cardThumbnailURL; } + get debugCardInfo(): string { + let cardInfo = this.args.model?.cardInfo; + if (!cardInfo) return 'no-cardInfo'; + let theme = cardInfo.theme; + if (theme === undefined) return 'theme-undefined'; + if (theme === null) return 'theme-null'; + let url = theme.cardThumbnailURL; + return `theme-url=${url ?? 'no-url'}`; + } + } From 1a3c9e00b468337b024bd618fec1aea1b2a6f8ce Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 19 Feb 2026 11:44:04 -0600 Subject: [PATCH 09/27] Remove hardcoded fallback icons from head template, use static icons as default - Remove debug diagnostic getter and meta tag from head.gts - Remove hardcoded staging URLs from head template fallback - Head template now only emits favicon/apple-touch-icon when themeIcon exists - Uncomment static favicon and apple-touch-icon in index.html (outside markers) - Add tests for all icon scenarios: static defaults, no-theme head injection, non-public realm, and theme icon injection - Add DB diagnostic in theme test to debug prerender resolution Co-Authored-By: Claude Opus 4.6 --- packages/base/default-templates/head.gts | 21 ----- packages/host/app/index.html | 4 +- .../server-endpoints/index-responses-test.ts | 79 ++++++++++++++++++- 3 files changed, 79 insertions(+), 25 deletions(-) diff --git a/packages/base/default-templates/head.gts b/packages/base/default-templates/head.gts index 724d7822cc..bd545d0f3b 100644 --- a/packages/base/default-templates/head.gts +++ b/packages/base/default-templates/head.gts @@ -25,16 +25,6 @@ export default class DefaultHeadTemplate extends GlimmerComponent<{ return this.args.model?.cardInfo?.theme?.cardThumbnailURL; } - get debugCardInfo(): string { - let cardInfo = this.args.model?.cardInfo; - if (!cardInfo) return 'no-cardInfo'; - let theme = cardInfo.theme; - if (theme === undefined) return 'theme-undefined'; - if (theme === null) return 'theme-null'; - let url = theme.cardThumbnailURL; - return `theme-url=${url ?? 'no-url'}`; - } - } diff --git a/packages/host/app/index.html b/packages/host/app/index.html index 408bf3f66c..50bc507fe0 100644 --- a/packages/host/app/index.html +++ b/packages/host/app/index.html @@ -10,8 +10,8 @@ - + + ]*>([\s\S]*?)data-boxel-head-end/, + ); + let headContent = headMatch?.[1] ?? ''; + + // The default head template should NOT emit icon links when there's no theme + // (the static icons from index.html serve as defaults) + assert.notOk( + headContent.includes('rel="icon"'), + 'injected head HTML does not contain favicon link (static ones from index.html are the default)', + ); + assert.notOk( + headContent.includes('rel="apple-touch-icon"'), + 'injected head HTML does not contain apple-touch-icon link', + ); + }); + + test('non-public realm preserves static favicon and apple-touch-icon', async function (assert) { + await context.dbAdapter.execute( + `DELETE FROM realm_user_permissions WHERE realm_url = '${testRealm2URL.href}' AND username = '*'`, + ); + + let response = await context.request2 + .get('/test/private-index-test') + .set('Accept', 'text/html'); + + assert.strictEqual(response.status, 200, 'serves HTML response'); + // Even without head injection, static icons from index.html remain + assert.ok( + response.text.includes('rel="icon"'), + 'static favicon link is present even without head injection', + ); + assert.ok( + response.text.includes('rel="apple-touch-icon"'), + 'static apple-touch-icon link is present even without head injection', + ); + }); + test('default head template includes favicon and apple-touch-icon from cardInfo.theme', async function (assert) { + // First, check what the indexer stored as head_html + let rows = (await context.dbAdapter.execute( + `SELECT head_html FROM boxel_index + WHERE url LIKE '%card-with-theme%' + AND type = 'instance' + AND is_deleted IS NOT TRUE + AND head_html IS NOT NULL + LIMIT 1`, + )) as { head_html: string }[]; + + let storedHeadHtml = rows[0]?.head_html ?? '(no head_html in DB)'; + let response = await context.request2 .get('/test/card-with-theme') .set('Accept', 'text/html'); @@ -599,13 +674,13 @@ module(`server-endpoints/${basename(__filename)}`, function () { headContent.includes( ' Date: Thu, 19 Feb 2026 11:55:02 -0600 Subject: [PATCH 10/27] Fix theme icon resolution: add linked card deps + fix test indexing order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include linked card URLs (from linksTo relationships) in the deps list computed by render/meta.ts. This ensures backward invalidation re-indexes a card when its linked cards are created or updated — critical for linksTo fields like cardInfo.theme that may not resolve during initial indexing if the linked card hasn't been indexed yet. Also rename test-theme.json to a-test-theme.json so it sorts alphabetically before card-with-theme.json. The from-scratch indexer processes .json files in alphabetical order without backward invalidation, so the theme card must be indexed first. Co-Authored-By: Claude Opus 4.6 --- packages/host/app/routes/render/meta.ts | 18 ++++++++++++++++++ .../server-endpoints/index-responses-test.ts | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/host/app/routes/render/meta.ts b/packages/host/app/routes/render/meta.ts index 16a7a1273c..f3dd2700f7 100644 --- a/packages/host/app/routes/render/meta.ts +++ b/packages/host/app/routes/render/meta.ts @@ -88,6 +88,24 @@ export default class RenderMetaRoute extends Route { ...(await recursiveModuleDeps(moduleDeps, this.loaderService.loader)), ]; + // Include linked card URLs as deps so that backward invalidation + // re-indexes this card when a linked card is created or updated. + // This is needed because linksTo fields (e.g. cardInfo.theme) may + // not be resolvable during initial indexing if the linked card + // hasn't been indexed yet. + for (let { relationship } of relationshipEntries( + serialized.data.relationships, + )) { + let selfLink = relationship.links?.self; + if (typeof selfLink === 'string' && selfLink) { + try { + deps.push(new URL(selfLink, instanceURL).href); + } catch { + // ignore malformed URLs + } + } + } + let Klass = getClass(instance); let types = getTypes(Klass); 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 64e58c334c..5d1d184f4e 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -287,7 +287,7 @@ module(`server-endpoints/${basename(__filename)}`, function () { ); // Cards for testing default head template with cardInfo.theme - writeJSONSync(join(context.testRealmDir, 'test-theme.json'), { + writeJSONSync(join(context.testRealmDir, 'a-test-theme.json'), { data: { type: 'card', attributes: { @@ -313,7 +313,7 @@ module(`server-endpoints/${basename(__filename)}`, function () { relationships: { 'cardInfo.theme': { links: { - self: './test-theme', + self: './a-test-theme', }, }, }, From 8201b287bae73ed54d3123a46a6bb478505b75b6 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 19 Feb 2026 13:12:34 -0600 Subject: [PATCH 11/27] Fix theme test: create card-with-theme via API for incremental indexing From-scratch indexing writes to boxel_index_working and only commits to boxel_index when the batch completes. This means cards within the same batch can't resolve linksTo references to each other during prerender. Create card-with-theme via API after the realm is up, triggering incremental indexing when the theme card is already in the production table. Co-Authored-By: Claude Opus 4.6 --- .../server-endpoints/index-responses-test.ts | 102 ++++++++++++------ 1 file changed, 68 insertions(+), 34 deletions(-) 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 5d1d184f4e..ed561f2965 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -304,27 +304,12 @@ module(`server-endpoints/${basename(__filename)}`, function () { }, }); - writeJSONSync(join(context.testRealmDir, 'card-with-theme.json'), { - data: { - type: 'card', - attributes: { - firstName: 'Themed Card', - }, - relationships: { - 'cardInfo.theme': { - links: { - self: './a-test-theme', - }, - }, - }, - meta: { - adoptsFrom: { - module: './person.gts', - name: 'Person', - }, - }, - }, - }); + // NOTE: card-with-theme.json is NOT written here because from-scratch + // indexing uses a batched write strategy (boxel_index_working → boxel_index). + // Cards within the same batch can't resolve linksTo references to each other + // because the data isn't in the production table yet. Instead, card-with-theme + // is created via API in the test itself, triggering incremental indexing + // after the theme card is already committed to boxel_index. }, }); @@ -647,17 +632,66 @@ module(`server-endpoints/${basename(__filename)}`, function () { }); test('default head template includes favicon and apple-touch-icon from cardInfo.theme', async function (assert) { - // First, check what the indexer stored as head_html - let rows = (await context.dbAdapter.execute( - `SELECT head_html FROM boxel_index - WHERE url LIKE '%card-with-theme%' - AND type = 'instance' - AND is_deleted IS NOT TRUE - AND head_html IS NOT NULL - LIMIT 1`, - )) as { head_html: string }[]; - - let storedHeadHtml = rows[0]?.head_html ?? '(no head_html in DB)'; + // Create card-with-theme via API so it's indexed incrementally AFTER + // the theme card is already in boxel_index (from-scratch indexing + // batches writes and can't resolve cross-card linksTo references). + let cardWithThemeJSON = JSON.stringify({ + data: { + type: 'card', + attributes: { + firstName: 'Themed Card', + }, + relationships: { + 'cardInfo.theme': { + links: { + self: './a-test-theme', + }, + }, + }, + meta: { + adoptsFrom: { + module: './person.gts', + name: 'Person', + }, + }, + }, + }); + + let writeResponse = await context.request2 + .post('/test/card-with-theme.json') + .set('Accept', 'application/vnd.card+source') + .send(cardWithThemeJSON); + + assert.strictEqual( + writeResponse.status, + 204, + 'card-with-theme file write was accepted', + ); + + // Wait for the card to be indexed with head_html containing theme icon + await waitUntil( + async () => { + let rows = (await context.dbAdapter.execute( + `SELECT head_html FROM boxel_index + WHERE url LIKE '%card-with-theme%' + AND type = 'instance' + AND is_deleted IS NOT TRUE + LIMIT 1`, + )) as { head_html: string | null }[]; + + return ( + rows.length > 0 && + rows[0].head_html != null && + rows[0].head_html.includes('brand-icon.png') + ); + }, + { + timeout: 30000, + interval: 500, + timeoutMessage: + 'Timed out waiting for card-with-theme to be indexed with theme icon in head_html', + }, + ); let response = await context.request2 .get('/test/card-with-theme') @@ -674,13 +708,13 @@ module(`server-endpoints/${basename(__filename)}`, function () { headContent.includes( ' Date: Thu, 19 Feb 2026 13:39:48 -0600 Subject: [PATCH 12/27] Touch nested linksTo fields before store.loaded() during prerender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prerender captures formats in sequence: meta → head → atom → etc. During renderMeta, store.loaded() is awaited and data-prerender-status is set to "ready". When the head format then renders, captureResult finds the status already "ready" and captures immediately — before any newly triggered async loads (like cardInfo.theme) can complete. Fix by having #touchIsUsedFields also touch linksTo/linksToMany fields nested within contains FieldDefs. This ensures their async loads are tracked by store.loaded() and resolved before the model is marked as "ready", so the head template captures the fully resolved theme icon. Co-Authored-By: Claude Opus 4.6 --- packages/host/app/routes/render.ts | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index 874f0defe6..eec5c457d7 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -432,6 +432,48 @@ export default class RenderRoute extends Route { } } } + // Touch linksTo/linksToMany fields nested within contains FieldDefs so + // their async loads are tracked by store.loaded() before the model is + // marked as "ready". Without this, the head format template (which + // accesses e.g. cardInfo.theme.cardThumbnailURL) would trigger the load + // AFTER the model is already "ready", and captureResult would capture + // the HTML before the linked card resolves. + this.#touchNestedLinksToFields(cardApi, instance); + } + + #touchNestedLinksToFields( + cardApi: typeof CardAPI, + instance: CardDef, + ): void { + let fields = cardApi.getFields(instance, { includeComputeds: true }); + for (let [fieldName, field] of Object.entries(fields)) { + if (field?.fieldType === 'contains') { + try { + let value = (instance as any)[fieldName]; + if (value != null && typeof value === 'object') { + let nestedFields = cardApi.getFields(value, { + includeComputeds: true, + }); + for (let [nestedName, nestedField] of Object.entries( + nestedFields, + )) { + if ( + nestedField?.fieldType === 'linksTo' || + nestedField?.fieldType === 'linksToMany' + ) { + try { + value[nestedName]; + } catch { + // ignore errors from touching nested linksTo fields + } + } + } + } + } catch { + // ignore errors from accessing contains fields + } + } + } } setupController(controller: Controller, model: Model) { From 3fc0d04200f65b4843a1004974312f48a95e4d8e Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 19 Feb 2026 14:02:46 -0600 Subject: [PATCH 13/27] Add diagnostic output to theme test for CI debugging Co-Authored-By: Claude Opus 4.6 --- .../server-endpoints/index-responses-test.ts | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) 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 ed561f2965..0c999108cc 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -668,31 +668,46 @@ module(`server-endpoints/${basename(__filename)}`, function () { 'card-with-theme file write was accepted', ); - // Wait for the card to be indexed with head_html containing theme icon + // Wait for the card to appear in the index (head_html may or may not have the theme) await waitUntil( async () => { let rows = (await context.dbAdapter.execute( - `SELECT head_html FROM boxel_index + `SELECT url, head_html FROM boxel_index WHERE url LIKE '%card-with-theme%' AND type = 'instance' AND is_deleted IS NOT TRUE LIMIT 1`, - )) as { head_html: string | null }[]; + )) as { url: string; head_html: string | null }[]; - return ( - rows.length > 0 && - rows[0].head_html != null && - rows[0].head_html.includes('brand-icon.png') - ); + return rows.length > 0 && rows[0].head_html != null; }, { timeout: 30000, interval: 500, timeoutMessage: - 'Timed out waiting for card-with-theme to be indexed with theme icon in head_html', + 'Timed out waiting for card-with-theme to be indexed', }, ); + // Diagnostic: check what the indexer stored + let diagRows = (await context.dbAdapter.execute( + `SELECT head_html, deps FROM boxel_index + WHERE url LIKE '%card-with-theme%' + AND type = 'instance' + AND is_deleted IS NOT TRUE + LIMIT 1`, + )) as { head_html: string | null; deps: string[] | null }[]; + let storedHeadHtml = diagRows[0]?.head_html ?? '(null)'; + let storedDeps = JSON.stringify(diagRows[0]?.deps?.filter((d: string) => d.includes('theme')) ?? []); + + // Also check if the theme card exists in the index + let themeRows = (await context.dbAdapter.execute( + `SELECT url, type FROM boxel_index + WHERE url LIKE '%test-theme%' OR url LIKE '%a-test-theme%' + LIMIT 5`, + )) as { url: string; type: string }[]; + let themeInfo = JSON.stringify(themeRows); + let response = await context.request2 .get('/test/card-with-theme') .set('Accept', 'text/html'); @@ -708,7 +723,7 @@ module(`server-endpoints/${basename(__filename)}`, function () { headContent.includes( ' Date: Thu, 19 Feb 2026 14:35:00 -0600 Subject: [PATCH 14/27] Fix nested linksTo deserialization for card-source format When a card has nested relationships like "cardInfo.theme" but no corresponding attribute entry for "cardInfo", the _updateFromSerialized function never called Contains.deserialize, silently losing the nested linksTo reference. This caused cardInfo.theme to always be null during prerender, preventing the head template from rendering theme icons. The fix identifies contains fields that have nested relationships but no attribute value and ensures they are included in the deserialization iteration. This creates the proper NotLoadedValue for nested linksTo fields, enabling lazy loading during prerender. Also removes diagnostic output from the theme head test. Co-Authored-By: Claude Opus 4.6 --- packages/base/card-api.gts | 32 +++++++++++++++++++ .../server-endpoints/index-responses-test.ts | 27 ++++------------ 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 930752d938..2b661da6f3 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -3255,11 +3255,43 @@ async function _updateFromSerialized({ return field; } + // Ensure contains fields with nested relationships are deserialized even + // when no attribute value is present. Without this, a relationship like + // "cardInfo.theme" is silently lost when "cardInfo" has no entry in + // resource.attributes (e.g. card-source format), because + // Contains.deserialize is never called and the nested linksTo reference + // is never set up as a NotLoadedValue. + let nestedRelFieldStubs: Record = {}; + if (resource.relationships) { + let attributeKeys = new Set(Object.keys(resource.attributes ?? {})); + for (let relName of Object.keys(resource.relationships)) { + let dotIdx = relName.indexOf('.'); + if (dotIdx === -1) { + continue; + } + let prefix = relName.substring(0, dotIdx); + let suffix = relName.substring(dotIdx + 1); + // Skip linksToMany numeric indices (e.g. "friends.0") since they + // are already handled via linksToManyRelationships + if (suffix.match(/^\d+$/)) { + continue; + } + if ( + !attributeKeys.has(prefix) && + !(prefix in nonNestedRelationships) && + !(prefix in linksToManyRelationships) + ) { + nestedRelFieldStubs[prefix] = undefined; + } + } + } + let values = (await Promise.all( Object.entries({ ...resource.attributes, ...nonNestedRelationships, ...linksToManyRelationships, + ...nestedRelFieldStubs, ...(resource.id !== undefined ? { id: resource.id } : {}), }).map(async ([fieldName, value]) => { let field = getField(instance, fieldName); 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 0c999108cc..2c8ad46f81 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -668,7 +668,11 @@ module(`server-endpoints/${basename(__filename)}`, function () { 'card-with-theme file write was accepted', ); - // Wait for the card to appear in the index (head_html may or may not have the theme) + // Wait for the card to be indexed with head_html containing the theme icon. + // The card-api fix ensures nested relationships (like cardInfo.theme) are + // deserialized even when the parent contains field has no attributes, and + // #touchNestedLinksToFields ensures the linked theme is loaded before the + // head template renders during prerender. await waitUntil( async () => { let rows = (await context.dbAdapter.execute( @@ -689,25 +693,6 @@ module(`server-endpoints/${basename(__filename)}`, function () { }, ); - // Diagnostic: check what the indexer stored - let diagRows = (await context.dbAdapter.execute( - `SELECT head_html, deps FROM boxel_index - WHERE url LIKE '%card-with-theme%' - AND type = 'instance' - AND is_deleted IS NOT TRUE - LIMIT 1`, - )) as { head_html: string | null; deps: string[] | null }[]; - let storedHeadHtml = diagRows[0]?.head_html ?? '(null)'; - let storedDeps = JSON.stringify(diagRows[0]?.deps?.filter((d: string) => d.includes('theme')) ?? []); - - // Also check if the theme card exists in the index - let themeRows = (await context.dbAdapter.execute( - `SELECT url, type FROM boxel_index - WHERE url LIKE '%test-theme%' OR url LIKE '%a-test-theme%' - LIMIT 5`, - )) as { url: string; type: string }[]; - let themeInfo = JSON.stringify(themeRows); - let response = await context.request2 .get('/test/card-with-theme') .set('Accept', 'text/html'); @@ -723,7 +708,7 @@ module(`server-endpoints/${basename(__filename)}`, function () { headContent.includes( ' Date: Thu, 19 Feb 2026 14:43:47 -0600 Subject: [PATCH 15/27] Fix prettier formatting in render.ts Co-Authored-By: Claude Opus 4.6 --- packages/host/app/routes/render.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index eec5c457d7..be4b68e7d1 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -441,10 +441,7 @@ export default class RenderRoute extends Route { this.#touchNestedLinksToFields(cardApi, instance); } - #touchNestedLinksToFields( - cardApi: typeof CardAPI, - instance: CardDef, - ): void { + #touchNestedLinksToFields(cardApi: typeof CardAPI, instance: CardDef): void { let fields = cardApi.getFields(instance, { includeComputeds: true }); for (let [fieldName, field] of Object.entries(fields)) { if (field?.fieldType === 'contains') { From 70dbaa1c65a4fc3132ffa427bfcffc7c1eb05bbe Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 19 Feb 2026 17:40:43 -0600 Subject: [PATCH 16/27] Add expanded diagnostics for theme head_html debugging Temporary diagnostic output to understand why head_html is empty string during prerender. Also checks the theme card itself in the DB. Co-Authored-By: Claude Opus 4.6 --- .../server-endpoints/index-responses-test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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 2c8ad46f81..202de08810 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -693,6 +693,34 @@ module(`server-endpoints/${basename(__filename)}`, function () { }, ); + // Diagnostic: check what was stored in the DB + let diagRows = (await context.dbAdapter.execute( + `SELECT head_html, isolated_html, error_doc, deps, resource FROM boxel_index + WHERE url LIKE '%card-with-theme%' + AND type = 'instance' + AND is_deleted IS NOT TRUE + LIMIT 1`, + )) as { head_html: string | null; isolated_html: string | null; error_doc: string | null; deps: string | null; resource: string | null }[]; + console.log('=== DIAG head_html ===', JSON.stringify(diagRows[0]?.head_html?.substring(0, 500))); + console.log('=== DIAG isolated_html length ===', diagRows[0]?.isolated_html?.length ?? 0); + console.log('=== DIAG error_doc ===', diagRows[0]?.error_doc?.substring(0, 500)); + console.log('=== DIAG deps ===', diagRows[0]?.deps); + let resource = diagRows[0]?.resource ? JSON.parse(diagRows[0].resource) : null; + console.log('=== DIAG resource.relationships ===', JSON.stringify(resource?.relationships)); + + // Also check the theme card itself + let themeRows = (await context.dbAdapter.execute( + `SELECT url, head_html, isolated_html, error_doc, resource FROM boxel_index + WHERE url LIKE '%a-test-theme%' + AND type = 'instance' + AND is_deleted IS NOT TRUE + LIMIT 1`, + )) as { url: string; head_html: string | null; isolated_html: string | null; error_doc: string | null; resource: string | null }[]; + let themeResource = themeRows[0]?.resource ? JSON.parse(themeRows[0].resource) : null; + console.log('=== DIAG theme card url ===', themeRows[0]?.url); + console.log('=== DIAG theme resource.attributes ===', JSON.stringify(themeResource?.attributes)?.substring(0, 300)); + console.log('=== DIAG theme error_doc ===', themeRows[0]?.error_doc?.substring(0, 300)); + let response = await context.request2 .get('/test/card-with-theme') .set('Accept', 'text/html'); @@ -708,7 +736,7 @@ module(`server-endpoints/${basename(__filename)}`, function () { headContent.includes( ' Date: Thu, 19 Feb 2026 18:02:36 -0600 Subject: [PATCH 17/27] Fix diagnostic query: use pristine_doc column instead of non-existent resource column Co-Authored-By: Claude Opus 4.6 --- .../server-endpoints/index-responses-test.ts | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) 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 202de08810..fa93d33b1e 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -668,11 +668,7 @@ module(`server-endpoints/${basename(__filename)}`, function () { 'card-with-theme file write was accepted', ); - // Wait for the card to be indexed with head_html containing the theme icon. - // The card-api fix ensures nested relationships (like cardInfo.theme) are - // deserialized even when the parent contains field has no attributes, and - // #touchNestedLinksToFields ensures the linked theme is loaded before the - // head template renders during prerender. + // Wait for the card to be indexed (head_html populated, even if empty string). await waitUntil( async () => { let rows = (await context.dbAdapter.execute( @@ -695,31 +691,31 @@ module(`server-endpoints/${basename(__filename)}`, function () { // Diagnostic: check what was stored in the DB let diagRows = (await context.dbAdapter.execute( - `SELECT head_html, isolated_html, error_doc, deps, resource FROM boxel_index + `SELECT head_html, isolated_html, error_doc, deps, pristine_doc FROM boxel_index WHERE url LIKE '%card-with-theme%' AND type = 'instance' AND is_deleted IS NOT TRUE LIMIT 1`, - )) as { head_html: string | null; isolated_html: string | null; error_doc: string | null; deps: string | null; resource: string | null }[]; + )) as { head_html: string | null; isolated_html: string | null; error_doc: string | null; deps: string | null; pristine_doc: Record | null }[]; console.log('=== DIAG head_html ===', JSON.stringify(diagRows[0]?.head_html?.substring(0, 500))); console.log('=== DIAG isolated_html length ===', diagRows[0]?.isolated_html?.length ?? 0); - console.log('=== DIAG error_doc ===', diagRows[0]?.error_doc?.substring(0, 500)); - console.log('=== DIAG deps ===', diagRows[0]?.deps); - let resource = diagRows[0]?.resource ? JSON.parse(diagRows[0].resource) : null; - console.log('=== DIAG resource.relationships ===', JSON.stringify(resource?.relationships)); + console.log('=== DIAG error_doc ===', JSON.stringify(diagRows[0]?.error_doc)?.substring(0, 500)); + console.log('=== DIAG deps ===', JSON.stringify(diagRows[0]?.deps)); + let pristineDoc = diagRows[0]?.pristine_doc; + console.log('=== DIAG pristine_doc.relationships ===', JSON.stringify((pristineDoc as any)?.data?.relationships)); // Also check the theme card itself let themeRows = (await context.dbAdapter.execute( - `SELECT url, head_html, isolated_html, error_doc, resource FROM boxel_index + `SELECT url, head_html, isolated_html, error_doc, pristine_doc FROM boxel_index WHERE url LIKE '%a-test-theme%' AND type = 'instance' AND is_deleted IS NOT TRUE LIMIT 1`, - )) as { url: string; head_html: string | null; isolated_html: string | null; error_doc: string | null; resource: string | null }[]; - let themeResource = themeRows[0]?.resource ? JSON.parse(themeRows[0].resource) : null; + )) as { url: string; head_html: string | null; isolated_html: string | null; error_doc: string | null; pristine_doc: Record | null }[]; + let themePristine = themeRows[0]?.pristine_doc; console.log('=== DIAG theme card url ===', themeRows[0]?.url); - console.log('=== DIAG theme resource.attributes ===', JSON.stringify(themeResource?.attributes)?.substring(0, 300)); - console.log('=== DIAG theme error_doc ===', themeRows[0]?.error_doc?.substring(0, 300)); + console.log('=== DIAG theme pristine_doc.attributes ===', JSON.stringify((themePristine as any)?.data?.attributes)?.substring(0, 300)); + console.log('=== DIAG theme error_doc ===', JSON.stringify(themeRows[0]?.error_doc)?.substring(0, 300)); let response = await context.request2 .get('/test/card-with-theme') From 18118c39c3f717f1d172f96d86e01c6d2fee434d Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 19 Feb 2026 18:25:59 -0600 Subject: [PATCH 18/27] Add render route diagnostics for touchNestedLinksToFields + fix pristine_doc structure Co-Authored-By: Claude Opus 4.6 --- packages/host/app/routes/render.ts | 31 ++++++++++++++++--- .../server-endpoints/index-responses-test.ts | 14 +++++---- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index be4b68e7d1..609b35908b 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -402,6 +402,17 @@ export default class RenderRoute extends Route { await this.#authGuard.race(() => this.store.loaded()); if (instance) { model.instance = instance; + // Diagnostic: check if cardInfo.theme was loaded + try { + let ci = (instance as any).cardInfo; + let th = ci?.theme; + let url = th?.cardThumbnailURL; + console.log( + `[render-diag] id=${id} cardInfo=${ci != null} theme=${th != null} thumbnailURL=${url}`, + ); + } catch (e: any) { + console.log(`[render-diag] id=${id} error=${e.message}`); + } } this.#scheduleReady(model); @@ -447,6 +458,9 @@ export default class RenderRoute extends Route { if (field?.fieldType === 'contains') { try { let value = (instance as any)[fieldName]; + console.log( + `[touch-nested] field=${fieldName} value=${value != null} type=${typeof value}`, + ); if (value != null && typeof value === 'object') { let nestedFields = cardApi.getFields(value, { includeComputeds: true, @@ -459,15 +473,22 @@ export default class RenderRoute extends Route { nestedField?.fieldType === 'linksToMany' ) { try { - value[nestedName]; - } catch { - // ignore errors from touching nested linksTo fields + let nestedVal = value[nestedName]; + console.log( + `[touch-nested] nested=${nestedName} fieldType=${nestedField.fieldType} value=${nestedVal}`, + ); + } catch (e: any) { + console.log( + `[touch-nested] nested=${nestedName} error=${e.message}`, + ); } } } } - } catch { - // ignore errors from accessing contains fields + } catch (e: any) { + console.log( + `[touch-nested] field=${fieldName} access error=${e.message}`, + ); } } } 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 fa93d33b1e..fe6670ab56 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -701,20 +701,22 @@ module(`server-endpoints/${basename(__filename)}`, function () { console.log('=== DIAG isolated_html length ===', diagRows[0]?.isolated_html?.length ?? 0); console.log('=== DIAG error_doc ===', JSON.stringify(diagRows[0]?.error_doc)?.substring(0, 500)); console.log('=== DIAG deps ===', JSON.stringify(diagRows[0]?.deps)); - let pristineDoc = diagRows[0]?.pristine_doc; - console.log('=== DIAG pristine_doc.relationships ===', JSON.stringify((pristineDoc as any)?.data?.relationships)); + let pristineDoc = diagRows[0]?.pristine_doc as any; + console.log('=== DIAG pristine_doc.relationships ===', JSON.stringify(pristineDoc?.relationships)); + console.log('=== DIAG pristine_doc keys ===', pristineDoc ? Object.keys(pristineDoc) : 'null'); // Also check the theme card itself let themeRows = (await context.dbAdapter.execute( - `SELECT url, head_html, isolated_html, error_doc, pristine_doc FROM boxel_index + `SELECT url, head_html, error_doc, pristine_doc FROM boxel_index WHERE url LIKE '%a-test-theme%' AND type = 'instance' AND is_deleted IS NOT TRUE LIMIT 1`, - )) as { url: string; head_html: string | null; isolated_html: string | null; error_doc: string | null; pristine_doc: Record | null }[]; - let themePristine = themeRows[0]?.pristine_doc; + )) as { url: string; head_html: string | null; error_doc: string | null; pristine_doc: Record | null }[]; + let themePristine = themeRows[0]?.pristine_doc as any; console.log('=== DIAG theme card url ===', themeRows[0]?.url); - console.log('=== DIAG theme pristine_doc.attributes ===', JSON.stringify((themePristine as any)?.data?.attributes)?.substring(0, 300)); + console.log('=== DIAG theme head_html ===', JSON.stringify(themeRows[0]?.head_html?.substring(0, 200))); + console.log('=== DIAG theme pristine_doc.attributes ===', JSON.stringify(themePristine?.attributes)?.substring(0, 300)); console.log('=== DIAG theme error_doc ===', JSON.stringify(themeRows[0]?.error_doc)?.substring(0, 300)); let response = await context.request2 From 4fa01976cbe5fec8c13c6a8ed407bd24a1e4eea4 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 19 Feb 2026 18:32:00 -0600 Subject: [PATCH 19/27] Add cardInfo to test card attributes to bypass nestedRelFieldStubs path This tests whether the issue is in the nestedRelFieldStubs fix by using the regular Contains deserialization path (attributes.cardInfo present). Co-Authored-By: Claude Opus 4.6 --- .../tests/server-endpoints/index-responses-test.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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 fe6670ab56..5c9a6df9b3 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -640,6 +640,12 @@ module(`server-endpoints/${basename(__filename)}`, function () { type: 'card', attributes: { firstName: 'Themed Card', + cardInfo: { + name: null, + summary: null, + cardThumbnailURL: null, + notes: null, + }, }, relationships: { 'cardInfo.theme': { From ee618a3b36f1b6687203e4a1e2b8e47f2aa547fb Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 20 Feb 2026 07:36:35 -0600 Subject: [PATCH 20/27] Remove diagnostics and broken nestedRelFieldStubs code - Remove nestedRelFieldStubs from _updateFromSerialized in card-api.gts (CI confirmed it doesn't work; real cards always have contains fields in attributes, making this code path unnecessary) - Remove diagnostic console.log from render.ts model hook and #touchNestedLinksToFields (keep method logic, remove logging) - Remove diagnostic DB queries from theme test Co-Authored-By: Claude Opus 4.6 --- packages/base/card-api.gts | 36 +----------- packages/host/app/routes/render.ts | 56 +++++-------------- .../server-endpoints/index-responses-test.ts | 30 ---------- 3 files changed, 16 insertions(+), 106 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 2b661da6f3..fe9cee8c64 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -1358,9 +1358,7 @@ class LinksTo implements Field { {{#if - (shouldRenderEditor - @format defaultFormats.cardDef isComputed - ) + (shouldRenderEditor @format defaultFormats.cardDef isComputed) }} ({ return field; } - // Ensure contains fields with nested relationships are deserialized even - // when no attribute value is present. Without this, a relationship like - // "cardInfo.theme" is silently lost when "cardInfo" has no entry in - // resource.attributes (e.g. card-source format), because - // Contains.deserialize is never called and the nested linksTo reference - // is never set up as a NotLoadedValue. - let nestedRelFieldStubs: Record = {}; - if (resource.relationships) { - let attributeKeys = new Set(Object.keys(resource.attributes ?? {})); - for (let relName of Object.keys(resource.relationships)) { - let dotIdx = relName.indexOf('.'); - if (dotIdx === -1) { - continue; - } - let prefix = relName.substring(0, dotIdx); - let suffix = relName.substring(dotIdx + 1); - // Skip linksToMany numeric indices (e.g. "friends.0") since they - // are already handled via linksToManyRelationships - if (suffix.match(/^\d+$/)) { - continue; - } - if ( - !attributeKeys.has(prefix) && - !(prefix in nonNestedRelationships) && - !(prefix in linksToManyRelationships) - ) { - nestedRelFieldStubs[prefix] = undefined; - } - } - } - let values = (await Promise.all( Object.entries({ ...resource.attributes, ...nonNestedRelationships, ...linksToManyRelationships, - ...nestedRelFieldStubs, ...(resource.id !== undefined ? { id: resource.id } : {}), }).map(async ([fieldName, value]) => { let field = getField(instance, fieldName); diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index 609b35908b..379dab92c7 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -402,17 +402,6 @@ export default class RenderRoute extends Route { await this.#authGuard.race(() => this.store.loaded()); if (instance) { model.instance = instance; - // Diagnostic: check if cardInfo.theme was loaded - try { - let ci = (instance as any).cardInfo; - let th = ci?.theme; - let url = th?.cardThumbnailURL; - console.log( - `[render-diag] id=${id} cardInfo=${ci != null} theme=${th != null} thumbnailURL=${url}`, - ); - } catch (e: any) { - console.log(`[render-diag] id=${id} error=${e.message}`); - } } this.#scheduleReady(model); @@ -456,39 +445,24 @@ export default class RenderRoute extends Route { let fields = cardApi.getFields(instance, { includeComputeds: true }); for (let [fieldName, field] of Object.entries(fields)) { if (field?.fieldType === 'contains') { - try { - let value = (instance as any)[fieldName]; - console.log( - `[touch-nested] field=${fieldName} value=${value != null} type=${typeof value}`, - ); - if (value != null && typeof value === 'object') { - let nestedFields = cardApi.getFields(value, { - includeComputeds: true, - }); - for (let [nestedName, nestedField] of Object.entries( - nestedFields, - )) { - if ( - nestedField?.fieldType === 'linksTo' || - nestedField?.fieldType === 'linksToMany' - ) { - try { - let nestedVal = value[nestedName]; - console.log( - `[touch-nested] nested=${nestedName} fieldType=${nestedField.fieldType} value=${nestedVal}`, - ); - } catch (e: any) { - console.log( - `[touch-nested] nested=${nestedName} error=${e.message}`, - ); - } + let value = (instance as any)[fieldName]; + if (value != null && typeof value === 'object') { + let nestedFields = cardApi.getFields(value, { + includeComputeds: true, + }); + for (let [nestedName, nestedField] of Object.entries(nestedFields)) { + if ( + nestedField?.fieldType === 'linksTo' || + nestedField?.fieldType === 'linksToMany' + ) { + try { + // Accessing the field triggers lazy loading of the linked card + value[nestedName]; + } catch { + // Errors are expected for not-yet-loaded links } } } - } catch (e: any) { - console.log( - `[touch-nested] field=${fieldName} access error=${e.message}`, - ); } } } 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 5c9a6df9b3..65c973b2c2 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -695,36 +695,6 @@ module(`server-endpoints/${basename(__filename)}`, function () { }, ); - // Diagnostic: check what was stored in the DB - let diagRows = (await context.dbAdapter.execute( - `SELECT head_html, isolated_html, error_doc, deps, pristine_doc FROM boxel_index - WHERE url LIKE '%card-with-theme%' - AND type = 'instance' - AND is_deleted IS NOT TRUE - LIMIT 1`, - )) as { head_html: string | null; isolated_html: string | null; error_doc: string | null; deps: string | null; pristine_doc: Record | null }[]; - console.log('=== DIAG head_html ===', JSON.stringify(diagRows[0]?.head_html?.substring(0, 500))); - console.log('=== DIAG isolated_html length ===', diagRows[0]?.isolated_html?.length ?? 0); - console.log('=== DIAG error_doc ===', JSON.stringify(diagRows[0]?.error_doc)?.substring(0, 500)); - console.log('=== DIAG deps ===', JSON.stringify(diagRows[0]?.deps)); - let pristineDoc = diagRows[0]?.pristine_doc as any; - console.log('=== DIAG pristine_doc.relationships ===', JSON.stringify(pristineDoc?.relationships)); - console.log('=== DIAG pristine_doc keys ===', pristineDoc ? Object.keys(pristineDoc) : 'null'); - - // Also check the theme card itself - let themeRows = (await context.dbAdapter.execute( - `SELECT url, head_html, error_doc, pristine_doc FROM boxel_index - WHERE url LIKE '%a-test-theme%' - AND type = 'instance' - AND is_deleted IS NOT TRUE - LIMIT 1`, - )) as { url: string; head_html: string | null; error_doc: string | null; pristine_doc: Record | null }[]; - let themePristine = themeRows[0]?.pristine_doc as any; - console.log('=== DIAG theme card url ===', themeRows[0]?.url); - console.log('=== DIAG theme head_html ===', JSON.stringify(themeRows[0]?.head_html?.substring(0, 200))); - console.log('=== DIAG theme pristine_doc.attributes ===', JSON.stringify(themePristine?.attributes)?.substring(0, 300)); - console.log('=== DIAG theme error_doc ===', JSON.stringify(themeRows[0]?.error_doc)?.substring(0, 300)); - let response = await context.request2 .get('/test/card-with-theme') .set('Accept', 'text/html'); From b03f44850e76e2c11f56dbd3f77557bed7ba1ff4 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 20 Feb 2026 07:55:10 -0600 Subject: [PATCH 21/27] Fix theme head_html: reset model state for format transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the overly aggressive #touchNestedLinksToFields approach (which loaded ALL nested linksTo fields, including hidden ones that shouldn't appear in deps) with #resetModelForFormatTransition. When the prerender pipeline transitions between formats (isolated → head → fitted → etc.), the model status is already "ready" from the previous format. If the new format template triggers lazy loads (e.g., the head template accessing cardInfo.theme.cardThumbnailURL), captureResult would capture before those loads resolve. The new approach resets the model status to "loading" before each format transition, then re-settles via afterRender + store.loaded(). This way captureResult waits for any lazy loads triggered by the new template without pre-loading hidden relationships. Also removes the manually-added relationship deps from render/meta.ts (now handled by the new dependency resolver from main). Co-Authored-By: Claude Opus 4.6 --- packages/host/app/index.html | 92 +++++++++++++------------ packages/host/app/routes/render.ts | 59 ++++++++-------- packages/host/app/routes/render/meta.ts | 18 ----- 3 files changed, 75 insertions(+), 94 deletions(-) diff --git a/packages/host/app/index.html b/packages/host/app/index.html index 50bc507fe0..eee30f05ad 100644 --- a/packages/host/app/index.html +++ b/packages/host/app/index.html @@ -1,48 +1,50 @@ - - - - - - - {{content-for "head"}} - - - - - - - - - - {{content-for "head-footer"}} - - - Boxel - - - - - - - - - -
- {{content-for "body"}} - - - - - - {{content-for "body-footer"}} - - - \ No newline at end of file +
+ {{content-for "body"}} + + + + + + {{content-for "body-footer"}} + + diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index 379dab92c7..8fcfb54b30 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -432,40 +432,31 @@ export default class RenderRoute extends Route { } } } - // Touch linksTo/linksToMany fields nested within contains FieldDefs so - // their async loads are tracked by store.loaded() before the model is - // marked as "ready". Without this, the head format template (which - // accesses e.g. cardInfo.theme.cardThumbnailURL) would trigger the load - // AFTER the model is already "ready", and captureResult would capture - // the HTML before the linked card resolves. - this.#touchNestedLinksToFields(cardApi, instance); } - #touchNestedLinksToFields(cardApi: typeof CardAPI, instance: CardDef): void { - let fields = cardApi.getFields(instance, { includeComputeds: true }); - for (let [fieldName, field] of Object.entries(fields)) { - if (field?.fieldType === 'contains') { - let value = (instance as any)[fieldName]; - if (value != null && typeof value === 'object') { - let nestedFields = cardApi.getFields(value, { - includeComputeds: true, - }); - for (let [nestedName, nestedField] of Object.entries(nestedFields)) { - if ( - nestedField?.fieldType === 'linksTo' || - nestedField?.fieldType === 'linksToMany' - ) { - try { - // Accessing the field triggers lazy loading of the linked card - value[nestedName]; - } catch { - // Errors are expected for not-yet-loaded links - } - } - } - } - } + // Reset model state so that captureResult waits for lazy loads triggered by + // subsequent format transitions (e.g., head, fitted). After the first format + // (isolated) settles, the model status is "ready". When transitioning to a + // new format, the template may access fields not touched by the first format + // (e.g., the head template accesses cardInfo.theme), triggering lazy loads. + // Resetting to "loading" makes captureResult wait; afterRender will re-settle. + #resetModelForFormatTransition() { + let model = (globalThis as any).__renderModel as Model | undefined; + if (!model) { + return; } + let modelState = this.#modelStates.get(model); + if (!modelState || !modelState.isReady) { + return; + } + modelState.isReady = false; + modelState.readyWatchdogStarted = false; + modelState.readyDeferred = new Deferred(); + modelState.state.set('status', 'loading'); + model.readyPromise = modelState.readyDeferred.promise; + this.#pendingReadyModels.add(model); + scheduleOnce('afterRender', this, this.#processPendingReadyModels); + this.#startReadyWatchdog(model); } setupController(controller: Controller, model: Model) { @@ -614,6 +605,12 @@ export default class RenderRoute extends Route { return; } if (typeof routeName === 'string' && routeName.startsWith('render.')) { + // Reset model state before subsequent format transitions so that + // captureResult waits for any lazy loads triggered by the new format + // template (e.g., head format accessing cardInfo.theme). Without + // this, the model status is already "ready" from the previous format + // and captureResult captures before new lazy loads resolve. + this.#resetModelForFormatTransition(); let normalized = [...params]; if ( normalized.length >= 3 && diff --git a/packages/host/app/routes/render/meta.ts b/packages/host/app/routes/render/meta.ts index 8239939c1a..ca5f4fe0d8 100644 --- a/packages/host/app/routes/render/meta.ts +++ b/packages/host/app/routes/render/meta.ts @@ -79,24 +79,6 @@ export default class RenderMetaRoute extends Route { ...(await transitiveModuleDeps(directDeps, this.loaderService.loader)), ]; - // Include linked card URLs as deps so that backward invalidation - // re-indexes this card when a linked card is created or updated. - // This is needed because linksTo fields (e.g. cardInfo.theme) may - // not be resolvable during initial indexing if the linked card - // hasn't been indexed yet. - for (let { relationship } of relationshipEntries( - serialized.data.relationships, - )) { - let selfLink = relationship.links?.self; - if (typeof selfLink === 'string' && selfLink) { - try { - deps.push(new URL(selfLink, instanceURL).href); - } catch { - // ignore malformed URLs - } - } - } - let Klass = getClass(instance); let types = getTypes(Klass); From 862919ecae9161293bf2b82ec0162da3eb2076ae Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 20 Feb 2026 08:20:00 -0600 Subject: [PATCH 22/27] Fix indexing-test timeout: only reset model state for render.html transitions #resetModelForFormatTransition was being called for ALL render.* child route transitions, including render.meta. The render.meta model hook explicitly awaits readyPromise, so resetting it to a new unfulfilled deferred could cause hangs due to Ember run loop timing issues. Only render.html transitions render card templates that might trigger new lazy loads (e.g., head format accessing cardInfo.theme). Other child routes (render.meta, render.icon, render.error) don't need the reset. Co-Authored-By: Claude Opus 4.6 --- packages/host/app/routes/render.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index 8fcfb54b30..dacaf3ae96 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -605,12 +605,18 @@ export default class RenderRoute extends Route { return; } if (typeof routeName === 'string' && routeName.startsWith('render.')) { - // Reset model state before subsequent format transitions so that + // Reset model state before render.html transitions so that // captureResult waits for any lazy loads triggered by the new format // template (e.g., head format accessing cardInfo.theme). Without // this, the model status is already "ready" from the previous format // and captureResult captures before new lazy loads resolve. - this.#resetModelForFormatTransition(); + // Only reset for render.html — other child routes (render.meta, + // render.icon, render.error) do not render card templates, and + // render.meta explicitly awaits readyPromise which would deadlock + // if we reset it here. + if (routeName === 'render.html') { + this.#resetModelForFormatTransition(); + } let normalized = [...params]; if ( normalized.length >= 3 && From 145a26df0852a932c86fb803587a344b7b200fc8 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 20 Feb 2026 12:20:49 -0600 Subject: [PATCH 23/27] Fix prerendering timeout by pre-loading cardInfo.theme during model build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #resetModelForFormatTransition() method was resetting model status to "loading" on every render.html transition, which interfered with the prerendering multi-format capture flow (isolated → meta → head → etc.) on reused pooled pages, causing timeouts in prerendering-test.ts. Instead, pre-load cardInfo.theme during initial model building so the data is already available when the head template renders favicon and apple-touch-icon tags. This avoids the need to reset model state between format transitions entirely. Co-Authored-By: Claude Opus 4.6 --- packages/host/app/routes/render.ts | 46 ++++++------------------------ 1 file changed, 9 insertions(+), 37 deletions(-) diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index dacaf3ae96..24003774a5 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -398,6 +398,15 @@ export default class RenderRoute extends Route { } if (instance) { await this.#authGuard.race(() => this.#touchIsUsedFields(instance)); + // Pre-load cardInfo.theme so the default head template can render + // favicon/apple-touch-icon tags without triggering lazy loads during + // format transitions. Accessing the getter triggers the linksTo load; + // store.loaded() below will wait for it to settle. + try { + (instance as any).cardInfo?.theme; + } catch { + // ignore – the field may not exist on all card types + } } await this.#authGuard.race(() => this.store.loaded()); if (instance) { @@ -434,31 +443,6 @@ export default class RenderRoute extends Route { } } - // Reset model state so that captureResult waits for lazy loads triggered by - // subsequent format transitions (e.g., head, fitted). After the first format - // (isolated) settles, the model status is "ready". When transitioning to a - // new format, the template may access fields not touched by the first format - // (e.g., the head template accesses cardInfo.theme), triggering lazy loads. - // Resetting to "loading" makes captureResult wait; afterRender will re-settle. - #resetModelForFormatTransition() { - let model = (globalThis as any).__renderModel as Model | undefined; - if (!model) { - return; - } - let modelState = this.#modelStates.get(model); - if (!modelState || !modelState.isReady) { - return; - } - modelState.isReady = false; - modelState.readyWatchdogStarted = false; - modelState.readyDeferred = new Deferred(); - modelState.state.set('status', 'loading'); - model.readyPromise = modelState.readyDeferred.promise; - this.#pendingReadyModels.add(model); - scheduleOnce('afterRender', this, this.#processPendingReadyModels); - this.#startReadyWatchdog(model); - } - setupController(controller: Controller, model: Model) { super.setupController(controller, model); this.#scheduleReady(model); @@ -605,18 +589,6 @@ export default class RenderRoute extends Route { return; } if (typeof routeName === 'string' && routeName.startsWith('render.')) { - // Reset model state before render.html transitions so that - // captureResult waits for any lazy loads triggered by the new format - // template (e.g., head format accessing cardInfo.theme). Without - // this, the model status is already "ready" from the previous format - // and captureResult captures before new lazy loads resolve. - // Only reset for render.html — other child routes (render.meta, - // render.icon, render.error) do not render card templates, and - // render.meta explicitly awaits readyPromise which would deadlock - // if we reset it here. - if (routeName === 'render.html') { - this.#resetModelForFormatTransition(); - } let normalized = [...params]; if ( normalized.length >= 3 && From 919e39c1a33eecfea23e1c5c02398eb6d70d6a27 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 20 Feb 2026 13:15:19 -0600 Subject: [PATCH 24/27] Remove cardInfo.theme pre-load, keep resetModelForFormatTransition removal Testing whether the pre-load is the cause of Host Tests (4, 16) failures. The prerendering-test fix (removal of resetModelForFormatTransition) stays. Co-Authored-By: Claude Opus 4.6 --- packages/host/app/routes/render.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index 24003774a5..874f0defe6 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -398,15 +398,6 @@ export default class RenderRoute extends Route { } if (instance) { await this.#authGuard.race(() => this.#touchIsUsedFields(instance)); - // Pre-load cardInfo.theme so the default head template can render - // favicon/apple-touch-icon tags without triggering lazy loads during - // format transitions. Accessing the getter triggers the linksTo load; - // store.loaded() below will wait for it to settle. - try { - (instance as any).cardInfo?.theme; - } catch { - // ignore – the field may not exist on all card types - } } await this.#authGuard.race(() => this.store.loaded()); if (instance) { From a7b3bc92acc9de501e2b8df435d448a0e9d95a0e Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 20 Feb 2026 20:40:24 -0600 Subject: [PATCH 25/27] Re-add resetModelForFormatTransition with nonce guard for page reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When prerendering captures multiple formats sequentially (isolated → head → fitted → etc.), the model is already "ready" after the first format settles. Subsequent format templates may access fields not touched by the first format (e.g., the head template reads cardInfo.theme), triggering lazy linksTo loads. Without resetting, captureResult sees "ready" immediately and captures before those loads complete — producing head HTML missing theme-based link tags. The previous version of this method broke page reuse: during reuse the __renderModel briefly points to the *previous* card's model while the new model builds. Resetting that stale model left its status stuck at "loading", blocking the page pool indefinitely. The fix adds a nonce guard: only reset when __renderModel.nonce matches the current renderBaseParams nonce, ensuring we never touch a stale model. Co-Authored-By: Claude Opus 4.6 --- packages/host/app/routes/render.ts | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index 874f0defe6..fdd8845f29 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -434,6 +434,38 @@ export default class RenderRoute extends Route { } } + // Reset model state so that captureResult waits for lazy loads triggered by + // subsequent format transitions (e.g., head, fitted). After the first format + // (isolated) settles, the model status is "ready". When transitioning to a + // new format, the template may access fields not touched by the first format + // (e.g., the head template accesses cardInfo.theme), triggering lazy loads. + // Resetting to "loading" makes captureResult wait; afterRender will re-settle. + #resetModelForFormatTransition() { + let model = (globalThis as any).__renderModel as Model | undefined; + if (!model) { + return; + } + let modelState = this.#modelStates.get(model); + if (!modelState || !modelState.isReady) { + return; + } + // Only reset when the current __renderModel belongs to the active prerender + // card. During page reuse the __renderModel may briefly point to the + // previous card while the new model is still building; resetting that stale + // model would leave its status stuck at "loading" and block the page pool. + if (model.nonce !== this.renderBaseParams?.[1]) { + return; + } + modelState.isReady = false; + modelState.readyWatchdogStarted = false; + modelState.readyDeferred = new Deferred(); + modelState.state.set('status', 'loading'); + model.readyPromise = modelState.readyDeferred.promise; + this.#pendingReadyModels.add(model); + scheduleOnce('afterRender', this, this.#processPendingReadyModels); + this.#startReadyWatchdog(model); + } + setupController(controller: Controller, model: Model) { super.setupController(controller, model); this.#scheduleReady(model); @@ -580,6 +612,18 @@ export default class RenderRoute extends Route { return; } if (typeof routeName === 'string' && routeName.startsWith('render.')) { + // Reset model state before render.html transitions so that + // captureResult waits for any lazy loads triggered by the new format + // template (e.g., head format accessing cardInfo.theme). Without + // this, the model status is already "ready" from the previous format + // and captureResult captures before new lazy loads resolve. + // Only reset for render.html — other child routes (render.meta, + // render.icon, render.error) do not render card templates, and + // render.meta explicitly awaits readyPromise which would deadlock + // if we reset it here. + if (routeName === 'render.html') { + this.#resetModelForFormatTransition(); + } let normalized = [...params]; if ( normalized.length >= 3 && From ebcb96db49501f29d6144efa829d314fb6eccbbb Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 21 Feb 2026 09:34:41 -0700 Subject: [PATCH 26/27] Replace resetModelForFormatTransition with cardInfo.theme pre-load Instead of resetting model state between format transitions (which broke page reuse in prerendering-test.ts), pre-load cardInfo.theme during #buildModel so the lazy linksTo load completes before store.loaded() settles. This ensures theme data is available for the head format render. The pre-load causes the getter helper (field-support.ts) to add 'theme' to the CardInfoField dataBucket via emptyValue, making it a "used field" for serialization. Updated test expectations to include the now-visible cardInfo.theme relationship. Co-Authored-By: Claude Opus 4.6 --- packages/host/app/routes/render.ts | 52 +++---------------- .../tests/acceptance/prerender-meta-test.gts | 13 ++++- 2 files changed, 19 insertions(+), 46 deletions(-) diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index fdd8845f29..15ac5d7c3e 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -398,6 +398,14 @@ export default class RenderRoute extends Route { } if (instance) { await this.#authGuard.race(() => this.#touchIsUsedFields(instance)); + // Pre-load cardInfo.theme so the head format template can render + // favicon/apple-touch-icon links. This is a nested linksTo inside a + // contains FieldDef, so #touchIsUsedFields doesn't reach it. + try { + (instance as any).cardInfo?.theme; + } catch { + // ignore — card may not have cardInfo or theme + } } await this.#authGuard.race(() => this.store.loaded()); if (instance) { @@ -434,38 +442,6 @@ export default class RenderRoute extends Route { } } - // Reset model state so that captureResult waits for lazy loads triggered by - // subsequent format transitions (e.g., head, fitted). After the first format - // (isolated) settles, the model status is "ready". When transitioning to a - // new format, the template may access fields not touched by the first format - // (e.g., the head template accesses cardInfo.theme), triggering lazy loads. - // Resetting to "loading" makes captureResult wait; afterRender will re-settle. - #resetModelForFormatTransition() { - let model = (globalThis as any).__renderModel as Model | undefined; - if (!model) { - return; - } - let modelState = this.#modelStates.get(model); - if (!modelState || !modelState.isReady) { - return; - } - // Only reset when the current __renderModel belongs to the active prerender - // card. During page reuse the __renderModel may briefly point to the - // previous card while the new model is still building; resetting that stale - // model would leave its status stuck at "loading" and block the page pool. - if (model.nonce !== this.renderBaseParams?.[1]) { - return; - } - modelState.isReady = false; - modelState.readyWatchdogStarted = false; - modelState.readyDeferred = new Deferred(); - modelState.state.set('status', 'loading'); - model.readyPromise = modelState.readyDeferred.promise; - this.#pendingReadyModels.add(model); - scheduleOnce('afterRender', this, this.#processPendingReadyModels); - this.#startReadyWatchdog(model); - } - setupController(controller: Controller, model: Model) { super.setupController(controller, model); this.#scheduleReady(model); @@ -612,18 +588,6 @@ export default class RenderRoute extends Route { return; } if (typeof routeName === 'string' && routeName.startsWith('render.')) { - // Reset model state before render.html transitions so that - // captureResult waits for any lazy loads triggered by the new format - // template (e.g., head format accessing cardInfo.theme). Without - // this, the model status is already "ready" from the previous format - // and captureResult captures before new lazy loads resolve. - // Only reset for render.html — other child routes (render.meta, - // render.icon, render.error) do not render card templates, and - // render.meta explicitly awaits readyPromise which would deadlock - // if we reset it here. - if (routeName === 'render.html') { - this.#resetModelForFormatTransition(); - } let normalized = [...params]; if ( normalized.length >= 3 && diff --git a/packages/host/tests/acceptance/prerender-meta-test.gts b/packages/host/tests/acceptance/prerender-meta-test.gts index efabb84498..4ed947c8e8 100644 --- a/packages/host/tests/acceptance/prerender-meta-test.gts +++ b/packages/host/tests/acceptance/prerender-meta-test.gts @@ -266,6 +266,11 @@ module('Acceptance | prerender | meta', function (hooks) { numOfPets: '3', }, relationships: { + 'cardInfo.theme': { + links: { + self: null, + }, + }, 'pets.0': { links: { self: '../Pet/mango', @@ -346,7 +351,9 @@ module('Acceptance | prerender | meta', function (hooks) { { id: `${testRealmURL}Pet/mango`, _cardType: 'Pet', - cardInfo: {}, + cardInfo: { + theme: null, + }, name: 'Mango', cardTitle: 'Mango', }, @@ -364,7 +371,9 @@ module('Acceptance | prerender | meta', function (hooks) { { id: `${testRealmURL}Pet/paper`, _cardType: 'Cat', - cardInfo: {}, + cardInfo: { + theme: null, + }, name: 'Paper', cardTitle: 'Paper', aliases: ['Satan', "Satan's Mistress"], From b915cc5516b86dbe681c885c29a2402374c5ef5f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Sat, 21 Feb 2026 11:47:00 -0700 Subject: [PATCH 27/27] Update test expectations for cardInfo.theme in relationships The cardInfo.theme pre-load causes the theme linksTo field to appear in serialized card relationships. Update create-file and patch-instance test expectations to include 'cardInfo.theme': { links: { self: null } }. Co-Authored-By: Claude Opus 4.6 --- .../host/tests/acceptance/code-submode/create-file-test.gts | 5 +++++ .../host/tests/integration/commands/patch-instance-test.gts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/packages/host/tests/acceptance/code-submode/create-file-test.gts b/packages/host/tests/acceptance/code-submode/create-file-test.gts index 685cc73461..b2c2e6a09e 100644 --- a/packages/host/tests/acceptance/code-submode/create-file-test.gts +++ b/packages/host/tests/acceptance/code-submode/create-file-test.gts @@ -385,6 +385,11 @@ module('Acceptance | code submode | create-file tests', function (hooks) { assert.deepEqual( json.data.relationships, { + 'cardInfo.theme': { + links: { + self: null, + }, + }, pet: { links: { self: null, diff --git a/packages/host/tests/integration/commands/patch-instance-test.gts b/packages/host/tests/integration/commands/patch-instance-test.gts index f52b0e48ce..86e48c0b87 100644 --- a/packages/host/tests/integration/commands/patch-instance-test.gts +++ b/packages/host/tests/integration/commands/patch-instance-test.gts @@ -487,6 +487,11 @@ module('Integration | commands | patch-instance', function (hooks) { instance.relationships, { bestFriend: { links: { self: `./queenzy` } }, + 'cardInfo.theme': { + links: { + self: null, + }, + }, friends: { links: { self: null,