diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 9a8696c74c4..a9878d7d903 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -3007,6 +3007,10 @@ function trackRuntimeRelationshipModuleDependencies( return; } + // getKnownConsumedModules is fast now: the Loader caches the dependency + // graph traversal result in collectKnownModuleDependencies, and + // trimModuleIdentifier uses string ops + a cache instead of URL + // construction. No need for a caller-side skip cache here. for (let dep of loader.getKnownConsumedModules(identity.module)) { trackRuntimeModuleDependency(dep, dependencyTrackingContext); } diff --git a/packages/runtime-common/dependency-tracker.ts b/packages/runtime-common/dependency-tracker.ts index 7c2abfe5dfa..6ab15061eec 100644 --- a/packages/runtime-common/dependency-tracker.ts +++ b/packages/runtime-common/dependency-tracker.ts @@ -1,5 +1,5 @@ import { logger } from './log'; -import { trimExecutableExtension } from './index'; +import { executableExtensions } from './index'; export type RuntimeDependencyNodeKind = 'module' | 'instance' | 'file'; export type RuntimeDependencyContextMode = 'query' | 'non-query'; @@ -47,23 +47,30 @@ interface ContextStackEntry { context: RuntimeDependencyTrackingContext; } +// String-based URL normalization to avoid expensive URL constructor calls. +// These are called on every dependency tracking operation (field getter access) +// so performance is critical. + function canonicalURL(url: string): string | undefined { - try { - let parsed = new URL(url); - parsed.search = ''; - parsed.hash = ''; - return parsed.href; - } catch (_err) { + if (!url || (!url.startsWith('http://') && !url.startsWith('https://'))) { return undefined; } + // Strip query string and hash using string ops instead of new URL() + let hashIdx = url.indexOf('#'); + if (hashIdx !== -1) { + url = url.slice(0, hashIdx); + } + let searchIdx = url.indexOf('?'); + if (searchIdx !== -1) { + url = url.slice(0, searchIdx); + } + return url; } -function hasPathExtension(pathname: string): boolean { - let segment = pathname.split('/').pop() ?? ''; - if (segment.length === 0) { - return false; - } - return segment.includes('.'); +function hasPathExtension(url: string): boolean { + let lastSlash = url.lastIndexOf('/'); + let segment = lastSlash !== -1 ? url.slice(lastSlash + 1) : url; + return segment.length > 0 && segment.includes('.'); } function normalizeModuleURL(url: string): string | undefined { @@ -71,7 +78,12 @@ function normalizeModuleURL(url: string): string | undefined { if (!canonical) { return undefined; } - return trimExecutableExtension(new URL(canonical)).href; + for (let ext of executableExtensions) { + if (canonical.endsWith(ext)) { + return canonical.slice(0, -ext.length); + } + } + return canonical; } function normalizeInstanceURL(url: string): string | undefined { @@ -79,11 +91,10 @@ function normalizeInstanceURL(url: string): string | undefined { if (!canonical) { return undefined; } - let parsed = new URL(canonical); - if (!hasPathExtension(parsed.pathname)) { - parsed.pathname = `${parsed.pathname}.json`; + if (!hasPathExtension(canonical)) { + return `${canonical}.json`; } - return parsed.href; + return canonical; } function normalizeFileURL(url: string): string | undefined { diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 2f73200628c..da6c65e3bdf 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -2,7 +2,7 @@ import TransformModulesAmdPlugin from 'transform-modules-amd-plugin'; import { transformAsync } from '@babel/core'; import { Deferred } from './deferred'; import { cachedFetch, type MaybeCachedResponse } from './cached-fetch'; -import { trimExecutableExtension, logger } from './index'; +import { executableExtensions, logger } from './index'; import { CardError } from './error'; import flatMap from 'lodash/flatMap'; @@ -103,6 +103,11 @@ export class Loader { private moduleShims = new Map>(); private moduleCanonicalURLs = new Map(); + // Cache the flattened dependency sets for evaluated modules. Once a module is + // evaluated its consumedModules never change, so the result of + // 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>(); private identities = new WeakMap< Function, { module: string; name: string } @@ -253,8 +258,10 @@ export class Loader { let knownDependencies = this.collectKnownModuleDependencies( resolvedModuleIdentifier, ); - knownDependencies.delete(resolvedModuleIdentifier); - return [...knownDependencies]; + // Filter rather than delete to avoid mutating the cached Set + return [...knownDependencies].filter( + (dep) => dep !== resolvedModuleIdentifier, + ); } private trackKnownModuleDependencies( @@ -276,6 +283,11 @@ export class Loader { private collectKnownModuleDependencies( rootModuleIdentifier: string, ): Set { + let cached = this.knownDepsCache.get(rootModuleIdentifier); + if (cached) { + return cached; + } + let pending = [rootModuleIdentifier]; let visited = new Set(); @@ -286,6 +298,16 @@ export class Loader { } visited.add(moduleIdentifier); + // If we already computed the full dep set for this subtree, merge it + // in and skip traversing its children. + let cachedSubtree = this.knownDepsCache.get(moduleIdentifier); + if (cachedSubtree) { + for (let dep of cachedSubtree) { + visited.add(dep); + } + continue; + } + let module = this.getModule(moduleIdentifier); if (!module) { continue; @@ -321,6 +343,7 @@ export class Loader { } } + this.knownDepsCache.set(rootModuleIdentifier, visited); return visited; } @@ -562,7 +585,7 @@ export class Loader { module: any, moduleIdentifier: string, ) { - let moduleId = trimExecutableExtension(new URL(moduleIdentifier)).href; + let moduleId = trimModuleIdentifier(moduleIdentifier); for (let propName of Object.keys(module)) { let exportedEntity = module[propName]; if ( @@ -850,8 +873,24 @@ function assertNever(value: never) { throw new Error(`should never happen ${value}`); } +// Cache and use string operations to avoid expensive URL construction on every +// getModule/setModule call. Module identifiers are always full URL strings so +// we only need to strip executable extensions from the end. +const trimCache = new Map(); function trimModuleIdentifier(moduleIdentifier: string): string { - return trimExecutableExtension(new URL(moduleIdentifier)).href; + let cached = trimCache.get(moduleIdentifier); + if (cached !== undefined) { + return cached; + } + let result = moduleIdentifier; + for (let ext of executableExtensions) { + if (moduleIdentifier.endsWith(ext)) { + result = moduleIdentifier.slice(0, -ext.length); + break; + } + } + trimCache.set(moduleIdentifier, result); + return result; } type ModuleState = Module['state'];