From cfeb5a24efb12f4e7b971e2e614664fb96da1f9b Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 13 Jul 2026 13:08:30 -0400 Subject: [PATCH 1/3] Render each type's icon once per indexing job Icon HTML is a per-type constant (the type's static icon component), yet the index visit rendered it once per file. The index visit's card pass now enters the render app via render.meta and renders the icon afterward as an in-page child transition, so a job-scoped memo keyed on the type the meta pass resolves (types[0]) can skip the icon route for every subsequent visit of the same type. The file pass memoizes the same way, keyed on the extract pass's types[0]; on a memo hit it performs no page work at all. The memo lives on the RenderRunner, one slot per affinity, keyed by jobId + loader epoch: indexing serializes per realm so at most one job is active per slot, a new job replaces the slot outright, and visits without a jobId never touch it. clearCache visits bypass the memo read and overwrite the entry with their fresh capture. releaseBatch drops the owning affinity's memo. Co-Authored-By: Claude Fable 5 --- .../realm-server/prerender/prerenderer.ts | 11 + .../realm-server/prerender/render-runner.ts | 207 +++++++++++++++--- .../realm-server/tests/prerendering-test.ts | 203 +++++++++++++++++ 3 files changed, 389 insertions(+), 32 deletions(-) diff --git a/packages/realm-server/prerender/prerenderer.ts b/packages/realm-server/prerender/prerenderer.ts index 078238f61c3..b80b71bb90f 100644 --- a/packages/realm-server/prerender/prerenderer.ts +++ b/packages/realm-server/prerender/prerenderer.ts @@ -265,6 +265,10 @@ export class Prerenderer { let owner = this.#batchOwnership.get(affinityKey); if (owner?.batchId === batchId) { this.#batchOwnership.delete(affinityKey); + // The job's icon memo has no readers once its batch releases the + // affinity (a later job carries a different job key), so drop it + // rather than letting it idle until the next job replaces it. + this.#renderRunner.clearIconMemo(affinityKey); log.debug(`batch ${batchId} released ownership of ${affinityKey}`); } } @@ -279,6 +283,13 @@ export class Prerenderer { return owner ? { batchId: owner.batchId, since: owner.since } : undefined; } + // Read-only observability accessor used by tests. Callers outside of + // tests should not rely on this shape; it's a debugging surface, not a + // stable API. + getIconMemo(affinityKey: string) { + return this.#renderRunner.getIconMemo(affinityKey); + } + // Back-compat static re-export: older callers / tests reference // `Prerenderer.decorateRenderErrorsWithTimings`. Delegates to the free // function in `render-settlement.ts`. diff --git a/packages/realm-server/prerender/render-runner.ts b/packages/realm-server/prerender/render-runner.ts index 51e71664087..ab25398a919 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -80,6 +80,16 @@ type PoolInfo = { timedOut: boolean; }; +// One indexing job's icon renderings for one affinity: the captured icon +// markup keyed by the type's internal key, plus hit/miss counters for the +// stats line emitted when the memo is replaced or released. +type IconMemo = { + jobKey: string; + icons: Map; + hits: number; + misses: number; +}; + const CLEAR_CACHE_RETRY_SIGNATURES: readonly (readonly string[])[] = [ // this is a side effect of glimmer scoped styles moving a DOM node that // glimmer is tracking. when we go to teardown the component glimmer gets mad @@ -133,6 +143,17 @@ export class RenderRunner { byAffinity: new Map(), }; #lastAuthByAffinity = new Map(); + // Job-scoped icon memo, one slot per affinity. Icon HTML is a pure + // function of the card's type — the type's static `icon` component, never + // the instance — so within one indexing job the first visit for a type + // renders the icon and every later visit of that type reuses the captured + // markup, skipping the icon route. Indexing serializes per realm (= per + // affinity), so at most one job is active per slot: a visit carrying a + // different job key (jobId + loader epoch) replaces the slot outright. A + // module edit arrives as a new job with a new key, so stale markup cannot + // leak across module changes. Visits without a jobId (on-demand renders) + // never touch the memo. + #iconMemoByAffinity = new Map(); constructor(options: { pagePool: PagePool; boxelHostURL: string }) { this.#pagePool = options.pagePool; @@ -172,6 +193,70 @@ export class RenderRunner { this.#lastAuthByAffinity.delete(affinityKey); } + // Resolve the icon memo for a visit. Only indexing visits participate + // (they carry a jobId); the loader epoch rides the job key as + // belt-and-braces so a memo can never outlive the module surface it was + // rendered against. + #iconMemoFor( + affinityKey: string, + jobId: string | undefined, + loaderEpoch: string | undefined, + ): IconMemo | undefined { + if (!jobId) { + return undefined; + } + let jobKey = `${jobId}|${loaderEpoch ?? ''}`; + let memo = this.#iconMemoByAffinity.get(affinityKey); + if (!memo || memo.jobKey !== jobKey) { + if (memo) { + this.#logIconMemoStats(affinityKey, memo, 'superseded'); + } + memo = { jobKey, icons: new Map(), hits: 0, misses: 0 }; + this.#iconMemoByAffinity.set(affinityKey, memo); + } + return memo; + } + + // Drop an affinity's icon memo. Called when the indexing batch that owns + // the affinity releases it; the job-key check in #iconMemoFor already + // guarantees a later job never reads another job's entries, so this is + // memory hygiene, not a correctness gate. + clearIconMemo(affinityKey: string) { + let memo = this.#iconMemoByAffinity.get(affinityKey); + if (memo) { + this.#logIconMemoStats(affinityKey, memo, 'released'); + this.#iconMemoByAffinity.delete(affinityKey); + } + } + + // Read-only observability accessor used by tests. Callers outside of + // tests should not rely on this shape; it's a debugging surface, not a + // stable API. + getIconMemo(affinityKey: string): + | { + jobKey: string; + types: string[]; + hits: number; + misses: number; + } + | undefined { + let memo = this.#iconMemoByAffinity.get(affinityKey); + return memo + ? { + jobKey: memo.jobKey, + types: [...memo.icons.keys()], + hits: memo.hits, + misses: memo.misses, + } + : undefined; + } + + #logIconMemoStats(affinityKey: string, memo: IconMemo, reason: string) { + log.debug( + `icon memo stats affinity=${affinityKey} types=${memo.icons.size} hits=${memo.hits} misses=${memo.misses} (${reason})`, + ); + } + // Builds the per-render profile context threaded into `withTimeout`. // The affinity key drives the airtight affinity-scoped CPU-profiler // gate (only the render whose affinity exactly matches @@ -1124,6 +1209,15 @@ export class RenderRunner { return; }; + let emptyMeta: PrerenderMeta = { + serialized: null, + searchDoc: null, + displayNames: null, + deps: null, + types: null, + }; + let meta: PrerenderMeta = emptyMeta; + if (runHtmlSteps) { let isolatedStart = Date.now(); let isolatedResult = await withTimeout( @@ -1166,46 +1260,65 @@ export class RenderRunner { capturedDeps = await this.#readCapturedDeps(page); } } else { - // An index visit never touches the html route, so the icon render - // is its entry into the render app; subsequent steps are in-page - // child transitions. - let iconResult = await withTimeout( - page, + // An index visit never touches the html route, so render.meta is + // its entry into the render app; the icon runs after it as an + // in-page child transition. Meta goes first because the icon is a + // pure function of the card's type (`types[0]`, which meta + // resolves): the job-scoped memo lets the first visit for a type + // render the icon and every later visit of that type reuse the + // captured markup, skipping the icon route. + let metaResult = await runTimedStep( + 'visit card render.meta', async () => { await transitionTo( page, - 'render.icon', + 'render.meta', url, nonce, serializedOptions, ); - return await renderIcon(page, captureOptions); + return await renderMeta(page, captureOptions); }, - opts?.timeoutMs, - this.#profileContext(affinityKey, url, 'card icon', jobId), ); - if (isRenderError(iconResult)) { - cardShortCircuit = true; - let renderError = iconResult as RenderError; - let evicted = await this.#maybeEvict( + if (metaResult !== undefined) { + meta = metaResult; + } + if (!cardShortCircuit) { + let iconMemo = this.#iconMemoFor( affinityKey, - 'visit card icon render', - renderError, + jobId, + baseOptions.loaderEpoch, ); - applyStepError(renderError, evicted); - } else { - iconHTML = iconResult as string; + let iconTypeKey = meta.types?.[0]; + // A clearCache visit is asked for a pristine render, so it + // bypasses the memo read; its fresh capture overwrites the + // entry below. + let memoizedIconHTML = + iconMemo && iconTypeKey !== undefined && !baseOptions.clearCache + ? iconMemo.icons.get(iconTypeKey) + : undefined; + if (memoizedIconHTML !== undefined) { + iconMemo!.hits++; + iconHTML = memoizedIconHTML; + log.debug( + `card icon memo hit type=${iconTypeKey} url=${url} affinity=${affinityKey}`, + ); + } else { + let iconResult = await runTimedStep( + 'visit card icon render', + () => renderIcon(page, captureOptions), + ); + if (iconResult !== undefined) { + iconHTML = iconResult; + if (iconMemo && iconTypeKey !== undefined) { + iconMemo.misses++; + iconMemo.icons.set(iconTypeKey, iconHTML); + } + } + } } } - let emptyMeta: PrerenderMeta = { - serialized: null, - searchDoc: null, - displayNames: null, - deps: null, - types: null, - }; - let meta: PrerenderMeta = emptyMeta; let typesForAncestors: PrerenderTypes = { types: null }; let headHTML: string | null = null; let atomHTML: string | null = null; @@ -1329,7 +1442,10 @@ export class RenderRunner { } } - if (!cardShortCircuit && runIndexSteps) { + // The fused visit runs meta last, after the format renders above + // marked the linksTo / linksToMany fields they read as "used"; the + // index visit ran meta as its entry instead. + if (!cardShortCircuit && runIndexSteps && runHtmlSteps) { let finalMetaResult = await runTimedStep( 'visit card render.meta', () => renderMeta(page, captureOptions), @@ -1421,11 +1537,28 @@ export class RenderRunner { timeoutMs: opts?.timeoutMs, }; - // stash file data for the render route model hook to consume - await page.evaluate((data) => { - (globalThis as any).__boxelFileRenderData = data; - }, effectiveFileData); - didStashFileRenderData = true; + // The index visit's only page work for a file is its icon, which + // is a pure function of the file's type (`effectiveTypes[0]`, + // resolved by the extract pass): on a memo hit the pass touches + // the page not at all — including the file-data stash, which only + // the render routes consume. A clearCache visit bypasses the memo + // read (see the card pass). + let iconMemo = runHtmlSteps + ? undefined + : this.#iconMemoFor(affinityKey, jobId, baseOptions.loaderEpoch); + let iconTypeKey = effectiveTypes?.[0]; + let memoizedIconHTML = + iconMemo && iconTypeKey !== undefined && !baseOptions.clearCache + ? iconMemo.icons.get(iconTypeKey) + : undefined; + + if (memoizedIconHTML === undefined) { + // stash file data for the render route model hook to consume + await page.evaluate((data) => { + (globalThis as any).__boxelFileRenderData = data; + }, effectiveFileData); + didStashFileRenderData = true; + } let fileError: RenderError | undefined; let fileShortCircuit = false; @@ -1493,6 +1626,12 @@ export class RenderRunner { } } } + } else if (memoizedIconHTML !== undefined) { + iconMemo!.hits++; + iconHTML = memoizedIconHTML; + log.debug( + `file icon memo hit type=${iconTypeKey} url=${url} affinity=${affinityKey}`, + ); } else { // The file's icon belongs to the index half, and an index visit // never touches the html route — so the icon render is its entry @@ -1522,6 +1661,10 @@ export class RenderRunner { applyStepError(renderError, evicted); } else { iconHTML = iconResult as string; + if (iconMemo && iconTypeKey !== undefined) { + iconMemo.misses++; + iconMemo.icons.set(iconTypeKey, iconHTML); + } } } diff --git a/packages/realm-server/tests/prerendering-test.ts b/packages/realm-server/tests/prerendering-test.ts index e70bb1a7214..e97883f72af 100644 --- a/packages/realm-server/tests/prerendering-test.ts +++ b/packages/realm-server/tests/prerendering-test.ts @@ -11,6 +11,7 @@ import type { } from '@cardstack/runtime-common'; import type { Realm as RuntimeRealm } from '@cardstack/runtime-common'; import type { Prerenderer } from '../prerender/index.ts'; +import { toAffinityKey } from '../prerender/affinity.ts'; import { PagePool } from '../prerender/page-pool.ts'; import { RenderRunner } from '../prerender/render-runner.ts'; import { BrowserManager } from '../prerender/browser-manager.ts'; @@ -7355,6 +7356,16 @@ module(basename(import.meta.filename), function () { } } `, + 'pet.gts': ` + import { CardDef } from '@cardstack/base/card-api'; + const PetIcon = ; + export class Pet extends CardDef { + static displayName = "Pet"; + static icon = PetIcon; + } + `, 'maple.json': { data: { attributes: { name: 'Maple' }, @@ -7366,6 +7377,28 @@ module(basename(import.meta.filename), function () { }, }, }, + 'willow.json': { + data: { + attributes: { name: 'Willow' }, + meta: { + adoptsFrom: { + module: rri('./person'), + name: 'Person', + }, + }, + }, + }, + 'rex.json': { + data: { + attributes: {}, + meta: { + adoptsFrom: { + module: rri('./pet'), + name: 'Pet', + }, + }, + }, + }, }, }, ], @@ -7748,5 +7781,175 @@ module(basename(import.meta.filename), function () { }); assert.true(result.pool.reused, 'second visit reused the pooled page'); }); + + // Icon HTML is a pure function of the card's type (the type's static + // `icon` component), so an indexing job renders each type's icon once: + // the first index visit for a type renders it and every later visit of + // that type reuses the captured markup, skipping the icon route. The + // memo is scoped to the visit's jobId; visits without one (on-demand + // renders) always render the icon and never touch the memo. + module('index-visit icon memo', function () { + let affinityKey = toAffinityKey({ + affinityType: 'realm', + affinityValue: realmURL, + }); + let indexVisit = (fileName: string, extra?: Record) => + prerenderer.prerenderVisit({ + affinityType: 'realm', + affinityValue: realmURL, + realm: realmURL, + url: `${realmURL}${fileName}`, + auth: auth(), + visitType: 'index', + renderOptions: { + cardRender: true, + fileExtract: true, + fileRender: true, + fileDefCodeRef: { + module: baseRRI('json-file-def'), + name: 'JsonFileDef', + }, + ...((extra?.renderOptions as object) ?? {}), + }, + ...(extra?.jobId ? { jobId: extra.jobId as string } : {}), + ...(extra?.batchId ? { batchId: extra.batchId as string } : {}), + }); + + test('one job renders each type icon once and reuses it', async function (assert) { + let jobId = 'icon-memo-reuse.1'; + let first = (await indexVisit('maple.json', { jobId })).response; + assert.notOk(first.card?.error, 'first visit completes cleanly'); + assert.ok( + first.card?.iconHTML?.startsWith(' Date: Mon, 13 Jul 2026 13:38:50 -0400 Subject: [PATCH 2/3] Drop the icon memo on affinity disposal Affinity disposal clears the batch-ownership entry, which makes the owner-matched memo clear in releaseBatch a no-op for a job whose affinity is disposed mid-run (cancel, idle eviction, capacity pressure). The onAffinityDisposed hook drops the icon memo along with the ownership entry so every disposal path releases it; a still-running job re-warms the memo with at most one icon render per type. Co-Authored-By: Claude Fable 5 --- packages/realm-server/prerender/prerenderer.ts | 8 ++++++++ .../realm-server/tests/prerendering-test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/realm-server/prerender/prerenderer.ts b/packages/realm-server/prerender/prerenderer.ts index b80b71bb90f..a406d2e47a0 100644 --- a/packages/realm-server/prerender/prerenderer.ts +++ b/packages/realm-server/prerender/prerenderer.ts @@ -110,6 +110,14 @@ export class Prerenderer { `batch ownership cleared for ${affinityKey} due to affinity disposal`, ); } + // Drop the affinity's icon memo with the rest of its warm state. + // Disposal also clears the ownership entry above, which makes the + // owner-matched clear in `releaseBatch` a no-op for this affinity — + // without this, a job whose affinity is disposed mid-run (cancel, + // idle eviction, capacity pressure) would leave its memo behind + // until another job for the same realm replaced it. A still-running + // job just re-renders each type's icon once as the memo re-warms. + this.#renderRunner.clearIconMemo(affinityKey); }, }); this.#renderRunner = new RenderRunner({ diff --git a/packages/realm-server/tests/prerendering-test.ts b/packages/realm-server/tests/prerendering-test.ts index e97883f72af..ba6933371c1 100644 --- a/packages/realm-server/tests/prerendering-test.ts +++ b/packages/realm-server/tests/prerendering-test.ts @@ -7910,6 +7910,23 @@ module(basename(import.meta.filename), function () { ); }); + test('disposing the affinity drops the memo', async function (assert) { + await indexVisit('maple.json', { jobId: 'icon-memo-dispose.1' }); + assert.ok( + prerenderer.getIconMemo(affinityKey), + 'memo established by the visit', + ); + await prerenderer.disposeAffinity({ + affinityType: 'realm', + affinityValue: realmURL, + }); + assert.strictEqual( + prerenderer.getIconMemo(affinityKey), + undefined, + 'affinity disposal drops the memo with the rest of the warm state', + ); + }); + test('a clearCache visit bypasses the memo read and releaseBatch drops the memo', async function (assert) { let jobId = 'icon-memo-clear.1'; let batchId = 'icon-memo-clear-batch'; From 3335610bfc77db62c3500e2fb220f019b1113636 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 13 Jul 2026 14:45:33 -0400 Subject: [PATCH 3/3] Short-circuit the index card pass when its meta entry errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A meta error leaves the page on the error route, so the icon render that follows would wait on fresh output the page can no longer produce, ride out the full render timeout, and evict the tab — a 60s+ stall for every card whose meta errors deterministically, such as a computed that reads a broken link. Mirror the entry-error semantics of the html-route visits: stop the pass at the failed entry and land the row as an error row with no icon. Co-Authored-By: Claude Fable 5 --- .../realm-server/prerender/render-runner.ts | 9 +++ .../realm-server/tests/prerendering-test.ts | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/packages/realm-server/prerender/render-runner.ts b/packages/realm-server/prerender/render-runner.ts index ab25398a919..b917494b3a7 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -1282,6 +1282,15 @@ export class RenderRunner { ); if (metaResult !== undefined) { meta = metaResult; + } else { + // The entry step failed, so the page is showing the error + // route: a subsequent capture would wait on fresh render + // output the page can no longer produce and ride out the + // full render timeout, evicting the tab. Skip the icon — + // the row is an error row either way. (A card whose meta + // errors deterministically — e.g. a computed that reads a + // broken link — takes this path on every visit.) + cardShortCircuit = true; } if (!cardShortCircuit) { let iconMemo = this.#iconMemoFor( diff --git a/packages/realm-server/tests/prerendering-test.ts b/packages/realm-server/tests/prerendering-test.ts index ba6933371c1..fae5379e131 100644 --- a/packages/realm-server/tests/prerendering-test.ts +++ b/packages/realm-server/tests/prerendering-test.ts @@ -7399,6 +7399,37 @@ module(basename(import.meta.filename), function () { }, }, }, + 'owner.gts': ` + import { CardDef, StringField, field, contains, linksTo } from '@cardstack/base/card-api'; + import { Person } from './person'; + export class Owner extends CardDef { + static displayName = "Owner"; + @field friend = linksTo(Person); + @field friendName = contains(StringField, { + computeVia: function() { + return this.friend.name; + } + }); + } + `, + 'stray.json': { + data: { + attributes: {}, + relationships: { + friend: { + links: { + self: 'http://localhost:9000/link-to-nowhere', + }, + }, + }, + meta: { + adoptsFrom: { + module: rri('./owner'), + name: 'Owner', + }, + }, + }, + }, }, }, ], @@ -7910,6 +7941,32 @@ module(basename(import.meta.filename), function () { ); }); + test('a meta error short-circuits the pass and skips the icon render', async function (assert) { + // `stray.json`'s `friendName` computed reads a link whose target + // never loads, so the meta entry errors on every visit. The pass + // must stop there: driving the icon render against the error + // route would wait out the full render timeout and evict the tab. + let { response, pool } = await indexVisit('stray.json', { + jobId: 'icon-memo-meta-error.1', + }); + assert.ok( + response.card?.error, + `card error captured: ${JSON.stringify( + response.card?.error?.error?.message ?? null, + )}`, + ); + assert.strictEqual( + response.card?.iconHTML, + null, + 'icon render is skipped once the meta entry has errored', + ); + assert.false( + pool.timedOut, + 'the visit completes without a render timeout', + ); + assert.false(pool.evicted, 'the page stays usable'); + }); + test('disposing the affinity drops the memo', async function (assert) { await indexVisit('maple.json', { jobId: 'icon-memo-dispose.1' }); assert.ok(