From fb02357fe597508c9f9a7c7e1ab95a9202b1a3de Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Thu, 8 Oct 2026 12:00:22 -0500 Subject: [PATCH] Mark exported classes in a static block, and identify by the mark alone The provenance plugin now visits export declarations and gives each class a module declares and exports a static block that marks it, with no shared helper and no marks on functions or other values. The host build applies it to base modules through babel.config.cjs instead of the bundled-base Vite plugin. Loader.identify reads the mark and nothing else. The loader puts the module's canonical identifier on import.meta for the mark to use, and marks what transpilation did not (shim exports, and modules transpiled before marks existed) instead of keeping its own identity map. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015idm3FXE4bujPmH1obGWFG --- packages/host/babel.config.cjs | 26 ++ packages/host/lib/bundled-base-scoped-css.mjs | 44 +-- .../integration/tools/read-text-file-test.gts | 4 +- packages/host/tests/unit/loader-test.ts | 28 +- packages/realm-server/tests/transpile-test.ts | 35 +- packages/runtime-common/etc/test-fixtures.ts | 31 +- packages/runtime-common/loader-plugin.ts | 332 +++++++++++------- packages/runtime-common/loader.ts | 84 ++--- packages/runtime-common/transpile.ts | 3 +- 9 files changed, 296 insertions(+), 291 deletions(-) diff --git a/packages/host/babel.config.cjs b/packages/host/babel.config.cjs index 9acfc33d825..387618454d9 100644 --- a/packages/host/babel.config.cjs +++ b/packages/host/babel.config.cjs @@ -2,6 +2,13 @@ const { babelCompatSupport, templateCompatSupport, } = require('@embroider/compat/babel'); +const path = require('node:path'); + +const { + moduleProvenancePlugin, +} = require('../runtime-common/loader-plugin.ts'); + +const baseDir = path.resolve(__dirname, '../base'); module.exports = { plugins: [ @@ -48,6 +55,25 @@ module.exports = { ...babelCompatSupport(), ], + overrides: [ + { + // A base module compiled into the host bundle is evaluated without a + // loader, so it marks each class it declares with the identifier the + // loader serves the module under, as the realm's transpiler does for a + // module the loader fetches. + test: (filename) => + Boolean(filename) && + filename.startsWith(`${baseDir}${path.sep}`) && + !filename.includes(`${path.sep}node_modules${path.sep}`), + plugins: [ + [ + moduleProvenancePlugin, + { moduleRoot: baseDir, modulePrefix: '@cardstack/base/' }, + ], + ], + }, + ], + generatorOpts: { compact: false, }, diff --git a/packages/host/lib/bundled-base-scoped-css.mjs b/packages/host/lib/bundled-base-scoped-css.mjs index 43ab2c5db1b..b4483252b71 100644 --- a/packages/host/lib/bundled-base-scoped-css.mjs +++ b/packages/host/lib/bundled-base-scoped-css.mjs @@ -18,18 +18,8 @@ // the loader is actually asked for get served, and one reached from inside an // already-bundled chunk never is, so a served module has to answer for the // stylesheets of everything it pulls in as well as its own. -// -// It also marks each class a base module declares with the module's name, as -// the loader's own transpiler does for a module it fetches. A module reached -// only from inside another module's chunk is never served, so the loader is -// never asked for it; the mark is how it can name that module's classes all -// the same. import { sep } from 'node:path'; -import { transformAsync } from '@babel/core'; - -import { moduleProvenancePlugin } from '../../runtime-common/loader-plugin.ts'; - const REGISTRY = '__boxelBundledBaseScopedCSS'; // `..glimmer-scoped.css` wherever it appears as @@ -142,7 +132,7 @@ export function bundledBaseScopedCSS() { // bundler resolves it: `enforce: 'post'` puts this at the end of the // transform chain. enforce: 'post', - async transform(code, id) { + transform(code, id) { if (!isBaseModule(id)) { return null; } @@ -158,31 +148,15 @@ export function bundledBaseScopedCSS() { ), ].filter((imported) => imported !== name); - // In a chunk `import.meta.url` is the chunk's, so the mark names the - // module by the identifier the loader serves it under. - let marked = await transformAsync(code, { - filename: id, - sourceType: 'module', - babelrc: false, - configFile: false, - sourceMaps: true, - plugins: [ - [ - moduleProvenancePlugin, - { moduleIdentifier: `@cardstack/base/${name}` }, - ], - ], - }); - let result = marked?.code ?? code; - - if (css.length || imports.length) { - // A name that turns out to be something other than a base module - // costs nothing: the reader walks only names the registry holds. - result += - `\n;(globalThis.${REGISTRY} ??= {})[${JSON.stringify(name)}] = ` + - `${JSON.stringify({ css, imports })};\n`; + if (!css.length && !imports.length) { + return null; } - return { code: result, map: marked?.map ?? null }; + // A name that turns out to be something other than a base module costs + // nothing: the reader walks only names the registry holds. + let registration = + `\n;(globalThis.${REGISTRY} ??= {})[${JSON.stringify(name)}] = ` + + `${JSON.stringify({ css, imports })};\n`; + return { code: code + registration, map: null }; }, }; } diff --git a/packages/host/tests/integration/tools/read-text-file-test.gts b/packages/host/tests/integration/tools/read-text-file-test.gts index 909fc2208b9..da802bef4a0 100644 --- a/packages/host/tests/integration/tools/read-text-file-test.gts +++ b/packages/host/tests/integration/tools/read-text-file-test.gts @@ -4,7 +4,7 @@ import type { RenderingTestContext } from '@ember/test-helpers'; import { getService } from '@universal-ember/test-support'; import { module, skip, test } from 'qunit'; -import { moduleProvenanceMarks } from '@cardstack/runtime-common/etc/test-fixtures'; +import { moduleProvenanceMark } from '@cardstack/runtime-common/etc/test-fixtures'; import RealmService from '@cardstack/host/services/realm'; import ReadTextFileTool from '@cardstack/host/tools/read-text-file'; @@ -89,7 +89,7 @@ module('Integration | tools | read-text-file', function (hooks) { // A .gts path is served as its transpiled module. assert.codeEqual( result.content, - `import Component from '@glimmer/component';\nexport default class TestComponent extends Component {}\n${moduleProvenanceMarks([['TestComponent', 'default']])}`, + `import Component from '@glimmer/component';\nexport default class TestComponent extends Component {\n${moduleProvenanceMark('default')}\n}`, ); }); diff --git a/packages/host/tests/unit/loader-test.ts b/packages/host/tests/unit/loader-test.ts index 4e4e5d9ddee..fe68b8b84c4 100644 --- a/packages/host/tests/unit/loader-test.ts +++ b/packages/host/tests/unit/loader-test.ts @@ -241,11 +241,6 @@ module('Unit | loader', function (hooks) { let { Person } = await loader.import<{ Person: unknown }>( `${testRealmURL}person`, ); - assert.deepEqual(loader.identify(Person), { - module: `${testRealmURL}person`, - name: 'Person', - }); - // The loader knows which loader instance was used to import the card assert.deepEqual(Loader.identify(Person), { module: `${testRealmURL}person`, name: 'Person', @@ -725,7 +720,7 @@ module('Unit | loader', function (hooks) { }); // This Loader is constructed without a `virtualNetwork`, so - // `captureIdentitiesOfModuleExports` records the raw shim module + // `markModuleExports` records the raw shim module // identifier without running it through `vn.unresolveURL`. The // identity stays in URL form. Other test setups that build a VN // alongside the Loader see the RRI canonical form here. @@ -743,24 +738,19 @@ module('Unit | loader', function (hooks) { let { Declared } = await loader.import<{ Declared: unknown }>( `${testRealmURL}declarer`, ); - let declarer = loader.identify(Declared); - // On the same network, so both loaders spell the module alike. - let { virtualNetwork } = getService('network'); - let throwIfFetch = new Loader( - async () => { - throw new Error( - 'fetch should not be invoked during shimmed module tests', - ); - }, - virtualNetwork.resolveImport, - { virtualNetwork }, - ); + let declarer = { module: `${testRealmURL}declarer`, name: 'Declared' }; + assert.deepEqual(Loader.identify(Declared), declarer); + let throwIfFetch = new Loader(async () => { + throw new Error( + 'fetch should not be invoked during shimmed module tests', + ); + }); throwIfFetch.shimModule('https://example.com/re-exporter.js', { Declared, }); assert.deepEqual( - throwIfFetch.identify(Declared), + Loader.identify(Declared), declarer, 'the re-exporter does not take the credit', ); diff --git a/packages/realm-server/tests/transpile-test.ts b/packages/realm-server/tests/transpile-test.ts index 15f11c1b320..4da74654135 100644 --- a/packages/realm-server/tests/transpile-test.ts +++ b/packages/realm-server/tests/transpile-test.ts @@ -2,6 +2,7 @@ import QUnit from 'qunit'; const { module, test } = QUnit; import { basename } from 'path'; import { transpileJS } from '@cardstack/runtime-common/transpile'; +import { moduleProvenanceMark } from '@cardstack/runtime-common/etc/test-fixtures'; import '@cardstack/runtime-common/helpers/code-equality-assertion'; module(basename(import.meta.filename), function () { @@ -59,14 +60,15 @@ module(basename(import.meta.filename), function () { }); module('module provenance', function () { - test('marks each class and function the module declares and exports', async function (assert) { + test('marks each class the module declares and exports', async function (assert) { let transpiled = await transpileJS( ` export class A {} export function f() {} export const g = () => 1; + export const H = class {}; class B {} - export { B, B as Bee }; + export { B as Bee, B }; export default class C {} `, 'test-module.ts', @@ -74,25 +76,21 @@ module(basename(import.meta.filename), function () { assert.codeEqual( transpiled, ` - export class A {} + export class A { + ${moduleProvenanceMark('A')} + } export function f() {} export const g = () => 1; - class B {} - export { B, B as Bee }; - export default class C {} - function _markModuleProvenance(value, name) { - let key = Symbol.for("module-provenance"); - if (typeof value === 'function' && Object.isExtensible(value) && !Object.prototype.hasOwnProperty.call(value, key)) { - Object.defineProperty(value, key, { - value: Object.freeze({ module: import.meta.url, name }) - }); - } + export const H = class { + ${moduleProvenanceMark('H')} + }; + class B { + ${moduleProvenanceMark('B')} + } + export { B as Bee, B }; + export default class C { + ${moduleProvenanceMark('default')} } - _markModuleProvenance(A, "A"); - _markModuleProvenance(f, "f"); - _markModuleProvenance(g, "g"); - _markModuleProvenance(B, "B"); - _markModuleProvenance(C, "default"); `, ); }); @@ -105,6 +103,7 @@ module(basename(import.meta.filename), function () { interface Shape { a: number } export type { Shape }; export declare const Declared: unknown; + export declare class DeclaredClass {} export { Imported }; export default Imported; `, diff --git a/packages/runtime-common/etc/test-fixtures.ts b/packages/runtime-common/etc/test-fixtures.ts index 8384f60fc6a..da297104a7f 100644 --- a/packages/runtime-common/etc/test-fixtures.ts +++ b/packages/runtime-common/etc/test-fixtures.ts @@ -37,6 +37,7 @@ import StringField from '@cardstack/base/string'; import { setComponentTemplate } from "@ember/component"; import { createTemplateFactory } from "@ember/template-factory"; export class Person extends CardDef { + ${moduleProvenanceMark('Person')} static displayName = 'Person'; static { dt7948.g(this.prototype, "firstName", [field], function () { @@ -71,30 +72,18 @@ export class Person extends CardDef { export let counter = 0; export function increment() { counter++; -} -${moduleProvenanceMarks([ - ['Person', 'Person'], - ['counter', 'counter'], - ['increment', 'increment'], -])}`.trim(); +}`.trim(); } -// What transpilation appends to a module that declares exports: a helper, -// then one call per declared export, as [local binding, export name]. -export function moduleProvenanceMarks(exports: [string, string][]) { - return ` -function _markModuleProvenance(value, name) { - let key = Symbol.for("module-provenance"); - if (typeof value === 'function' && Object.isExtensible(value) && !Object.prototype.hasOwnProperty.call(value, key)) { - Object.defineProperty(value, key, { +// The static block transpilation puts first in each class a module declares +// and exports, which marks the class with that module and its export name. +export function moduleProvenanceMark(name: string) { + return `static { + Object.defineProperty(this, Symbol.for("module-provenance"), { value: Object.freeze({ - module: import.meta.url, - name + module: import.meta.moduleIdentifier ?? import.meta.url, + name: "${name}" }) }); - } -} -${exports - .map(([local, name]) => `_markModuleProvenance(${local}, "${name}");`) - .join('\n')}`.trim(); + }`; } diff --git a/packages/runtime-common/loader-plugin.ts b/packages/runtime-common/loader-plugin.ts index 0b5a733046c..812debb4b5c 100644 --- a/packages/runtime-common/loader-plugin.ts +++ b/packages/runtime-common/loader-plugin.ts @@ -1,12 +1,12 @@ import type * as Babel from '@babel/core'; import type { types as t } from '@babel/core'; -import type { NodePath } from '@babel/traverse'; +import type { NodePath, Binding } from '@babel/traverse'; -// The own property an exported class or function carries to name the module -// that declares it, as `{ module, name }`. A module that only re-exports a -// binding marks nothing, so the mark names the declarer whichever module -// exposing the binding is loaded first. Non-enumerable and keyed by a symbol, -// so nothing that lists a class's own keys sees it. +// The own property an exported class carries to name the module that declares +// it, as `{ module, name }`. A module that only re-exports a class marks +// nothing, so the mark names the declarer whichever module exposing the class +// is loaded first. Non-enumerable and keyed by a symbol, so nothing that lists +// a class's own keys sees it. export const MODULE_PROVENANCE = Symbol.for('module-provenance'); export interface ModuleProvenance { @@ -30,152 +30,219 @@ export function moduleProvenanceOf( return undefined; } +// Marks a value that transpilation did not, such as a class in a module the +// loader was handed as a shim. A value that already has a mark keeps it. +export function markModuleProvenance( + value: Function, + provenance: ModuleProvenance, +): void { + if ( + Object.isExtensible(value) && + !Object.prototype.hasOwnProperty.call(value, MODULE_PROVENANCE) + ) { + Object.defineProperty(value, MODULE_PROVENANCE, { + value: Object.freeze({ ...provenance }), + }); + } +} + export interface ModuleProvenanceOptions { - // The module the marks name. Without it a mark names `import.meta.url`, - // which is how a module names itself; a build that puts the module in a - // chunk, where `import.meta.url` is the chunk's, names it here instead. - moduleIdentifier?: string; + // For a build that puts modules in chunks, where `import.meta` describes the + // chunk: the directory the modules come from, and the prefix the loader + // serves them under. A module at `/a/b.gts` is marked as + // `a/b`. Without these, a mark names the module the loader + // evaluated it as. + moduleRoot?: string; + modulePrefix?: string; } -// Marks each class or function a module declares and exports with the module -// and the name it is exported under. Runs on `Program` exit, after the -// TypeScript transform has removed type-only exports. +// The class nodes this plugin has marked: each one's block, and the name its +// mark carries. +const markedClasses = new WeakMap< + t.Class, + { block: t.StaticBlock; name: string } +>(); + +// Gives each class a module declares and exports a static block that marks it +// with the module and the name it is exported under. Only classes: card and +// field definitions are classes, and they are what the loader is asked to +// identify. The block is the class's first member, so the mark is in place +// before any other static code of the class runs. // -// Only a binding this module declares is marked. `import { X } from './y'; +// Only a class this module declares is marked. `import { X } from './y'; // export { X }` and `export { X } from './y'` mark nothing: `./y` declares X, -// and marks it itself. A binding exported under more than one name is marked +// and marks it itself. A class exported under more than one name is marked // with the name that sorts first, which is the first key of the module's // namespace. -// -// The mark is set only when the value has none yet, so when a declaration -// holds a value another module declared (`export const A = ImportedClass`), -// the declarer's mark — set as it was evaluated, which is first — stands. export function moduleProvenancePlugin( babel: typeof Babel, options: ModuleProvenanceOptions = {}, -) { - return { - visitor: { - Program: { - exit(path: NodePath) { - markDeclaredExports(babel, path, options); - }, - }, - }, - }; -} - -function markDeclaredExports( - babel: typeof Babel, - program: NodePath, - options: ModuleProvenanceOptions, ) { let t = babel.types; - // local binding → every name it is exported under - let exposedNames = new Map(); - let record = (local: string, exposed: string) => { - let names = exposedNames.get(local) ?? []; - names.push(exposed); - exposedNames.set(local, names); - }; - for (let statement of program.node.body) { - if (t.isExportNamedDeclaration(statement)) { - if (statement.source || statement.exportKind === 'type') { - continue; - } - if (statement.declaration) { - for (let name of Object.keys( - t.getOuterBindingIdentifiers(statement.declaration), - )) { - record(name, name); - } - } - for (let specifier of statement.specifiers) { - if ( - !t.isExportSpecifier(specifier) || - specifier.exportKind === 'type' - ) { - continue; - } - record( - specifier.local.name, - t.isIdentifier(specifier.exported) - ? specifier.exported.name - : specifier.exported.value, - ); - } - } else if (t.isExportDefaultDeclaration(statement)) { - let declaration = statement.declaration; - if ( - (t.isClassDeclaration(declaration) || - t.isFunctionDeclaration(declaration)) && - declaration.id - ) { - record(declaration.id.name, 'default'); - } else if (t.isIdentifier(declaration)) { - record(declaration.name, 'default'); + function moduleExpression(filename: string | undefined): t.Expression { + let { moduleRoot, modulePrefix } = options; + if (moduleRoot !== undefined && modulePrefix !== undefined && filename) { + let root = moduleRoot.replace(/\\/g, '/').replace(/\/$/, ''); + let file = filename.replace(/\\/g, '/'); + if (file.startsWith(`${root}/`)) { + let name = file + .slice(root.length + 1) + .split('?')[0] + .replace(/\.(gts|gjs|ts|js)$/, ''); + return t.stringLiteral(`${modulePrefix}${name}`); } } + // The loader puts the identifier it serves the module under on + // `import.meta`; `import.meta.url` covers a module evaluated without it. + return babel.template.expression.ast( + 'import.meta.moduleIdentifier ?? import.meta.url', + ); } - let marks: [local: string, exposed: string][] = []; - for (let [local, names] of exposedNames) { - if (isDeclaredHere(program, local)) { - marks.push([local, [...names].sort()[0]]); - } - } - if (marks.length === 0) { - return; + function markStatement(module: t.Expression, name: string): t.Statement { + return t.expressionStatement( + t.callExpression( + t.memberExpression( + t.identifier('Object'), + t.identifier('defineProperty'), + ), + [ + t.thisExpression(), + t.callExpression( + t.memberExpression(t.identifier('Symbol'), t.identifier('for')), + [t.stringLiteral(MODULE_PROVENANCE.description!)], + ), + t.objectExpression([ + t.objectProperty( + t.identifier('value'), + t.callExpression( + t.memberExpression( + t.identifier('Object'), + t.identifier('freeze'), + ), + [ + t.objectExpression([ + t.objectProperty(t.identifier('module'), module), + t.objectProperty( + t.identifier('name'), + t.stringLiteral(name), + ), + ]), + ], + ), + ), + ]), + ], + ), + ); } - let helper = program.scope.generateUidIdentifier('markModuleProvenance'); - let moduleExpression = - options.moduleIdentifier !== undefined - ? JSON.stringify(options.moduleIdentifier) - : 'import.meta.url'; - let source = ` - function ${helper.name}(value, name) { - let key = Symbol.for(${JSON.stringify(MODULE_PROVENANCE.description)}); - if ( - typeof value === 'function' && - Object.isExtensible(value) && - !Object.prototype.hasOwnProperty.call(value, key) - ) { - Object.defineProperty(value, key, { - value: Object.freeze({ module: ${moduleExpression}, name }), - }); + function mark( + cls: NodePath, + name: string, + filename: string | undefined, + ) { + let existing = markedClasses.get(cls.node); + if (existing) { + if (name < existing.name) { + existing.block.body = [markStatement(moduleExpression(filename), name)]; + existing.name = name; } + return; } - ${marks - .map( - ([local, exposed]) => - `${helper.name}(${local}, ${JSON.stringify(exposed)});`, - ) - .join('\n')} - `; - program.pushContainer( - 'body', - babel.template.statements.ast(source, { sourceType: 'module' }), - ); -} - -// A value binding declared at the top level of this module: not an import, -// not a type, and not a TypeScript `declare`, none of which exist at runtime. -function isDeclaredHere(program: NodePath, name: string): boolean { - let binding = program.scope.getBinding(name); - if (!binding || binding.kind === 'module' || binding.path.removed) { - return false; + let block = t.staticBlock([ + markStatement(moduleExpression(filename), name), + ]); + cls.get('body').unshiftContainer('body', block); + markedClasses.set(cls.node, { block, name }); } - let declaration = binding.path; - if (declaration.isVariableDeclarator()) { - let parent = declaration.parentPath; - return !(parent?.isVariableDeclaration() && parent.node.declare); + + // The class a module-level binding holds, when this module declares it: + // `class X {}` or `const X = class {}`. Not an import, and not a TypeScript + // `declare`, which has nothing behind it at runtime. + function declaredClass(binding: Binding | undefined) { + if (!binding || binding.kind === 'module' || binding.path.removed) { + return undefined; + } + let declaration = binding.path; + if (declaration.isClassDeclaration()) { + return declaration.node.declare ? undefined : declaration; + } + if (declaration.isVariableDeclarator()) { + let parent = declaration.parentPath; + if (parent?.isVariableDeclaration() && parent.node.declare) { + return undefined; + } + let init = declaration.get('init'); + return init.isClassExpression() ? init : undefined; + } + return undefined; } - return ( - (declaration.isClassDeclaration() || declaration.isFunctionDeclaration()) && - !declaration.node.declare - ); + + return { + visitor: { + ExportNamedDeclaration( + path: NodePath, + state: { filename?: string }, + ) { + if (path.node.source || path.node.exportKind === 'type') { + return; + } + let declaration = path.get('declaration'); + if ( + declaration.isClassDeclaration() || + declaration.isVariableDeclaration() + ) { + for (let name of Object.keys( + t.getOuterBindingIdentifiers(declaration.node), + )) { + let cls = declaredClass(path.scope.getBinding(name)); + if (cls) { + mark(cls, name, state.filename); + } + } + } + for (let specifier of path.node.specifiers) { + if ( + !t.isExportSpecifier(specifier) || + specifier.exportKind === 'type' + ) { + continue; + } + let cls = declaredClass(path.scope.getBinding(specifier.local.name)); + if (cls) { + mark( + cls, + t.isIdentifier(specifier.exported) + ? specifier.exported.name + : specifier.exported.value, + state.filename, + ); + } + } + }, + ExportDefaultDeclaration( + path: NodePath, + state: { filename?: string }, + ) { + let declaration = path.get('declaration'); + if ( + declaration.isClassDeclaration() || + declaration.isClassExpression() + ) { + if (!(declaration.node as t.ClassDeclaration).declare) { + mark(declaration, 'default', state.filename); + } + } else if (declaration.isIdentifier()) { + let cls = declaredClass(path.scope.getBinding(declaration.node.name)); + if (cls) { + mark(cls, 'default', state.filename); + } + } + }, + }, + }; } export function loaderPlugin(babel: typeof Babel) { @@ -261,13 +328,6 @@ export function loaderPlugin(babel: typeof Babel) { path.replaceWith(createLoaderImportCall(path.node.arguments)); } }, - // Last, so the marks name what remains exported once the TypeScript - // transform has removed what was only a type. - Program: { - exit(path: NodePath) { - markDeclaredExports(babel, path, {}); - }, - }, }, }; } diff --git a/packages/runtime-common/loader.ts b/packages/runtime-common/loader.ts index 319f380f160..21bbb770d00 100644 --- a/packages/runtime-common/loader.ts +++ b/packages/runtime-common/loader.ts @@ -14,7 +14,7 @@ import { type RuntimeDependencyTrackingContext, } from './dependency-tracker.ts'; import type { VirtualNetwork } from './virtual-network.ts'; -import { moduleProvenanceOf } from './loader-plugin.ts'; +import { markModuleProvenance, moduleProvenanceOf } from './loader-plugin.ts'; type FetchingModule = { state: 'fetching'; @@ -253,10 +253,6 @@ export class Loader { 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 } - >(); private static loaders = new WeakMap(); private fetchImplementation: Fetch; @@ -397,7 +393,7 @@ export class Loader { shimModule(moduleIdentifier: string, module: Record) { moduleIdentifier = this.resolveImport(moduleIdentifier); - this.captureIdentitiesOfModuleExports(module, moduleIdentifier); + this.markModuleExports(module, moduleIdentifier); this.setCanonicalModuleURL(moduleIdentifier, moduleIdentifier); this.moduleShims.set(moduleIdentifier, module); @@ -516,24 +512,10 @@ export class Loader { static identify( value: unknown, ): { module: string; name: string } | undefined { - if (typeof value !== 'function') { - return undefined; - } - // A loader that served the value has already read its mark, and put the - // module in canonical form. The mark alone answers for a value no loader - // served: one evaluated outside every loader, such as in a module compiled - // into a bundle that no loader was asked for. - return ( - Loader.loaders.get(value)?.identify(value) ?? moduleProvenanceOf(value) - ); - } - - identify(value: unknown): { module: string; name: string } | undefined { - if (typeof value === 'function') { - return this.identities.get(value); - } else { - return undefined; - } + // The mark a class carries names the module that declares it, in the + // canonical form a code ref persists. See `moduleProvenancePlugin` and + // `markModuleExports`. + return moduleProvenanceOf(value); } static getLoaderFor(value: unknown): Loader | undefined { @@ -1331,44 +1313,24 @@ export class Loader { // (`https://localhost:4201/base/X`) for the same module. Returns the // input unchanged when no virtual alias is registered. - private captureIdentitiesOfModuleExports( - module: any, - moduleIdentifier: string, - ) { - // Identities are recorded in canonical identifier form so that - // `identify()` output matches the form persisted in code refs. + // Records this loader as the one a module's exports came from, for + // `getLoaderFor`, and marks each export that has no provenance mark yet with + // this module. Transpilation marks the classes a module declares, as it is + // evaluated, so this marks what it did not: a value in a module the loader + // was handed as a shim, or in a module transpiled before marks existed. + private markModuleExports(module: any, moduleIdentifier: string) { + // 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 ( - typeof exportedEntity === 'function' && - typeof propName === 'string' && - !this.identities.has(exportedEntity) - ) { - // A value marked with the module that declares it is credited to that - // module, not to the first module that happened to expose it. The two - // differ when a module that re-exports the value is served before its - // declarer — possible for a shimmed module, whose imports the loader - // never sees evaluate. A mark naming this very module (by the URL it - // was evaluated at) changes nothing. - let provenance = moduleProvenanceOf(exportedEntity); - let declaredElsewhere = - provenance && - provenance.module !== moduleIdentifier && - provenance.module !== this.getCanonicalModuleURL(moduleIdentifier); - this.identities.set( - exportedEntity, - declaredElsewhere - ? { - module: this.canonicalIdentifier( - trimModuleIdentifier(provenance!.module), - ), - name: provenance!.name, - } - : { module: moduleId, name: propName }, - ); + if (typeof exportedEntity === 'function') { + markModuleProvenance(exportedEntity, { + module: moduleId, + name: propName, + }); Loader.loaders.set(exportedEntity, this); } } @@ -1457,7 +1419,7 @@ export class Loader { this.setCanonicalModuleURL(moduleIdentifier, canonicalURL); if (loaded.type === 'shimmed') { - this.captureIdentitiesOfModuleExports(loaded.module, moduleIdentifier); + this.markModuleExports(loaded.module, moduleIdentifier); this.setModule(moduleIdentifier, { state: 'evaluated', @@ -1593,6 +1555,10 @@ export class Loader { this.getCanonicalModuleURL(moduleIdentifier) ?? moduleIdentifier, loader: this, + // What the provenance marks in this module name it as. + moduleIdentifier: this.canonicalIdentifier( + trimModuleIdentifier(moduleIdentifier), + ), }; case 'completing-dep': case 'dep': { @@ -1635,7 +1601,7 @@ export class Loader { ); this.#currentlyEvaluatingModule = previouslyEvaluating; } - this.captureIdentitiesOfModuleExports(moduleProxy, moduleIdentifier); + this.markModuleExports(moduleProxy, moduleIdentifier); this.setModule(moduleIdentifier, { state: 'evaluated', moduleInstance: moduleProxy, diff --git a/packages/runtime-common/transpile.ts b/packages/runtime-common/transpile.ts index 461707480de..31cec1645fa 100644 --- a/packages/runtime-common/transpile.ts +++ b/packages/runtime-common/transpile.ts @@ -5,7 +5,7 @@ import makeEmberTemplatePlugin from 'babel-plugin-ember-template-compilation/bro import type { Options as EmberTemplatePluginOptions } from 'babel-plugin-ember-template-compilation/src/plugin'; //@ts-ignore breaks esbuild for VS Code extension import type { ExtendedPluginBuilder } from 'babel-plugin-ember-template-compilation/src/js-utils'; -import { loaderPlugin } from './loader-plugin.ts'; +import { loaderPlugin, moduleProvenancePlugin } from './loader-plugin.ts'; //@ts-ignore ironically no types are available import typescriptPlugin from '@babel/plugin-transform-typescript'; //@ts-ignore no types are available @@ -83,6 +83,7 @@ export async function transpileJS( [decoratorTransforms], [makeEmberTemplatePlugin, templateOptions], loaderPlugin, + moduleProvenancePlugin, ], highlightCode: false, // Do not output ANSI color codes in error messages so that the client can display them plainly });