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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/host/app/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
48 changes: 48 additions & 0 deletions packages/host/app/routes/render/types.ts
Original file line number Diff line number Diff line change
@@ -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<Model> {
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 };
}
}
13 changes: 13 additions & 0 deletions packages/host/app/templates/render/types.gts
Original file line number Diff line number Diff line change
@@ -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(
<template>
<pre>{{stringify @model null 2}}</pre>
</template> satisfies TemplateOnlyComponent<{ Args: { model: Model } }>,
);
40 changes: 21 additions & 19 deletions packages/realm-server/prerender/render-runner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type PrerenderMeta,
type PrerenderTypes,
type RenderError,
type RenderResponse,
type ModuleRenderResponse,
Expand Down Expand Up @@ -30,6 +31,7 @@ import {
renderHTML,
renderIcon,
renderMeta,
renderTypes,
type RenderCapture,
type CaptureOptions,
type ModuleCapture,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1128,36 +1130,36 @@ 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<PrerenderMeta>(
'visit card render.meta (types)',
() => renderMeta(page, captureOptions),
let typesResult = await runTimedStep<PrerenderTypes>(
'visit card render.types',
() => renderTypes(page, captureOptions),
);
if (metaForTypesResult !== undefined) {
metaForTypes = metaForTypesResult;
if (typesResult !== undefined) {
typesForAncestors = typesResult;
}
Comment thread
habdelra marked this conversation as resolved.
}

if (!cardShortCircuit && metaForTypes.types) {
if (!cardShortCircuit && typesForAncestors.types) {
const ancestorSteps = [
{
name: 'visit card fitted render',
cb: () =>
renderAncestors(
page,
'fitted',
metaForTypes.types!,
typesForAncestors.types!,
captureOptions,
),
assign: (v: Record<string, string>) => {
Expand All @@ -1170,7 +1172,7 @@ export class RenderRunner {
renderAncestors(
page,
'embedded',
metaForTypes.types!,
typesForAncestors.types!,
captureOptions,
),
assign: (v: Record<string, string>) => {
Expand All @@ -1190,7 +1192,7 @@ export class RenderRunner {

if (!cardShortCircuit) {
let finalMetaResult = await runTimedStep<PrerenderMeta>(
'visit card render.meta (final)',
'visit card render.meta',
() => renderMeta(page, captureOptions),
);
if (finalMetaResult !== undefined) {
Expand Down
58 changes: 58 additions & 0 deletions packages/realm-server/prerender/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
delay,
logger,
type PrerenderMeta,
type PrerenderTypes,
type RenderError,
type RenderTimeoutDiagnostics,
} from '@cardstack/runtime-common';
Expand Down Expand Up @@ -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<PrerenderTypes | RenderError> {
log.debug(`renderTypes start url=${page.url()}`);
await transitionTo(page, 'render.types');
await waitForRoutePathSuffix(page, '/types', opts);
await waitForPrerenderSettle(page);
Comment thread
habdelra marked this conversation as resolved.
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,
Expand Down
14 changes: 14 additions & 0 deletions packages/runtime-common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading