diff --git a/packages/core/src/module-request.ts b/packages/core/src/module-request.ts index c439dcbbd..253157a3e 100644 --- a/packages/core/src/module-request.ts +++ b/packages/core/src/module-request.ts @@ -121,3 +121,13 @@ export class ModuleRequest implements Modul return new ModuleRequest(this.#adapter, this) as this; } } + +export async function extractResolution( + res: Res | (() => Promise) +): Promise { + if (typeof res === 'function') { + return await res(); + } else { + return res; + } +} diff --git a/packages/core/src/module-resolver.ts b/packages/core/src/module-resolver.ts index aa83cbf73..218abedff 100644 --- a/packages/core/src/module-resolver.ts +++ b/packages/core/src/module-resolver.ts @@ -3,7 +3,10 @@ import { emberVirtualPeerDeps, extensionsPattern, packageName as getPackageName, + isInComponents, + needsSyntheticComponentJS, packageName, + syntheticJStoHBS, } from '@embroider/shared-internals'; import { dirname, resolve, posix, basename } from 'path'; import type { Package, V2Package } from '@embroider/shared-internals'; @@ -18,7 +21,7 @@ import { readFileSync } from 'fs'; import { nodeResolve } from './node-resolve'; import type { Options, EngineConfig } from './module-resolver-options'; import { satisfies } from 'semver'; -import type { ModuleRequest, Resolution } from './module-request'; +import { extractResolution, type ModuleRequest, type Resolution } from './module-request'; const debug = makeDebug('embroider:resolver'); @@ -145,15 +148,13 @@ export class Resolver { let resolution: ResolveResolution; if (request.resolvedTo) { - if (typeof request.resolvedTo === 'function') { - resolution = await request.resolvedTo(); - } else { - resolution = request.resolvedTo; - } + resolution = await extractResolution(request.resolvedTo); } else { resolution = await request.defaultResolve(); } + resolution = await this.afterResolve(request, resolution); + switch (resolution.type) { case 'found': return resolution; @@ -172,11 +173,7 @@ export class Resolver { } if (nextRequest.resolvedTo) { - if (typeof nextRequest.resolvedTo === 'function') { - return await nextRequest.resolvedTo(); - } else { - return nextRequest.resolvedTo; - } + return await extractResolution(nextRequest.resolvedTo); } if (nextRequest.fromFile === request.fromFile && nextRequest.specifier === request.specifier) { @@ -206,6 +203,45 @@ export class Resolver { return RewrittenPackageCache.shared('embroider', this.options.appRoot); } + private async afterResolve(request: ModuleRequest, resolution: Res): Promise { + resolution = await this.handleSyntheticComponents(request, resolution); + return resolution; + } + + private async handleSyntheticComponents( + request: ModuleRequest, + resolution: Res + ): Promise { + // Key assumption: the system's defaultResolve performs extension search for + // extensionless requests, with JS at a higher priority than HBS. + + // When the request had an explicit ".js" extension, the system default + // extension search doesn't help us locate an HBS, so we need to check for + // it ourselves here. + if (resolution.type === 'not_found') { + let hbsSpecifier = syntheticJStoHBS(request.specifier); + if (hbsSpecifier) { + resolution = await this.resolve(request.alias(hbsSpecifier)); + } + } + + // At this point, we might have resolved an HBS file (either because the + // request was extensionless and the default search found it, or because of + // our own code above) when the request was for a JS file. + if (resolution.type === 'found') { + let syntheticId = needsSyntheticComponentJS(request.specifier, resolution.filename); + if (syntheticId && isInComponents(resolution.filename, this.packageCache)) { + let newRequest = logTransition( + `synthetic component JS`, + request, + request.virtualize({ type: 'template-only-component-js', specifier: syntheticId }) + ); + return await extractResolution(newRequest.resolvedTo!); + } + } + return resolution; + } + private logicalPackage(owningPackage: V2Package, file: string): V2Package { let logicalLocation = this.reverseSearchAppTree(owningPackage, file); if (logicalLocation) { diff --git a/packages/core/src/virtual-content.ts b/packages/core/src/virtual-content.ts index 30726c2ac..7cc369446 100644 --- a/packages/core/src/virtual-content.ts +++ b/packages/core/src/virtual-content.ts @@ -1,6 +1,6 @@ import { posix, sep, join } from 'path'; import type { Resolver, AddonPackage, Package } from '.'; -import { extensionsPattern } from '.'; +import { extensionsPattern, syntheticJStoHBS, templateOnlyComponentSource } from '.'; import { compile } from './js-handlebars'; import { renderImplicitTestScripts, type TestSupportResponse } from './virtual-test-support'; import { renderTestSupportStyles, type TestSupportStylesResponse } from './virtual-test-support-styles'; @@ -21,6 +21,7 @@ export type VirtualResponse = { specifier: string } & ( | VirtualVendorResponse | VirtualVendorStylesResponse | VirtualPairResponse + | TemplateOnlyComponentResponse ); export interface VirtualContentResult { @@ -53,6 +54,8 @@ export function virtualContent(response: VirtualResponse, resolver: Resolver): V return renderRouteEntrypoint(response, resolver); case 'fastboot-switch': return renderFastbootSwitchTemplate(response); + case 'template-only-component-js': + return renderTemplateOnlyComponent(response); default: throw assertNever(response); } @@ -256,3 +259,17 @@ function orderAddons(depA: Package, depB: Package): number { return depAIdx - depBIdx; } + +export interface TemplateOnlyComponentResponse { + type: 'template-only-component-js'; + specifier: string; +} + +function renderTemplateOnlyComponent({ specifier }: TemplateOnlyComponentResponse): VirtualContentResult { + let watches = [specifier]; + let hbs = syntheticJStoHBS(specifier); + if (hbs) { + watches.push(hbs); + } + return { src: templateOnlyComponentSource(), watches }; +} diff --git a/packages/vite/src/esbuild-resolver.ts b/packages/vite/src/esbuild-resolver.ts index 5874d32b0..91b09cc3b 100644 --- a/packages/vite/src/esbuild-resolver.ts +++ b/packages/vite/src/esbuild-resolver.ts @@ -1,7 +1,7 @@ import type { Plugin as EsBuildPlugin, OnLoadResult, PluginBuild, ResolveResult } from 'esbuild'; import { transformAsync } from '@babel/core'; import core, { ModuleRequest, type VirtualResponse } from '@embroider/core'; -const { ResolverLoader, virtualContent, needsSyntheticComponentJS, isInComponents } = core; +const { ResolverLoader, virtualContent } = core; import fs from 'fs-extra'; const { readFileSync } = fs; import { EsBuildRequestAdapter } from './esbuild-request.js'; @@ -10,9 +10,6 @@ import { hbsToJS } from '@embroider/core'; import { Preprocessor } from 'content-tag'; import { extname } from 'path'; -const templateOnlyComponent = - `import templateOnly from '@ember/component/template-only';\n` + `export default templateOnly();\n`; - export function esBuildResolver(): EsBuildPlugin { let resolverLoader = new ResolverLoader(process.cwd()); let preprocessor = new Preprocessor(); @@ -35,9 +32,7 @@ export function esBuildResolver(): EsBuildPlugin { pluginData?: { virtual: VirtualResponse }; }): Promise { let src: string; - if (namespace === 'embroider-template-only-component') { - src = templateOnlyComponent; - } else if (namespace === 'embroider-virtual') { + if (namespace === 'embroider-virtual') { // castin because response in our namespace are supposed to always have // this pluginData. src = virtualContent(pluginData!.virtual, resolverLoader.resolver).src; @@ -85,38 +80,11 @@ export function esBuildResolver(): EsBuildPlugin { } }); - // template-only-component synthesis - build.onResolve({ filter: /./ }, async ({ path, importer, namespace, resolveDir, pluginData, kind }) => { - if (pluginData?.embroiderHBSResolving) { - // reentrance - return null; - } - - let result = await build.resolve(path, { - namespace, - resolveDir, - importer, - kind, - // avoid reentrance - pluginData: { ...pluginData, embroiderHBSResolving: true }, - }); - - if (result.errors.length === 0 && !result.external) { - let syntheticPath = needsSyntheticComponentJS(path, result.path); - if (syntheticPath && isInComponents(result.path, resolverLoader.resolver.packageCache)) { - return { path: syntheticPath, namespace: 'embroider-template-only-component' }; - } - } - - return result; - }); - if (phase === 'bundling') { // during bundling phase, we need to provide our own extension // searching. We do it here in its own resolve plugin so that it's - // sitting beneath both embroider resolver and template-only-component - // synthesizer, since both expect the ambient system to have extension - // search. + // sitting beneath the embroider resolver since it expects the ambient + // system to have extension search. build.onResolve({ filter: /./ }, async ({ path, importer, namespace, resolveDir, pluginData, kind }) => { if (pluginData?.embroiderExtensionResolving) { // reentrance @@ -148,8 +116,7 @@ export function esBuildResolver(): EsBuildPlugin { }); } - // we need to handle everything from one of our three special namespaces: - build.onLoad({ namespace: 'embroider-template-only-component', filter: /./ }, onLoad); + // we need to handle everything from our special namespaces build.onLoad({ namespace: 'embroider-virtual', filter: /./ }, onLoad); build.onLoad({ namespace: 'embroider-template-tag', filter: /./ }, onLoad); diff --git a/packages/vite/src/hbs.ts b/packages/vite/src/hbs.ts index 793a09838..b64d9aee2 100644 --- a/packages/vite/src/hbs.ts +++ b/packages/vite/src/hbs.ts @@ -1,81 +1,14 @@ import { createFilter } from '@rollup/pluginutils'; import type { PluginContext } from 'rollup'; import type { Plugin } from 'vite'; -import { - hbsToJS, - ResolverLoader, - needsSyntheticComponentJS, - isInComponents, - templateOnlyComponentSource, - syntheticJStoHBS, -} from '@embroider/core'; +import { hbsToJS, templateOnlyComponentSource } from '@embroider/core'; -const resolverLoader = new ResolverLoader(process.cwd()); const hbsFilter = createFilter('**/*.hbs?([?]*)'); export function hbs(): Plugin { return { name: 'rollup-hbs-plugin', enforce: 'pre', - async resolveId(source: string, importer: string | undefined, options) { - if (options.custom?.depScan) { - // during depscan we have a corresponding esbuild plugin that is - // responsible for this stuff instead. We don't want to fight with it. - return null; - } - - if (options.custom?.embroider?.isExtensionSearch) { - return null; - } - - let resolution = await this.resolve(source, importer, { - skipSelf: true, - }); - - if (!resolution) { - // vite already has extension search fallback for extensionless imports. - // This is different, it covers an explicit .js import fallback to the - // corresponding hbs. - let hbsSource = syntheticJStoHBS(source); - if (hbsSource) { - resolution = await this.resolve(hbsSource, importer, { - skipSelf: true, - custom: { - embroider: { - // we don't want to recurse into the whole embroider compatbility - // resolver here. It has presumably already steered our request to the - // correct place. All we want to do is slightly modify the request we - // were given (changing the extension) and check if that would resolve - // instead. - // - // Currently this guard is only actually exercised in rollup, not in - // vite, due to https://github.com/vitejs/vite/issues/13852 - enableCustomResolver: false, - isExtensionSearch: true, - }, - }, - }); - } - - if (!resolution) { - return null; - } - } - - let syntheticId = needsSyntheticComponentJS(source, resolution.id); - if (syntheticId && isInComponents(resolution.id, resolverLoader.resolver.packageCache)) { - return { - id: syntheticId, - meta: { - 'rollup-hbs-plugin': { - type: 'template-only-component-js', - }, - }, - }; - } - - return resolution; - }, load(id: string) { if (getMeta(this, id)?.type === 'template-only-component-js') {