diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index eac330aeba4..98b16d9092a 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -497,11 +497,13 @@ export type GetSearchResourceFunc = ( ) => StoreSearchResource; export interface CardStore { - // The VirtualNetwork that owns this store's realm mappings, used for - // prefix/RRI resolution during (de)serialization. Required — every store - // implementation must supply one (production stores, test stubs, the - // FallbackCardStore). - virtualNetwork: VirtualNetwork; + // Resolve a (possibly relative or RRI) reference to a real, fetchable URL. + // Stores expose URL-resolution capability — never the VirtualNetwork object + // itself — so card code can satisfy boundaries that require a real URL (an + // ``, `new URL(...)`) without holding the network. Returns + // undefined when the reference can't be resolved (no network available, or + // an unresolvable reference) so callers can degrade to URL math. + resolveURL(reference: string, base?: string): URL | undefined; getCard(url: string): CardDef | undefined; getFileMeta(url: string): FileDef | undefined; setCard(url: string, instance: CardDef): void; @@ -4810,25 +4812,16 @@ function getStore(instance: BaseDef): CardStore { // real URL to a boundary that can't consume canonical RRI (an ``, the // AI source-file reader's `new URL(...)`) use this rather than reaching for the // VirtualNetwork object directly — they get back a URL, not the network itself. -// Resolves through the instance's own store's VirtualNetwork, which may carry -// realm mappings the module loader's does not (e.g. a card deserialized with an +// Resolves through the instance's own store, which may carry realm mappings +// the module loader's network does not (e.g. a card deserialized with an // explicit store). Returns undefined when none is available, or when the // reference can't be resolved, so callers can degrade to URL math. export function resolveInstanceURL( instance: CardDef, reference: string, ): URL | undefined { - let virtualNetwork: VirtualNetwork | undefined; try { - virtualNetwork = getStore(instance).virtualNetwork; - } catch { - return undefined; - } - if (!virtualNetwork) { - return undefined; - } - try { - return virtualNetwork.resolveURL( + return getStore(instance).resolveURL( reference, instance.id ?? instance[relativeTo], ); @@ -4867,16 +4860,33 @@ class FallbackCardStore implements CardStore { #inFlight: Set> = new Set(); #loadGeneration = 0; // mirrors host store tracking to detect new loads - get virtualNetwork(): VirtualNetwork { + #requireVirtualNetwork(): VirtualNetwork { let vn = myLoader().getVirtualNetwork(); if (!vn) { throw new Error( - `FallbackCardStore.virtualNetwork requires the active Loader to have a VirtualNetwork`, + `FallbackCardStore requires the active Loader to have a VirtualNetwork`, ); } return vn; } + resolveURL(reference: string, base?: string): URL | undefined { + let vn: VirtualNetwork | undefined; + try { + vn = myLoader().getVirtualNetwork(); + } catch { + return undefined; + } + if (!vn) { + return undefined; + } + try { + return vn.resolveURL(reference, base); + } catch { + return undefined; + } + } + getCard(id: string) { id = id.replace(/\.json$/, ''); return this.#instances.get(id); @@ -4935,7 +4945,7 @@ class FallbackCardStore implements CardStore { opts?: { dependencyTrackingContext?: RuntimeDependencyTrackingContext }, ) { trackRuntimeInstanceDependency(url, opts?.dependencyTrackingContext); - let promise = loadCardDocument(fetch, url, this.virtualNetwork); + let promise = loadCardDocument(fetch, url, this.#requireVirtualNetwork()); this.trackLoad(promise); return await promise; } @@ -4945,7 +4955,11 @@ class FallbackCardStore implements CardStore { opts?: { dependencyTrackingContext?: RuntimeDependencyTrackingContext }, ) { trackRuntimeFileDependency(url, opts?.dependencyTrackingContext); - let promise = loadFileMetaDocument(fetch, url, this.virtualNetwork); + let promise = loadFileMetaDocument( + fetch, + url, + this.#requireVirtualNetwork(), + ); this.trackLoad(promise); return await promise; } diff --git a/packages/host/app/lib/gc-card-store.ts b/packages/host/app/lib/gc-card-store.ts index 7a18e7dfb22..0d6cb0a7582 100644 --- a/packages/host/app/lib/gc-card-store.ts +++ b/packages/host/app/lib/gc-card-store.ts @@ -204,8 +204,12 @@ export default class CardStoreWithGarbageCollection implements CardStore { this.#storeHooks = storeHooks; } - get virtualNetwork(): VirtualNetwork { - return this.#virtualNetwork; + resolveURL(reference: string, base?: string): URL | undefined { + try { + return this.#virtualNetwork.resolveURL(reference, base); + } catch { + return undefined; + } } getCard(id: string): CardDef | undefined { diff --git a/packages/host/app/services/render-service.ts b/packages/host/app/services/render-service.ts index ca0c1465e83..b6b5975396c 100644 --- a/packages/host/app/services/render-service.ts +++ b/packages/host/app/services/render-service.ts @@ -67,8 +67,12 @@ export class CardStoreWithErrors implements CardStore { this.#virtualNetwork = virtualNetwork; } - get virtualNetwork(): VirtualNetwork { - return this.#virtualNetwork; + resolveURL(reference: string, base?: string): URL | undefined { + try { + return this.#virtualNetwork.resolveURL(reference, base); + } catch { + return undefined; + } } getCard(id: string): CardDef | undefined { diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 684565478be..6d27ba0ec3c 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -1991,6 +1991,15 @@ export default class StoreService extends Service implements StoreInterface { if (!resource.id) { throw new Error('resource must have an id'); } + // One-shot boundary canonicalization: search `item` resources carry the + // index's URL-form ids, while instance and file-meta GET responses arrive + // canonical (RRI prefix form for mapped realms). Fold the id to canonical + // form here so an instance's identity — and everything keyed off it, like + // the markdown pill slots — doesn't depend on which path hydrated it. + let canonicalId = this.network.virtualNetwork.unresolveURL(resource.id); + if (canonicalId !== resource.id) { + (resource as { id: string }).id = canonicalId; + } // Handle file-meta resources if (isFileMetaResource(resource)) { diff --git a/packages/host/tests/integration/components/rich-markdown-field-test.gts b/packages/host/tests/integration/components/rich-markdown-field-test.gts index 15cf239445d..cd2d9bc22e7 100644 --- a/packages/host/tests/integration/components/rich-markdown-field-test.gts +++ b/packages/host/tests/integration/components/rich-markdown-field-test.gts @@ -36,7 +36,7 @@ import { setupMockMatrix } from '../../helpers/mock-matrix'; import { renderCard } from '../../helpers/render-component'; import { setupRenderingTest } from '../../helpers/setup'; -import type { BaseDef } from '@cardstack/base/card-api'; +import type { BaseDef, CardDef as CardDefType } from '@cardstack/base/card-api'; let loader: Loader; @@ -306,6 +306,76 @@ module('Integration | RichMarkdownField', function (hooks) { .exists('card reference placeholder is rendered'); }); + test('display path renders a relative card-ref pill in a prefix-mapped realm', async function (assert) { + // End-to-end over the whole reference chain in a prefix-mapped realm: the + // markdown field extracts the ref in RRI space, the query-backed + // linkedCards field searches by that RRI, the index and the client-side + // filter matcher tolerate the URL-form instance id, and the pill slot + // matches — so the referenced card's atom actually renders. Guards the + // regression where the client re-filter dropped the URL-form instance + // against the RRI filter value, leaving the pill unresolved. + class Pet extends CardDef { + static displayName = 'Pet'; + @field name = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Pet) { + return this.name; + }, + }); + static atom = class Atom extends Component { + + }; + } + class ArticleCard extends CardDef { + @field body = contains(RichMarkdownField); + static isolated = class Isolated extends Component { + + }; + } + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'pet.gts': { Pet }, + 'article.gts': { ArticleCard }, + 'Pet/mango.json': { + data: { + attributes: { name: 'Mango', cardTitle: 'Mango' }, + meta: { adoptsFrom: { module: '../pet', name: 'Pet' } }, + }, + }, + 'article-1.json': { + data: { + attributes: { body: { content: `Inline: :card[./Pet/mango]` } }, + meta: { adoptsFrom: { module: './article', name: 'ArticleCard' } }, + }, + }, + }, + }); + let virtualNetwork = getService('network').virtualNetwork; + virtualNetwork.addRealmMapping('@test/cards/', testRealmURL); + try { + let store = getService('store'); + let article = (await store.get( + `${testRealmURL}article-1`, + )) as CardDefType; + let refs = (article as any).body?.cardReferenceUrls; + assert.deepEqual( + refs, + ['@test/cards/Pet/mango'], + 'the relative ref resolves against the prefix-form base into RRI space', + ); + await renderCard(loader, article, 'isolated'); + await waitFor('[data-test-pet-atom]'); + assert + .dom('[data-test-pet-atom]') + .hasText('Mango', 'the referenced card renders as a pill'); + } finally { + virtualNetwork.removeRealmMapping('@test/cards/'); + } + }); + test('renders footnotes', async function (assert) { class TestCard extends CardDef { @field body = contains(RichMarkdownField); diff --git a/packages/host/tests/integration/field-configuration-test.gts b/packages/host/tests/integration/field-configuration-test.gts index 4ee9f5b584b..5c240fbf4e9 100644 --- a/packages/host/tests/integration/field-configuration-test.gts +++ b/packages/host/tests/integration/field-configuration-test.gts @@ -44,7 +44,14 @@ import type { FileDef } from '@cardstack/base/file-api'; let loader: Loader; class DeferredLinkStore implements CardStore { - virtualNetwork: VirtualNetwork = new VirtualNetwork(); + #virtualNetwork: VirtualNetwork = new VirtualNetwork(); + resolveURL(reference: string, base?: string): URL | undefined { + try { + return this.#virtualNetwork.resolveURL(reference, base); + } catch { + return undefined; + } + } private cardInstances = new Map(); private fileMetaInstances = new Map(); private readyCardDocs = new Map();