diff --git a/packages/compat/tests/stage2.test.ts b/packages/compat/tests/stage2.test.ts index 2d90d654a..23f2164a9 100644 --- a/packages/compat/tests/stage2.test.ts +++ b/packages/compat/tests/stage2.test.ts @@ -461,10 +461,10 @@ describe('stage2 build', function () { test('addon/hello-world.js', function () { let assertFile = expectFile('node_modules/my-addon/components/hello-world.js').transform(build.transpile); assertFile.matches( - /window\.define\(["']\my-addon\/synthetic-import-1["'],\s*function\s\(\)\s*\{\s*return\s+require\(["']\.\.\/synthetic-import-1/ + /window\.define\(["']\my-addon\/synthetic-import-1["'],\s*function\s\(\)\s*\{\s*return\s+esc\(require\(["']\.\.\/synthetic-import-1/ ); assertFile.matches( - /window\.define\(["']my-app\/templates\/components\/second-choice["'],\s*function\s\(\)\s*\{\s*return\s+require\(["']\.\.\/\.\.\/\.\.\/templates\/components\/second-choice\.hbs["']/ + /window\.define\(["']my-app\/templates\/components\/second-choice["'],\s*function\s\(\)\s*\{\s*return\s+esc\(require\(["']\.\.\/\.\.\/\.\.\/templates\/components\/second-choice\.hbs["']/ ); assertFile.matches( /import somethingExternal from ["'].*\/externals\/not-a-resolvable-package["']/, @@ -475,7 +475,7 @@ describe('stage2 build', function () { test('app/hello-world.js', function () { let assertFile = expectFile('./components/hello-world.js').transform(build.transpile); assertFile.matches( - /window\.define\(["']\my-addon\/synthetic-import-1["'],\s*function\s\(\)\s*\{\s*return\s+require\(["']\.\.\/node_modules\/my-addon\/synthetic-import-1/ + /window\.define\(["']\my-addon\/synthetic-import-1["'],\s*function\s\(\)\s*\{\s*return\s+esc\(require\(["']\.\.\/node_modules\/my-addon\/synthetic-import-1/ ); assertFile.matches( /export \{ default \} from ['"]\.\.\/node_modules\/my-addon\/components\/hello-world['"]/, diff --git a/packages/macros/package.json b/packages/macros/package.json index e29f2f04c..5ea04239a 100644 --- a/packages/macros/package.json +++ b/packages/macros/package.json @@ -26,6 +26,7 @@ "dependencies": { "@embroider/shared-internals": "0.50.1", "assert-never": "^1.2.1", + "babel-import-util": "^1.1.0", "ember-cli-babel": "^7.26.6", "find-up": "^5.0.0", "lodash": "^4.17.21", diff --git a/packages/macros/src/addon/es-compat.js b/packages/macros/src/addon/es-compat.js new file mode 100644 index 000000000..ddb4b6d1f --- /dev/null +++ b/packages/macros/src/addon/es-compat.js @@ -0,0 +1,3 @@ +export default function esCompat(m) { + return m?.__esModule ? m : { default: m }; +} diff --git a/packages/macros/src/babel/dependency-satisfies.ts b/packages/macros/src/babel/dependency-satisfies.ts index 62a8109a9..2dde9daa7 100644 --- a/packages/macros/src/babel/dependency-satisfies.ts +++ b/packages/macros/src/babel/dependency-satisfies.ts @@ -1,6 +1,6 @@ import type { NodePath } from '@babel/traverse'; import type { types as t } from '@babel/core'; -import State, { sourceFile } from './state'; +import State from './state'; import { satisfies } from 'semver'; import error from './error'; import { assertArray } from './evaluate-json'; @@ -22,9 +22,8 @@ export default function dependencySatisfies(path: NodePath, st `the second argument to dependencySatisfies must be a string literal` ); } - let sourceFileName = sourceFile(path, state); try { - let us = state.packageCache.ownerOfFile(sourceFileName); + let us = state.packageCache.ownerOfFile(state.sourceFile); if (!us?.hasDependency(packageName.value)) { return false; } diff --git a/packages/macros/src/babel/each.ts b/packages/macros/src/babel/each.ts index 150668012..a508fb987 100644 --- a/packages/macros/src/babel/each.ts +++ b/packages/macros/src/babel/each.ts @@ -2,7 +2,7 @@ import type { NodePath } from '@babel/traverse'; import { buildLiterals, Evaluator } from './evaluate-json'; import type { types as t } from '@babel/core'; import error from './error'; -import State, { cloneDeep } from './state'; +import State from './state'; import type * as Babel from '@babel/core'; type CallEachExpression = NodePath & { @@ -51,14 +51,14 @@ export function insertEach(path: EachPath, state: State, context: typeof Babel) if (state.opts.mode === 'run-time') { let callee = path.get('right').get('callee'); - state.neededRuntimeImports.set(callee.node.name, 'each'); + callee.replaceWith(state.importUtil.import(callee, state.pathToOurAddon('runtime'), 'each')); } else { for (let element of array.value) { let literalElement = buildLiterals(element, context); for (let target of nameRefs) { target.replaceWith(literalElement); } - path.insertBefore(cloneDeep(path.get('body').node, state)); + path.insertBefore(state.cloneDeep(path.get('body').node)); } path.remove(); } diff --git a/packages/macros/src/babel/evaluate-json.ts b/packages/macros/src/babel/evaluate-json.ts index 0df6370d7..c3775e425 100644 --- a/packages/macros/src/babel/evaluate-json.ts +++ b/packages/macros/src/babel/evaluate-json.ts @@ -1,7 +1,7 @@ import type { NodePath } from '@babel/traverse'; import type * as Babel from '@babel/core'; import type { types as t } from '@babel/core'; -import State, { owningPackage } from './state'; +import State from './state'; import dependencySatisfies from './dependency-satisfies'; import moduleExists from './module-exists'; import getConfig from './get-config'; @@ -339,11 +339,13 @@ export class Evaluator { // to runtime. That's why we've made `value` lazy. It lets us check the // confidence without actually forcing the value. private maybeEvaluateRuntimeConfig(path: NodePath): EvaluateResult { + if (!this.state) { + return { confident: false }; + } let callee = path.get('callee'); if (callee.isIdentifier()) { - let { name } = callee.node; // Does the identifier refer to our runtime config? - if (this.state?.neededRuntimeImports.get(name) === 'config') { + if (callee.referencesImport(this.state.pathToOurAddon('runtime'), 'config')) { return { confident: true, get value() { @@ -387,7 +389,7 @@ export class Evaluator { if (callee.referencesImport('@embroider/macros', 'isDevelopingThisPackage')) { return { confident: true, - value: this.state.opts.isDevelopingPackageRoots.includes(owningPackage(path, this.state).root), + value: this.state.opts.isDevelopingPackageRoots.includes(this.state.owningPackage().root), }; } if (callee.referencesImport('@embroider/macros', 'isTesting')) { diff --git a/packages/macros/src/babel/get-config.ts b/packages/macros/src/babel/get-config.ts index 45f3e3eae..a07518aaf 100644 --- a/packages/macros/src/babel/get-config.ts +++ b/packages/macros/src/babel/get-config.ts @@ -1,5 +1,5 @@ import type { NodePath } from '@babel/traverse'; -import State, { sourceFile, unusedNameLike } from './state'; +import State from './state'; import { PackageCache, Package } from '@embroider/shared-internals'; import error from './error'; import { Evaluator, assertArray, buildLiterals, ConfidentResult } from './evaluate-json'; @@ -28,7 +28,7 @@ function getPackage(path: NodePath, state: State, mode: 'own' } else { assertNever(mode); } - return targetPackage(sourceFile(path, state), packageName, state.packageCache); + return targetPackage(state.sourceFile, packageName, state.packageCache); } // this evaluates to the actual value of the config. It can be used directly by the Evaluator. @@ -55,7 +55,8 @@ export function insertConfig(path: NodePath, state: State, mod collapsed.path.replaceWith(literalResult); } else { if (mode === 'getGlobalConfig') { - state.neededRuntimeImports.set(calleeName(path, context), 'getGlobalConfig'); + let callee = path.get('callee'); + callee.replaceWith(state.importUtil.import(callee, state.pathToOurAddon('runtime'), 'getGlobalConfig')); } else { let pkg = getPackage(path, state, mode); let pkgRoot; @@ -64,9 +65,11 @@ export function insertConfig(path: NodePath, state: State, mod } else { pkgRoot = context.types.identifier('undefined'); } - let name = unusedNameLike('config', path); - path.replaceWith(context.types.callExpression(context.types.identifier(name), [pkgRoot])); - state.neededRuntimeImports.set(name, 'config'); + path.replaceWith( + context.types.callExpression(state.importUtil.import(path, state.pathToOurAddon('runtime'), 'config'), [ + pkgRoot, + ]) + ); } } } @@ -106,11 +109,3 @@ export function inlineRuntimeConfig(path: NodePath, state ), ]; } - -function calleeName(path: NodePath, context: typeof Babel): string { - let callee = path.node.callee; - if (context.types.isIdentifier(callee)) { - return callee.name; - } - throw new Error(`bug: our macros should only be invoked as identifiers`); -} diff --git a/packages/macros/src/babel/macro-condition.ts b/packages/macros/src/babel/macro-condition.ts index db3eea1a8..5c40513d9 100644 --- a/packages/macros/src/babel/macro-condition.ts +++ b/packages/macros/src/babel/macro-condition.ts @@ -38,7 +38,7 @@ export default function macroCondition(conditionalPath: MacroConditionPath, stat if (state.opts.mode === 'run-time') { let callee = conditionalPath.get('test').get('callee'); - state.neededRuntimeImports.set(callee.node.name, 'macroCondition'); + callee.replaceWith(state.importUtil.import(callee, state.pathToOurAddon('runtime'), 'macroCondition')); } else { let [kept, removed] = predicate.value ? [consequent.node, alternate.node] : [alternate.node, consequent.node]; if (kept) { diff --git a/packages/macros/src/babel/macros-babel-plugin.ts b/packages/macros/src/babel/macros-babel-plugin.ts index abec0019c..482aa280c 100644 --- a/packages/macros/src/babel/macros-babel-plugin.ts +++ b/packages/macros/src/babel/macros-babel-plugin.ts @@ -1,7 +1,7 @@ import type { NodePath } from '@babel/traverse'; import type { types as t } from '@babel/core'; import { PackageCache } from '@embroider/shared-internals'; -import State, { sourceFile, pathToRuntime } from './state'; +import State, { initState } from './state'; import { inlineRuntimeConfig, insertConfig, Mode as GetConfigMode } from './get-config'; import macroCondition, { isMacroConditionPath } from './macro-condition'; import { isEachPath, insertEach } from './each'; @@ -15,19 +15,14 @@ export default function main(context: typeof Babel): unknown { let t = context.types; let visitor = { Program: { - enter(_: NodePath, state: State) { - state.generatedRequires = new Set(); - state.jobs = []; - state.removed = new Set(); - state.calledIdentifiers = new Set(); - state.neededRuntimeImports = new Map(); - state.neededEagerImports = new Map(); + enter(path: NodePath, state: State) { + initState(t, path, state); + state.packageCache = PackageCache.shared('embroider-stage3', state.opts.appPackageRoot); }, - exit(path: NodePath, state: State) { - pruneMacroImports(path); - addRuntimeImports(path, state, context); - addEagerImports(path, state, t); + exit(_: NodePath, state: State) { + // @embroider/macros itself has no runtime behaviors and should always be removed + state.importUtil.removeAllImports('@embroider/macros'); for (let handler of state.jobs) { handler(); } @@ -53,7 +48,7 @@ export default function main(context: typeof Babel): unknown { enter(path: NodePath, state: State) { let id = path.get('id'); if (id.isIdentifier() && id.node.name === 'initializeRuntimeMacrosConfig') { - let pkg = state.packageCache.ownerOfFile(sourceFile(path, state)); + let pkg = state.owningPackage(); if (pkg && pkg.name === '@embroider/macros') { inlineRuntimeConfig(path, state, context); } @@ -106,7 +101,7 @@ export default function main(context: typeof Babel): unknown { // instead falls through to evaluateMacroCall. if (callee.referencesImport('@embroider/macros', 'isTesting') && state.opts.mode === 'run-time') { state.calledIdentifiers.add(callee.node); - state.neededRuntimeImports.set(callee.node.name, 'isTesting'); + callee.replaceWith(state.importUtil.import(callee, state.pathToOurAddon('runtime'), 'isTesting')); return; } @@ -132,17 +127,16 @@ export default function main(context: typeof Babel): unknown { if (specifier?.type !== 'StringLiteral') { throw new Error(`importSync eager mode doesn't implement non string literal arguments yet`); } - let replacePaths = state.neededEagerImports.get(specifier.value); - if (!replacePaths) { - replacePaths = []; - state.neededEagerImports.set(specifier.value, replacePaths); - } - replacePaths.push(path); + path.replaceWith(state.importUtil.import(path, specifier.value, '*')); state.calledIdentifiers.add(callee.node); } else { let r = t.identifier('require'); state.generatedRequires.add(r); - callee.replaceWith(r); + path.replaceWith( + t.callExpression(state.importUtil.import(path, state.pathToOurAddon('es-compat'), 'default', 'esc'), [ + t.callExpression(r, path.node.arguments), + ]) + ); } return; } @@ -195,7 +189,7 @@ export default function main(context: typeof Babel): unknown { path.node.name === 'require' && !state.generatedRequires.has(path.node) && !path.scope.hasBinding('require') && - ownedByEmberPackage(path, state) + state.owningPackage().isEmberPackage() ) { // Our importSync macro has been compiled to `require`. But we want to // distinguish that from any pre-existing, user-written `require` in an @@ -223,59 +217,3 @@ export default function main(context: typeof Babel): unknown { return { visitor }; } - -// This removes imports from "@embroider/macros" itself, because we have no -// runtime behavior at all. -function pruneMacroImports(path: NodePath) { - if (!path.isProgram()) { - return; - } - for (let topLevelPath of path.get('body')) { - if (topLevelPath.isImportDeclaration() && topLevelPath.get('source').node.value === '@embroider/macros') { - topLevelPath.remove(); - } - } -} - -function addRuntimeImports(path: NodePath, state: State, context: typeof Babel) { - let t = context.types; - if (state.neededRuntimeImports.size > 0) { - path.node.body.push( - t.importDeclaration( - [...state.neededRuntimeImports].map(([local, imported]) => - t.importSpecifier(t.identifier(local), t.identifier(imported)) - ), - t.stringLiteral(pathToRuntime(path, state)) - ) - ); - } -} - -function addEagerImports(path: NodePath, state: State, t: typeof Babel['types']) { - let createdNames = new Set(); - for (let [specifier, replacePaths] of state.neededEagerImports.entries()) { - let local = unusedNameLike('a', replacePaths, createdNames); - createdNames.add(local); - path.node.body.push( - t.importDeclaration([t.importNamespaceSpecifier(t.identifier(local))], t.stringLiteral(specifier)) - ); - for (let nodePath of replacePaths) { - nodePath.replaceWith(t.identifier(local)); - } - } -} - -function ownedByEmberPackage(path: NodePath, state: State) { - let filename = sourceFile(path, state); - let pkg = state.packageCache.ownerOfFile(filename); - return pkg && pkg.isEmberPackage(); -} - -function unusedNameLike(name: string, paths: NodePath[], banned: Set) { - let candidate = name; - let counter = 0; - while (banned.has(candidate) || paths.some(path => path.scope.getBinding(candidate))) { - candidate = `${name}${counter++}`; - } - return candidate; -} diff --git a/packages/macros/src/babel/module-exists.ts b/packages/macros/src/babel/module-exists.ts index 9e03c0c10..cb4b51db3 100644 --- a/packages/macros/src/babel/module-exists.ts +++ b/packages/macros/src/babel/module-exists.ts @@ -1,6 +1,6 @@ import type { NodePath } from '@babel/traverse'; import type { types as t } from '@babel/core'; -import State, { sourceFile } from './state'; +import State from './state'; import error from './error'; import { assertArray } from './evaluate-json'; import resolve from 'resolve'; @@ -14,9 +14,8 @@ export default function moduleExists(path: NodePath, state: St if (moduleSpecifier.type !== 'StringLiteral') { throw error(assertArray(path.get('arguments'))[0], `the first argument to moduleExists must be a string literal`); } - let sourceFileName = sourceFile(path, state); try { - resolve.sync(moduleSpecifier.value, { basedir: dirname(sourceFileName) }); + resolve.sync(moduleSpecifier.value, { basedir: dirname(state.sourceFile) }); return true; } catch (err) { if (err.code !== 'MODULE_NOT_FOUND') { diff --git a/packages/macros/src/babel/state.ts b/packages/macros/src/babel/state.ts index 41621f53a..c6c91dac4 100644 --- a/packages/macros/src/babel/state.ts +++ b/packages/macros/src/babel/state.ts @@ -3,23 +3,20 @@ import cloneDeepWith from 'lodash/cloneDeepWith'; import lodashCloneDeep from 'lodash/cloneDeep'; import { join, dirname, resolve } from 'path'; import { explicitRelative, Package, PackageCache } from '@embroider/shared-internals'; +import { ImportUtil } from 'babel-import-util'; +import type * as Babel from '@babel/core'; export default interface State { + importUtil: ImportUtil; generatedRequires: Set; removed: Set; calledIdentifiers: Set; jobs: (() => void)[]; - - // map from local name to imported name from @embroider/macros own runtime - // implementations. - neededRuntimeImports: Map; - - // when we're running with importSync's eager implementation, this maps from - // module specifier to the set of nodes that should be replaced with the - // module value. - neededEagerImports: Map; - packageCache: PackageCache; + sourceFile: string; + pathToOurAddon(moduleName: string): string; + owningPackage(): Package; + cloneDeep(node: Node): Node; opts: { userConfigs: { @@ -52,13 +49,25 @@ export default interface State { }; } -const runtimePath = resolve(join(__dirname, '..', 'addon', 'runtime')); +export function initState(t: typeof Babel.types, path: NodePath, state: State) { + state.importUtil = new ImportUtil(t, path); + state.generatedRequires = new Set(); + state.jobs = []; + state.removed = new Set(); + state.calledIdentifiers = new Set(); + state.packageCache = PackageCache.shared('embroider-stage3', state.opts.appPackageRoot); + state.sourceFile = state.opts.owningPackageRoot || path.hub.file.opts.filename; + state.pathToOurAddon = pathToAddon; + state.owningPackage = owningPackage; + state.cloneDeep = cloneDeep; +} + +const runtimeAddonPath = resolve(join(__dirname, '..', 'addon')); -export function pathToRuntime(path: NodePath, state: State): string { - if (!state.opts.owningPackageRoot) { +function pathToAddon(this: State, moduleName: string): string { + if (!this.opts.owningPackageRoot) { // running inside embroider, so make a relative path to the module - let source = sourceFile(path, state); - return explicitRelative(dirname(source), runtimePath); + return explicitRelative(dirname(this.sourceFile), join(runtimeAddonPath, moduleName)); } else { // running inside a classic build, so use a classic-compatible runtime // specifier. @@ -68,24 +77,20 @@ export function pathToRuntime(path: NodePath, state: State): string { // introducing incompatible changes to its API, you need to change this name // (by tacking on a version number, etc) and rename the corresponding file // in ../addon. - return '@embroider/macros/runtime'; + return `@embroider/macros/${moduleName}`; } } -export function sourceFile(path: NodePath, state: State): string { - return state.opts.owningPackageRoot || path.hub.file.opts.filename; -} - -export function owningPackage(path: NodePath, state: State): Package { - let file = sourceFile(path, state); - let pkg = state.packageCache.ownerOfFile(file); +function owningPackage(this: State): Package { + let pkg = this.packageCache.ownerOfFile(this.sourceFile); if (!pkg) { - throw new Error(`unable to determine which npm package owns the file ${file}`); + throw new Error(`unable to determine which npm package owns the file ${this.sourceFile}`); } return pkg; } -export function cloneDeep(node: Node, state: State): Node { +function cloneDeep(this: State, node: Node): Node { + let state = this; return cloneDeepWith(node, function (value: any) { if (state.generatedRequires.has(value)) { let cloned = lodashCloneDeep(value); @@ -94,12 +99,3 @@ export function cloneDeep(node: Node, state: State): Node { } }); } - -export function unusedNameLike(name: string, path: NodePath) { - let candidate = name; - let counter = 0; - while (path.scope.getBinding(candidate)) { - candidate = `${name}${counter++}`; - } - return candidate; -} diff --git a/packages/macros/tests/babel/each.test.ts b/packages/macros/tests/babel/each.test.ts index 189d15159..ea24b7e3a 100644 --- a/packages/macros/tests/babel/each.test.ts +++ b/packages/macros/tests/babel/each.test.ts @@ -25,11 +25,11 @@ describe('each', function () { import { each, getOwnConfig, importSync } from '@embroider/macros'; let plugins = []; for (let plugin of each(getOwnConfig().plugins)) { - plugins.push(importSync(plugin)); + plugins.push(plugin); } `); - expect(code).toMatch(/plugins\.push\(require\(["']beta['"]\)\)/); - expect(code).toMatch(/plugins\.push\(require\(["']alpha['"]\)\)/); + expect(code).toMatch(/plugins\.push\(["']beta['"]\)/); + expect(code).toMatch(/plugins\.push\(["']alpha['"]\)/); expect(code).not.toMatch(/for/); }); diff --git a/packages/macros/tests/babel/env-macros.test.ts b/packages/macros/tests/babel/env-macros.test.ts index 2f4acaffc..3ddca24af 100644 --- a/packages/macros/tests/babel/env-macros.test.ts +++ b/packages/macros/tests/babel/env-macros.test.ts @@ -81,7 +81,7 @@ describe(`env macros`, function () { } `); expect(run(code)).toBe(true); - expect(code).toMatch(/return isTesting\(\)/); + expect(code).toMatch(/return isTesting\d*\(\)/); }); buildTimeTest('isTesting: use within conditional', () => { @@ -98,7 +98,7 @@ describe(`env macros`, function () { expect(run(code)).toBe('yes'); expect(code).toMatch(/return 'yes'/); expect(code).not.toMatch(/return 'no'/); - expect(code).not.toMatch(/isTesting\(\)/); + expect(code).not.toMatch(/isTesting\d*\(\)/); }); runTimeTest('isTesting: use within conditional', () => { @@ -115,7 +115,7 @@ describe(`env macros`, function () { expect(run(code)).toBe('yes'); expect(code).toMatch(/return 'yes'/); expect(code).toMatch(/return 'no'/); - expect(code).toMatch(/isTesting\(\)/); + expect(code).toMatch(/isTesting\d*\(\)/); }); }); diff --git a/packages/macros/tests/babel/import-sync.test.ts b/packages/macros/tests/babel/import-sync.test.ts index ae35894f7..c8bcc1bc8 100644 --- a/packages/macros/tests/babel/import-sync.test.ts +++ b/packages/macros/tests/babel/import-sync.test.ts @@ -6,12 +6,13 @@ describe('importSync', function () { config.setOwnConfig(__filename, { target: 'my-plugin' }); config.finalize(); - test('importSync becomes require', () => { + test('importSync becomes esc(require())', () => { let code = transform(` import { importSync } from '@embroider/macros'; importSync('foo'); `); - expect(code).toMatch(/require\(['"]foo['"]\)/); + expect(code).toMatch(/import esc from "\.\.\/\.\.\/src\/addon\/es-compat"/); + expect(code).toMatch(/esc\(require\(['"]foo['"]\)\)/); expect(code).not.toMatch(/window/); }); test('aliased importSync becomes require', () => { diff --git a/test-packages/support/index.ts b/test-packages/support/index.ts index 1de9de50e..b7b62db02 100644 --- a/test-packages/support/index.ts +++ b/test-packages/support/index.ts @@ -103,7 +103,7 @@ export function definesPattern(runtimeName: string, buildTimeName: string): RegE runtimeName = escapeRegExp(runtimeName); buildTimeName = escapeRegExp(buildTimeName); return new RegExp( - `d\\(['"]${runtimeName}['"], *function *\\(\\) *\\{[\\s\\n]*return require\\(['"]${buildTimeName}['"]\\);?[\\s\\n]*\\}\\)` + `d\\(['"]${runtimeName}['"], *function *\\(\\) *\\{[\\s\\n]*return esc\\(require\\(['"]${buildTimeName}['"]\\)\\);?[\\s\\n]*\\}\\)` ); }