From 8b18530d1a9165066f9f908d8f6721ad73be31a2 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 16 Oct 2025 22:03:45 -0400 Subject: [PATCH 01/15] Refactor /render route model loading - waiting for linked fields to load only _after_ /render/html templates have been rendered - more efficient render options - improve error msg settling - all node indexing test for headless chrome passing --- packages/base/card-api.gts | 7 +- .../host/app/components/card-prerender.gts | 32 +- packages/host/app/lib/current-run.ts | 55 +- packages/host/app/lib/window-error-handler.ts | 14 +- packages/host/app/routes/render.ts | 297 ++++++++-- packages/host/app/routes/render/meta.ts | 5 +- .../host/app/services/render-error-state.ts | 24 +- packages/host/app/templates/render.gts | 4 +- packages/host/app/templates/render/error.gts | 8 +- .../tests/acceptance/prerender-html-test.gts | 2 +- .../tests/acceptance/prerender-meta-test.gts | 2 +- packages/host/tests/helpers/index.gts | 67 ++- .../realm-server/prerender/prerender-app.ts | 12 + packages/realm-server/prerender/utils.ts | 104 +++- .../tests/headless-chrome-indexing-test.ts | 520 +++++++----------- .../realm-server/tests/prerendering-test.ts | 96 +++- .../runtime-common/render-route-options.ts | 11 +- 17 files changed, 789 insertions(+), 471 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 28e72019728..536715f673f 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -280,6 +280,9 @@ export function instanceOf(instance: BaseDef, clazz: typeof BaseDef): boolean { class Logger { private promises: Promise[] = []; + // TODO this doesn't look like it's used anymore. in the past this was used to + // keep track of async when eagerly running computes after a property had been set. + // consider removing this. log(promise: Promise) { this.promises.push(promise); // make an effort to resolve the promise at the time it is logged @@ -3047,9 +3050,9 @@ export function setCardAsSavedForTest(instance: CardDef, id?: string): void { instance[isSavedInstance] = true; } -export async function searchDoc( +export function searchDoc( instance: InstanceType, -): Promise> { +): Record { return getQueryableValue(instance.constructor, instance) as Record< string, any diff --git a/packages/host/app/components/card-prerender.gts b/packages/host/app/components/card-prerender.gts index 2d1ec7c46dd..a24fd29e1b9 100644 --- a/packages/host/app/components/card-prerender.gts +++ b/packages/host/app/components/card-prerender.gts @@ -32,6 +32,7 @@ import { import { CurrentRun } from '../lib/current-run'; +import type LoaderService from '../services/loader-service'; import type LocalIndexer from '../services/local-indexer'; import type NetworkService from '../services/network'; import type RenderService from '../services/render-service'; @@ -47,8 +48,9 @@ export default class CardPrerender extends Component { @service private declare renderService: RenderService; @service private declare fastboot: { isFastBoot: boolean }; @service private declare localIndexer: LocalIndexer; + @service private declare loaderService: LoaderService; #nonce = 0; - #shouldResetStoreForNextRender = true; + #shouldClearCacheForNextRender = true; #renderBasePath(url: string, renderOptions?: RenderRouteOptions) { let optionsSegment = encodeURIComponent( @@ -128,17 +130,21 @@ export default class CardPrerender extends Component { this.#nonce++; this.localIndexer.renderError = undefined; this.localIndexer.prerenderStatus = 'loading'; - let shouldResetStore = this.#consumeResetStoreForRender( - renderOptions?.resetStore === true, + let shouldClearCache = this.#consumeClearCacheForRender( + Boolean(renderOptions?.clearCache), ); let initialRenderOptions: RenderRouteOptions = { ...(renderOptions ?? {}), }; - if (shouldResetStore) { - initialRenderOptions.resetStore = true; + if (shouldClearCache) { + initialRenderOptions.clearCache = true; + this.loaderService.resetLoader({ + clearFetchCache: true, + reason: 'card-prerender clearCache', + }); this.store.resetCache(); } else { - delete initialRenderOptions.resetStore; + delete initialRenderOptions.clearCache; } let error: RenderError | undefined; let isolatedHTML: string | null = null; @@ -452,14 +458,14 @@ export default class CardPrerender extends Component { } } - #consumeResetStoreForRender(requestedReset = false): boolean { - if (requestedReset) { - this.#shouldResetStoreForNextRender = true; + #consumeClearCacheForRender(requestedClear = false): boolean { + if (requestedClear) { + this.#shouldClearCacheForNextRender = true; } - if (!this.#shouldResetStoreForNextRender) { + if (!this.#shouldClearCacheForNextRender) { return false; } - this.#shouldResetStoreForNextRender = false; + this.#shouldClearCacheForNextRender = false; return true; } @@ -484,8 +490,8 @@ function getRunnerOpts(optsId: number): RunnerOpts { } function omitOneTimeOptions(options: RenderRouteOptions): RenderRouteOptions { - if (options.includesCodeChange === true || options.resetStore === true) { - let { includesCodeChange: _ic, resetStore: _rs, ...rest } = options; + if (options.clearCache) { + let { clearCache: _clearCache, ...rest } = options; return rest as RenderRouteOptions; } return options; diff --git a/packages/host/app/lib/current-run.ts b/packages/host/app/lib/current-run.ts index 7d5e32fe160..65f7e0b596b 100644 --- a/packages/host/app/lib/current-run.ts +++ b/packages/host/app/lib/current-run.ts @@ -119,9 +119,8 @@ export class CurrentRun { definitionErrors: 0, totalIndexEntries: 0, }; - #hasCodeChangeForNextRender = false; + #shouldClearCacheForNextRender = true; #pendingLoaderReset = false; - #shouldResetStoreForNextRender = true; @service declare private loaderService: LoaderService; @service declare private network: NetworkService; @@ -244,15 +243,16 @@ export class CurrentRun { let invalidations = CurrentRun.#sortInvalidations( current.batch.invalidations.map((href) => new URL(href)), ); - if ( - !current.#hasCodeChangeForNextRender && - invalidations.some((url) => hasExecutableExtension(url.href)) - ) { - current.#hasCodeChangeForNextRender = true; - current.#pendingLoaderReset = true; - log.debug( - `${jobIdentity(current.#jobInfo)} detected executable invalidation, scheduling loader reset`, - ); + let hasExecutableInvalidation = invalidations.some((url) => + hasExecutableExtension(url.href), + ); + if (hasExecutableInvalidation) { + if (!current.#shouldClearCacheForNextRender) { + log.debug( + `${jobIdentity(current.#jobInfo)} detected executable invalidation, scheduling loader reset`, + ); + } + current.#scheduleClearCacheForNextRender(); } let hrefs = urls.map((u) => u.href); @@ -323,14 +323,19 @@ export class CurrentRun { } get hasCodeChange(): boolean { - return this.#hasCodeChangeForNextRender; + return this.#shouldClearCacheForNextRender; + } + + #scheduleClearCacheForNextRender() { + this.#shouldClearCacheForNextRender = true; + this.#pendingLoaderReset = true; } - #consumeHasCodeChangeForRender(): boolean { - if (!this.#hasCodeChangeForNextRender) { + #consumeClearCacheForRender(): boolean { + if (!this.#shouldClearCacheForNextRender) { return false; } - this.#hasCodeChangeForNextRender = false; + this.#shouldClearCacheForNextRender = false; return true; } @@ -345,14 +350,6 @@ export class CurrentRun { this.#pendingLoaderReset = false; } - #consumeResetStoreForRender(): boolean { - if (!this.#shouldResetStoreForNextRender) { - return false; - } - this.#shouldResetStoreForNextRender = false; - return true; - } - static #sortInvalidations(urls: URL[]): URL[] { if ((globalThis as any).__useHeadlessChromePrerender?.()) { return urls.sort((a, b) => { @@ -742,15 +739,9 @@ export class CurrentRun { if ((globalThis as any).__useHeadlessChromePrerender?.()) { let renderResult: RenderResponse | undefined; try { - let includesCodeChange = this.#consumeHasCodeChangeForRender(); - let shouldResetStore = this.#consumeResetStoreForRender(); + let shouldClearCache = this.#consumeClearCacheForRender(); let prerenderOptions: RenderRouteOptions | undefined = - includesCodeChange || shouldResetStore - ? { - ...(shouldResetStore ? { resetStore: true } : {}), - ...(includesCodeChange ? { includesCodeChange: true } : {}), - } - : undefined; + shouldClearCache ? { clearCache: true } : undefined; renderResult = await this.#prerenderer({ url: fileURL, realm: this.#realmURL.href, @@ -951,7 +942,7 @@ export class CurrentRun { }, }, }) as SingleCardDocument; - searchData = await api.searchDoc(card); + searchData = api.searchDoc(card); if (!searchData) { throw new Error( diff --git a/packages/host/app/lib/window-error-handler.ts b/packages/host/app/lib/window-error-handler.ts index 6068dd4cc0b..3a69cd18ed4 100644 --- a/packages/host/app/lib/window-error-handler.ts +++ b/packages/host/app/lib/window-error-handler.ts @@ -25,11 +25,15 @@ export function windowErrorHandler({ 'reason' in event ? (event as any).reason : (event as CustomEvent).detail?.reason; - if (!reason && 'message' in event && (event as ErrorEvent).message) { - reason = { - message: (event as ErrorEvent).message, - status: 500, - }; + if (!reason && event instanceof ErrorEvent) { + if (event.error) { + reason = event.error; + } else if (event.message) { + reason = { + message: event.message, + status: 500, + }; + } } // Coerce stringified JSON into objects so our type guards work if (typeof reason === 'string') { diff --git a/packages/host/app/routes/render.ts b/packages/host/app/routes/render.ts index a073752206d..4543a3517f1 100644 --- a/packages/host/app/routes/render.ts +++ b/packages/host/app/routes/render.ts @@ -1,8 +1,9 @@ +import type Controller from '@ember/controller'; import { action } from '@ember/object'; import Route from '@ember/routing/route'; import RouterService from '@ember/routing/router-service'; import Transition from '@ember/routing/transition'; -import { join } from '@ember/runloop'; +import { join, scheduleOnce } from '@ember/runloop'; import { service } from '@ember/service'; import { TrackedMap } from 'tracked-built-ins'; @@ -18,6 +19,7 @@ import { parseRenderRouteOptions, serializeRenderRouteOptions, } from '@cardstack/runtime-common'; +import { Deferred } from '@cardstack/runtime-common/deferred'; import { serializableError } from '@cardstack/runtime-common/error'; import type { CardDef } from 'https://cardstack.com/base/card-api'; @@ -34,7 +36,22 @@ import type RealmServerService from '../services/realm-server'; import type RenderErrorStateService from '../services/render-error-state'; import type StoreService from '../services/store'; -export type Model = { instance: CardDef; ready: boolean; nonce: string }; +type RenderStatus = 'loading' | 'ready' | 'error' | 'unusable'; + +export type Model = { + instance: CardDef; + nonce: string; + cardId: string; + readonly status: RenderStatus; + readonly ready: boolean; + readyPromise: Promise; +}; + +type ModelState = { + state: TrackedMap; + readyDeferred: Deferred; + isReady: boolean; +}; export default class RenderRoute extends Route { @service declare store: StoreService; @@ -49,6 +66,9 @@ export default class RenderRoute extends Route { private lastStoreResetKey: string | undefined; private renderBaseParams: [string, string, string] | undefined; private lastSerializedError: string | undefined; + #modelStates = new Map(); + #pendingReadyModels = new Set(); + #modelPromises = new Map>(); errorHandler = (event: Event) => { windowErrorHandler({ @@ -63,6 +83,7 @@ export default class RenderRoute extends Route { }, currentURL: this.router.currentURL, }); + this.#setAllModelStatuses('unusable'); (globalThis as any)._lazilyLoadLinks = undefined; (globalThis as any)._boxelRenderContext = undefined; }; @@ -85,6 +106,9 @@ export default class RenderRoute extends Route { this.renderBaseParams = undefined; this.lastSerializedError = undefined; this.renderErrorState.clear(); + this.#modelStates.clear(); + this.#pendingReadyModels.clear(); + this.#modelPromises.clear(); } beforeModel() { @@ -104,19 +128,38 @@ export default class RenderRoute extends Route { let parsedOptions = parseRenderRouteOptions(options); let canonicalOptions = serializeRenderRouteOptions(parsedOptions); this.#setupTransitionHelper(id, nonce, canonicalOptions); + let key = `${id}|${nonce}|${canonicalOptions}`; + let existing = this.#modelPromises.get(key); + if (existing) { + return await existing; + } + // the window.boxelTransitionTo() function helper first normalizes the base + // params by transitioning the router back to 'render' before it goes on to + // 'render.html', 'render.meta', etc. That’s why you see the /render model + // hook fire twice per prerender step: every format capture goes through a + // parent transition (render), then to the actual child route, so the parent + // model executes twice per prerender, hence the need to share the work. + let promise = this.#buildModel({ id, nonce }, parsedOptions); + this.#modelPromises.set(key, promise); + return await promise; + } + + async #buildModel( + { id, nonce }: { id: string; nonce: string }, + parsedOptions: ReturnType, + ): Promise { // Opt in to reading the in-progress index, as opposed to the last completed // index. This matters for any related cards that we will be loading, not // for our own card, which we're going to load directly from source. - let shouldResetLoader = parsedOptions.includesCodeChange === true; - if (shouldResetLoader) { + if (parsedOptions.clearCache) { this.loaderService.resetLoader({ clearFetchCache: true, - reason: 'render-route includesCodeChange', + reason: 'render-route clearCache', }); } this.loaderService.setIsIndexing(true); - if (parsedOptions.resetStore === true) { + if (parsedOptions.clearCache) { let resetKey = `${id}:${nonce}`; if (this.lastStoreResetKey !== resetKey) { this.store.resetCache(); @@ -137,17 +180,52 @@ export default class RenderRoute extends Route { let lastModified = new Date(response.headers.get('last-modified')!); let doc: LooseSingleCardDocument | CardErrorsJSONAPI = await response.json(); + let state = new TrackedMap(); + state.set('status', 'loading'); + + // the rendering of the templates is what pulls on the linked fields to load + // them. before the card templates are rendered there are no in-flight + // requests for linked fields. so in order to properly wait for the linked + // fields to load we must first render the /render/html route (preferably + // the isolated format), and then the store will start tracking the + // in-flight link requests. after the store settles the prerendered output + // will be ready to capture. this readyDeferred will let us know when the + // prerendered output is ready for capture. + let readyDeferred = new Deferred(); + let modelState: ModelState = { + state, + readyDeferred, + isReady: false, + }; + let canonicalId = id.replace(/\.json$/, ''); + let model: Model = { + instance: undefined as unknown as CardDef, + nonce, + cardId: canonicalId, + get status(): RenderStatus { + return (state.get('status') as RenderStatus) ?? 'loading'; + }, + get ready(): boolean { + return (state.get('status') as RenderStatus) === 'ready'; + }, + readyPromise: readyDeferred.promise, + }; + this.#modelStates.set(model, modelState); + let instance: CardDef | undefined; - if ('errors' in doc) { - throw new Error(JSON.stringify(doc.errors[0], null, 2)); - } else { + try { + if ('errors' in doc) { + this.#dispositionModel(model, 'error'); + throw new Error(JSON.stringify(doc.errors[0], null, 2)); + } + await this.realm.ensureRealmMeta(realmURL); let enhancedDoc: LooseSingleCardDocument = { ...doc, data: { ...doc.data, - id: id.replace(/\.json$/, ''), + id: canonicalId, type: 'card', meta: { ...doc.data.meta, @@ -163,24 +241,89 @@ export default class RenderRoute extends Route { realm: realmURL, doNotPersist: true, }); + model.instance = instance; + } catch (e: any) { + console.warn( + `Encountered error when deserializing doc for ${id}: ${e.message}: ${e.responseText}`, + ); + this.#dispositionModel(model, 'error'); + throw e; } - - let state = new TrackedMap(); - state.set('ready', false); await this.store.loaded(); - state.set('ready', true); + if (instance) { + model.instance = instance; + } + this.#scheduleReady(model); // this is to support in-browser rendering, where we actually don't have the // ability to lookup the parent route using RouterService.recognizeAndLoad() (globalThis as any).__renderInstance = instance; this.currentTransition = undefined; - return { - instance, - nonce, - get ready(): boolean { - return Boolean(state.get('ready')); - }, - }; + return model; + } + + setupController(controller: Controller, model: Model) { + super.setupController(controller, model); + this.#scheduleReady(model); + } + + #scheduleReady(model: Model) { + let modelState = this.#modelStates.get(model); + if (!modelState || modelState.isReady) { + return; + } + this.#pendingReadyModels.add(model); + scheduleOnce('afterRender', this, this.#processPendingReadyModels); + } + + #processPendingReadyModels() { + if (this.isDestroying || this.isDestroyed) { + this.#pendingReadyModels.clear(); + return; + } + for (let model of this.#pendingReadyModels) { + void this.#settleModelAfterRender(model).catch((error) => { + this.#dispositionModel(model, 'error'); + this.handleRenderError(error); + }); + } + this.#pendingReadyModels.clear(); + } + + async #settleModelAfterRender(model: Model): Promise { + let modelState = this.#modelStates.get(model); + if (!modelState || modelState.isReady) { + return; + } + await this.store.loaded(); + modelState.state.set('status', 'ready'); + modelState.isReady = true; + modelState.readyDeferred.fulfill(); + } + + #dispositionModel(model: Model, status: RenderStatus = 'error') { + let modelState = this.#modelStates.get(model); + if (!modelState) { + return; + } + this.#pendingReadyModels.delete(model); + modelState.state.set('status', status); + if (!modelState.isReady) { + modelState.isReady = true; + modelState.readyDeferred.fulfill(); + } + } + + #rejectAllModelStates(status: RenderStatus = 'error') { + for (let model of this.#modelStates.keys()) { + this.#dispositionModel(model, status); + } + } + + #setAllModelStatuses(status: RenderStatus) { + for (let model of this.#modelStates.keys()) { + this.#dispositionModel(model, status); + } } // Headless prerendering drives Ember via this hook, and it may repeatedly @@ -259,11 +402,31 @@ export default class RenderRoute extends Route { } else { error = errorOrEvent; } + this.#processRenderError(error, transition); + }; + + #processRenderError(error: any, transition?: Transition) { this.currentTransition?.abort(); - let serializedError: string; + this.#rejectAllModelStates('error'); + let serializedError = this.#serializeRenderError(error, transition); + if (serializedError === this.lastSerializedError) { + return; + } + this.lastSerializedError = serializedError; + let context = this.#deriveErrorContext(transition); + this.renderErrorState.setError({ + reason: serializedError, + cardId: context.cardId, + nonce: context.nonce, + }); + this.#applyErrorMetadata(context); + this.#transitionToErrorRoute(transition); + } + + #serializeRenderError(error: any, transition?: Transition): string { try { let cardError: CardError = JSON.parse(error.message); - serializedError = JSON.stringify( + return JSON.stringify( { type: 'error', error: cardError, @@ -271,7 +434,7 @@ export default class RenderRoute extends Route { null, 2, ); - } catch (e) { + } catch (_e) { let current: Transition['to'] | null = transition?.to; let id: string | undefined; do { @@ -281,26 +444,86 @@ export default class RenderRoute extends Route { } } while (current && !id); if (isCardError(error)) { - // Preserve full CardError details including deps for prerender indexing - serializedError = JSON.stringify( + return JSON.stringify( { type: 'error', error: serializableError(error) }, null, 2, ); - } else { - let errorJSONAPI = formattedError(id, error).errors[0]; - let errorPayload = errorJsonApiToErrorEntry(errorJSONAPI); - serializedError = JSON.stringify(errorPayload, null, 2); } + let errorJSONAPI = formattedError(id, error).errors[0]; + let errorPayload = errorJsonApiToErrorEntry(errorJSONAPI); + return JSON.stringify(errorPayload, null, 2); } - if (serializedError === this.lastSerializedError) { + } + + #deriveErrorContext(transition?: Transition): { + cardId?: string; + nonce?: string; + } { + let cardId: string | undefined; + let nonce: string | undefined; + let base = this.renderBaseParams; + if (base) { + cardId = this.#normalizeCardId(base[0]); + nonce = base[1]; + } + if ((!cardId || !nonce) && transition) { + let current: Transition['to'] | null = transition.to; + while (current) { + let params = current.params as Record | undefined; + if (params) { + if (!cardId && typeof params.id === 'string') { + cardId = this.#normalizeCardId(params.id); + } + if (!nonce && typeof params.nonce === 'string') { + nonce = params.nonce; + } + } + current = current.parent; + } + } + return { cardId, nonce }; + } + + #normalizeCardId(id: string): string { + try { + let decoded = decodeURIComponent(id); + return decoded.replace(/\.json$/, ''); + } catch { + return id.replace(/\.json$/, ''); + } + } + + #applyErrorMetadata(context: { cardId?: string; nonce?: string }) { + if (typeof document === 'undefined') { return; } - this.lastSerializedError = serializedError; - // Store the serialized error so the child render.error route can read it - // even though this transition abort prevents its usual model hook from - // running. - this.renderErrorState.setReason(serializedError); + let container = document.querySelector( + '[data-prerender]', + ) as HTMLElement | null; + if (container) { + container.dataset.prerenderStatus = 'error'; + if (context.cardId) { + container.dataset.prerenderId = context.cardId; + } + if (context.nonce) { + container.dataset.prerenderNonce = context.nonce; + } + } + let errorElement = document.querySelector( + '[data-prerender-error]', + ) as HTMLElement | null; + if (errorElement) { + if (context.cardId) { + errorElement.dataset.prerenderId = context.cardId; + } + if (context.nonce) { + errorElement.dataset.prerenderNonce = context.nonce; + } + } + } + + #transitionToErrorRoute(transition?: Transition) { let baseParams = this.renderBaseParams; if (baseParams) { if (transition) { @@ -321,5 +544,5 @@ export default class RenderRoute extends Route { } else { join(() => this.router.transitionTo('render.error')); } - }; + } } diff --git a/packages/host/app/routes/render/meta.ts b/packages/host/app/routes/render/meta.ts index 1e991136dd2..6df8f95e62f 100644 --- a/packages/host/app/routes/render/meta.ts +++ b/packages/host/app/routes/render/meta.ts @@ -36,7 +36,8 @@ export default class RenderMetaRoute extends Route { async model(_: unknown, transition: Transition) { let api = await this.cardService.getAPI(); - let parentModel = this.modelFor('render') as ParentModel; + let parentModel = this.modelFor('render') as ParentModel | undefined; + await parentModel?.readyPromise; let instance: CardDef; if (!parentModel) { // this is to support in-browser rendering, where we actually don't have the @@ -78,7 +79,7 @@ export default class RenderMetaRoute extends Route { let types = getTypes(Klass); let displayNames = getDisplayNames(Klass); - let searchDoc = await api.searchDoc(instance); + let searchDoc = api.searchDoc(instance); // Add a "pseudo field" to the search doc for the card type. We use the // "_" prefix to make a decent attempt to not pollute the userland // namespace for cards diff --git a/packages/host/app/services/render-error-state.ts b/packages/host/app/services/render-error-state.ts index b237f627add..6bdb422e7c7 100644 --- a/packages/host/app/services/render-error-state.ts +++ b/packages/host/app/services/render-error-state.ts @@ -4,19 +4,33 @@ import { tracked } from '@glimmer/tracking'; // Render route errors abort the parent transition, so the nested render.error // route may not receive params or run its model hook. This service preserves // the serialized error payload so the template can still display it. +interface RenderErrorContext { + reason: string; + cardId?: string; + nonce?: string; +} + export default class RenderErrorStateService extends Service { - @tracked private _reason: string | undefined; + @tracked private _context: RenderErrorContext | undefined; - setReason(reason: string) { - this._reason = reason; + setError(context: RenderErrorContext) { + this._context = context; } get reason(): string | undefined { - return this._reason; + return this._context?.reason; + } + + get cardId(): string | undefined { + return this._context?.cardId; + } + + get nonce(): string | undefined { + return this._context?.nonce; } clear() { - this._reason = undefined; + this._context = undefined; } } diff --git a/packages/host/app/templates/render.gts b/packages/host/app/templates/render.gts index 0c158640e17..b9a5233f5ea 100644 --- a/packages/host/app/templates/render.gts +++ b/packages/host/app/templates/render.gts @@ -7,9 +7,9 @@ import { Model } from '../routes/render'; const Render =