From 3e215f9dc427061713c10d100f6a0102863a257c Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 20 Mar 2026 13:21:38 -0400 Subject: [PATCH 1/2] Optimize prerender performance: eliminate URL() construction in dependency tracking hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flame chart profiling revealed that 98% of active CPU per frame during card prerendering was spent in the runtime dependency tracking system, primarily constructing URL objects. The render produced 22 identical ~9-second long tasks (one per card deserialization), totaling ~200 seconds of blocked main thread for a card with 23 linksToMany relationships. Three optimizations applied: 1. trimModuleIdentifier (loader.ts): Replace `new URL(id).href` with string slice operations + a Map cache. Module identifiers are already full URL strings, so extension trimming only needs string ops. This was the single largest CPU consumer at 52.8% of active time (~5s per card). 2. collectKnownModuleDependencies (loader.ts): Cache the flattened dependency set per module identifier. Once a module is evaluated its consumedModules never change, so repeated graph walks for the same module return the cached result. This turns O(cards × modules) into O(modules). 3. trackRuntimeRelationshipModuleDependencies (card-api.gts): Track which modules have already had their full dep trees tracked and skip redundant getKnownConsumedModules() calls. This function was called on every linksTo field getter access during rendering, each time walking the full module dependency graph. Additionally, normalizeModuleURL/normalizeInstanceURL/canonicalURL in dependency-tracker.ts now use string operations instead of URL construction, eliminating another hot source of URL() calls in the tracking pipeline. Closes CS-10473 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/base/card-api.gts | 14 ++++++ packages/runtime-common/dependency-tracker.ts | 47 ++++++++++++------- packages/runtime-common/loader.ts | 43 +++++++++++++++-- 3 files changed, 83 insertions(+), 21 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 9a8696c74c4..8a5329b0603 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -2982,6 +2982,13 @@ function trackRuntimeRelationshipDependencies( } } +// Track which modules have already had their full dependency trees tracked in +// this session to avoid redundant getKnownConsumedModules() calls. This is +// critical for performance: without it, every linksTo field getter access +// walks the entire module dependency graph, leading to O(cards × modules) +// scaling with expensive URL construction on each node. +const trackedRelationshipModules = new Set(); + function trackRuntimeRelationshipModuleDependencies( value: unknown, dependencyTrackingContext?: RuntimeDependencyTrackingContext, @@ -3002,6 +3009,13 @@ function trackRuntimeRelationshipModuleDependencies( trackRuntimeModuleDependency(identity.module, dependencyTrackingContext); + // Skip the expensive dependency graph walk if we already tracked this + // module's full dep tree. The dep tree for an evaluated module is immutable. + if (trackedRelationshipModules.has(identity.module)) { + return; + } + trackedRelationshipModules.add(identity.module); + let loader = Loader.getLoaderFor(ctor); if (!loader) { return; 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..d8993cda546 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 } @@ -276,6 +281,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 +296,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 +341,7 @@ export class Loader { } } + this.knownDepsCache.set(rootModuleIdentifier, visited); return visited; } @@ -562,7 +583,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 +871,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']; From 2754d6f62c9211a1209d268d89186c54c2bdf4da Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 20 Mar 2026 13:50:04 -0400 Subject: [PATCH 2/2] Fix cached Set mutation and remove session-scoping issue in dep tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: - getKnownConsumedModules: filter instead of delete to avoid mutating the cached Set returned by collectKnownModuleDependencies - Remove trackedRelationshipModules skip cache from card-api.gts — it was process-global and not cleared between dependency tracking sessions, which could cause subsequent renders to under-report module deps. The Loader-level caching in collectKnownModuleDependencies already makes getKnownConsumedModules fast enough without a caller-side skip. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/base/card-api.gts | 18 ++++-------------- packages/runtime-common/loader.ts | 6 ++++-- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 8a5329b0603..a9878d7d903 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -2982,13 +2982,6 @@ function trackRuntimeRelationshipDependencies( } } -// Track which modules have already had their full dependency trees tracked in -// this session to avoid redundant getKnownConsumedModules() calls. This is -// critical for performance: without it, every linksTo field getter access -// walks the entire module dependency graph, leading to O(cards × modules) -// scaling with expensive URL construction on each node. -const trackedRelationshipModules = new Set(); - function trackRuntimeRelationshipModuleDependencies( value: unknown, dependencyTrackingContext?: RuntimeDependencyTrackingContext, @@ -3009,18 +3002,15 @@ function trackRuntimeRelationshipModuleDependencies( trackRuntimeModuleDependency(identity.module, dependencyTrackingContext); - // Skip the expensive dependency graph walk if we already tracked this - // module's full dep tree. The dep tree for an evaluated module is immutable. - if (trackedRelationshipModules.has(identity.module)) { - return; - } - trackedRelationshipModules.add(identity.module); - let loader = Loader.getLoaderFor(ctor); if (!loader) { 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/loader.ts b/packages/runtime-common/loader.ts index d8993cda546..da6c65e3bdf 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -258,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(