diff --git a/packages/addon-shim/src/index.ts b/packages/addon-shim/src/index.ts index 97d9b772b..340e4814a 100644 --- a/packages/addon-shim/src/index.ts +++ b/packages/addon-shim/src/index.ts @@ -12,6 +12,16 @@ import { satisfies } from 'semver'; export interface ShimOptions { disabled?: (options: any) => boolean; + + // this part only applies when running under ember-auto-import. It's intended + // to let a V2 addon tweak how it's interpreted by ember-auto-import inside + // the classic build in order to achieve backward compatibility with how it + // behaved as a V1 addon. + autoImportCompat?: { + // can modify the `ember-addon` metadata that ember-auto-import is using to + // do resolution. Right now that means the `renamed-modules`. + customizeMeta?: (meta: AddonMeta) => AddonMeta; + }; } function addonMeta(pkgJSON: PackageInfo): AddonMeta { @@ -22,6 +32,16 @@ function addonMeta(pkgJSON: PackageInfo): AddonMeta { return meta as AddonMeta; } +type OwnType = AddonInstance & { + _eaiAssertions(): void; + _internalRegisterV2Addon( + name: string, + root: string, + autoImportCompat?: ShimOptions['autoImportCompat'] + ): void; + _parentName(): string; +}; + export function addonV1Shim(directory: string, options: ShimOptions = {}) { let pkg: PackageInfo = JSON.parse( readFileSync(resolve(directory, './package.json'), 'utf8') @@ -73,12 +93,7 @@ export function addonV1Shim(directory: string, options: ShimOptions = {}) { return { name: pkg.name, - included( - this: AddonInstance & { - registerV2Addon(name: string, dir: string): void; - }, - ...args: unknown[] - ) { + included(this: OwnType, ...args: unknown[]) { let parentOptions; if (isDeepAddonInstance(this)) { parentOptions = this.parent.options; @@ -86,7 +101,12 @@ export function addonV1Shim(directory: string, options: ShimOptions = {}) { parentOptions = this.app.options; } - this.registerV2Addon(this.name, directory); + this._eaiAssertions(); + this._internalRegisterV2Addon( + this.name, + directory, + options.autoImportCompat + ); if (options.disabled) { disabled = options.disabled(parentOptions); @@ -139,38 +159,94 @@ export function addonV1Shim(directory: string, options: ShimOptions = {}) { return isInside(directory, appInstance.project.root); }, - registerV2Addon(this: AddonInstance, name: string, root: string): void { - let parentName: string; - if (isDeepAddonInstance(this)) { - parentName = this.parent.name; - } else { - parentName = this.parent.name(); - } - + _eaiAssertions(this: OwnType) { // if we're being used by a v1 package, that package needs ember-auto-import 2 if ((this.parent.pkg['ember-addon']?.version ?? 1) < 2) { + // important: here we're talking about the version of ember-auto-import + // declared by the package that is trying to use our V2 addon. Which is + // distinct from the version that may be installed in the top-level app, + // and which is also distinct from the elected ember-auto-import leader. let autoImport = locateAutoImport(this.parent.addons); if (!autoImport.present) { throw new Error( - `${parentName} needs to depend on ember-auto-import in order to use ${this.name}` + `${this._parentName()} needs to depend on ember-auto-import in order to use ${ + this.name + }` ); } - if (!autoImport.satisfiesV2) { throw new Error( - `${parentName} has ember-auto-import ${autoImport.version} which is not new enough to use ${this.name}. It needs to upgrade to >=2.0` + `${this._parentName()} has ember-auto-import ${ + autoImport.version + } which is not new enough to use ${ + this.name + }. It needs to upgrade to >=2.0` ); } - autoImport.instance.registerV2Addon(name, root); + } + }, + + _internalRegisterV2Addon( + this: OwnType, + name: string, + root: string, + options?: ShimOptions['autoImportCompat'] + ) { + // this is searching the top-level app for ember-auto-import, which is + // different from how we searched above in _eaiAssertions. We're going + // straight to the top because we definitely want to locate EAI if it's + // present, but our addon's immediate parent won't necessarily have EAI if + // that parent is itself a V2 addon. + let autoImport = locateAutoImport(this.project.addons); + if (!autoImport.present || !autoImport.satisfiesV2) { + // We don't assert here because it's not our responsibility. In + // _eaiAssertions we check the condition of our immediate parent, which + // makes the error messages more actionable. If our parent has EAI>=2, + // its copy of EAI will in turn assert that the app has one as well. + // + // This case is actually fine for a v2 app under Embroider, where EAI is + // not needed. + return; + } + + // we're not using autoImport.instance.registerV2Addon because not all 2.x + // versions will forward the third argument to the current leader. Whereas + // we can confidently ensure that the leader itself supports the third + // argument by adding it as a dependency of our V2 addon, since the newest + // copy that satisfies the app's requested semver range will win the + // election. + + let leader: ReturnType>; + if (autoImport.instance.leader) { + // sufficiently new EAI lets us directly ask for the leader + leader = autoImport.instance.leader(); } else { - // This should only be done if we're being consumed by an addon - if (this.parent.pkg['ember-addon'].type === 'addon') { - // if we're being used by a v2 addon, it also has this shim and will - // forward our registration onward to ember-auto-import - (this.parent as EAI2Instance).registerV2Addon(name, root); - } + // otherwise we need to reach inside + // eslint-disable-next-line @typescript-eslint/no-require-imports + let AutoImport = require(join( + autoImport.instance.root, + 'auto-import.js' + )).default; + leader = AutoImport.lookup(autoImport.instance); + } + + leader.registerV2Addon(name, root, options); + }, + + _parentName(this: OwnType): string { + if (isDeepAddonInstance(this)) { + return this.parent.name; + } else { + return this.parent.name(); } }, + + // This continues to exist because there are earlier versions of addon-shim + // that forward v2 addon registration through their parent V2 addon, thus + // calling this method. + registerV2Addon(this: OwnType, name: string, root: string): void { + this._internalRegisterV2Addon(name, root); + }, }; } @@ -180,7 +256,20 @@ function isInside(parentDir: string, otherDir: string): boolean { } type EAI2Instance = AddonInstance & { + // all 2.x versions of EAI have this method registerV2Addon(name: string, root: string): void; + + // EAI >= 2.10.0 offers this API, which is intended to be more extensible + // since it lets you talk directly to the current leader. That's better + // because the newest version of EAI present becomes the leader, so you can + // guarantee a minimum leader version by making it your own dependency. + leader?: () => { + registerV2Addon( + name: string, + root: string, + options?: ShimOptions['autoImportCompat'] + ): void; + }; }; function locateAutoImport(addons: AddonInstance[]):