diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 2524d863f88..f36980add62 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -4076,24 +4076,17 @@ function trackRuntimeRelationshipModuleDependencies( return; } - // 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. + // Loader identities and dependency lists are already in canonical RRI form, + // which is what the tracker records — so they are passed through as they are. + trackRuntimeModuleDependency(identity.module, dependencyTrackingContext); + 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( - loader.dependencyTrackingKey(dep), - dependencyTrackingContext, - ); + trackRuntimeModuleDependency(dep, dependencyTrackingContext); } } diff --git a/packages/realm-server/tests/runtime-dependency-tracker-test.ts b/packages/realm-server/tests/runtime-dependency-tracker-test.ts index 4e5f92a0db8..1a4dabc9b1a 100644 --- a/packages/realm-server/tests/runtime-dependency-tracker-test.ts +++ b/packages/realm-server/tests/runtime-dependency-tracker-test.ts @@ -809,4 +809,84 @@ module(basename(import.meta.filename), function (hooks) { 'module dep is recorded once across repeated imports', ); }); + + test('records prefix-form identifiers alongside URL-form ones', async function (assert) { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'session-prefix-form', + rootURL: 'https://example.com/root.json', + rootKind: 'instance', + }); + + await withRuntimeDependencyTrackingContext( + { + mode: 'non-query', + source: 'test:prefix-form', + consumer: 'https://example.com/root.json', + consumerKind: 'instance', + }, + async () => { + // A module identifier, its executable extension stripped as for a URL. + trackRuntimeModuleDependency('@cardstack/base/card-api.gts'); + // An instance identifier, given the `.json` suffix the index carries. + trackRuntimeInstanceDependency('@cardstack/catalog/Author/mango'); + // A file identifier, kept verbatim. + trackRuntimeFileDependency('@cardstack/base/README.md'); + // Query strings and fragments are stripped in prefix form too. + trackRuntimeModuleDependency('@cardstack/base/pet?v=2#frag'); + }, + ); + + let { deps } = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + assert.true( + deps.includes('@cardstack/base/card-api'), + 'a prefix-form module dep is recorded with its extension stripped', + ); + assert.true( + deps.includes('@cardstack/catalog/Author/mango.json'), + 'a prefix-form instance dep is recorded in the form the index carries', + ); + assert.true( + deps.includes('@cardstack/base/README.md'), + 'a prefix-form file dep is recorded verbatim', + ); + assert.true( + deps.includes('@cardstack/base/pet'), + 'a prefix-form dep is recorded without its query string or fragment', + ); + }); + + test('drops identifiers that are neither a URL nor a prefix-form RRI', async function (assert) { + beginRuntimeDependencyTrackingSession({ + sessionKey: 'session-junk', + rootURL: 'https://example.com/root.json', + rootKind: 'instance', + }); + + await withRuntimeDependencyTrackingContext( + { + mode: 'non-query', + source: 'test:junk', + consumer: 'https://example.com/root.json', + consumerKind: 'instance', + }, + async () => { + // None of these name a resource the index can invalidate against, so + // recording them would put an edge in the graph that nothing satisfies. + for (let junk of [ + 'lodash-es', + './relative-module', + '../up-one', + '/root-relative', + 'data:text/javascript,export default 1', + 'blob:https://example.com/abc', + '', + ]) { + trackRuntimeModuleDependency(junk); + } + }, + ); + + let { deps } = snapshotRuntimeDependencies({ excludeQueryOnly: true }); + assert.deepEqual(deps, [], 'no non-canonical identifier is recorded'); + }); }); diff --git a/packages/runtime-common/dependency-tracker.ts b/packages/runtime-common/dependency-tracker.ts index adce8836b72..d395bb8a8d6 100644 --- a/packages/runtime-common/dependency-tracker.ts +++ b/packages/runtime-common/dependency-tracker.ts @@ -44,8 +44,18 @@ interface ContextStackEntry { // These are called on every dependency tracking operation (field getter access) // so performance is critical. +// Accepts the two canonical remote forms and nothing else. A URL starts with +// `http://`/`https://` and a prefix-form RRI starts with `@`, which tells them +// apart without a VirtualNetwork — the same syntactic test `isLocalId` relies +// on. Everything else (bare specifiers, relative references, `data:`/`blob:` +// URLs) has no stable identity for a dependency edge and is dropped. function canonicalURL(url: string): string | undefined { - if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) { + if ( + !url || + (!url.startsWith('http://') && + !url.startsWith('https://') && + !url.startsWith('@')) + ) { return undefined; } // Strip query string and hash using string ops instead of new URL() diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 1d186ed9c1c..4ef0707cf04 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -201,6 +201,8 @@ export class Loader { // collectKnownModuleDependencies is stable and can be reused across repeated // loader.import() calls (e.g. when deserializing 22 cards of the same type). private knownDepsCache = new Map>(); + // Module identifier → the key it is tracked under (see trackingKey). + private trackingKeyCache = new Map(); private identities = new WeakMap< Function, { module: string; name: string } @@ -246,6 +248,7 @@ export class Loader { this.modules.clear(); this.moduleCanonicalURLs.clear(); this.knownDepsCache.clear(); + this.trackingKeyCache.clear(); }); } @@ -429,17 +432,12 @@ export class Loader { let resolvedModule = new URL(moduleIdentifier); let resolvedModuleIdentifier = resolvedModule.href; if (!this.moduleShims.has(resolvedModuleIdentifier)) { - // Normalize tracker keys to the virtual-alias URL form when one - // exists (the dependency tracker requires `http://`/`https://` - // URLs — see `canonicalURL` in dependency-tracker.ts — so RRI - // prefix forms can't be used as keys). Without this, a base - // module imported via the virtual alias - // (`https://cardstack.com/base/X`) and the same module imported - // via the RRI prefix (`@cardstack/base/X` → resolveImport → - // resolved real URL `https://localhost:4201/base/X`) get tracked - // as two separate entries. - let trackingKey = this.canonicalizeTrackingKey(resolvedModuleIdentifier); - trackRuntimeModuleDependency(trackingKey, dependencyTrackingContext); + // Tracked under the form the index names this module's realm by, so a + // module reached by any of its spellings is one node rather than several. + trackRuntimeModuleDependency( + this.trackingKey(resolvedModuleIdentifier), + dependencyTrackingContext, + ); } await this.advanceToState(resolvedModule, 'evaluated'); @@ -546,18 +544,6 @@ export class Loader { 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, @@ -580,11 +566,10 @@ export class Loader { rootModuleIdentifier, )) { if (!this.moduleShims.has(moduleIdentifier)) { - // Same canonicalization as the top-level import-time tracking - // call — collapse virtual-alias / resolved real URL forms onto - // the virtual-alias URL. - let trackingKey = this.canonicalizeTrackingKey(moduleIdentifier); - trackRuntimeModuleDependency(trackingKey, dependencyTrackingContext); + trackRuntimeModuleDependency( + this.trackingKey(moduleIdentifier), + dependencyTrackingContext, + ); } } } @@ -920,6 +905,50 @@ export class Loader { : trimmed; } + // The key a module is recorded under with the dependency tracker: the form + // the index carries for that module's realm, so a dep here matches the rows + // invalidation searches. + // + // A realm reached through a registered prefix is named by its RRI. A realm + // that has only a URL mapping — the alias a test or deployment serves it + // under — is named by that alias, not by the host actually serving it. + // `unresolveURL` covers the first and leaves the second alone (it maps an + // alias *to* the real URL, never back), so the alias case needs its own fold. + // Memoized because the dependency walk re-derives the key for every module in + // a root's transitive set on every import of that root, and the alias branch + // below allocates a URL. Keyed by the raw identifier so the trim is memoized + // too. Discarded with the other mapping-derived caches when a realm mapping + // changes, since the key it produces is only stable between those changes. + private trackingKey(moduleIdentifier: string): string { + let cached = this.trackingKeyCache.get(moduleIdentifier); + if (cached !== undefined) { + return cached; + } + let key = this.computeTrackingKey(moduleIdentifier); + this.trackingKeyCache.set(moduleIdentifier, key); + return key; + } + + private computeTrackingKey(moduleIdentifier: string): string { + let trimmed = trimModuleIdentifier(moduleIdentifier); + if (!this.virtualNetwork) { + return trimmed; + } + let unresolved = this.virtualNetwork.unresolveURL(trimmed); + if (unresolved !== trimmed) { + return unresolved; + } + try { + let virtual = this.virtualNetwork.mapURL(trimmed, 'real-to-virtual'); + if (virtual) { + return virtual.href; + } + } catch { + // Not a parseable URL — a prefix-form identifier, already canonical. + } + return trimmed; + } + private getModule(moduleIdentifier: string): Module | undefined { return this.modules.get(this.moduleCacheKey(moduleIdentifier)); } @@ -947,18 +976,6 @@ export class Loader { // virtual-alias (`https://cardstack.com/base/X`) and resolved real URL // (`https://localhost:4201/base/X`) for the same module. Returns the // input unchanged when no virtual alias is registered. - private canonicalizeTrackingKey(moduleIdentifier: string): string { - if (!this.virtualNetwork) { - return moduleIdentifier; - } - try { - let parsed = new URL(moduleIdentifier); - let virtual = this.virtualNetwork.mapURL(parsed, 'real-to-virtual'); - return virtual ? virtual.href : moduleIdentifier; - } catch { - return moduleIdentifier; - } - } private captureIdentitiesOfModuleExports( module: any,