diff --git a/packages/host/app/router.ts b/packages/host/app/router.ts index 5cdfab0ecb1..15affb8a005 100644 --- a/packages/host/app/router.ts +++ b/packages/host/app/router.ts @@ -12,6 +12,7 @@ Router.map(function () { this.route('html', { path: '/html/:format/:ancestor_level' }); this.route('icon'); this.route('meta'); + this.route('types'); this.route('file-extract'); this.route('error'); }); diff --git a/packages/host/app/routes/render/types.ts b/packages/host/app/routes/render/types.ts new file mode 100644 index 00000000000..717bd3548c2 --- /dev/null +++ b/packages/host/app/routes/render/types.ts @@ -0,0 +1,48 @@ +import Route from '@ember/routing/route'; +import type Transition from '@ember/routing/transition'; + +import { + internalKeyFor, + type PrerenderTypes, + type RenderError, +} from '@cardstack/runtime-common'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import { getClass, getTypes } from './meta'; + +import type { Model as ParentModel } from '../render'; + +export type Model = PrerenderTypes | RenderError | undefined; + +// Lightweight sibling of render.meta. The runner needs the ancestor +// type chain to drive the fitted/embedded format renders, but those +// renders are also what mark linksTo / linksToMany fields as "used" +// so the final render.meta's search doc walks them. Running a full +// serializeCard + searchDoc here just to read the type list paid for +// a duplicate traversal — this route returns only the type chain so +// the heavy work happens exactly once, after the format renders. +export default class RenderTypesRoute extends Route { + async model(_: unknown, transition: Transition) { + let parentModel = this.modelFor('render') as ParentModel | undefined; + // the global use below is to support in-browser rendering, where we + // actually don't have the ability to lookup the parent route using + // RouterService.recognizeAndLoad() + let renderModel = + parentModel ?? + ((globalThis as any).__renderModel as ParentModel | undefined); + await renderModel?.readyPromise; + let instance: CardDef | undefined = renderModel?.instance; + + if (!instance) { + // the lack of an instance is dealt with in the parent route + transition.abort(); + return; + } + + let Klass = getClass(instance); + let types = getTypes(Klass).map((t) => internalKeyFor(t, undefined)); + + return { types }; + } +} diff --git a/packages/host/app/templates/render/types.gts b/packages/host/app/templates/render/types.gts new file mode 100644 index 00000000000..9a6cc493f3d --- /dev/null +++ b/packages/host/app/templates/render/types.gts @@ -0,0 +1,13 @@ +import type { TemplateOnlyComponent } from '@ember/component/template-only'; + +import RouteTemplate from 'ember-route-template'; + +import type { Model } from '../../routes/render/types'; + +const { stringify } = JSON; + +export default RouteTemplate( + satisfies TemplateOnlyComponent<{ Args: { model: Model } }>, +); diff --git a/packages/realm-server/prerender/render-runner.ts b/packages/realm-server/prerender/render-runner.ts index 9f73d7963cc..ff4c3ea663e 100644 --- a/packages/realm-server/prerender/render-runner.ts +++ b/packages/realm-server/prerender/render-runner.ts @@ -1,5 +1,6 @@ import { type PrerenderMeta, + type PrerenderTypes, type RenderError, type RenderResponse, type ModuleRenderResponse, @@ -30,6 +31,7 @@ import { renderHTML, renderIcon, renderMeta, + renderTypes, type RenderCapture, type CaptureOptions, type ModuleCapture, @@ -1082,7 +1084,7 @@ export class RenderRunner { types: null, }; let meta: PrerenderMeta = emptyMeta; - let metaForTypes: PrerenderMeta = emptyMeta; + let typesForAncestors: PrerenderTypes = { types: null }; let headHTML: string | null = null; let atomHTML: string | null = null; let iconHTML: string | null = null; @@ -1128,28 +1130,28 @@ export class RenderRunner { } } - // Two render.meta calls. The first extracts `meta.types` for - // the ancestor renders below; the second captures the final - // serialized + searchDoc payload. The two are not duplicate - // work: the ancestor renders that run in between cause - // fitted/embedded format reads to load + mark linksTo / - // linksToMany fields as "used", which the final renderMeta's - // queryableValue then includes in the search doc. Collapsing - // these into one call breaks the isUsed-via-non-isolated-render - // contract that + // First pass is the lightweight /types route — just the type + // chain the ancestor renders below need. The full render.meta + // (serialized + searchDoc + deps + displayNames) runs once + // afterwards, because the fitted/embedded ancestor renders are + // what mark linksTo / linksToMany fields as "used"; the final + // renderMeta's queryableValue then includes those linked fields + // in the search doc. Running render.meta before the ancestor + // renders breaks the isUsed-via-non-isolated-render contract + // that // `non-isolated formats render linked fields and those links appear in search doc` // covers. if (!cardShortCircuit) { - let metaForTypesResult = await runTimedStep( - 'visit card render.meta (types)', - () => renderMeta(page, captureOptions), + let typesResult = await runTimedStep( + 'visit card render.types', + () => renderTypes(page, captureOptions), ); - if (metaForTypesResult !== undefined) { - metaForTypes = metaForTypesResult; + if (typesResult !== undefined) { + typesForAncestors = typesResult; } } - if (!cardShortCircuit && metaForTypes.types) { + if (!cardShortCircuit && typesForAncestors.types) { const ancestorSteps = [ { name: 'visit card fitted render', @@ -1157,7 +1159,7 @@ export class RenderRunner { renderAncestors( page, 'fitted', - metaForTypes.types!, + typesForAncestors.types!, captureOptions, ), assign: (v: Record) => { @@ -1170,7 +1172,7 @@ export class RenderRunner { renderAncestors( page, 'embedded', - metaForTypes.types!, + typesForAncestors.types!, captureOptions, ), assign: (v: Record) => { @@ -1190,7 +1192,7 @@ export class RenderRunner { if (!cardShortCircuit) { let finalMetaResult = await runTimedStep( - 'visit card render.meta (final)', + 'visit card render.meta', () => renderMeta(page, captureOptions), ); if (finalMetaResult !== undefined) { diff --git a/packages/realm-server/prerender/utils.ts b/packages/realm-server/prerender/utils.ts index b0c7ca15513..75fee29ccee 100644 --- a/packages/realm-server/prerender/utils.ts +++ b/packages/realm-server/prerender/utils.ts @@ -3,6 +3,7 @@ import { delay, logger, type PrerenderMeta, + type PrerenderTypes, type RenderError, type RenderTimeoutDiagnostics, } from '@cardstack/runtime-common'; @@ -198,6 +199,63 @@ export async function renderMeta( } } +// Lightweight first pass: the runner only needs the ancestor type chain +// to drive fitted/embedded format renders. Hitting /meta for that +// pulled in a full serializeCard + searchDoc walk we then threw away; +// /types returns just the type list. The full render.meta still runs +// after the format renders, where its searchDoc legitimately depends +// on the linksTo / linksToMany fields those renders marked as "used". +export async function renderTypes( + page: Page, + opts?: CaptureOptions, +): Promise { + log.debug(`renderTypes start url=${page.url()}`); + await transitionTo(page, 'render.types'); + await waitForRoutePathSuffix(page, '/types', opts); + await waitForPrerenderSettle(page); + log.debug(`renderTypes capture url=${page.url()}`); + let result = await captureResult(page, 'textContent', opts); + log.debug( + `renderTypes captured status=${result.status} id=${result.id} nonce=${result.nonce}`, + ); + if (result.status === 'error' || result.status === 'unusable') { + return renderCaptureToError(page, result, 'render.types'); + } + if (opts?.expectedId && result.id && result.id !== opts.expectedId) { + return buildInvalidRenderResponseError( + page, + `render.types captured stale prerender output for ${result.id} (expected ${opts.expectedId})`, + { title: 'Stale render response', evict: true }, + ); + } + if ( + opts?.expectedNonce && + result.nonce && + result.nonce !== opts.expectedNonce + ) { + return buildInvalidRenderResponseError( + page, + `render.types captured stale prerender output for nonce ${result.nonce} (expected ${opts.expectedNonce})`, + { title: 'Stale render response', evict: true }, + ); + } + try { + return JSON.parse(result.value) as PrerenderTypes; + } catch { + await page.evaluate(() => { + let el = document.querySelector('[data-prerender]') as HTMLElement; + console.log( + `capturing HTML for unknown types result\n${el.outerHTML.trim()}`, + ); + }); + return buildInvalidRenderResponseError( + page, + `render.types returned a non-JSON response: ${result.value}`, + { title: 'Invalid render types response' }, + ); + } +} + async function waitForRoutePathSuffix( page: Page, suffix: string, diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 17b578d747a..e0583cf273e 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -80,6 +80,20 @@ export interface PrerenderMeta { diagnostics?: PrerenderMetaDiagnostics; } +// Lightweight payload produced by the host app's render.types route. The +// runner needs the ancestor type list before the fitted/embedded format +// renders run, but those renders are what mark linksTo / linksToMany +// fields as "used"; running a full render.meta (with serializeCard + +// searchDoc) for that early type lookup paid the cost of one extra +// per-card traversal. /types returns just the type chain so the +// runner can drive ancestor renders without that extra walk; a single +// render.meta then runs after the fitted/embedded passes have populated +// the per-instance data bucket and the search doc picks up the linked +// fields the embedded template touched. +export interface PrerenderTypes { + types: string[] | null; +} + export interface RenderResponse extends PrerenderMeta { isolatedHTML: string | null; headHTML: string | null;