diff --git a/packages/host/app/services/realm.ts b/packages/host/app/services/realm.ts index 16bbaea85f..c01e3dc7f0 100644 --- a/packages/host/app/services/realm.ts +++ b/packages/host/app/services/realm.ts @@ -1057,7 +1057,11 @@ export default class RealmService extends Service { for (const realm of this.realms.keys()) { let paths = this.realmPathsCache.get(realm); if (!paths) { - paths = new RealmPaths(new URL(realm)); + // The VirtualNetwork lets inRealm() match prefix-form ids (the store + // keys mapped-realm instances in prefix form) against URL-keyed + // realms. The cached RealmPaths reads the live VN's mappings at call + // time, so entries don't go stale as mappings register. + paths = new RealmPaths(new URL(realm), this.network.virtualNetwork); this.realmPathsCache.set(realm, paths); } if (paths.inRealm(id)) { diff --git a/packages/host/app/services/render-service.ts b/packages/host/app/services/render-service.ts index b6b5975396..596d3fc97f 100644 --- a/packages/host/app/services/render-service.ts +++ b/packages/host/app/services/render-service.ts @@ -140,13 +140,19 @@ export class CardStoreWithErrors implements CardStore { } private normalizeKey(id: string): string { - return this.normalizeURL(id).replace(/\.json$/, ''); + let key = id.replace(/\.json$/, ''); + // Cache keys fold to the canonical spelling (prefix form where a realm + // mapping exists) so prefix-form and URL-form lookups share an entry, + // matching the host store's keying. + return isLocalId(key) + ? key + : this.#virtualNetwork.unresolveURL(this.#virtualNetwork.toURL(key).href); } private normalizeURL(id: string): string { let key = id.replace(/\.json$/, ''); - // Local IDs pass through; remote IDs canonicalize to URL form via the - // VN so prefix-form and URL-form lookups share a cache key. + // Load targets resolve to the real URL — this is the fetch boundary, and + // the loaded document's id is stamped from it. return isLocalId(key) ? id : this.#virtualNetwork.toURL(id).href; } diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index ea1de30fd5..27b109fd94 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -1673,7 +1673,13 @@ export default class StoreService extends Service implements StoreInterface { ); } - // if there are no more subscribers to this realm then unsubscribe from realm + // if there are no more subscribers to this realm then unsubscribe from + // realm. Note this uses a same-form startsWith, so a mapped realm whose + // instances are keyed in prefix form (e.g. the base realm) never matches + // and its subscription is left installed for the session — a benign, + // deliberate leak. Making the teardown form-aware races invalidation + // delivery, and removing it entirely regresses invalidation-heavy paths; + // see the realm-subscription-lifecycle follow-up. let realmHref = !isLocalId(id) ? [...this.subscriptions.keys()].find((realmURL) => id.startsWith(realmURL), @@ -2918,12 +2924,13 @@ export function asURL( return urlOrDoc.data.id; } let id = urlOrDoc.replace(/\.json$/, ''); - // Locals stay as-is; remotes resolve through the VN to a normalized URL. - // Keying stays in URL form so it matches gc-card-store, which keys instances - // by their (URL-form) data.id. Flipping the store's canonical key to RRI is - // deferred — it needs gc-card-store keyed the same way and the URL - // normalization `toURL` provides here (see CS-11730). - return isLocalId(id) ? id : vn.toURL(id).href; + // Locals stay as-is; remotes fold to the canonical spelling — through the + // VN's URL parse first (normalizing slashes/encoding the way a served URL + // is spelled), then back to prefix form where a realm mapping exists. This + // matches the document branch above, which returns `data.id` verbatim and + // is canonical (prefix form for mapped realms) as served, so both branches + // key a mapped realm's instance identically. + return isLocalId(id) ? id : vn.unresolveURL(vn.toURL(id).href); } function isSystemCardDefaultId( diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 9e5591b2a8..28de918a3e 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -343,6 +343,31 @@ module('Integration | Store', function (hooks) { assert.strictEqual(file, undefined, 'delete() removes the remote card'); }); + test('adding a prefix-form reference installs the realm subscription', async function (assert) { + getService('network').virtualNetwork.addRealmMapping( + '@test-prefix/', + testRealmURL, + ); + let subscriptions = (storeService as any).subscriptions as Map< + string, + { unsubscribe: () => void } + >; + assert.false( + subscriptions.has(testRealmURL), + 'realm is not subscribed before adding the reference', + ); + + storeService.addReference('@test-prefix/Person/hassan'); + + assert.true( + subscriptions.has(testRealmURL), + 'a prefix-form reference subscribes to its realm under the URL-form key', + ); + + await storeService.flush(); + storeService.dropReference('@test-prefix/Person/hassan'); + }); + test('deleting a linked target rewrites a loaded consumer linksTo slot to a broken-link sentinel', async function (assert) { // Load the consumer and the target, then link them so the consumer holds // the target as a resolved (present) link — the state a user is looking at