From 95e988ef5065cd6f0a79fa6a06cb1181fdf62f5a Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 18 May 2026 14:55:44 -0400 Subject: [PATCH 1/3] Indexer: pre-warm every realm .gts/.gjs module, not just per-row deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `preWarmModulesTable` previously walked each invalidation's `boxel_index.deps` and added any executable URLs it found. That misses modules referenced by *string* in card templates — a typical pattern in dashboard cards: `cohort.gts` is referenced as a string parameter to the search filter, never imported into the dashboard's runtime module graph. So it never appears in the dashboard row's `deps`, never gets pre-warmed, and when the dashboard renders during indexing the `_federated-search` call fires a same-affinity `prerenderModule` for `cohort.gts` mid-card- render. That sub-prerender queues behind the dashboard's tab — the self-referential prerender deadlock. Add a realm-wide sweep on top of the existing per-row deps walk. Source is the filesystem-mtimes map: from-scratch already pays for the walk via `discoverInvalidations`, so we just filter and reuse it; incremental adds a fresh `reader.mtimes()` call before pre-warm (typical ~200 ms on a 500-file realm, one call per job, completely dominated by the indexing work that follows). Filter to `.gts` + `.gjs` only — cards can only live in template-bearing modules, so `.ts`/`.js` helpers are correctly excluded from the realm-wide layer. Helpers that ARE needed by a card already get pre-warmed through the existing per-row deps walk; the realm-wide layer is purely additive on top. Adds `hasCardExtension` / `cardExtensions` next to `hasExecutableExtension` / `executableExtensions` in `runtime-common/index.ts`, same shape — `.gts` and `.gjs` are the extensions a `CardDef` / `FieldDef` can live in because they're the ones with a Glimmer template surface. The modules cache treats `definitions: {}` (an empty result from a non-card module — e.g. a rare `.gts` helper) as a valid cache hit at `definition-lookup.ts:561-563`. So the worst case is one wasted prerender per non-card `.gts` per `clearRealmCache` cycle; subsequent lookups skip the prerender entirely. That contract is pinned by `getModuleCacheEntry caches non-card modules as no-card markers` in PR A. Cost analysis for ambitious-piranha (the staging realm where the deadlock fired): 9 `.gts` files + 9 `.ts` files = 18 executables, but only 9 `.gts` files in the realm-wide sweep (the `.ts` files are `bxl/*` helpers). Pre-warm is still serial (concurrency comes in a follow-up PR after baseline measurement). Roughly ~200 ms per file × 9 files = ~1.8 s additional pre-warm cost per from-scratch reindex. Stacked on top of PR #4863 (PagePool tab-materialization + priority plumbing). The two changes attack the same bug from different angles: PR #4863 makes sure the deadlock cannot happen when a sub-prerender DOES fire; this PR makes sure it almost never has to fire in the first place. PR #4863 is the load-bearing fix; this is the preventive layer. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/runtime-common/index-runner.ts | 47 +++++++++++++++++++++---- packages/runtime-common/index.ts | 16 +++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/runtime-common/index-runner.ts b/packages/runtime-common/index-runner.ts index a38c892378b..34266c67e62 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,26 @@ 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 that are referenced by *string* in templates (e.g. + // ``) + // and so do not appear in any instance's runtime `deps`. Those + // modules are exactly what `_federated-search` needs the modules- + // table cache to be warm for; without this layer the search fires a + // same-affinity `prerenderModule` mid-card-render and risks the + // self-referential prerender deadlock. + 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 +618,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 2a0e21c7a6a..914c6c19a8c 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -645,6 +645,13 @@ export * from './pr-manifest'; export * from './file-def-code-ref'; export const executableExtensions = ['.js', '.gjs', '.ts', '.gts']; +// Card / field definitions live in template-bearing modules — `.gts` +// (Glimmer + TS) or `.gjs` (Glimmer + JS) — since `CardDef` / +// `FieldDef` components need a Glimmer template surface. Plain `.ts` +// and `.js` cannot host a card definition and so are excluded from +// the realm-wide pre-warm sweep that primes the modules cache before +// the visit loop. +export const cardExtensions = ['.gts', '.gjs']; export { createResponse } from './create-response'; export * from './db-queries/db-types'; @@ -1007,6 +1014,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 { From 9a40c88e1ad8af810f6de07c58d7cf6160c37fe7 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 18 May 2026 15:59:41 -0400 Subject: [PATCH 2/3] Broaden pre-warm sweep to all executables: .ts/.js can host CardDef too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the `hasCardExtension` helper added in the previous commit. Copilot's review caught a real case I'd missed: `packages/catalog-realm/commands/collect-submission-files.ts` defines `class CollectSubmissionFilesInput extends CardDef` — a card-defining `.ts` file, no Glimmer template. The `.gts`/`.gjs` extension filter would have skipped it on every realm-wide pre-warm sweep, and any `_federated-search` filter referencing it as a `type:` would have fired a same-affinity `prerenderModule` mid-card-render — the exact deadlock pattern this PR aims to prevent. Fix: realm-wide sweep now uses the existing `hasExecutableExtension` (`.gts` / `.gjs` / `.ts` / `.js`, excluding `.d.ts`). Non-card modules pre-warmed by the sweep persist to the modules table as empty- definitions rows (no-card markers — `definition-lookup.ts:561-563`), so subsequent `getCachedDefinitions` / `lookupDefinition` calls short-circuit at the cache without re-firing the prerenderer. That contract is pinned by the `getCachedDefinitions caches non-card modules as no-card markers` test in PR #4863. Variable renamed `allRealmCardModules` → `allRealmExecutables` and comments updated. The `hasCardExtension` helper + `cardExtensions` constant are removed entirely. Cost analysis update: ambitious-piranha goes from 9 candidates (just the `.gts` files) to 18 (9 `.gts` + 9 `.ts` helpers in `bxl/`). At ~200 ms per prerender × 18 modules with serial pre-warm = ~3.6 s additional cost on top of the from-scratch reindex. After the first sweep, the modules table caches the helper rows; subsequent reindexes only pay for modules that actually have card defs. Bounded. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/runtime-common/index-runner.ts | 72 ++++++++++++++++--------- packages/runtime-common/index.ts | 16 ------ 2 files changed, 46 insertions(+), 42 deletions(-) diff --git a/packages/runtime-common/index-runner.ts b/packages/runtime-common/index-runner.ts index 752f8bdb19b..da5e75a8985 100644 --- a/packages/runtime-common/index-runner.ts +++ b/packages/runtime-common/index-runner.ts @@ -8,7 +8,6 @@ import { Memoize } from 'typescript-memoize'; import { logger, - hasCardExtension, hasExecutableExtension, SupportedMimeType, jobIdentity, @@ -205,16 +204,26 @@ export class IndexRunner { 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 + // most modules used during a from-scratch pass) with a realm-wide + // sweep of every executable file in the realm — `.gts` / `.gjs` + // (cards with Glimmer templates) and `.ts` / `.js` (cards without + // templates, e.g. command-input cards in catalog-realm; and pure + // helpers). The realm-wide sweep catches sibling modules referenced + // by string in card templates, the typical // `` - // pattern). The filesystem-mtimes walk was already paid by + // pattern that no instance's runtime `deps` will capture. Non-card + // modules pre-warmed by this sweep persist to the modules table as + // empty-definitions rows (no-card markers — see + // `definition-lookup.ts:561-563`), so subsequent + // `lookupDefinition` / `getCachedDefinitions` calls short-circuit + // at the cache without re-firing the prerenderer. + // + // The filesystem-mtimes walk was already paid by // discoverInvalidations above; we just filter and reuse it. - let allRealmCardModules = Object.keys( + let allRealmExecutables = Object.keys( discoverResult.filesystemMtimes, - ).filter(hasCardExtension); - await current.preWarmModulesTable(invalidations, allRealmCardModules); + ).filter(hasExecutableExtension); + await current.preWarmModulesTable(invalidations, allRealmExecutables); let resumedRows = current.batch.resumedRows; let resumedSkipped = 0; current.#onProgress?.({ @@ -365,14 +374,17 @@ export class IndexRunner { } current.#scheduleClearCacheForNextRender(); } - // 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. + // Pre-warm: combine per-row deps with a realm-wide sweep of every + // executable file in the realm. 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. Non-card modules pre-warmed by this + // sweep persist to the modules table as empty-definitions rows + // (no-card markers) and short-circuit subsequent lookups. let incrementalMtimes = await current.#reader.mtimes(); - let allRealmCardModules = - Object.keys(incrementalMtimes).filter(hasCardExtension); - await current.preWarmModulesTable(invalidations, allRealmCardModules); + let allRealmExecutables = + Object.keys(incrementalMtimes).filter(hasExecutableExtension); + await current.preWarmModulesTable(invalidations, allRealmExecutables); let hrefs = urls.map((u) => u.href); let resumedRows = current.batch.resumedRows; @@ -588,23 +600,31 @@ export class IndexRunner { // module. private async preWarmModulesTable( invalidations: URL[], - allRealmCardModules: string[] = [], + allRealmExecutables: string[] = [], ): Promise { - if (invalidations.length === 0 && allRealmCardModules.length === 0) { + if (invalidations.length === 0 && allRealmExecutables.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 that are referenced by *string* in templates (e.g. + // Base layer: every executable 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. // ``) - // and so do not appear in any instance's runtime `deps`. Those - // modules are exactly what `_federated-search` needs the modules- - // table cache to be warm for; without this layer the search fires a - // same-affinity `prerenderModule` mid-card-render and risks the - // self-referential prerender deadlock. - let toWarm = new Set(allRealmCardModules); + // — those don't appear in any instance's runtime `deps`. They're + // exactly what `_federated-search` needs the modules-table cache + // to be warm for; without this layer the search fires a same- + // affinity `prerenderModule` mid-card-render and risks the self- + // referential prerender deadlock. + // + // Includes `.ts` / `.js` files because card definitions can live + // there too (e.g. `catalog-realm/commands/collect-submission-files.ts` + // — `class CollectSubmissionFilesInput extends CardDef`). Non-card + // helpers in `.ts` / `.js` pre-warmed here persist as empty- + // definitions rows in the modules table (no-card markers — see + // `definition-lookup.ts:561-563`); subsequent lookups short-circuit + // at the cache without re-firing the prerenderer. + let toWarm = new Set(allRealmExecutables); let hrefs = invalidations.map((u) => u.href); let existingRows = await this.batch.getDependencyRows(hrefs); diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 914c6c19a8c..2a0e21c7a6a 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -645,13 +645,6 @@ export * from './pr-manifest'; export * from './file-def-code-ref'; export const executableExtensions = ['.js', '.gjs', '.ts', '.gts']; -// Card / field definitions live in template-bearing modules — `.gts` -// (Glimmer + TS) or `.gjs` (Glimmer + JS) — since `CardDef` / -// `FieldDef` components need a Glimmer template surface. Plain `.ts` -// and `.js` cannot host a card definition and so are excluded from -// the realm-wide pre-warm sweep that primes the modules cache before -// the visit loop. -export const cardExtensions = ['.gts', '.gjs']; export { createResponse } from './create-response'; export * from './db-queries/db-types'; @@ -1014,15 +1007,6 @@ 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 { From 8bff6681c97fe40c841b532980a18cee90d6fef3 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Mon, 18 May 2026 16:07:01 -0400 Subject: [PATCH 3/3] Restore `.gts`/`.gjs`-only pre-warm sweep with corrected rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the previous "broaden to all executables" commit. The case that broadening fixed — `.ts` files that host CardDef (rare; the only example in the repo today is command-input cards) — is handled correctly by the on-demand `lookupDefinition` read-through during the visit. The PagePool's new 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), so the cost trade favors the narrow sweep: - Realm-wide pre-warm of `.gts` / `.gjs` only: ~9 prerenders × ~200 ms on a typical card-heavy realm. - Broader sweep including `.ts` / `.js` helpers: ~18+ prerenders, most of them producing no-card-marker rows for files that almost never define a card. This is an optimization on every reindex, not a correctness change. Comments updated to call out the trade-off honestly: `.ts` / `.js` CAN host card defs, the narrow filter exists because the on-demand path is now safe. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/runtime-common/index-runner.ts | 82 +++++++++++-------------- packages/runtime-common/index.ts | 22 +++++++ 2 files changed, 59 insertions(+), 45 deletions(-) diff --git a/packages/runtime-common/index-runner.ts b/packages/runtime-common/index-runner.ts index da5e75a8985..1e06d505872 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, @@ -204,26 +205,16 @@ export class IndexRunner { invalidations, ); // Pre-warm the modules cache. Combines per-row deps (which catch - // most modules used during a from-scratch pass) with a realm-wide - // sweep of every executable file in the realm — `.gts` / `.gjs` - // (cards with Glimmer templates) and `.ts` / `.js` (cards without - // templates, e.g. command-input cards in catalog-realm; and pure - // helpers). The realm-wide sweep catches sibling modules referenced - // by string in card templates, the typical + // 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 that no instance's runtime `deps` will capture. Non-card - // modules pre-warmed by this sweep persist to the modules table as - // empty-definitions rows (no-card markers — see - // `definition-lookup.ts:561-563`), so subsequent - // `lookupDefinition` / `getCachedDefinitions` calls short-circuit - // at the cache without re-firing the prerenderer. - // - // The filesystem-mtimes walk was already paid by + // pattern). The filesystem-mtimes walk was already paid by // discoverInvalidations above; we just filter and reuse it. - let allRealmExecutables = Object.keys( + let allRealmCardModules = Object.keys( discoverResult.filesystemMtimes, - ).filter(hasExecutableExtension); - await current.preWarmModulesTable(invalidations, allRealmExecutables); + ).filter(hasCardExtension); + await current.preWarmModulesTable(invalidations, allRealmCardModules); let resumedRows = current.batch.resumedRows; let resumedSkipped = 0; current.#onProgress?.({ @@ -374,17 +365,14 @@ export class IndexRunner { } current.#scheduleClearCacheForNextRender(); } - // Pre-warm: combine per-row deps with a realm-wide sweep of every - // executable file in the realm. 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. Non-card modules pre-warmed by this - // sweep persist to the modules table as empty-definitions rows - // (no-card markers) and short-circuit subsequent lookups. + // 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 allRealmExecutables = - Object.keys(incrementalMtimes).filter(hasExecutableExtension); - await current.preWarmModulesTable(invalidations, allRealmExecutables); + let allRealmCardModules = + Object.keys(incrementalMtimes).filter(hasCardExtension); + await current.preWarmModulesTable(invalidations, allRealmCardModules); let hrefs = urls.map((u) => u.href); let resumedRows = current.batch.resumedRows; @@ -600,31 +588,35 @@ export class IndexRunner { // module. private async preWarmModulesTable( invalidations: URL[], - allRealmExecutables: string[] = [], + allRealmCardModules: string[] = [], ): Promise { - if (invalidations.length === 0 && allRealmExecutables.length === 0) { + if (invalidations.length === 0 && allRealmCardModules.length === 0) { return; } let preWarmStart = Date.now(); - // Base layer: every executable 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. + // 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`. They're - // exactly what `_federated-search` needs the modules-table cache - // to be warm for; without this layer the search fires a same- - // affinity `prerenderModule` mid-card-render and risks the self- - // referential prerender deadlock. + // — 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. // - // Includes `.ts` / `.js` files because card definitions can live - // there too (e.g. `catalog-realm/commands/collect-submission-files.ts` - // — `class CollectSubmissionFilesInput extends CardDef`). Non-card - // helpers in `.ts` / `.js` pre-warmed here persist as empty- - // definitions rows in the modules table (no-card markers — see - // `definition-lookup.ts:561-563`); subsequent lookups short-circuit - // at the cache without re-firing the prerenderer. - let toWarm = new Set(allRealmExecutables); + // `.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); diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 2a0e21c7a6a..11ff54faa2f 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 {