diff --git a/packages/host/app/components/operator-mode/interact-submode.gts b/packages/host/app/components/operator-mode/interact-submode.gts index 9291c64d928..e8f652e9a60 100644 --- a/packages/host/app/components/operator-mode/interact-submode.gts +++ b/packages/host/app/components/operator-mode/interact-submode.gts @@ -663,31 +663,43 @@ export default class InteractSubmode extends Component { } let items: { name: string; icon: Icon; ref: ResolvedCodeRef }[] = []; + let seenTypeKeys = 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 typeKey = `${ref.module}::${ref.name}`; + if (seenTypeKeys.has(typeKey)) { + // do not add duplicates of the same card type + continue; + } + + let name = cardTypeDisplayName(card); + seenTypeKeys.add(typeKey); + 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, 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..db1cce8a596 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -1028,6 +1028,85 @@ 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: Relationship | undefined) => { + 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 as any).id); + if (normalizedId === (relationship.data as any).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 ? {} : { id: normalizedId }), + ...(normalizedRelationships + ? { + relationships: + normalizedRelationships as typeof resource.relationships, + } + : {}), + } as R; + }; + + // Normalize prefix-form IDs to absolute URLs before hydration so that + // 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), + ), + } + : {}), + } as typeof doc; + let api = await this.cardService.getAPI(); let shouldStubTimers = this.renderContextBlocksPersistence() && !isTesting(); @@ -1333,6 +1412,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 +1993,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 +2020,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/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(); diff --git a/packages/runtime-common/code-ref.ts b/packages/runtime-common/code-ref.ts index 3244abfd061..b433bdd8cd6 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 = { @@ -245,9 +248,17 @@ 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. 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 - ? { ...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