Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
47 changes: 29 additions & 18 deletions packages/runtime-common/dependency-tracker.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,43 +47,54 @@ 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 {
let canonical = canonicalURL(url);
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 {
let canonical = canonicalURL(url);
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 {
Expand Down
49 changes: 44 additions & 5 deletions packages/runtime-common/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -103,6 +103,11 @@ export class Loader {

private moduleShims = new Map<string, Record<string, any>>();
private moduleCanonicalURLs = new Map<string, string>();
// 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<string, Set<string>>();
private identities = new WeakMap<
Function,
{ module: string; name: string }
Expand Down Expand Up @@ -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(
Expand All @@ -276,6 +283,11 @@ export class Loader {
private collectKnownModuleDependencies(
rootModuleIdentifier: string,
): Set<string> {
let cached = this.knownDepsCache.get(rootModuleIdentifier);
if (cached) {
return cached;
}

Comment thread
habdelra marked this conversation as resolved.
let pending = [rootModuleIdentifier];
let visited = new Set<string>();

Expand All @@ -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;
Expand Down Expand Up @@ -321,6 +343,7 @@ export class Loader {
}
}

this.knownDepsCache.set(rootModuleIdentifier, visited);
return visited;
}

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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<string, string>();
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'];
Expand Down
Loading