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 });