Skip to content
Merged
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
56 changes: 35 additions & 21 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -497,11 +497,13 @@ export type GetSearchResourceFunc<T extends CardDef | FileDef = CardDef> = (
) => StoreSearchResource<T>;

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
// `<img src>`, `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;
Expand Down Expand Up @@ -4810,25 +4812,16 @@ function getStore(instance: BaseDef): CardStore {
// real URL to a boundary that can't consume canonical RRI (an `<img src>`, 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],
);
Expand Down Expand Up @@ -4867,16 +4860,33 @@ class FallbackCardStore implements CardStore {
#inFlight: Set<Promise<unknown>> = 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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
8 changes: 6 additions & 2 deletions packages/host/app/lib/gc-card-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions packages/host/app/services/render-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions packages/host/app/services/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<typeof this> {
<template>
<span data-test-pet-atom>{{@model.name}}</span>
</template>
};
}
class ArticleCard extends CardDef {
@field body = contains(RichMarkdownField);
static isolated = class Isolated extends Component<typeof this> {
<template><@fields.body /></template>
};
}
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);
Expand Down
9 changes: 8 additions & 1 deletion packages/host/tests/integration/field-configuration-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CardDefType>();
private fileMetaInstances = new Map<string, FileDef>();
private readyCardDocs = new Map<string, SingleCardDocument>();
Expand Down
Loading