From 1fca86dd3c9a294a650f46b1ee18ff767817071c Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 10 Jul 2026 13:17:40 -0500 Subject: [PATCH 01/13] refactor: key the host store by canonical identifier spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asURL's string branch normalized remote ids to URL form while its document branch returned data.id verbatim — which is canonical (prefix form for mapped realms) as served — so the two branches could key the same mapped-realm card under different spellings. Fold the string branch to the canonical spelling too: parse through the VN's URL machinery for normalization, then back to prefix form where a realm mapping exists. The render store's key normalization gets the same treatment. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/render-service.ts | 4 +++- packages/host/app/services/store.ts | 13 +++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/host/app/services/render-service.ts b/packages/host/app/services/render-service.ts index ca0c1465e83..2de8ed26674 100644 --- a/packages/host/app/services/render-service.ts +++ b/packages/host/app/services/render-service.ts @@ -143,7 +143,9 @@ export class CardStoreWithErrors implements CardStore { 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. - return isLocalId(key) ? id : this.#virtualNetwork.toURL(id).href; + return isLocalId(key) + ? id + : this.#virtualNetwork.unresolveURL(this.#virtualNetwork.toURL(id).href); } trackLoad(load: Promise) { diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 684565478be..cf416bea800 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -2875,12 +2875,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( From d95b506a08f8f689d2401091841198a15fe5c46f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 10 Jul 2026 13:59:52 -0500 Subject: [PATCH 02/13] fix: keep the render store's load path in resolved-URL form The render store's cache-key normalization and its document-load normalization shared one helper, so folding keys to canonical spelling also changed the URL that loadCardDocument stamps onto the returned document's id. Split them: cache keys fold to the canonical spelling; load targets resolve to the real URL at the fetch boundary. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/render-service.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/host/app/services/render-service.ts b/packages/host/app/services/render-service.ts index 2de8ed26674..07f71ccbcd3 100644 --- a/packages/host/app/services/render-service.ts +++ b/packages/host/app/services/render-service.ts @@ -136,16 +136,20 @@ 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. - return isLocalId(key) - ? id - : this.#virtualNetwork.unresolveURL(this.#virtualNetwork.toURL(id).href); + // 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; } trackLoad(load: Promise) { From 061552d7707608419da85746190f185e6d12dd68 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Mon, 13 Jul 2026 15:04:23 -0500 Subject: [PATCH 03/13] fix: resolve prefix-form ids in realmOf so realm subscriptions install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store keys mapped-realm instances in prefix form, and addReference passes those ids to subscribeToRealm -> realm.realmOf. realmOf scanned its URL-keyed realms with a VirtualNetwork-less RealmPaths, whose inRealm only matches same-form inputs — so prefix-form ids never matched, no Matrix subscription was installed, and cards in mapped realms missed invalidation events. Pass the VirtualNetwork into RealmPaths so its cross-form matching applies. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/realm.ts | 6 ++++- .../host/tests/integration/store-test.gts | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/host/app/services/realm.ts b/packages/host/app/services/realm.ts index 16bbaea85f3..c01e3dc7f0a 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/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 6774d94a355..3943466d51b 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -344,6 +344,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 From 0000c78f21ee2e8238da742fb39f97980d6ddd45 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 14 Jul 2026 07:36:19 -0500 Subject: [PATCH 04/13] fix: compare realm membership in URL space when unsubscribing unsubscribeFromInstance tested realm membership with a bare id.startsWith(realmURL), but the store now keys mapped-realm instances and reference counts in prefix form while subscription keys stay realm-root URLs (realm events route by URL). The prefix-form id never matched the URL-form realm key, so a mapped realm's Matrix subscription leaked and its reference scan mis-counted. Resolve each id to URL space via toURLHref before the comparison, mirroring the cross-form fix on the subscribe side in realmOf. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/store.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index cf416bea800..9fe160eb4c3 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -1639,10 +1639,14 @@ 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. + // Subscription keys are realm-root URLs (realm events route by URL), while + // ids are in canonical form (prefix form for mapped realms), so resolve + // each id to URL space before the realm-membership comparison. + let vn = this.network.virtualNetwork; let realmHref = !isLocalId(id) ? [...this.subscriptions.keys()].find((realmURL) => - id.startsWith(realmURL), + vn.toURLHref(id).startsWith(realmURL), ) : undefined; if (!realmHref) { @@ -1656,7 +1660,7 @@ export default class StoreService extends Service implements StoreInterface { ([referenceId, count]) => !isLocalId(referenceId) && count > 0 && - referenceId.startsWith(realmHref), + vn.toURLHref(referenceId).startsWith(realmHref), ) ) { subscription.unsubscribe(); From 7b1ab21136a1db80e2c68b7edd3facb40947485d Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 14 Jul 2026 07:41:04 -0500 Subject: [PATCH 05/13] perf: use memoized toURLHref for store id resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asURL runs on nearly every store operation and resolved ids via vn.toURL(id).href, allocating a URL per call. toURLHref is the memoized equivalent — same resolution and throw behavior — so repeat ids become a Map lookup. Also apply it to the two prefix-resolution read sites that used the same pattern. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/store.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 9fe160eb4c3..dbeb33025ed 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -2131,7 +2131,7 @@ export default class StoreService extends Service implements StoreInterface { } // Resolve registered prefix IDs (e.g. @cardstack/skills/...) to actual // URLs so they can be used for fetching. - let url = vn.isRegisteredPrefix(id) ? vn.toURL(id).href : id; + let url = vn.isRegisteredPrefix(id) ? vn.toURLHref(id) : id; let doc = (typeof idOrDoc !== 'string' ? idOrDoc : undefined) as | SingleCardDocument | undefined; @@ -2301,7 +2301,7 @@ export default class StoreService extends Service implements StoreInterface { if (isLocalId(id) && !vn.isRegisteredPrefix(id)) { throw new Error(`file-meta reads do not support local ids (${id})`); } - let url = vn.isRegisteredPrefix(id) ? vn.toURL(id).href : id; + let url = vn.isRegisteredPrefix(id) ? vn.toURLHref(id) : id; let fileMetaDoc: SingleFileMetaDocument | CardError; if (this.isRenderStore && (globalThis as any).__boxelRenderContext) { fileMetaDoc = await this.extractFileMetaDirectly(url); @@ -2885,7 +2885,7 @@ export function asURL( // 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); + return isLocalId(id) ? id : vn.unresolveURL(vn.toURLHref(id)); } function isSystemCardDefaultId( From 8aef3627eafd9248b5f49b7a7b66fb3d7f79383e Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 14 Jul 2026 07:44:31 -0500 Subject: [PATCH 06/13] perf: use memoized toURLHref in render-service id normalization normalizeKey runs on every render-store cache get/set and normalizeURL on the load path; both resolved via vn.toURL(id).href, allocating a URL per call. Swap to the memoized toURLHref, which has identical resolution and throw behavior, so repeat ids become a Map lookup. normalizeURL keeps resolving the original id (not the .json-stripped key) so the loaded document's id is stamped from the fetch-form URL. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/render-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/host/app/services/render-service.ts b/packages/host/app/services/render-service.ts index 07f71ccbcd3..494def0bbc2 100644 --- a/packages/host/app/services/render-service.ts +++ b/packages/host/app/services/render-service.ts @@ -142,14 +142,14 @@ export class CardStoreWithErrors implements CardStore { // matching the host store's keying. return isLocalId(key) ? key - : this.#virtualNetwork.unresolveURL(this.#virtualNetwork.toURL(key).href); + : this.#virtualNetwork.unresolveURL(this.#virtualNetwork.toURLHref(key)); } private normalizeURL(id: string): string { let key = id.replace(/\.json$/, ''); // 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; + return isLocalId(key) ? id : this.#virtualNetwork.toURLHref(id); } trackLoad(load: Promise) { From b61992765ec29cd45b7d4bcf73733e47418568a2 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 14 Jul 2026 07:57:21 -0500 Subject: [PATCH 07/13] test: assert prefix-form reference unsubscribes on last drop The realm-subscription lifecycle test installed the subscription and dropped the reference but never asserted the teardown, so the unsubscribe realm-membership mismatch went uncaught. Assert the subscription is gone after the last drop; this fails on a URL-space startsWith against a prefix-form id and passes with the URL-space comparison. Co-Authored-By: Claude Fable 5 --- packages/host/tests/integration/store-test.gts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 3943466d51b..f1596d43994 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -344,7 +344,7 @@ 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) { + test('installs and removes the realm subscription across a prefix-form reference lifecycle', async function (assert) { getService('network').virtualNetwork.addRealmMapping( '@test-prefix/', testRealmURL, @@ -367,6 +367,11 @@ module('Integration | Store', function (hooks) { await storeService.flush(); storeService.dropReference('@test-prefix/Person/hassan'); + + assert.false( + subscriptions.has(testRealmURL), + 'dropping the last prefix-form reference unsubscribes from its realm', + ); }); test('deleting a linked target rewrites a loaded consumer linksTo slot to a broken-link sentinel', async function (assert) { From b281d348a4f56590e79e886611662a484ee9295e Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 14 Jul 2026 09:19:22 -0500 Subject: [PATCH 08/13] Revert unsubscribe realm-membership fix and its test Enabling teardown of mapped-realm subscriptions (which the store now keys in prefix form) destabilized acceptance tests: a subscription torn down on a reference drop stops delivering the realm invalidations a test awaits, so the test hangs to timeout. The pre-existing behavior leaks the subscription but keeps invalidations flowing, so restore it here. A safe teardown needs the subscription lifecycle stabilized separately, not the key flip plus eager unsubscribe in one step. The toURLHref performance changes are unaffected and stay. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/store.ts | 10 +++------- packages/host/tests/integration/store-test.gts | 7 +------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index dbeb33025ed..f0b2d905009 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -1639,14 +1639,10 @@ export default class StoreService extends Service implements StoreInterface { ); } - // if there are no more subscribers to this realm then unsubscribe from realm. - // Subscription keys are realm-root URLs (realm events route by URL), while - // ids are in canonical form (prefix form for mapped realms), so resolve - // each id to URL space before the realm-membership comparison. - let vn = this.network.virtualNetwork; + // if there are no more subscribers to this realm then unsubscribe from realm let realmHref = !isLocalId(id) ? [...this.subscriptions.keys()].find((realmURL) => - vn.toURLHref(id).startsWith(realmURL), + id.startsWith(realmURL), ) : undefined; if (!realmHref) { @@ -1660,7 +1656,7 @@ export default class StoreService extends Service implements StoreInterface { ([referenceId, count]) => !isLocalId(referenceId) && count > 0 && - vn.toURLHref(referenceId).startsWith(realmHref), + referenceId.startsWith(realmHref), ) ) { subscription.unsubscribe(); diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index f1596d43994..3943466d51b 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -344,7 +344,7 @@ module('Integration | Store', function (hooks) { assert.strictEqual(file, undefined, 'delete() removes the remote card'); }); - test('installs and removes the realm subscription across a prefix-form reference lifecycle', async function (assert) { + test('adding a prefix-form reference installs the realm subscription', async function (assert) { getService('network').virtualNetwork.addRealmMapping( '@test-prefix/', testRealmURL, @@ -367,11 +367,6 @@ module('Integration | Store', function (hooks) { await storeService.flush(); storeService.dropReference('@test-prefix/Person/hassan'); - - assert.false( - subscriptions.has(testRealmURL), - 'dropping the last prefix-form reference unsubscribes from its realm', - ); }); test('deleting a linked target rewrites a loaded consumer linksTo slot to a broken-link sentinel', async function (assert) { From d873935c5d961b450df067d7b0edbcf27103aa8d Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 14 Jul 2026 09:42:32 -0500 Subject: [PATCH 09/13] Decouple realm subscription lifetime from instance reference counts Realm subscriptions are keyed by realm-root URL (invalidation events route by URL) while the store keys instances and reference counts in canonical form (prefix form for mapped realms). Reconciling the two forms on every reference drop to decide when to unsubscribe races invalidation delivery: tearing a subscription down as the last reference drops loses updates a caller is still awaiting, which hung acceptance tests once the prefix-form keying made the drop path actually fire. Realms are few and their subscriptions cheap, so stop reference-counting them: install one on first use and release them all together when the store resets or is destroyed. unsubscribeFromInstance now only stops per-instance field-change tracking; a new unsubscribeFromAllRealms runs from resetState and the destructor. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/store.ts | 41 +++++++++---------- .../host/tests/integration/store-test.gts | 11 ++++- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index f0b2d905009..bf357f1db7d 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -308,6 +308,7 @@ export default class StoreService extends Service implements StoreInterface { this.ready = this.setup(); registerDestructor(this, () => { clearInterval(this.gcInterval); + this.unsubscribeFromAllRealms(); }); } @@ -331,6 +332,7 @@ export default class StoreService extends Service implements StoreInterface { resetState() { clearInterval(this.gcInterval); + this.unsubscribeFromAllRealms(); this.subscriptions = new Map(); this.cardInvalidationSubscribers = new Map(); this.onSaveSubscriber = undefined; @@ -1631,6 +1633,10 @@ export default class StoreService extends Service implements StoreInterface { } private unsubscribeFromInstance(id: string) { + // Stop tracking this instance for in-place field changes. The realm-level + // Matrix subscription is intentionally left in place — see + // unsubscribeFromAllRealms for why realm subscriptions are not + // reference-counted. let instance = this.store.getCard(id); if (instance && this.cardApiCache) { this.cardApiCache.unsubscribeFromChanges( @@ -1638,30 +1644,21 @@ export default class StoreService extends Service implements StoreInterface { this.onInstanceUpdated, ); } + } - // if there are no more subscribers to this realm then unsubscribe from realm - let realmHref = !isLocalId(id) - ? [...this.subscriptions.keys()].find((realmURL) => - id.startsWith(realmURL), - ) - : undefined; - if (!realmHref) { - return; - } - - let subscription = this.subscriptions.get(realmHref); - if ( - subscription && - ![...this.referenceCount.entries()].find( - ([referenceId, count]) => - !isLocalId(referenceId) && - count > 0 && - referenceId.startsWith(realmHref), - ) - ) { - subscription.unsubscribe(); - this.subscriptions.delete(realmHref); + // Realm subscriptions are keyed by realm-root URL (realm invalidation events + // route by URL) while the store keys instances and reference counts in + // canonical form (prefix form for mapped realms). Reconciling those forms on + // every reference drop to decide when to unsubscribe is both error-prone and + // races invalidation delivery — tearing a subscription down mid-flight drops + // updates a caller is still waiting on. Realms are few and their + // subscriptions cheap, so install one on first use (subscribeToRealm) and + // release them all together when the store resets or is destroyed. + private unsubscribeFromAllRealms() { + for (let { unsubscribe } of this.subscriptions.values()) { + unsubscribe(); } + this.subscriptions.clear(); } private createCardStore(): CardStore { diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 3943466d51b..79ee9ba64e9 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -344,7 +344,7 @@ 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) { + test('a prefix-form reference installs a realm subscription that outlives the reference', async function (assert) { getService('network').virtualNetwork.addRealmMapping( '@test-prefix/', testRealmURL, @@ -367,6 +367,15 @@ module('Integration | Store', function (hooks) { await storeService.flush(); storeService.dropReference('@test-prefix/Person/hassan'); + + // Realm subscriptions are not reference-counted: dropping the last + // reference leaves the subscription in place (it is released only when the + // store resets or is destroyed), so realm invalidations keep flowing to + // callers that re-reference a card in this realm. + assert.true( + subscriptions.has(testRealmURL), + 'dropping the last reference leaves the realm subscription installed', + ); }); test('deleting a linked target rewrites a loaded consumer linksTo slot to a broken-link sentinel', async function (assert) { From a9c808edf2f3a56096a787f450c70f32f3016d3f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 14 Jul 2026 12:15:04 -0500 Subject: [PATCH 10/13] Only release realm subscriptions on destroy, not resetState resetState runs at mid-session boundaries (realm/workspace switch, route deactivate), not just teardown. Unsubscribing there stops realm invalidations reaching cards that outlive the reset, hanging any test that loads a card, crosses a resetState, then waits for an update. Keep resetState's original behavior (drop the map, leave the subscriptions to be re-established on next use) and release subscriptions only from the destructor. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/store.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index bf357f1db7d..02e07267b68 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -332,7 +332,11 @@ export default class StoreService extends Service implements StoreInterface { resetState() { clearInterval(this.gcInterval); - this.unsubscribeFromAllRealms(); + // Deliberately does NOT unsubscribe here: resetState runs at mid-session + // boundaries (realm/workspace switch, route deactivate), and realm + // invalidations must keep flowing to cards that survive the reset. + // Subscriptions are released only when the store is destroyed + // (see the destructor's unsubscribeFromAllRealms). this.subscriptions = new Map(); this.cardInvalidationSubscribers = new Map(); this.onSaveSubscriber = undefined; @@ -1653,7 +1657,9 @@ export default class StoreService extends Service implements StoreInterface { // races invalidation delivery — tearing a subscription down mid-flight drops // updates a caller is still waiting on. Realms are few and their // subscriptions cheap, so install one on first use (subscribeToRealm) and - // release them all together when the store resets or is destroyed. + // release them all together when the store is destroyed. This runs only from + // the destructor — not resetState, which is a mid-session boundary where + // subscriptions must persist. private unsubscribeFromAllRealms() { for (let { unsubscribe } of this.subscriptions.values()) { unsubscribe(); From 8b321c8cd0a27bdbf136c3c2943d9e038bb822d9 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 14 Jul 2026 12:58:34 -0500 Subject: [PATCH 11/13] Update file-meta subscription test to the not-reference-counted contract Realm subscriptions are no longer torn down when a reference count hits zero, so this test's post-drop assertion is inverted: the subscription now persists past the last reference (released only on store destroy). Co-Authored-By: Claude Fable 5 --- packages/host/tests/integration/store-test.gts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index 79ee9ba64e9..c98060218c8 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -769,7 +769,7 @@ module('Integration | Store', function (hooks) { ); }); - test('realm subscription is removed when file-meta reference count drops to zero', async function (assert) { + test('a file-meta reference installs a realm subscription that outlives the reference', async function (assert) { await testRealm.write('hero.png', 'mock hero image'); let fileUrl = `${testRealmURL}hero.png`; let subscriptions = (storeService as any).subscriptions as Map< @@ -789,9 +789,12 @@ module('Integration | Store', function (hooks) { ); storeService.dropReference(fileUrl); - assert.false( + // Realm subscriptions are not reference-counted: dropping the last + // reference leaves the subscription installed (released only when the + // store is destroyed), so invalidations keep flowing. + assert.true( subscriptions.has(testRealmURL), - 'realm subscription is removed when file-meta reference reaches zero', + 'realm subscription persists after the file-meta reference drops', ); }); From e26b2c89e9c0833bd3a3a757d2e4912356ed813f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 15 Jul 2026 10:47:33 -0500 Subject: [PATCH 12/13] =?UTF-8?q?Drop=20the=20realm-subscription=20lifetim?= =?UTF-8?q?e=20rework;=20keep=20=C2=A73=20keying=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session-lifetime subscription change removed the deterministic hang but regressed invalidation-heavy tests ~2.7x (28s -> 78s): keeping every realm subscription up means the store processes invalidations it previously dropped during reference-count churn. Restore the pre-existing per-reference teardown, which leaves mapped-realm (prefix-keyed) subscriptions installed for the session as a benign, documented leak. §3 core canonical keying and the toURLHref perf tweaks are unaffected. The subscription-lifecycle rework (safe teardown + cheaper invalidation handling) is deferred; see the realm-subscription-lifecycle follow-up. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/store.ts | 53 ++++++++++--------- .../host/tests/integration/store-test.gts | 20 ++----- 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 38909047b1f..49cd757056b 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -308,7 +308,6 @@ export default class StoreService extends Service implements StoreInterface { this.ready = this.setup(); registerDestructor(this, () => { clearInterval(this.gcInterval); - this.unsubscribeFromAllRealms(); }); } @@ -332,11 +331,6 @@ export default class StoreService extends Service implements StoreInterface { resetState() { clearInterval(this.gcInterval); - // Deliberately does NOT unsubscribe here: resetState runs at mid-session - // boundaries (realm/workspace switch, route deactivate), and realm - // invalidations must keep flowing to cards that survive the reset. - // Subscriptions are released only when the store is destroyed - // (see the destructor's unsubscribeFromAllRealms). this.subscriptions = new Map(); this.cardInvalidationSubscribers = new Map(); this.onSaveSubscriber = undefined; @@ -1642,10 +1636,6 @@ export default class StoreService extends Service implements StoreInterface { } private unsubscribeFromInstance(id: string) { - // Stop tracking this instance for in-place field changes. The realm-level - // Matrix subscription is intentionally left in place — see - // unsubscribeFromAllRealms for why realm subscriptions are not - // reference-counted. let instance = this.store.getCard(id); if (instance && this.cardApiCache) { this.cardApiCache.unsubscribeFromChanges( @@ -1653,23 +1643,36 @@ export default class StoreService extends Service implements StoreInterface { this.onInstanceUpdated, ); } - } - // Realm subscriptions are keyed by realm-root URL (realm invalidation events - // route by URL) while the store keys instances and reference counts in - // canonical form (prefix form for mapped realms). Reconciling those forms on - // every reference drop to decide when to unsubscribe is both error-prone and - // races invalidation delivery — tearing a subscription down mid-flight drops - // updates a caller is still waiting on. Realms are few and their - // subscriptions cheap, so install one on first use (subscribeToRealm) and - // release them all together when the store is destroyed. This runs only from - // the destructor — not resetState, which is a mid-session boundary where - // subscriptions must persist. - private unsubscribeFromAllRealms() { - for (let { unsubscribe } of this.subscriptions.values()) { - unsubscribe(); + // 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), + ) + : undefined; + if (!realmHref) { + return; + } + + let subscription = this.subscriptions.get(realmHref); + if ( + subscription && + ![...this.referenceCount.entries()].find( + ([referenceId, count]) => + !isLocalId(referenceId) && + count > 0 && + referenceId.startsWith(realmHref), + ) + ) { + subscription.unsubscribe(); + this.subscriptions.delete(realmHref); } - this.subscriptions.clear(); } private createCardStore(): CardStore { diff --git a/packages/host/tests/integration/store-test.gts b/packages/host/tests/integration/store-test.gts index f9bf24ea289..28de918a3e1 100644 --- a/packages/host/tests/integration/store-test.gts +++ b/packages/host/tests/integration/store-test.gts @@ -343,7 +343,7 @@ module('Integration | Store', function (hooks) { assert.strictEqual(file, undefined, 'delete() removes the remote card'); }); - test('a prefix-form reference installs a realm subscription that outlives the reference', async function (assert) { + test('adding a prefix-form reference installs the realm subscription', async function (assert) { getService('network').virtualNetwork.addRealmMapping( '@test-prefix/', testRealmURL, @@ -366,15 +366,6 @@ module('Integration | Store', function (hooks) { await storeService.flush(); storeService.dropReference('@test-prefix/Person/hassan'); - - // Realm subscriptions are not reference-counted: dropping the last - // reference leaves the subscription in place (it is released only when the - // store resets or is destroyed), so realm invalidations keep flowing to - // callers that re-reference a card in this realm. - assert.true( - subscriptions.has(testRealmURL), - 'dropping the last reference leaves the realm subscription installed', - ); }); test('deleting a linked target rewrites a loaded consumer linksTo slot to a broken-link sentinel', async function (assert) { @@ -768,7 +759,7 @@ module('Integration | Store', function (hooks) { ); }); - test('a file-meta reference installs a realm subscription that outlives the reference', async function (assert) { + test('realm subscription is removed when file-meta reference count drops to zero', async function (assert) { await testRealm.write('hero.png', 'mock hero image'); let fileUrl = `${testRealmURL}hero.png`; let subscriptions = (storeService as any).subscriptions as Map< @@ -788,12 +779,9 @@ module('Integration | Store', function (hooks) { ); storeService.dropReference(fileUrl); - // Realm subscriptions are not reference-counted: dropping the last - // reference leaves the subscription installed (released only when the - // store is destroyed), so invalidations keep flowing. - assert.true( + assert.false( subscriptions.has(testRealmURL), - 'realm subscription persists after the file-meta reference drops', + 'realm subscription is removed when file-meta reference reaches zero', ); }); From df29628a56d642898b859305142bb0319a2671dd Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 15 Jul 2026 13:54:14 -0500 Subject: [PATCH 13/13] Revert toURLHref keying back to toURL(id).href MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the store and render-service identifier keying to exactly the form that was green (the §3-core state before any PR-feedback commits). The toURLHref change was a perf suggestion layered on afterward; with the unsubscribe fix and subscription-lifetime rework already reverted, this returns the branch to pure §3-core canonical keying on current main. Co-Authored-By: Claude Fable 5 --- packages/host/app/services/render-service.ts | 4 ++-- packages/host/app/services/store.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/host/app/services/render-service.ts b/packages/host/app/services/render-service.ts index 3395cb8c0cd..596d3fc97f9 100644 --- a/packages/host/app/services/render-service.ts +++ b/packages/host/app/services/render-service.ts @@ -146,14 +146,14 @@ export class CardStoreWithErrors implements CardStore { // matching the host store's keying. return isLocalId(key) ? key - : this.#virtualNetwork.unresolveURL(this.#virtualNetwork.toURLHref(key)); + : this.#virtualNetwork.unresolveURL(this.#virtualNetwork.toURL(key).href); } private normalizeURL(id: string): string { let key = id.replace(/\.json$/, ''); // 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.toURLHref(id); + return isLocalId(key) ? id : this.#virtualNetwork.toURL(id).href; } trackLoad(load: Promise) { diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 49cd757056b..0874f2bec4b 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -2147,7 +2147,7 @@ export default class StoreService extends Service implements StoreInterface { } // Resolve registered prefix IDs (e.g. @cardstack/skills/...) to actual // URLs so they can be used for fetching. - let url = vn.isRegisteredPrefix(id) ? vn.toURLHref(id) : id; + let url = vn.isRegisteredPrefix(id) ? vn.toURL(id).href : id; let doc = (typeof idOrDoc !== 'string' ? idOrDoc : undefined) as | SingleCardDocument | undefined; @@ -2317,7 +2317,7 @@ export default class StoreService extends Service implements StoreInterface { if (isLocalId(id) && !vn.isRegisteredPrefix(id)) { throw new Error(`file-meta reads do not support local ids (${id})`); } - let url = vn.isRegisteredPrefix(id) ? vn.toURLHref(id) : id; + let url = vn.isRegisteredPrefix(id) ? vn.toURL(id).href : id; let fileMetaDoc: SingleFileMetaDocument | CardError; if (this.isRenderStore && (globalThis as any).__boxelRenderContext) { fileMetaDoc = await this.extractFileMetaDirectly(url); @@ -2901,7 +2901,7 @@ export function asURL( // 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.toURLHref(id)); + return isLocalId(id) ? id : vn.unresolveURL(vn.toURL(id).href); } function isSystemCardDefaultId(