From 4c25bf91895610c7372d3cb0258dfe3770845a5d Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 9 Jul 2026 14:08:18 -0500 Subject: [PATCH] Emit canonical RRI identifiers from the Loader's dependency and identity APIs getConsumedModules and getKnownConsumedModules now return module identifiers in canonical RRI form (realm-prefix spelling where a prefix mapping is registered), deduping spellings that collapse to one module and excluding self references under any spelling. identify() already recorded identities via unresolveURL; it now shares the same canonicalIdentifier helper. The runtime dependency tracker keys module nodes by http(s) URL, so the loader exposes dependencyTrackingKey() for crossing that boundary, and card-api's relationship module walk uses it. This also fixes prefix-form identity.module values being silently dropped by the tracker's URL guard. Module-cache keys deliberately stay URL-keyed; the moduleCacheKey comment explains why a realm-prefix-sensitive key would orphan cached entries and split class identities when mappings change mid-life. Co-Authored-By: Claude Fable 5 --- packages/base/card-api.gts | 15 ++++- packages/host/tests/unit/loader-test.ts | 23 ++++--- packages/runtime-common/loader.ts | 85 ++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index eac330aeba..ba11170df1 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -3929,15 +3929,24 @@ function trackRuntimeRelationshipModuleDependencies( return; } - trackRuntimeModuleDependency(identity.module, dependencyTrackingContext); - + // Loader identities and dependency lists are in canonical RRI form, while + // the dependency tracker keys module nodes by http(s) URL and drops + // anything else — so convert at this boundary via the loader's + // tracking-key form. let loader = Loader.getLoaderFor(ctor); + trackRuntimeModuleDependency( + loader ? loader.dependencyTrackingKey(identity.module) : identity.module, + dependencyTrackingContext, + ); if (!loader) { return; } for (let dep of loader.getKnownConsumedModules(identity.module)) { - trackRuntimeModuleDependency(dep, dependencyTrackingContext); + trackRuntimeModuleDependency( + loader.dependencyTrackingKey(dep), + dependencyTrackingContext, + ); } } diff --git a/packages/host/tests/unit/loader-test.ts b/packages/host/tests/unit/loader-test.ts index c1d6a74b2d..0bd3264e12 100644 --- a/packages/host/tests/unit/loader-test.ts +++ b/packages/host/tests/unit/loader-test.ts @@ -232,10 +232,10 @@ module('Unit | loader', function (hooks) { assert.strictEqual(myLoader(), loader, 'the loader instance is correct'); }); - // Regression test for CS-10498: after the import-maps change, module - // identifiers can be in registered prefix form (e.g. @cardstack/catalog/...). - // getConsumedModules passed these directly to new URL() which throws - // TypeError: Invalid URL. The fix uses resolveCardReference() first. + // Module identifiers can be in registered prefix form (e.g. + // @cardstack/catalog/...); the loader accepts either spelling as input and + // emits dependency lists in canonical form — the prefix spelling wherever a + // realm-prefix mapping is registered. test('can determine consumed modules using prefix-form module identifier', async function (assert) { // Realm-prefix mappings live on the per-app VirtualNetwork — without // the finally clause this registration leaks into later tests, @@ -247,14 +247,19 @@ module('Unit | loader', function (hooks) { // Import the module using its regular URL so it's in the loader cache await loader.import(`${testRealmURL}f`); - // Now call getConsumedModules with the prefix-form identifier. - // Without VN-aware resolution this throws TypeError: Invalid URL - // because new URL('@test-loader/f') is not a valid URL. + // Call getConsumedModules with the prefix-form identifier. This + // requires VN-aware resolution — new URL('@test-loader/f') is not a + // valid URL. let consumed = await loader.getConsumedModules(`@test-loader/f`); assert.deepEqual( consumed, - [`${testRealmURL}b`, `${testRealmURL}c`, `${testRealmURL}g`], - 'consumed modules resolved correctly from prefix-form identifier', + [`@test-loader/b`, `@test-loader/c`, `@test-loader/g`], + 'consumed modules come out in canonical prefix form', + ); + assert.deepEqual( + await loader.getConsumedModules(`${testRealmURL}f`), + consumed, + 'URL-form input produces the same canonical output', ); } finally { virtualNetwork.removeRealmMapping('@test-loader/'); diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 5a99dee2ec..a59a1c7b3b 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -314,6 +314,12 @@ export class Loader { }); } + // Returns the transitive consumed modules of `moduleIdentifier` in + // canonical identifier form: the registered realm-prefix (RRI) spelling + // (e.g. `@cardstack/base/card-api`) when the virtual network has a matching + // prefix mapping, otherwise the module URL. Accepts either spelling as + // input. Callers that need a fetchable URL resolve via the virtual network + // at the network boundary. async getConsumedModules(moduleIdentifier: string): Promise { // Normalize to resolved URL href so that prefix-form identifiers // (e.g. @cardstack/catalog/...) and their resolved URL equivalents @@ -355,7 +361,10 @@ export class Loader { await walk(moduleIdentifier, initialHref); // you can't consume yourself visited.delete(initialHref); - return [...visited]; + return this.canonicalizeIdentifiers( + visited, + this.canonicalIdentifier(initialHref), + ); } static identify( @@ -440,17 +449,67 @@ export class Loader { } } + // Synchronous sibling of `getConsumedModules` limited to modules already + // known to this loader. Output is in the same canonical identifier form: + // realm-prefix (RRI) spelling where a prefix mapping is registered, + // module URL otherwise. getKnownConsumedModules(moduleIdentifier: string): string[] { let resolvedModuleIdentifier = this.resolveImport(moduleIdentifier); let knownDependencies = this.collectKnownModuleDependencies( resolvedModuleIdentifier, ); - // Filter rather than delete to avoid mutating the cached Set - return [...knownDependencies].filter( - (dep) => dep !== resolvedModuleIdentifier, + // Copy rather than delete from the cached Set to avoid mutating it + return this.canonicalizeIdentifiers( + knownDependencies, + this.canonicalIdentifier(resolvedModuleIdentifier), ); } + // Canonical form for module identifiers that flow out of the loader + // (dependency lists, identities): the registered realm-prefix (RRI) + // spelling when the virtual network has a matching mapping, otherwise the + // identifier unchanged. Resolving back to a fetchable URL is the virtual + // network's job at the network boundary. + private canonicalIdentifier(moduleIdentifier: string): string { + return this.virtualNetwork + ? this.virtualNetwork.unresolveURL(moduleIdentifier) + : moduleIdentifier; + } + + // Map a set of module identifiers to canonical form, deduped (distinct + // spellings of one module — a real URL and its virtual alias — collapse to + // one canonical identifier) and with the module itself excluded: a module + // doesn't consume itself, and the canonical comparison catches self + // references under any spelling. + private canonicalizeIdentifiers( + identifiers: Iterable, + selfCanonical: string, + ): string[] { + let seen = new Set(); + let result: string[] = []; + for (let identifier of identifiers) { + let canonical = this.canonicalIdentifier(identifier); + if (canonical === selfCanonical || seen.has(canonical)) { + continue; + } + seen.add(canonical); + result.push(canonical); + } + return result; + } + + // The runtime dependency tracker keys module nodes by http(s) URL — its + // canonicalURL guard drops realm-prefix identifiers (see + // dependency-tracker.ts) — and this loader collapses each tracked module + // onto its virtual-alias URL when one is registered (see + // canonicalizeTrackingKey). Converts any module identifier, canonical RRI + // form included, into that tracking-key form. Callers recording + // loader-derived module identifiers with the tracker must cross this + // boundary; identifiers stay in canonical RRI form everywhere else. + dependencyTrackingKey(moduleIdentifier: string): string { + return this.canonicalizeTrackingKey(this.resolveImport(moduleIdentifier)); + } + private trackKnownModuleDependencies( rootModuleIdentifier: string, dependencyTrackingContext?: RuntimeDependencyTrackingContext, @@ -789,6 +848,15 @@ export class Loader { // and RRI forms evaluate as two distinct modules and `instanceof` / // polymorphic-field identity checks across them diverge. Mirrors the // real→virtual convention used by `canonicalizeTrackingKey`. + // + // The key deliberately does NOT use the realm-prefix (RRI) form even + // though that's the canonical form for identifiers flowing out of the + // loader: realm-prefix mappings can be registered and removed while a + // loader is live (tests scope temporary prefixes via + // `addRealmMapping`/`removeRealmMapping`), and a mapping-sensitive key + // would orphan already-cached entries whenever a mapping changes — + // re-evaluating the same module under a new key and splitting class + // identities across the old and new copies. private moduleCacheKey(moduleIdentifier: string): string { let trimmed = trimModuleIdentifier(moduleIdentifier); if (this.virtualNetwork) { @@ -848,10 +916,11 @@ export class Loader { module: any, moduleIdentifier: string, ) { - let trimmed = trimModuleIdentifier(moduleIdentifier); - let moduleId = this.virtualNetwork - ? this.virtualNetwork.unresolveURL(trimmed) - : trimmed; + // Identities are recorded in canonical identifier form so that + // `identify()` output matches the form persisted in code refs. + let moduleId = this.canonicalIdentifier( + trimModuleIdentifier(moduleIdentifier), + ); for (let propName of Object.keys(module)) { let exportedEntity = module[propName]; if (