From 3d8599bf0424b16eea5dd09e96237a194cd8ea7e Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 20 Apr 2026 12:44:09 +0800 Subject: [PATCH 1/7] fix: resolve prefix-form card IDs at entry points to prevent Invalid URL crashes across catalog workflows --- packages/host/PREFIX_FORM_ID_EXPLAINER.md | 58 +++++++++++++++++++ packages/host/app/routes/module.ts | 3 +- .../services/operator-mode-state-service.ts | 18 +++++- packages/host/app/services/store.ts | 48 ++++++++++++++- packages/runtime-common/code-ref.ts | 9 ++- packages/runtime-common/virtual-network.ts | 7 +++ 6 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 packages/host/PREFIX_FORM_ID_EXPLAINER.md diff --git a/packages/host/PREFIX_FORM_ID_EXPLAINER.md b/packages/host/PREFIX_FORM_ID_EXPLAINER.md new file mode 100644 index 00000000000..6dc2b243372 --- /dev/null +++ b/packages/host/PREFIX_FORM_ID_EXPLAINER.md @@ -0,0 +1,58 @@ +# Fix: Prefix-form card IDs crashing multiple catalog workflows + +## Problem + +The backend stores card IDs as a portability shorthand (prefix form), e.g.: + +``` +@cardstack/catalog/piano/Piano/abc123 +``` + +instead of the absolute URL the frontend needs: + +``` +http://localhost:4201/catalog/piano/Piano/abc123 +``` + +The frontend was passing these prefix strings directly to `new URL()` and `new Request()`, which only accept valid URLs. This caused `TypeError: Invalid URL` crashes that broke **all catalog-realm card interactions**: + +- **spec-preview** — completely broken when viewing any catalog card +- **Create card / Create listing** — crashed on save, card never appeared in store +- **Delete card** — crash trying to build the delete request URL +- **Remix** — crashed opening the remix panel for a catalog card +- **View card / click to open** — crashed navigating to any catalog card + +## Root cause + +There were **five distinct entry points** where prefix-form IDs entered the frontend without being resolved. No single fix covered all of them — each entry point bypassed the others. + +## Fix strategy + +Rather than adding guards in every affected component (dozens of call sites), the fix resolves prefix form **at the highest possible upstream point** for each entry path. Downstream components like `spec-preview` require no changes — they keep their existing `new URL(card.id)` calls, which now always receive a valid absolute URL. + +**All fixes are frontend-only, in-memory. The database is never touched — it intentionally keeps prefix form for portability.** + +## Fixes + +| # | File | Entry point fixed | Why this location | +|---|---|---|---| +| 1 | `virtual-network.ts` | Prefix string passed as a `fetch()` URL | Every network call goes through here; intercept before `new Request()` is built | +| 2 | `store.ts` `createFromSerialized` | `resource.id` / `included[].id` from server HTTP response | Normalize before card API hydrates JSON into a card instance — `card.id` is always absolute after this | +| 3 | `store.ts` `persistAndUpdate` | `json.data.id` returned by server after save | Normalize immediately after save response; prevents false-positive `needsServerStateMerge` and wrong `api.setId` | +| 4 | `code-ref.ts` `identifyCard` | `codeRef.module` from Loader's internal identity map | Loader stores module paths as prefix internally; resolve once here so all 8+ callers automatically get absolute module paths | +| 5 | `operator-mode-state-service.ts` `deserialize` | Trail card IDs read from localStorage / URL hash | Persisted state bypasses all runtime fixes above; `normalizeTrailItem` resolves on read, drops corrupt entries | +| 6 | `routes/module.ts` `buildModuleModel` | Module `id` from browser URL query string | Route parameter enters the app before any store or Loader processing; `cardIdToURL(id)` replaces `new URL(id)` | + +## Key utility + +```typescript +// card-reference-resolver.ts +cardIdToURL("@cardstack/catalog/piano/Piano/abc123") + → new URL("http://localhost:4201/catalog/piano/Piano/abc123") ✅ + +cardIdToURL("http://localhost:4201/catalog/piano/...") + → passes through unchanged ✅ + +// Always guard local IDs (unsaved cards) first: +if (!isLocalId(id)) { cardIdToURL(id) } +``` diff --git a/packages/host/app/routes/module.ts b/packages/host/app/routes/module.ts index 4f3bfe108e2..9c3f833fd44 100644 --- a/packages/host/app/routes/module.ts +++ b/packages/host/app/routes/module.ts @@ -30,6 +30,7 @@ import { SupportedMimeType, getFieldDefinitions, CardError, + cardIdToURL, unixTime, type RenderRouteOptions, } from '@cardstack/runtime-common'; @@ -200,7 +201,7 @@ export async function buildModuleModel( context: ModuleModelContext, ): Promise { let parsedOptions = renderOptions ?? {}; - let moduleURL = trimExecutableExtension(new URL(id)); + let moduleURL = trimExecutableExtension(cardIdToURL(id)); registerBoxelTransitionTo(context.router, context.owner); if (parsedOptions.clearCache) { diff --git a/packages/host/app/services/operator-mode-state-service.ts b/packages/host/app/services/operator-mode-state-service.ts index 9f322d1c19d..7f22894c84f 100644 --- a/packages/host/app/services/operator-mode-state-service.ts +++ b/packages/host/app/services/operator-mode-state-service.ts @@ -985,6 +985,19 @@ export default class OperatorModeStateService extends Service { // Deserialize a stringified JSON version of OperatorModeState into a Glimmer tracked object // so that templates can react to changes in stacks and their items deserialize(rawState: SerializedState): OperatorModeState { + // Trail items are card IDs persisted in URL hash / localStorage. + // They may be prefix-form (e.g. "@cardstack/catalog/...") if saved before + // the prefix normalization fix. Resolve to absolute URL, drop invalid entries. + let normalizeTrailItem = (item: string | undefined): string | undefined => { + if (!item || isLocalId(item)) { + return undefined; + } + try { + return cardIdToURL(item).href.replace(/\.json$/, ''); + } catch (_e) { + return undefined; + } + }; let openDirs = new TrackedMap( Object.entries(rawState.openDirs ?? {}).map(([realmURL, dirs]) => [ realmURL, @@ -996,11 +1009,12 @@ export default class OperatorModeStateService extends Service { stacks: new TrackedArray([]), submode: rawState.submode ?? Submodes.Interact, codePath: rawState.codePath ? new URL(rawState.codePath) : null, - hostModePrimaryCard: rawState.trail?.[0]?.replace(/\.json$/, '') ?? null, + hostModePrimaryCard: normalizeTrailItem(rawState.trail?.[0]) ?? null, hostModeStack: new TrackedArray( rawState.trail ?.slice(1, rawState.trail?.length) - .map((item) => item.replace(/\.json$/, '')) ?? [], + .map((item) => normalizeTrailItem(item)) + .filter((item): item is string => Boolean(item)) ?? [], ), fileView: rawState.fileView ?? 'inspector', openDirs, diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 1367ca19f69..e6f0404c26d 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -1028,6 +1028,31 @@ export default class StoreService extends Service implements StoreInterface { relativeTo?: URL | undefined, dependencyTrackingContext?: RuntimeDependencyTrackingContext, ): Promise { + // Normalize prefix-form IDs to absolute URLs before hydration so that + // card.id is always an absolute URL after createFromSerialized returns. + // This prevents downstream callers (e.g. spec-preview) from crashing when + // they call new URL(card.id) on a prefix-form string. + if (resource.id && !isLocalId(resource.id)) { + let absoluteId = cardIdToURL(resource.id).href; + resource = { ...resource, id: absoluteId }; + doc = { + ...doc, + data: { ...(doc as LooseSingleCardDocument).data, id: absoluteId }, + } as LooseSingleCardDocument; + } + // Also normalize IDs in included resources so linked cards have absolute + // URL ids after deserialization (e.g. linksToMany fields). + if (doc.included?.length) { + doc = { + ...doc, + included: doc.included.map((included) => { + if (included.id && !isLocalId(included.id)) { + return { ...included, id: cardIdToURL(included.id).href }; + } + return included; + }), + } as typeof doc; + } let api = await this.cardService.getAPI(); let shouldStubTimers = this.renderContextBlocksPersistence() && !isTesting(); @@ -1333,6 +1358,12 @@ export default class StoreService extends Service implements StoreInterface { relativeTo: URL | undefined, dependencyTrackingContext?: RuntimeDependencyTrackingContext, ): Promise { + // Normalize prefix-form ID to absolute URL before hydration. + if (resource.id && !isLocalId(resource.id)) { + let absoluteId = cardIdToURL(resource.id).href; + resource = { ...resource, id: absoluteId }; + doc = { ...doc, data: { ...doc.data, id: absoluteId } }; + } let api = await this.cardService.getAPI(); let instance = (await api.createFromSerialized(resource, doc, relativeTo, { store: this.store, @@ -1908,6 +1939,20 @@ export default class StoreService extends Service implements StoreInterface { clientRequestId: opts?.clientRequestId, }); + // Normalize the server response ID to absolute URL BEFORE + // needsServerStateMerge runs. needsServerStateMerge compares + // instance.id (absolute URL) against json.data.id (could be prefix + // form from the server). If they don't match it calls + // updateFromSerialized which then tries to change the existing ID → + // "cannot change the id for saved instance" crash. + if (json.data.id && !isLocalId(json.data.id)) { + let absoluteId = cardIdToURL(json.data.id).href; + json = { + ...json, + data: { ...json.data, id: absoluteId }, + } as SingleCardDocument; + } + let api = await this.cardService.getAPI(); // the store state represents the latest state and the server state is // potentially out-of-date. As such we only merge the server state that @@ -1921,7 +1966,8 @@ export default class StoreService extends Service implements StoreInterface { await api.updateFromSerialized(instance, serverState, this.store); } if (isNew) { - api.setId(instance, json.data.id!); + // Normalize prefix-form ID to absolute URL before setting on instance. + api.setId(instance, cardIdToURL(json.data.id!).href); this.subscribeToRealm(cardIdToURL(instance.id)); this.operatorModeStateService.handleCardIdAssignment( instance[localIdSymbol], diff --git a/packages/runtime-common/code-ref.ts b/packages/runtime-common/code-ref.ts index 3244abfd061..4acfe8f8e39 100644 --- a/packages/runtime-common/code-ref.ts +++ b/packages/runtime-common/code-ref.ts @@ -245,9 +245,14 @@ export function identifyCard( let ref = Loader.identify(card); if (ref) { + // Loader stores module paths as prefix form internally (e.g. + // "@cardstack/catalog/piano") for portability. Resolve to absolute URL + // here so all callers get a usable module path — otherwise any caller + // doing new URL(codeRef.module) would crash with "Invalid URL". + let resolvedRef = { ...ref, module: resolveCardReference(ref.module, undefined) }; return maybeRelativeURL - ? { ...ref, module: maybeRelativeURL(ref.module) } - : ref; + ? { ...resolvedRef, module: maybeRelativeURL(resolvedRef.module) } + : resolvedRef; } let local = localIdentities.get(card); diff --git a/packages/runtime-common/virtual-network.ts b/packages/runtime-common/virtual-network.ts index 6254224cdbb..f529d650849 100644 --- a/packages/runtime-common/virtual-network.ts +++ b/packages/runtime-common/virtual-network.ts @@ -8,6 +8,10 @@ import { } from './package-shim-handler'; import type { Readable } from 'stream'; import { fetcher, type FetcherMiddlewareHandler } from './fetcher'; +import { + isRegisteredPrefix, + resolveCardReference, +} from './card-reference-resolver'; export interface ResponseWithNodeStream extends Response { nodeStream?: Readable; @@ -118,6 +122,9 @@ export class VirtualNetwork { urlOrRequest: string | URL | Request, init?: RequestInit, ) => { + if (typeof urlOrRequest === 'string' && isRegisteredPrefix(urlOrRequest)) { + urlOrRequest = resolveCardReference(urlOrRequest, undefined); + } let request = urlOrRequest instanceof Request ? urlOrRequest From 5bbf07a63afd1b51d9001c36814baa4ee78e7746 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 20 Apr 2026 13:00:11 +0800 Subject: [PATCH 2/7] fix lint --- packages/host/PREFIX_FORM_ID_EXPLAINER.md | 58 ----------------------- packages/runtime-common/code-ref.ts | 5 +- 2 files changed, 4 insertions(+), 59 deletions(-) delete mode 100644 packages/host/PREFIX_FORM_ID_EXPLAINER.md diff --git a/packages/host/PREFIX_FORM_ID_EXPLAINER.md b/packages/host/PREFIX_FORM_ID_EXPLAINER.md deleted file mode 100644 index 6dc2b243372..00000000000 --- a/packages/host/PREFIX_FORM_ID_EXPLAINER.md +++ /dev/null @@ -1,58 +0,0 @@ -# Fix: Prefix-form card IDs crashing multiple catalog workflows - -## Problem - -The backend stores card IDs as a portability shorthand (prefix form), e.g.: - -``` -@cardstack/catalog/piano/Piano/abc123 -``` - -instead of the absolute URL the frontend needs: - -``` -http://localhost:4201/catalog/piano/Piano/abc123 -``` - -The frontend was passing these prefix strings directly to `new URL()` and `new Request()`, which only accept valid URLs. This caused `TypeError: Invalid URL` crashes that broke **all catalog-realm card interactions**: - -- **spec-preview** — completely broken when viewing any catalog card -- **Create card / Create listing** — crashed on save, card never appeared in store -- **Delete card** — crash trying to build the delete request URL -- **Remix** — crashed opening the remix panel for a catalog card -- **View card / click to open** — crashed navigating to any catalog card - -## Root cause - -There were **five distinct entry points** where prefix-form IDs entered the frontend without being resolved. No single fix covered all of them — each entry point bypassed the others. - -## Fix strategy - -Rather than adding guards in every affected component (dozens of call sites), the fix resolves prefix form **at the highest possible upstream point** for each entry path. Downstream components like `spec-preview` require no changes — they keep their existing `new URL(card.id)` calls, which now always receive a valid absolute URL. - -**All fixes are frontend-only, in-memory. The database is never touched — it intentionally keeps prefix form for portability.** - -## Fixes - -| # | File | Entry point fixed | Why this location | -|---|---|---|---| -| 1 | `virtual-network.ts` | Prefix string passed as a `fetch()` URL | Every network call goes through here; intercept before `new Request()` is built | -| 2 | `store.ts` `createFromSerialized` | `resource.id` / `included[].id` from server HTTP response | Normalize before card API hydrates JSON into a card instance — `card.id` is always absolute after this | -| 3 | `store.ts` `persistAndUpdate` | `json.data.id` returned by server after save | Normalize immediately after save response; prevents false-positive `needsServerStateMerge` and wrong `api.setId` | -| 4 | `code-ref.ts` `identifyCard` | `codeRef.module` from Loader's internal identity map | Loader stores module paths as prefix internally; resolve once here so all 8+ callers automatically get absolute module paths | -| 5 | `operator-mode-state-service.ts` `deserialize` | Trail card IDs read from localStorage / URL hash | Persisted state bypasses all runtime fixes above; `normalizeTrailItem` resolves on read, drops corrupt entries | -| 6 | `routes/module.ts` `buildModuleModel` | Module `id` from browser URL query string | Route parameter enters the app before any store or Loader processing; `cardIdToURL(id)` replaces `new URL(id)` | - -## Key utility - -```typescript -// card-reference-resolver.ts -cardIdToURL("@cardstack/catalog/piano/Piano/abc123") - → new URL("http://localhost:4201/catalog/piano/Piano/abc123") ✅ - -cardIdToURL("http://localhost:4201/catalog/piano/...") - → passes through unchanged ✅ - -// Always guard local IDs (unsaved cards) first: -if (!isLocalId(id)) { cardIdToURL(id) } -``` diff --git a/packages/runtime-common/code-ref.ts b/packages/runtime-common/code-ref.ts index 4acfe8f8e39..a448995c738 100644 --- a/packages/runtime-common/code-ref.ts +++ b/packages/runtime-common/code-ref.ts @@ -249,7 +249,10 @@ export function identifyCard( // "@cardstack/catalog/piano") for portability. Resolve to absolute URL // here so all callers get a usable module path — otherwise any caller // doing new URL(codeRef.module) would crash with "Invalid URL". - let resolvedRef = { ...ref, module: resolveCardReference(ref.module, undefined) }; + let resolvedRef = { + ...ref, + module: resolveCardReference(ref.module, undefined), + }; return maybeRelativeURL ? { ...resolvedRef, module: maybeRelativeURL(resolvedRef.module) } : resolvedRef; From 83c268fa4bae4542fa4a8254dee7df72128f6f7a Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 20 Apr 2026 14:51:35 +0800 Subject: [PATCH 3/7] fix copilot feedbck --- packages/host/app/services/store.ts | 104 ++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 23 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index e6f0404c26d..7bbbc263b6d 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -1028,31 +1028,89 @@ export default class StoreService extends Service implements StoreInterface { relativeTo?: URL | undefined, dependencyTrackingContext?: RuntimeDependencyTrackingContext, ): Promise { + let normalizeId = (id: string | undefined) => { + if (!id || isLocalId(id)) { + return id; + } + return cardIdToURL(id).href; + }; + + let normalizeRelationship = ( + relationship: CardDef['relationships'] extends Record + ? R + : any, + ) => { + if (!relationship?.data) { + return relationship; + } + + if (Array.isArray(relationship.data)) { + return { + ...relationship, + data: relationship.data.map((resourceIdentifier: any) => { + let normalizedId = normalizeId(resourceIdentifier.id); + return normalizedId === resourceIdentifier.id + ? resourceIdentifier + : { ...resourceIdentifier, id: normalizedId }; + }), + }; + } + + let normalizedId = normalizeId(relationship.data.id); + if (normalizedId === relationship.data.id) { + return relationship; + } + + return { + ...relationship, + data: { ...relationship.data, id: normalizedId }, + }; + }; + + let normalizeResource = (resource: R): R => { + let normalizedId = normalizeId(resource.id); + let normalizedRelationships = resource.relationships + ? Object.fromEntries( + Object.entries(resource.relationships).map( + ([name, relationship]) => [ + name, + normalizeRelationship(relationship as any), + ], + ), + ) + : resource.relationships; + + return { + ...resource, + ...(normalizedId === resource.id ? null : { id: normalizedId }), + ...(normalizedRelationships + ? { + relationships: + normalizedRelationships as typeof resource.relationships, + } + : null), + } as R; + }; + // Normalize prefix-form IDs to absolute URLs before hydration so that - // card.id is always an absolute URL after createFromSerialized returns. - // This prevents downstream callers (e.g. spec-preview) from crashing when - // they call new URL(card.id) on a prefix-form string. - if (resource.id && !isLocalId(resource.id)) { - let absoluteId = cardIdToURL(resource.id).href; - resource = { ...resource, id: absoluteId }; - doc = { - ...doc, - data: { ...(doc as LooseSingleCardDocument).data, id: absoluteId }, - } as LooseSingleCardDocument; - } - // Also normalize IDs in included resources so linked cards have absolute - // URL ids after deserialization (e.g. linksToMany fields). - if (doc.included?.length) { - doc = { - ...doc, - included: doc.included.map((included) => { - if (included.id && !isLocalId(included.id)) { - return { ...included, id: cardIdToURL(included.id).href }; + // saved/non-local card IDs are absolute URLs after createFromSerialized + // returns. This prevents downstream callers (e.g. spec-preview) from + // crashing when they call new URL(card.id) on a prefix-form string. + // Keep resource IDs and relationship identifiers consistent across the + // whole JSON:API document so included resources still side-load correctly. + resource = normalizeResource(resource); + doc = { + ...doc, + data: normalizeResource((doc as LooseSingleCardDocument).data), + ...(doc.included?.length + ? { + included: doc.included.map((included) => + normalizeResource(included as LooseCardResource), + ), } - return included; - }), - } as typeof doc; - } + : null), + } as typeof doc; + let api = await this.cardService.getAPI(); let shouldStubTimers = this.renderContextBlocksPersistence() && !isTesting(); From b9911ba1897bf2146b966349f1b9e470c2b6f605 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 20 Apr 2026 14:51:53 +0800 Subject: [PATCH 4/7] fix host test --- .../operator-mode/interact-submode.gts | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/host/app/components/operator-mode/interact-submode.gts b/packages/host/app/components/operator-mode/interact-submode.gts index 9291c64d928..9ae2d211907 100644 --- a/packages/host/app/components/operator-mode/interact-submode.gts +++ b/packages/host/app/components/operator-mode/interact-submode.gts @@ -663,31 +663,42 @@ export default class InteractSubmode extends Component { } let items: { name: string; icon: Icon; ref: ResolvedCodeRef }[] = []; + let seenTypeNames = new Set(); const excludedCardIds = this.realmServer.availableRealmIndexCardIds; - recentCards - .filter((card) => !excludedCardIds.includes(card.id)) // filter out realm index cards - .map((card) => { - let ref = identifyCard(card.constructor); - let name = cardTypeDisplayName(card); - if (isResolvedCodeRef(ref)) { - if (items.find((item) => item.ref === ref && item.name === name)) { - // do not add duplicate of the same card type - return; - } - items.push({ - name, - icon: cardTypeIcon(card) as Icon, - ref, - }); - } + for (let card of recentCards) { + if (excludedCardIds.includes(card.id)) { + // filter out realm index cards + continue; + } + + let ref = identifyCard(card.constructor); + if (!isResolvedCodeRef(ref)) { + continue; + } + + let name = cardTypeDisplayName(card); + if (seenTypeNames.has(name)) { + // do not add duplicates of the same card type + continue; + } + + seenTypeNames.add(name); + items.push({ + name, + icon: cardTypeIcon(card) as Icon, + ref, }); - let cardTypes = [...new Set(items)].slice(0, 2); // need only the 2 most-recent + if (items.length === 2) { + // need only the 2 most-recent + break; + } + } let menuItems: (MenuItem | MenuDivider)[] = []; - if (cardTypes.length) { - cardTypes.map(({ name, icon, ref }) => { + if (items.length) { + items.map(({ name, icon, ref }) => { menuItems.push( new MenuItem({ label: name, From 1b8a0a260402e62c4cf3fc1b9aac2e413677e5c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 07:04:09 +0000 Subject: [PATCH 5/7] fix: address code review feedback - null spread, code-ref guard, dedup by ref key Agent-Logs-Url: https://github.com/cardstack/boxel/sessions/43cd8c22-56eb-492c-835a-c853cf72e5c4 Co-authored-by: lucaslyl <57783526+lucaslyl@users.noreply.github.com> --- .../operator-mode/interact-submode.gts | 9 +++++---- packages/host/app/services/store.ts | 6 +++--- packages/runtime-common/code-ref.ts | 20 +++++++++++-------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/host/app/components/operator-mode/interact-submode.gts b/packages/host/app/components/operator-mode/interact-submode.gts index 9ae2d211907..e8f652e9a60 100644 --- a/packages/host/app/components/operator-mode/interact-submode.gts +++ b/packages/host/app/components/operator-mode/interact-submode.gts @@ -663,7 +663,7 @@ export default class InteractSubmode extends Component { } let items: { name: string; icon: Icon; ref: ResolvedCodeRef }[] = []; - let seenTypeNames = new Set(); + let seenTypeKeys = new Set(); const excludedCardIds = this.realmServer.availableRealmIndexCardIds; for (let card of recentCards) { @@ -677,13 +677,14 @@ export default class InteractSubmode extends Component { continue; } - let name = cardTypeDisplayName(card); - if (seenTypeNames.has(name)) { + let typeKey = `${ref.module}::${ref.name}`; + if (seenTypeKeys.has(typeKey)) { // do not add duplicates of the same card type continue; } - seenTypeNames.add(name); + let name = cardTypeDisplayName(card); + seenTypeKeys.add(typeKey); items.push({ name, icon: cardTypeIcon(card) as Icon, diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 7bbbc263b6d..9c3a3928552 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -1082,13 +1082,13 @@ export default class StoreService extends Service implements StoreInterface { return { ...resource, - ...(normalizedId === resource.id ? null : { id: normalizedId }), + ...(normalizedId === resource.id ? {} : { id: normalizedId }), ...(normalizedRelationships ? { relationships: normalizedRelationships as typeof resource.relationships, } - : null), + : {}), } as R; }; @@ -1108,7 +1108,7 @@ export default class StoreService extends Service implements StoreInterface { normalizeResource(included as LooseCardResource), ), } - : null), + : {}), } as typeof doc; let api = await this.cardService.getAPI(); diff --git a/packages/runtime-common/code-ref.ts b/packages/runtime-common/code-ref.ts index a448995c738..54d1ae89ad5 100644 --- a/packages/runtime-common/code-ref.ts +++ b/packages/runtime-common/code-ref.ts @@ -22,7 +22,10 @@ import { CardError } from './error'; import { cardIdToURL } from './card-reference-resolver'; import type { LooseCardResource, FileMetaResource } from './index'; import { trimExecutableExtension } from './index'; -import { resolveCardReference } from './card-reference-resolver'; +import { + resolveCardReference, + isRegisteredPrefix, +} from './card-reference-resolver'; import type { RuntimeDependencyTrackingContext } from './dependency-tracker'; export type ResolvedCodeRef = { @@ -246,13 +249,14 @@ export function identifyCard( let ref = Loader.identify(card); if (ref) { // Loader stores module paths as prefix form internally (e.g. - // "@cardstack/catalog/piano") for portability. Resolve to absolute URL - // here so all callers get a usable module path — otherwise any caller - // doing new URL(codeRef.module) would crash with "Invalid URL". - let resolvedRef = { - ...ref, - module: resolveCardReference(ref.module, undefined), - }; + // "@cardstack/catalog/piano") for portability. Only resolve to absolute + // URL when the module path is a registered prefix — relative paths and + // bare specifiers are left untouched so identifyCard() never throws. + let resolvedModule = + isRegisteredPrefix(ref.module) + ? resolveCardReference(ref.module, undefined) + : ref.module; + let resolvedRef = { ...ref, module: resolvedModule }; return maybeRelativeURL ? { ...resolvedRef, module: maybeRelativeURL(resolvedRef.module) } : resolvedRef; From da7791a2c05104cf414b4699fa85bd337d105d97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 07:14:14 +0000 Subject: [PATCH 6/7] fix: resolve lint failures - use Relationship type in store.ts and fix prettier in code-ref.ts Agent-Logs-Url: https://github.com/cardstack/boxel/sessions/43cd8c22-56eb-492c-835a-c853cf72e5c4 Co-authored-by: lucaslyl <57783526+lucaslyl@users.noreply.github.com> --- packages/host/app/services/store.ts | 6 +----- packages/runtime-common/code-ref.ts | 7 +++---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 9c3a3928552..2edd41916c6 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -1035,11 +1035,7 @@ export default class StoreService extends Service implements StoreInterface { return cardIdToURL(id).href; }; - let normalizeRelationship = ( - relationship: CardDef['relationships'] extends Record - ? R - : any, - ) => { + let normalizeRelationship = (relationship: Relationship | undefined) => { if (!relationship?.data) { return relationship; } diff --git a/packages/runtime-common/code-ref.ts b/packages/runtime-common/code-ref.ts index 54d1ae89ad5..b433bdd8cd6 100644 --- a/packages/runtime-common/code-ref.ts +++ b/packages/runtime-common/code-ref.ts @@ -252,10 +252,9 @@ export function identifyCard( // "@cardstack/catalog/piano") for portability. Only resolve to absolute // URL when the module path is a registered prefix — relative paths and // bare specifiers are left untouched so identifyCard() never throws. - let resolvedModule = - isRegisteredPrefix(ref.module) - ? resolveCardReference(ref.module, undefined) - : ref.module; + let resolvedModule = isRegisteredPrefix(ref.module) + ? resolveCardReference(ref.module, undefined) + : ref.module; let resolvedRef = { ...ref, module: resolvedModule }; return maybeRelativeURL ? { ...resolvedRef, module: maybeRelativeURL(resolvedRef.module) } From 3591caf00522631f02e661500ba4ff3437246200 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 20 Apr 2026 15:47:43 +0800 Subject: [PATCH 7/7] Added a new integration regression test --- packages/host/app/services/store.ts | 4 +- .../host/tests/integration/store-test.gts | 97 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 2edd41916c6..db1cce8a596 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -1052,8 +1052,8 @@ export default class StoreService extends Service implements StoreInterface { }; } - let normalizedId = normalizeId(relationship.data.id); - if (normalizedId === relationship.data.id) { + let normalizedId = normalizeId((relationship.data as any).id); + if (normalizedId === (relationship.data as any).id) { return relationship; } diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 6ac63e9e7ba..6aa254e183e 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -337,6 +337,103 @@ module('Integration | Store', function (hooks) { assert.strictEqual(file, undefined, 'delete() removes the remote card'); }); + test('createFromSerialized normalizes prefix-form ids consistently for relationships and included resources', async function (assert) { + registerCardReferencePrefix('@test-prefix/', testRealmURL); + + let doc = { + data: { + type: 'card', + id: '@test-prefix/Person/hassan', + attributes: { + name: 'Hassan', + }, + relationships: { + bestFriend: { + data: { type: 'card', id: '@test-prefix/Person/jade' }, + }, + }, + meta: { + adoptsFrom: { + module: '@test-prefix/person', + name: 'Person', + }, + }, + }, + included: [ + { + type: 'card', + id: '@test-prefix/Person/jade', + attributes: { + name: 'Jade', + }, + meta: { + adoptsFrom: { + module: '@test-prefix/person', + name: 'Person', + }, + }, + }, + ], + } as LooseSingleCardDocument; + + let card = (await (storeService as any).__dangerousCreateFromSerialized( + doc.data, + doc, + new URL(testRealmURL), + )) as CardDefType; + + assert.strictEqual( + card.id, + `${testRealmURL}Person/hassan`, + 'primary resource id is normalized to absolute url', + ); + assert.strictEqual( + (card as any).bestFriend?.id, + `${testRealmURL}Person/jade`, + 'relationship identifier stays consistent with normalized included id', + ); + assert.strictEqual( + (card as any).bestFriend.name, + 'Jade', + 'included related resource is resolved via side-loading after normalization', + ); + }); + + test('add() handles prefix-form save response ids and assigns absolute id to new cards', async function (assert) { + registerCardReferencePrefix('@test-prefix/', testRealmURL); + + let originalSaveCardDocument = (storeService as any).saveCardDocument; + (storeService as any).saveCardDocument = async () => + ({ + data: { + type: 'card', + id: '@test-prefix/Person/andrea', + attributes: { + name: 'Andrea', + }, + meta: { + adoptsFrom: { + module: `${testRealmURL}person`, + name: 'Person', + }, + }, + }, + }) as SingleCardDocument; + + try { + let instance = new PersonDef({ name: 'Andrea' }); + await storeService.add(instance); + + assert.strictEqual( + instance.id, + `${testRealmURL}Person/andrea`, + 'new instance id is normalized from prefix-form save response', + ); + } finally { + (storeService as any).saveCardDocument = originalSaveCardDocument; + } + }); + test('peekError returns the server state error when a stale instance exists', async function (assert) { storeService.addReference(`${testRealmURL}Person/hassan`); await storeService.flush();