From acfd936533a30b39493d8531f457bbf051442f66 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 4 Jun 2026 12:27:56 -0500 Subject: [PATCH 1/6] Make SerializeOpts.virtualNetwork required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip SerializeOpts.virtualNetwork from optional to required. The maybeRelativeReference closures in serializeCard / serializeFileDef no longer carry no-VN branches — the type system enforces a VN at every call site instead. opts on both entry points is also tightened from optional to required. Callers that didn't supply a VN have been updated: - copy-and-edit.ts:findRelationshipPath pulls the VN off the loader - tests/helpers/adapter.ts:openFile, tests/helpers/index.gts's setCardAsSavedWithId helper, and tests/helpers/indexer.ts's serializeCard helper all assert the loader has a VN - tests/helpers/base-realm.ts wraps serializeCard / serializeFileDef so the many bare `serializeCard(t)` call sites in test bodies stay working — the wrapper supplies VN from the active loader card-service.ts:serializeCard widens its opts to `Omit` because it threads VN internally; callers shouldn't have to. file-def-manager.ts's local opts annotation gets the same Omit. serializers/code-ref.ts's serialize and codeRefAdjustments take `Omit` with their own optional VN — that surface is driven by the framework's serializer registry, not by direct callers, and field-deserialize lands there without opts. CS-11374 item 4 will tighten this once VN is threaded through the field- deserialize protocol. serializeCardResource keeps opts? optional so the recursive symbol- method path (which legitimately has no opts) continues to work. The in-place merge that combines opts + overrides is cast through `SerializeOpts | undefined` because that code path never reads virtualNetwork. Co-Authored-By: Claude Opus 4.7 --- packages/base/card-serialization.ts | 76 ++++++------------- packages/host/app/commands/copy-and-edit.ts | 8 +- packages/host/app/lib/file-def-manager.ts | 2 +- packages/host/app/services/card-service.ts | 2 +- packages/host/tests/helpers/adapter.ts | 10 ++- packages/host/tests/helpers/base-realm.ts | 40 +++++++++- packages/host/tests/helpers/index.gts | 8 +- packages/host/tests/helpers/indexer.ts | 9 ++- .../runtime-common/serializers/code-ref.ts | 4 +- 9 files changed, 92 insertions(+), 67 deletions(-) diff --git a/packages/base/card-serialization.ts b/packages/base/card-serialization.ts index 8888d61f3ed..529f7bb6cd1 100644 --- a/packages/base/card-serialization.ts +++ b/packages/base/card-serialization.ts @@ -73,10 +73,8 @@ export interface SerializeOpts { maybeRelativeReference?: (possibleReference: string) => string; overrides?: Map; // The VirtualNetwork to consult for prefix/RRI resolution during - // serialization. Optional: when absent, the `maybeRelativeReference` - // closures degrade — prefix-form refs pass through unchanged and only - // URL-form bases support URL math. - virtualNetwork?: VirtualNetwork; + // serialization. Required — every caller must thread a VN. + virtualNetwork: VirtualNetwork; } export interface DeserializeOpts { @@ -212,7 +210,7 @@ export function resourceFrom( export function serializeCard( model: CardDef, - opts?: SerializeOpts, + opts: SerializeOpts, ): LooseSingleCardDocument { let doc = { data: { @@ -222,40 +220,22 @@ export function serializeCard( }; let modelRelativeTo: RealmResourceIdentifier | URL | undefined = model.id ?? model[relativeTo]; + let vn = opts.virtualNetwork; let data = serializeCardResource(model, doc, { ...opts, ...{ maybeRelativeReference(possibleReference: string) { - let vn = opts?.virtualNetwork; // Registered prefix refs (e.g. @cardstack/catalog/foo) are already - // in their canonical portable form — return as-is. Without a VN - // we can't know which prefixes are registered, so the most we can - // do for prefix-form refs is pass them through unchanged. - if (vn ? vn.isRegisteredPrefix(possibleReference) : false) { + // in their canonical portable form — return as-is. + if (vn.isRegisteredPrefix(possibleReference)) { return possibleReference; } - let modelRelativeToForURL: URL | undefined; - if (typeof modelRelativeTo === 'string') { - if (vn) { - modelRelativeToForURL = vn.toURL(modelRelativeTo); - } else if ( - modelRelativeTo.startsWith('http://') || - modelRelativeTo.startsWith('https://') - ) { - modelRelativeToForURL = new URL(modelRelativeTo); - } - } else { - modelRelativeToForURL = modelRelativeTo; - } + let modelRelativeToForURL: URL | undefined = + typeof modelRelativeTo === 'string' + ? vn.toURL(modelRelativeTo) + : modelRelativeTo; let url = maybeURL(possibleReference, modelRelativeToForURL); if (!url) { - if (!vn) { - // Without a VN we can't resolve a prefix-form reference. Pass - // through unchanged so callers that don't need relativization - // (e.g. test adapters serializing same-realm cards by URL) - // don't blow up on portable refs. - return possibleReference; - } throw new Error( `could not determine url from '${possibleReference}' relative to ${modelRelativeTo}`, ); @@ -302,7 +282,12 @@ export function serializeCardResource( usedLinksToFieldsOnly: !opts?.includeUnrenderedFields, }); let overrides = getFieldOverrides(model); - opts = { ...(opts ?? {}), overrides }; + // `serializeCardResource` is reachable from the recursive field-serialize + // symbol path without opts (e.g. callSerializeHook with no opts arg). + // That path doesn't read `opts.virtualNetwork`, so the synthesized + // working opts can lack it; cast through SerializeOpts | undefined to + // satisfy the required-VN type while preserving runtime behavior. + opts = { ...(opts ?? {}), overrides } as SerializeOpts | undefined; let fieldResources = Object.entries(fields) .filter( ([_fieldName, field]) => @@ -330,7 +315,7 @@ export function serializeCardResource( export function serializeFileDef( model: FileDef, - opts?: SerializeOpts, + opts: SerializeOpts, ): LooseSingleFileMetaDocument { let doc = { data: { @@ -340,6 +325,7 @@ export function serializeFileDef( }; let modelRelativeTo: RealmResourceIdentifier | URL | undefined = model.id ?? model[relativeTo]; + let vn = opts.virtualNetwork; let data = serializeCardResource( model, doc, @@ -347,33 +333,17 @@ export function serializeFileDef( ...opts, ...{ maybeRelativeReference(possibleReference: string) { - let vn = opts?.virtualNetwork; // Registered prefix refs (e.g. @cardstack/catalog/foo) are // already in their canonical portable form — return as-is. - // Without a VN we can't know which prefixes are registered, - // so the most we can do for prefix-form refs is pass them - // through unchanged. - if (vn ? vn.isRegisteredPrefix(possibleReference) : false) { + if (vn.isRegisteredPrefix(possibleReference)) { return possibleReference; } - let modelRelativeToForURL: URL | undefined; - if (typeof modelRelativeTo === 'string') { - if (vn) { - modelRelativeToForURL = vn.toURL(modelRelativeTo); - } else if ( - modelRelativeTo.startsWith('http://') || - modelRelativeTo.startsWith('https://') - ) { - modelRelativeToForURL = new URL(modelRelativeTo); - } - } else { - modelRelativeToForURL = modelRelativeTo; - } + let modelRelativeToForURL: URL | undefined = + typeof modelRelativeTo === 'string' + ? vn.toURL(modelRelativeTo) + : modelRelativeTo; let url = maybeURL(possibleReference, modelRelativeToForURL); if (!url) { - if (!vn) { - return possibleReference; - } throw new Error( `could not determine url from '${possibleReference}' relative to ${modelRelativeTo}`, ); diff --git a/packages/host/app/commands/copy-and-edit.ts b/packages/host/app/commands/copy-and-edit.ts index f01d1068738..08209655d6c 100644 --- a/packages/host/app/commands/copy-and-edit.ts +++ b/packages/host/app/commands/copy-and-edit.ts @@ -279,7 +279,13 @@ export default class CopyAndEditCommand extends HostBaseCommand< fieldName: string, ): string | undefined { try { - let serialized = this.#cardAPI?.serializeCard(card); + let vn = this.loaderService.loader.getVirtualNetwork(); + if (!vn) { + return undefined; + } + let serialized = this.#cardAPI?.serializeCard(card, { + virtualNetwork: vn, + }); let relationships = (serialized?.data as any)?.relationships ?? {}; return Object.keys(relationships).find( (key) => key === fieldName || key.endsWith(`.${fieldName}`), diff --git a/packages/host/app/lib/file-def-manager.ts b/packages/host/app/lib/file-def-manager.ts index eb3543d3bba..a370cd2443b 100644 --- a/packages/host/app/lib/file-def-manager.ts +++ b/packages/host/app/lib/file-def-manager.ts @@ -315,7 +315,7 @@ export default class FileDefManagerImpl serialization: LooseSingleCardDocument; }[] = await Promise.all( cards.map(async (card) => { - let opts: CardAPI.SerializeOpts = { + let opts: Omit = { useAbsoluteURL: true, includeComputeds: true, }; diff --git a/packages/host/app/services/card-service.ts b/packages/host/app/services/card-service.ts index b2f375f3dbd..676dc8edbab 100644 --- a/packages/host/app/services/card-service.ts +++ b/packages/host/app/services/card-service.ts @@ -200,7 +200,7 @@ export default class CardService extends Service { async serializeCard( card: CardDef, - opts?: SerializeOpts & { withIncluded?: true }, + opts?: Omit & { withIncluded?: true }, ): Promise { let api = await this.getAPI(); let serialized = api.serializeCard(card, { diff --git a/packages/host/tests/helpers/adapter.ts b/packages/host/tests/helpers/adapter.ts index 3a788729bea..02b02006e73 100644 --- a/packages/host/tests/helpers/adapter.ts +++ b/packages/host/tests/helpers/adapter.ts @@ -265,9 +265,13 @@ export class TestRealmAdapter implements RealmAdapter { `${baseRealm.url}card-api`, ); if (cardApi.isCard(value)) { - let doc = cardApi.serializeCard(value, { - virtualNetwork: this.#loader.getVirtualNetwork(), - }); + let vn = this.#loader.getVirtualNetwork(); + if (!vn) { + throw new Error( + `TestRealmAdapter.openFile needs the test loader to have a VirtualNetwork to serialize ${path}`, + ); + } + let doc = cardApi.serializeCard(value, { virtualNetwork: vn }); fileRefContent = JSON.stringify(doc); } else { fileRefContent = diff --git a/packages/host/tests/helpers/base-realm.ts b/packages/host/tests/helpers/base-realm.ts index 426361a0591..a85f1387b4c 100644 --- a/packages/host/tests/helpers/base-realm.ts +++ b/packages/host/tests/helpers/base-realm.ts @@ -157,8 +157,40 @@ let MaybeBase64Field: (typeof CardAPIModule)['MaybeBase64Field']; let CSSField: (typeof CardAPIModule)['CSSField']; let createFromSerialized: (typeof CardAPIModule)['createFromSerialized']; let updateFromSerialized: (typeof CardAPIModule)['updateFromSerialized']; -let serializeCard: (typeof CardAPIModule)['serializeCard']; -let serializeFileDef: (typeof CardAPIModule)['serializeFileDef']; +let rawSerializeCard: (typeof CardAPIModule)['serializeCard']; +let rawSerializeFileDef: (typeof CardAPIModule)['serializeFileDef']; + +// Test-side wrappers around the raw card-api serialize functions that +// auto-supply `virtualNetwork` from the active loader. Tests that need a +// non-default VN can still pass one in `opts` and it overrides the +// defaulted value. +function serializeCard( + card: Parameters<(typeof CardAPIModule)['serializeCard']>[0], + opts?: Partial[1]>, +): ReturnType<(typeof CardAPIModule)['serializeCard']> { + let loader = getService('loader-service').loader; + let vn = loader.getVirtualNetwork(); + if (!vn) { + throw new Error( + `base-realm test helper's serializeCard requires the active loader to have a VirtualNetwork`, + ); + } + return rawSerializeCard(card, { virtualNetwork: vn, ...opts }); +} + +function serializeFileDef( + fileDef: Parameters<(typeof CardAPIModule)['serializeFileDef']>[0], + opts?: Partial[1]>, +): ReturnType<(typeof CardAPIModule)['serializeFileDef']> { + let loader = getService('loader-service').loader; + let vn = loader.getVirtualNetwork(); + if (!vn) { + throw new Error( + `base-realm test helper's serializeFileDef requires the active loader to have a VirtualNetwork`, + ); + } + return rawSerializeFileDef(fileDef, { virtualNetwork: vn, ...opts }); +} let isSaved: (typeof CardAPIModule)['isSaved']; let getRelationship: (typeof CardAPIModule)['getRelationship']; let getBrokenLinks: (typeof CardAPIModule)['getBrokenLinks']; @@ -362,8 +394,8 @@ async function initialize() { getFields, createFromSerialized, updateFromSerialized, - serializeCard, - serializeFileDef, + serializeCard: rawSerializeCard, + serializeFileDef: rawSerializeFileDef, isSaved, getRelationship, getBrokenLinks, diff --git a/packages/host/tests/helpers/index.gts b/packages/host/tests/helpers/index.gts index 3baa0ce5f56..79bbd8aa405 100644 --- a/packages/host/tests/helpers/index.gts +++ b/packages/host/tests/helpers/index.gts @@ -1468,7 +1468,13 @@ export async function saveCard( realmURL?: RealmIdentifier, ) { let api = await loader.import(`${baseRealm.url}card-api`); - let doc = api.serializeCard(instance); + let vn = loader.getVirtualNetwork(); + if (!vn) { + throw new Error( + `setCardAsSavedWithId test helper needs the loader to have a VirtualNetwork`, + ); + } + let doc = api.serializeCard(instance, { virtualNetwork: vn }); doc.data.id = id; if (realmURL) { doc.data.meta = { diff --git a/packages/host/tests/helpers/indexer.ts b/packages/host/tests/helpers/indexer.ts index 560f6cd5fa4..23589227231 100644 --- a/packages/host/tests/helpers/indexer.ts +++ b/packages/host/tests/helpers/indexer.ts @@ -77,7 +77,14 @@ export async function getTypes(instance: CardDef): Promise { export async function serializeCard(card: CardDef): Promise { let api = await apiFor(card); - return api.serializeCard(card).data as CardResource; + let loader = loaderFor(card); + let virtualNetwork = loader.getVirtualNetwork(); + if (!virtualNetwork) { + throw new Error( + `serializeCard test helper requires a Loader with an attached VirtualNetwork`, + ); + } + return api.serializeCard(card, { virtualNetwork }).data as CardResource; } // we can relax the resource here since we will be asserting an ID when we diff --git a/packages/runtime-common/serializers/code-ref.ts b/packages/runtime-common/serializers/code-ref.ts index 3263df1e9d9..08e64f30ea3 100644 --- a/packages/runtime-common/serializers/code-ref.ts +++ b/packages/runtime-common/serializers/code-ref.ts @@ -26,7 +26,7 @@ export function serialize( codeRef: ResolvedCodeRef | {}, doc: any, _visited?: Set, - opts?: SerializeOpts & { + opts?: Omit & { relativeTo?: RealmResourceIdentifier | URL; trimExecutableExtension?: true; maybeRelativeReference?: (reference: string) => string; @@ -75,7 +75,7 @@ export async function deserializeAbsolute( function codeRefAdjustments( codeRef: any, relativeTo?: RealmResourceIdentifier | URL, - opts?: SerializeOpts & { + opts?: Omit & { trimExecutableExtension?: true; maybeRelativeReference?: (reference: string) => string; allowRelative?: true; From d1ca544aab50853ef24570f44a1b130994f41625 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 4 Jun 2026 12:29:56 -0500 Subject: [PATCH 2/6] Take VirtualNetwork as an explicit parameter to resolveRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveRef previously walked through `store?.virtualNetwork` internally, making the store the dependency for prefix/RRI resolution. Reshape the helper to take VN as its first parameter so the dependency at each call site is the VN itself, not the store. Every call site already has a store in scope, so the change is mechanical — replace `resolveRef(store, ref, relativeTo)` with `resolveRef(store.virtualNetwork, ref, relativeTo)`. The function's no-VN graceful-degrade fallback stays in place; CS-11374 item 2b follows to make CardStore.virtualNetwork required so the `?: VN` at the resolveRef parameter can also tighten. Co-Authored-By: Claude Opus 4.7 --- packages/base/card-api.gts | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 4e6629a6d7e..765a64a2e9f 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -1428,7 +1428,7 @@ class LinksTo implements Field { if (reference == null || reference === '') { return null; } - let href = resolveRef(store, reference, relativeTo); + let href = resolveRef(store.virtualNetwork, reference, relativeTo); let cachedInstance = isFileDef(this.card) ? store.getFileMeta(href) : store.getCard(href); @@ -1997,7 +1997,11 @@ class LinksToMany implements Field< if (reference == null) { return null; } - let normalizedReference = resolveRef(store, reference, relativeTo); + let normalizedReference = resolveRef( + store.virtualNetwork, + reference, + relativeTo, + ); let cachedInstance = isFileDef(this.card) ? store.getFileMeta(normalizedReference) : store.getCard(normalizedReference); @@ -2443,7 +2447,7 @@ export class BaseDef { return maybeRelativeReference; } return resolveRef( - getStore(value), + getStore(value).virtualNetwork, maybeRelativeReference, value[relativeTo], ); @@ -2470,7 +2474,7 @@ export class BaseDef { let normalizedId = rawValue.reference; if (value[relativeTo]) { normalizedId = resolveRef( - getStore(value), + getStore(value).virtualNetwork, normalizedId, value[relativeTo], ); @@ -3443,7 +3447,11 @@ function lazilyLoadLink( inflightLinkLoads.set(instance, inflightLoads); } let store = getStore(instance); - let reference = resolveRef(store, link, instance.id ?? instance[relativeTo]); + let reference = resolveRef( + store.virtualNetwork, + link, + instance.id ?? instance[relativeTo], + ); let key = `${field.name}/${reference}`; let promise = inflightLoads.get(key); if (promise) { @@ -3515,7 +3523,7 @@ function lazilyLoadLink( continue; } let notLoadedRef = resolveRef( - store, + store.virtualNetwork, item.reference, instance.id ?? instance[relativeTo], ); @@ -3603,7 +3611,7 @@ function lazilyLoadLink( continue; } let notLoadedRef = resolveRef( - store, + store.virtualNetwork, item.reference, instance.id ?? instance[relativeTo], ); @@ -4654,21 +4662,20 @@ export function virtualNetworkFor( } // Resolve a (possibly prefix-form or relative) reference to an absolute URL -// string through the store's VirtualNetwork. When the store doesn't carry -// a VN (test stubs, detached instances), fall back to plain URL math: it +// string through the supplied VirtualNetwork. When the caller can't supply +// one (test stubs, detached instances), fall back to plain URL math: it // covers URL-form refs and relative refs against URL-form bases. Prefix-form // refs and refs against prefix-form bases can't be resolved without a VN — // `new URL()` throws on those, so we return the raw reference unchanged // instead of bubbling the error to callers (e.g. relationship deserialize // uses the returned string as a "did this resolve?" signal). function resolveRef( - store: CardStore | undefined, + virtualNetwork: VirtualNetwork | undefined, reference: string, relativeTo: RealmResourceIdentifier | URL | undefined, ): string { - let vn = store?.virtualNetwork; - if (vn) { - return vn.resolveURL(reference, relativeTo).href; + if (virtualNetwork) { + return virtualNetwork.resolveURL(reference, relativeTo).href; } let base: URL | string | undefined; if (relativeTo instanceof URL) { From 4099c0afea7767a49d353894c75c2a978ab4958e Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 4 Jun 2026 12:33:21 -0500 Subject: [PATCH 3/6] Make CardStore.virtualNetwork required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the CardStore interface so every implementation must expose a VirtualNetwork. Implementations updated: - gc-card-store.ts's CardStoreWithGarbageCollection already had it - render-service.ts's CardStoreWithErrors gains a public getter that exposes its existing #virtualNetwork field - field-configuration-test.gts's DeferredLinkStore test stub gains a `virtualNetwork: new VirtualNetwork()` field - card-api.gts's FallbackCardStore.virtualNetwork getter throws when the active loader can't supply one (was `undefined` before); the loadCardDocument / loadFileMetaDocument methods that previously threw on missing VN drop their now-redundant runtime checks virtualNetworkFor's external contract stays `VirtualNetwork | undefined` — that's the documented out-of-scope item for CS-11374 (detached instances, static parsers). It wraps the getStore(instance).virtualNetwork access in try/catch so a FallbackCardStore-without-loader-VN reaches the function as undefined the way callers already expect. Co-Authored-By: Claude Opus 4.7 --- packages/base/card-api.gts | 45 +++++++++---------- packages/host/app/services/render-service.ts | 4 ++ .../integration/field-configuration-test.gts | 3 +- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 765a64a2e9f..8376d3dd7e2 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -469,10 +469,10 @@ export type GetSearchResourceFunc = ( export interface CardStore { // The VirtualNetwork that owns this store's realm mappings, used for - // prefix/RRI resolution during (de)serialization. Optional so test doubles - // don't need to implement it; resolution sites degrade — URL-form refs - // still URL-join, prefix-form refs pass through unchanged. - virtualNetwork?: VirtualNetwork; + // prefix/RRI resolution during (de)serialization. Required — every store + // implementation must supply one (production stores, test stubs, the + // FallbackCardStore). + virtualNetwork: VirtualNetwork; getCard(url: string): CardDef | undefined; getFileMeta(url: string): FileDef | undefined; setCard(url: string, instance: CardDef): void; @@ -4653,12 +4653,17 @@ function getStore(instance: BaseDef): CardStore { } // The VirtualNetwork associated with an instance's store, for prefix/RRI -// resolution outside this module. Returns undefined when the store can't -// supply one — callers handle that by degrading to URL math or throwing. +// resolution outside this module. Returns undefined when the instance is +// detached (no store, no loader-attached VN) — callers handle that by +// degrading to URL math or throwing. export function virtualNetworkFor( instance: BaseDef, ): VirtualNetwork | undefined { - return getStore(instance).virtualNetwork; + try { + return getStore(instance).virtualNetwork; + } catch { + return undefined; + } } // Resolve a (possibly prefix-form or relative) reference to an absolute URL @@ -4712,8 +4717,14 @@ class FallbackCardStore implements CardStore { #inFlight: Set> = new Set(); #loadGeneration = 0; // mirrors host store tracking to detect new loads - get virtualNetwork(): VirtualNetwork | undefined { - return myLoader().getVirtualNetwork(); + get virtualNetwork(): VirtualNetwork { + let vn = myLoader().getVirtualNetwork(); + if (!vn) { + throw new Error( + `FallbackCardStore.virtualNetwork requires the active Loader to have a VirtualNetwork`, + ); + } + return vn; } getCard(id: string) { @@ -4774,13 +4785,7 @@ class FallbackCardStore implements CardStore { opts?: { dependencyTrackingContext?: RuntimeDependencyTrackingContext }, ) { trackRuntimeInstanceDependency(url, opts?.dependencyTrackingContext); - let vn = this.virtualNetwork; - if (!vn) { - throw new Error( - `CardStore.loadCardDocument requires a Loader with a VirtualNetwork`, - ); - } - let promise = loadCardDocument(fetch, url, vn); + let promise = loadCardDocument(fetch, url, this.virtualNetwork); this.trackLoad(promise); return await promise; } @@ -4790,13 +4795,7 @@ class FallbackCardStore implements CardStore { opts?: { dependencyTrackingContext?: RuntimeDependencyTrackingContext }, ) { trackRuntimeFileDependency(url, opts?.dependencyTrackingContext); - let vn = this.virtualNetwork; - if (!vn) { - throw new Error( - `CardStore.loadFileMetaDocument requires a Loader with a VirtualNetwork`, - ); - } - let promise = loadFileMetaDocument(fetch, url, vn); + let promise = loadFileMetaDocument(fetch, url, this.virtualNetwork); this.trackLoad(promise); return await promise; } diff --git a/packages/host/app/services/render-service.ts b/packages/host/app/services/render-service.ts index 32c93bc47fb..9780a3f10e0 100644 --- a/packages/host/app/services/render-service.ts +++ b/packages/host/app/services/render-service.ts @@ -68,6 +68,10 @@ export class CardStoreWithErrors implements CardStore { this.#virtualNetwork = virtualNetwork; } + get virtualNetwork(): VirtualNetwork { + return this.#virtualNetwork; + } + getCard(id: string): CardDef | undefined { id = this.normalizeKey(id); return this.#cards.get(id); diff --git a/packages/host/tests/integration/field-configuration-test.gts b/packages/host/tests/integration/field-configuration-test.gts index 03649b4baa3..a1c61e5fc16 100644 --- a/packages/host/tests/integration/field-configuration-test.gts +++ b/packages/host/tests/integration/field-configuration-test.gts @@ -4,7 +4,7 @@ import { settled } from '@ember/test-helpers'; import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; -import { baseRealm, Deferred } from '@cardstack/runtime-common'; +import { baseRealm, Deferred, VirtualNetwork } from '@cardstack/runtime-common'; import type { SingleCardDocument, SingleFileMetaDocument, @@ -44,6 +44,7 @@ import { setupRenderingTest } from '../helpers/setup'; let loader: Loader; class DeferredLinkStore implements CardStore { + virtualNetwork: VirtualNetwork = new VirtualNetwork(); private cardInstances = new Map(); private fileMetaInstances = new Map(); private readyCardDocs = new Map(); From 70424d72149979ce3d957c2a6ae1c76f59ce613f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 4 Jun 2026 12:38:04 -0500 Subject: [PATCH 4/6] Thread VN through serializers/code-ref.ts and delete the no-VN branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `codeRefAdjustments` carried a no-VN branch reachable from the `deserializeAbsolute` path because the framework field-deserialize machinery called it without opts. Now `deserializeAbsolute` accepts the framework's store parameter and threads `store.virtualNetwork` into `codeRefAdjustments`, so the no-VN branch in the inner `resolve` helper is unreachable from any framework-driven path. Tighten `codeRefAdjustments` to require `opts.virtualNetwork` and drop the no-VN URL-join fallback. `serialize` also tightens its opts.virtualNetwork to required; the early-out when opts is missing preserves the public surface for direct callers that pass no opts. `deserializeAbsolute` has an early-out when no store is supplied — only direct test callers reach that path, and they get a passthrough. The code-ref-test.ts unit test that exercised the `serialize` no-VN path is updated to pass an empty `new VirtualNetwork()`; the assertion is unchanged because the URL-form base+ref still resolves the same way through VN. Co-Authored-By: Claude Opus 4.7 --- packages/host/tests/unit/code-ref-test.ts | 2 + .../runtime-common/serializers/code-ref.ts | 65 +++++++++---------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/packages/host/tests/unit/code-ref-test.ts b/packages/host/tests/unit/code-ref-test.ts index b8376da0b22..926a31bd8d4 100644 --- a/packages/host/tests/unit/code-ref-test.ts +++ b/packages/host/tests/unit/code-ref-test.ts @@ -6,6 +6,7 @@ import { module, test } from 'qunit'; import type { Loader, LooseCardResource } from '@cardstack/runtime-common'; import { + VirtualNetwork, baseRealm, loadCardDef, rri, @@ -194,6 +195,7 @@ module('code-ref', function (hooks) { let doc = { data: { id: base.href } }; let serialized = CodeRefSerializer.serialize(ref, doc, undefined, { relativeTo: base, + virtualNetwork: new VirtualNetwork(), }) as any; assert.strictEqual( serialized.module, diff --git a/packages/runtime-common/serializers/code-ref.ts b/packages/runtime-common/serializers/code-ref.ts index 08e64f30ea3..9921f9ffbda 100644 --- a/packages/runtime-common/serializers/code-ref.ts +++ b/packages/runtime-common/serializers/code-ref.ts @@ -1,6 +1,7 @@ import type { BaseDefConstructor, BaseInstanceType, + CardStore, } from 'https://cardstack.com/base/card-api'; import { type ResolvedCodeRef, @@ -31,17 +32,20 @@ export function serialize( trimExecutableExtension?: true; maybeRelativeReference?: (reference: string) => string; allowRelative?: true; - virtualNetwork?: VirtualNetwork; + virtualNetwork: VirtualNetwork; }, ): ResolvedCodeRef | {} { - let vn = opts?.virtualNetwork; + if (!opts) { + return { ...codeRef }; + } + let vn = opts.virtualNetwork; let baseURL: URL | undefined; - if (opts?.relativeTo instanceof URL) { + if (opts.relativeTo instanceof URL) { baseURL = opts.relativeTo; - } else if (typeof opts?.relativeTo === 'string') { - baseURL = vn ? vn.toURL(opts.relativeTo) : undefined; + } else if (typeof opts.relativeTo === 'string') { + baseURL = vn.toURL(opts.relativeTo); } else if (doc?.data?.id && typeof doc.data.id === 'string') { - baseURL = vn ? vn.toURL(doc.data.id) : undefined; + baseURL = vn.toURL(doc.data.id); } return { ...codeRef, @@ -65,21 +69,33 @@ export async function deserializeAbsolute( this: T, codeRef: ResolvedCodeRef | {}, relativeTo: RealmResourceIdentifier | URL | undefined, + _doc?: unknown, + store?: CardStore, ): Promise> { + if (!store) { + // Reached only by direct test callers that bypass the framework + // protocol; the framework's field-deserialize path always supplies + // a store. Without a VN we can't resolve prefix-form refs or + // round-trip URL-form refs through registered mappings, so leave + // the codeRef untouched. + return { ...codeRef } as BaseInstanceType; + } return { ...codeRef, - ...codeRefAdjustments(codeRef, relativeTo), + ...codeRefAdjustments(codeRef, relativeTo, { + virtualNetwork: store.virtualNetwork, + }), } as BaseInstanceType; } function codeRefAdjustments( codeRef: any, - relativeTo?: RealmResourceIdentifier | URL, - opts?: Omit & { + relativeTo: RealmResourceIdentifier | URL | undefined, + opts: Omit & { trimExecutableExtension?: true; maybeRelativeReference?: (reference: string) => string; allowRelative?: true; - virtualNetwork?: VirtualNetwork; + virtualNetwork: VirtualNetwork; }, ) { if (!codeRef) { @@ -88,35 +104,18 @@ function codeRefAdjustments( if (!isResolvedCodeRef(codeRef)) { return {}; } - // The `deserializeAbsolute` field-deserialize path reaches this without - // opts (no VN, no `allowRelative`, no `maybeRelativeReference`). For - // URL-like refs we can still do a plain URL-join against `relativeTo` - // and apply `trimExecutableExtension`. Bare specifiers (e.g. - // `@cardstack/boxel-host/…`) throw — `resolve` is wrapped in try/catch - // below, so the original ref stays intact for the loader's importMap - // shim. - let vn = opts?.virtualNetwork; - let resolve = (ref: string) => { - if (vn) { - return resolveModuleHref(ref, relativeTo, vn); - } - if (!isUrlLike(ref)) { - throw new Error( - `Cannot resolve bare package specifier "${ref}" — no matching prefix mapping registered`, - ); - } - return new URL(ref, relativeTo).href; - }; + let vn = opts.virtualNetwork; + let resolve = (ref: string) => resolveModuleHref(ref, relativeTo, vn); if (!isUrlLike(codeRef.module)) { // Try resolving via registered prefix mappings (e.g., @cardstack/catalog/) try { let resolved = resolve(codeRef.module); if (resolved !== codeRef.module) { let module: string = resolved; - if (opts?.trimExecutableExtension) { + if (opts.trimExecutableExtension) { module = trimExecutableExtension(rri(module)); } - if (opts?.allowRelative && opts?.maybeRelativeReference) { + if (opts.allowRelative && opts.maybeRelativeReference) { module = opts.maybeRelativeReference(module); } return { module }; @@ -128,10 +127,10 @@ function codeRefAdjustments( } if (relativeTo) { let module: string = resolve(codeRef.module); - if (opts?.trimExecutableExtension) { + if (opts.trimExecutableExtension) { module = trimExecutableExtension(rri(module)); } - if (opts?.allowRelative && opts?.maybeRelativeReference) { + if (opts.allowRelative && opts.maybeRelativeReference) { module = opts.maybeRelativeReference(module); } return { module }; From 12c70f92fa5bb41009b5a4f2e90b1d752c672e3c Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 5 Jun 2026 12:33:10 -0500 Subject: [PATCH 5/6] Propagate opts through Contains.serialize's non-primitive branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contains.serialize's primitive branch passes its opts argument to callSerializeHook; the non-primitive branch dropped opts on the floor, which made the recursive serializeCardResource synthesize an opts object with no virtualNetwork (via the `{ ...opts ?? {}, overrides }` merge). A code-ref field on the contained card then reached its serializer with vn=undefined and `vn.toURL(doc.data.id)` threw. Pass opts through so the recursion sees the same virtualNetwork the top-level serializeCard supplied. This keeps the serializer's required-VN type signature honest at runtime — no defensive no-VN guard is needed at the serializer because the propagation chain now matches what the type says. Co-Authored-By: Claude Opus 4.7 --- packages/base/card-api.gts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 8376d3dd7e2..a1b301fc986 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -1035,7 +1035,7 @@ class Contains implements Field { } else { let serialized: JSONAPISingleResourceDocument['data'] & { meta: Record; - } = callSerializeHook(this.card, value, doc); + } = callSerializeHook(this.card, value, doc, undefined, opts); let resource: JSONAPIResource = { attributes: { [this.name]: serialized?.attributes, From 0694776b0f6dd72c3200205577fa3597062a6e08 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 5 Jun 2026 13:58:27 -0500 Subject: [PATCH 6/6] Revert Contains opts propagation; restore no-VN guard in code-ref serializer Propagating opts through Contains.serialize's non-primitive branch (the previous "root-cause" fix) broke several serialization tests: nested-FieldDef values stopped being isolated from the outer card's opts, so `includeComputeds`/`includeUnrenderedFields` leaked into the inner serializeCardResource recursion. Nested FieldDef computeds started serializing where they previously didn't (e.g. Person.cardTitle inside Appointment.contact), and inner CardInfoField linksTo fields started landing in the outer card's relationships block (e.g. 'cardInfo.theme', 'cardInfo.cardThumbnail') where the tests expect nothing. The Contains-doesn't-propagate-opts shape is intentional, isolating inner serialization from outer opts. Revert that change and take the bot's original recommendation: handle the no-VN case defensively at the serializer boundary. `code-ref` serializer's `serialize` and `codeRefAdjustments` accept `opts.virtualNetwork` as optional again and degrade to URL math + raw-ref fallback (matching the contract `deserializeAbsolute` already had since CS-11374 item 4). Co-Authored-By: Claude Opus 4.7 --- packages/base/card-api.gts | 2 +- .../runtime-common/serializers/code-ref.ts | 57 +++++++++++++++---- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index a1b301fc986..8376d3dd7e2 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -1035,7 +1035,7 @@ class Contains implements Field { } else { let serialized: JSONAPISingleResourceDocument['data'] & { meta: Record; - } = callSerializeHook(this.card, value, doc, undefined, opts); + } = callSerializeHook(this.card, value, doc); let resource: JSONAPIResource = { attributes: { [this.name]: serialized?.attributes, diff --git a/packages/runtime-common/serializers/code-ref.ts b/packages/runtime-common/serializers/code-ref.ts index 9921f9ffbda..7d45f79f2a1 100644 --- a/packages/runtime-common/serializers/code-ref.ts +++ b/packages/runtime-common/serializers/code-ref.ts @@ -32,9 +32,15 @@ export function serialize( trimExecutableExtension?: true; maybeRelativeReference?: (reference: string) => string; allowRelative?: true; - virtualNetwork: VirtualNetwork; + virtualNetwork?: VirtualNetwork; }, ): ResolvedCodeRef | {} { + // The recursive serialize path through a non-primitive `Contains` field + // intentionally isolates the inner card's serialization from the outer + // card's opts (see `Contains.serialize` in card-api.gts), so opts can + // arrive here as `undefined` or as a synthesized `{ overrides }` object + // with no `virtualNetwork`. URL-form refs can still be resolved with + // plain URL math; prefix-form refs need a VN and are left alone. if (!opts) { return { ...codeRef }; } @@ -43,9 +49,23 @@ export function serialize( if (opts.relativeTo instanceof URL) { baseURL = opts.relativeTo; } else if (typeof opts.relativeTo === 'string') { - baseURL = vn.toURL(opts.relativeTo); + if (vn) { + baseURL = vn.toURL(opts.relativeTo); + } else if ( + opts.relativeTo.startsWith('http://') || + opts.relativeTo.startsWith('https://') + ) { + baseURL = new URL(opts.relativeTo); + } } else if (doc?.data?.id && typeof doc.data.id === 'string') { - baseURL = vn.toURL(doc.data.id); + if (vn) { + baseURL = vn.toURL(doc.data.id); + } else if ( + doc.data.id.startsWith('http://') || + doc.data.id.startsWith('https://') + ) { + baseURL = new URL(doc.data.id); + } } return { ...codeRef, @@ -91,11 +111,11 @@ export async function deserializeAbsolute( function codeRefAdjustments( codeRef: any, relativeTo: RealmResourceIdentifier | URL | undefined, - opts: Omit & { + opts?: Omit & { trimExecutableExtension?: true; maybeRelativeReference?: (reference: string) => string; allowRelative?: true; - virtualNetwork: VirtualNetwork; + virtualNetwork?: VirtualNetwork; }, ) { if (!codeRef) { @@ -104,18 +124,33 @@ function codeRefAdjustments( if (!isResolvedCodeRef(codeRef)) { return {}; } - let vn = opts.virtualNetwork; - let resolve = (ref: string) => resolveModuleHref(ref, relativeTo, vn); + // opts may arrive without a VN — the recursive non-primitive-Contains + // serialize path isolates inner cards from the outer card's opts, and + // `deserializeAbsolute` may also be called without a store. URL-like + // refs still resolve through plain URL math; bare specifiers fall + // through to the loader's importMap shim via the surrounding try/catch. + let vn = opts?.virtualNetwork; + let resolve = (ref: string) => { + if (vn) { + return resolveModuleHref(ref, relativeTo, vn); + } + if (!isUrlLike(ref)) { + throw new Error( + `Cannot resolve bare package specifier "${ref}" — no matching prefix mapping registered`, + ); + } + return new URL(ref, relativeTo).href; + }; if (!isUrlLike(codeRef.module)) { // Try resolving via registered prefix mappings (e.g., @cardstack/catalog/) try { let resolved = resolve(codeRef.module); if (resolved !== codeRef.module) { let module: string = resolved; - if (opts.trimExecutableExtension) { + if (opts?.trimExecutableExtension) { module = trimExecutableExtension(rri(module)); } - if (opts.allowRelative && opts.maybeRelativeReference) { + if (opts?.allowRelative && opts?.maybeRelativeReference) { module = opts.maybeRelativeReference(module); } return { module }; @@ -127,10 +162,10 @@ function codeRefAdjustments( } if (relativeTo) { let module: string = resolve(codeRef.module); - if (opts.trimExecutableExtension) { + if (opts?.trimExecutableExtension) { module = trimExecutableExtension(rri(module)); } - if (opts.allowRelative && opts.maybeRelativeReference) { + if (opts?.allowRelative && opts?.maybeRelativeReference) { module = opts.maybeRelativeReference(module); } return { module };