diff --git a/packages/runtime-common/index-runner.ts b/packages/runtime-common/index-runner.ts index e7f1883a73..1e06d50587 100644 --- a/packages/runtime-common/index-runner.ts +++ b/packages/runtime-common/index-runner.ts @@ -8,6 +8,7 @@ import { Memoize } from 'typescript-memoize'; import { logger, + hasCardExtension, hasExecutableExtension, SupportedMimeType, jobIdentity, @@ -203,7 +204,17 @@ export class IndexRunner { await current.#dependencyResolver.orderInvalidationsByDependencies( invalidations, ); - await current.preWarmModulesTable(invalidations); + // Pre-warm the modules cache. Combines per-row deps (which catch + // most modules used during a from-scratch pass) with the realm- + // wide `.gts` / `.gjs` sweep (which catches sibling card modules + // referenced by string in templates — the typical + // `` + // pattern). The filesystem-mtimes walk was already paid by + // discoverInvalidations above; we just filter and reuse it. + let allRealmCardModules = Object.keys( + discoverResult.filesystemMtimes, + ).filter(hasCardExtension); + await current.preWarmModulesTable(invalidations, allRealmCardModules); let resumedRows = current.batch.resumedRows; let resumedSkipped = 0; current.#onProgress?.({ @@ -354,7 +365,14 @@ export class IndexRunner { } current.#scheduleClearCacheForNextRender(); } - await current.preWarmModulesTable(invalidations); + // Pre-warm: combine per-row deps with a realm-wide `.gts`/`.gjs` + // sweep. Incremental skips `discoverInvalidations` so the + // filesystem-mtimes walk hasn't happened yet — call it here. + // Typical realm sizes make this < 200 ms; one call per job. + let incrementalMtimes = await current.#reader.mtimes(); + let allRealmCardModules = + Object.keys(incrementalMtimes).filter(hasCardExtension); + await current.preWarmModulesTable(invalidations, allRealmCardModules); let hrefs = urls.map((u) => u.href); let resumedRows = current.batch.resumedRows; @@ -568,11 +586,38 @@ export class IndexRunner { // Failures here are warned but do not fail the batch — a mid-render // sub-prerender will still fire on demand if pre-warm misses a // module. - private async preWarmModulesTable(invalidations: URL[]): Promise { - if (invalidations.length === 0) { + private async preWarmModulesTable( + invalidations: URL[], + allRealmCardModules: string[] = [], + ): Promise { + if (invalidations.length === 0 && allRealmCardModules.length === 0) { return; } let preWarmStart = Date.now(); + + // Base layer: every `.gts` / `.gjs` file in the realm, regardless of + // whether it appears in this batch's invalidation set. Catches sibling + // card modules referenced by *string* in templates (e.g. + // ``) + // — those don't appear in any instance's runtime `deps`. Without + // this layer the search fires a same-affinity `prerenderModule` + // mid-card-render at lookup time, which is the wait-shape the + // PagePool's tab-materialization for module/command callers is + // meant to relieve. + // + // `.gts` / `.gjs` only is an optimization, not a correctness gate: + // `.ts` / `.js` files CAN host `CardDef` (e.g. command-input + // cards). If pre-warm misses such a module, the on-demand + // `lookupDefinition` read-through during the visit fires a + // `prerenderModule` for it — safe because the PagePool now + // materializes a tab for the sub-prerender instead of queueing it + // behind the render that triggered the lookup. Restricting the + // sweep to the extensions where cards live almost exclusively + // avoids paying the prerender cost on every reindex for files that + // rarely define a card (typical realms have many helper `.ts` + // files alongside their cards). + let toWarm = new Set(allRealmCardModules); + let hrefs = invalidations.map((u) => u.href); let existingRows = await this.batch.getDependencyRows(hrefs); let bestByUrl = new Map(); @@ -585,13 +630,15 @@ export class IndexRunner { } } - let toWarm = new Set(); let novelJsonUrls: URL[] = []; for (let url of invalidations) { // Module files in the invalidation set are deps that instances // in the same batch will consume — pre-warm them directly. This // covers from-scratch and atomic-update batches where most rows - // have no prior `deps` data yet. + // have no prior `deps` data yet. Unlike the realm-wide layer + // above, this includes `.ts` / `.js` helpers — only the ones the + // batch is actually touching, so cost is bounded by invalidation + // size rather than realm size. if (hasExecutableExtension(url.href)) { toWarm.add(url.href); } diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 2a0e21c7a6..11ff54faa2 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -645,6 +645,19 @@ export * from './pr-manifest'; export * from './file-def-code-ref'; export const executableExtensions = ['.js', '.gjs', '.ts', '.gts']; +// Extensions covered by the realm-wide pre-warm sweep that primes the +// modules cache before the visit loop. This is an optimization, not a +// correctness gate: a `.ts` / `.js` file CAN host a `CardDef` +// (e.g. command-input cards), and if pre-warm misses one the on-demand +// `lookupDefinition` cache read-through fires a `prerenderModule` for +// it during the visit. The PagePool's tab-materialization for +// module/command callers makes that on-demand path safe (the sub- +// prerender gets its own tab instead of queueing behind the render +// that triggered it). Restricting the sweep to `.gts` / `.gjs` — where +// cards live almost exclusively in practice — avoids paying the +// prerender cost on every index for a file type that rarely contains +// card definitions. +export const cardExtensions = ['.gts', '.gjs']; export { createResponse } from './create-response'; export * from './db-queries/db-types'; @@ -1007,6 +1020,15 @@ export function hasExecutableExtension(path: string): boolean { return false; } +export function hasCardExtension(path: string): boolean { + for (let extension of cardExtensions) { + if (path.endsWith(extension)) { + return true; + } + } + return false; +} + export function trimExecutableExtension( input: RealmResourceIdentifier, ): RealmResourceIdentifier {