diff --git a/packages/platform/src/lib/options.ts b/packages/platform/src/lib/options.ts index a02697ce3..26b5e1397 100644 --- a/packages/platform/src/lib/options.ts +++ b/packages/platform/src/lib/options.ts @@ -200,6 +200,26 @@ export interface Options { */ useAngularCompilationAPI?: boolean; + /** + * Forward Angular selectorless compilation support into + * `@analogjs/vite-plugin-angular`. + * + * This toggles Angular's compiler-wide selectorless mode. Analog may + * otherwise infer a default from file-based pages or route entry points, + * including additional page roots discovered from libraries. That auto + * mode remains the default for backwards compatibility with existing + * selectorless route components, so set this explicitly when you need to + * pin the behavior. + * + * Also accepted at `vite.experimental.enableSelectorless` for backwards + * compatibility. + * + * Has no effect when `vite` is set to `false`. + */ + enableSelectorless?: PluginOptions['experimental'] extends object + ? NonNullable['enableSelectorless'] + : boolean; + /** * Enable typed route table generation for type-safe navigation. * diff --git a/packages/platform/src/lib/platform-plugin.spec.ts b/packages/platform/src/lib/platform-plugin.spec.ts index 6791294ee..f364cd8e8 100644 --- a/packages/platform/src/lib/platform-plugin.spec.ts +++ b/packages/platform/src/lib/platform-plugin.spec.ts @@ -141,6 +141,43 @@ describe('platformPlugin', () => { ); }); + it('forwards experimental.enableSelectorless to the Angular vite plugin', () => { + platformPlugin({ + experimental: { + enableSelectorless: false, + }, + }); + + expect(angularSpy).toHaveBeenCalledWith( + expect.objectContaining({ + experimental: expect.objectContaining({ + enableSelectorless: false, + }), + }), + ); + }); + + it('prefers top-level selectorless config over legacy vite.experimental config', () => { + platformPlugin({ + experimental: { + enableSelectorless: false, + }, + vite: { + experimental: { + enableSelectorless: true, + }, + }, + }); + + expect(angularSpy).toHaveBeenCalledWith( + expect.objectContaining({ + experimental: expect.objectContaining({ + enableSelectorless: false, + }), + }), + ); + }); + it('does not force semantic type checking onto the dev hot path by default', () => { platformPlugin(); diff --git a/packages/platform/src/lib/platform-plugin.ts b/packages/platform/src/lib/platform-plugin.ts index 054373596..735726755 100644 --- a/packages/platform/src/lib/platform-plugin.ts +++ b/packages/platform/src/lib/platform-plugin.ts @@ -59,11 +59,19 @@ export function platformPlugin(opts: Options = {}): Plugin[] { ); } + // Keep the top-level Analog experimental surface and the legacy + // `vite.experimental` compatibility surface in lockstep. Missing one of + // these compiler flags here makes app config appear correct while the + // lower-level Angular plugin still sees a different value. const useAngularCompilationAPI = platformOptions.experimental?.useAngularCompilationAPI ?? viteExperimental?.useAngularCompilationAPI; + const enableSelectorless = + platformOptions.experimental?.enableSelectorless ?? + viteExperimental?.enableSelectorless; debugPlatform('experimental options resolved', { useAngularCompilationAPI: !!useAngularCompilationAPI, + enableSelectorless, typedRouter: platformOptions.experimental?.typedRouter, stylePipeline: !!platformOptions.experimental?.stylePipeline, }); @@ -132,6 +140,7 @@ export function platformPlugin(opts: Options = {}): Plugin[] { experimental: { ...(viteExperimental ?? {}), useAngularCompilationAPI, + enableSelectorless, }, }), )), diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin-live-reload.spec.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin-live-reload.spec.ts index 324b0606f..5524a0067 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin-live-reload.spec.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin-live-reload.spec.ts @@ -32,6 +32,9 @@ async function setupLiveReloadPlugin(options: { filename: string; }>; include?: string[]; + experimental?: { + enableSelectorless?: boolean; + }; stylePreprocessor?: ( code: string, filename: string, @@ -140,6 +143,7 @@ async function setupLiveReloadPlugin(options: { workspaceRoot: resolvedWorkspaceRoot, experimental: { useAngularCompilationAPI: true, + enableSelectorless: options.experimental?.enableSelectorless, }, }).find((entry) => entry.name === '@analogjs/vite-plugin-angular') as any; @@ -272,6 +276,110 @@ describe('angular hmr style preprocessing', () => { }, ); + it( + 'allows selectorless compilation to be disabled explicitly', + { timeout: 15_000 }, + async () => { + const workspaceRoot = mkdtempSync( + join(tmpdir(), 'analog-live-reload-selectorless-explicit-off-'), + ); + temporaryWorkspaceRoots.add(workspaceRoot); + mkdirSync(join(workspaceRoot, 'src/app/pages'), { recursive: true }); + writeFileSync( + join(workspaceRoot, 'src/app/pages/home.page.ts'), + ` + import { Component } from '@angular/core'; + + @Component({ + template: '

Home

', + }) + export default class HomePageComponent {} + `, + ); + + const { initialize } = await setupLiveReloadPlugin({ + workspaceRoot, + experimental: { + enableSelectorless: false, + }, + }); + + const initializeCall = initialize.mock.calls[0]; + expect(initializeCall).toBeTruthy(); + + const mutateTsCompilerOptions = initializeCall?.[2] as + | ((options: Record) => Record) + | undefined; + + expect(mutateTsCompilerOptions).toBeTypeOf('function'); + + const mutated = mutateTsCompilerOptions?.({}); + + // Explicit app config must win even when the route heuristic would have + // enabled selectorless by default. + expect(mutated?._enableSelectorless).toBeUndefined(); + }, + ); + + it( + 'defaults selectorless compilation off when the app has no file-based pages', + { timeout: 15_000 }, + async () => { + const { initialize } = await setupLiveReloadPlugin({}); + + const initializeCall = initialize.mock.calls[0]; + expect(initializeCall).toBeTruthy(); + + const mutateTsCompilerOptions = initializeCall?.[2] as + | ((options: Record) => Record) + | undefined; + + expect(mutateTsCompilerOptions).toBeTypeOf('function'); + + const mutated = mutateTsCompilerOptions?.({}); + + expect(mutated?._enableSelectorless).toBeUndefined(); + }, + ); + + it( + 'defaults selectorless compilation on when the app has file-based pages', + { timeout: 15_000 }, + async () => { + const workspaceRoot = mkdtempSync( + join(tmpdir(), 'analog-live-reload-selectorless-pages-'), + ); + temporaryWorkspaceRoots.add(workspaceRoot); + mkdirSync(join(workspaceRoot, 'src/app/pages'), { recursive: true }); + writeFileSync( + join(workspaceRoot, 'src/app/pages/home.page.ts'), + ` + import { Component } from '@angular/core'; + + @Component({ + template: '

Home

', + }) + export default class HomePageComponent {} + `, + ); + + const { initialize } = await setupLiveReloadPlugin({ workspaceRoot }); + + const initializeCall = initialize.mock.calls[0]; + expect(initializeCall).toBeTruthy(); + + const mutateTsCompilerOptions = initializeCall?.[2] as + | ((options: Record) => Record) + | undefined; + + expect(mutateTsCompilerOptions).toBeTypeOf('function'); + + const mutated = mutateTsCompilerOptions?.({}); + + expect(mutated?._enableSelectorless).toBe(true); + }, + ); + it('prepends content via stylePreprocessor through the HMR stylesheet path', async () => { const prepender = (code: string, _filename: string) => `@reference "../styles/tailwind.css";\n${code}`; diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts index 284d2bef4..04f2253de 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts @@ -177,6 +177,20 @@ export interface PluginOptions { experimental?: { useAngularCompilationAPI?: boolean; useAnalogCompiler?: boolean; + /** + * Controls Angular's experimental selectorless compilation mode. + * + * Defaults to `true` only when Angular 20+ applications use file-based + * pages or route entry points that rely on selectorless compilation. + * That auto mode is preserved for backwards compatibility with existing + * Analog route files that intentionally omit `selector`. + * Angular treats this as a compiler-wide mode, so once enabled it applies + * to the whole program, including workspace libraries pulled in through + * `include` globs or tsconfig references. + * Set this explicitly to pin behavior when route discovery would otherwise + * auto-enable selectorless for a larger app graph than intended. + */ + enableSelectorless?: boolean; /** * Compilation output mode for the Analog compiler. * - `'full'` (default): Emit final Ivy definitions for application builds. @@ -303,6 +317,55 @@ export function normalizeIncludeGlob( return normalizePath(resolve(normalizedWorkspaceRoot, normalizedGlob)); } +function isSelectorlessRouteFile(file: string): boolean { + const normalized = normalizePath(file); + return ( + normalized.endsWith('.page.ts') || + normalized.includes('/src/app/routes/') || + normalized.includes('/app/routes/') + ); +} + +/** + * Infer selectorless support from route/page entry points. + * + * This heuristic is intentionally broad because file-based routing commonly + * relies on selectorless components. The resulting Angular compiler flag is + * program-wide, not route-scoped, so a matching route-like include can affect + * every file in the current Angular program. + */ +function detectSelectorlessRouteUsage( + root: string, + workspaceRoot: string, + includeGlobs: string[], +): { enabled: boolean; routeFileCount: number } { + const normalizedRoot = normalizePath(resolve(root)); + const normalizedWorkspaceRoot = normalizePath(resolve(workspaceRoot)); + const appRouteGlobs = [ + `${normalizedRoot}/src/app/pages/**/*.page.ts`, + `${normalizedRoot}/src/app/routes/**/*.ts`, + `${normalizedRoot}/app/routes/**/*.ts`, + ]; + const routeLikeIncludeGlobs = includeGlobs + .map((glob) => normalizeIncludeGlob(normalizedWorkspaceRoot, glob)) + .filter( + (glob) => + glob.includes('.page.') || + glob.includes('/pages/') || + glob.includes('/app/routes/') || + glob.includes('/src/app/routes/'), + ); + const routeFiles = globSync([...appRouteGlobs, ...routeLikeIncludeGlobs], { + absolute: true, + dot: true, + onlyFiles: true, + }).filter(isSelectorlessRouteFile); + + return { + enabled: routeFiles.length > 0, + routeFileCount: routeFiles.length, + }; +} const classNames = new Map(); export function evictDeletedFileMetadata( file: string, @@ -445,6 +508,7 @@ export function buildStylePreprocessor( export function angular(options?: PluginOptions): Plugin[] { applyDebugOption(options?.debug, options?.workspaceRoot); const liveReload = options?.liveReload ?? true; + const explicitEnableSelectorless = options?.experimental?.enableSelectorless; /** * Normalize plugin options so defaults @@ -471,6 +535,7 @@ export function angular(options?: PluginOptions): Plugin[] { fileReplacements: options?.fileReplacements ?? [], useAngularCompilationAPI: options?.experimental?.useAngularCompilationAPI ?? false, + enableSelectorless: explicitEnableSelectorless, hasTailwindCss: !!options?.tailwindCss, tailwindCss: options?.tailwindCss, stylePreprocessor: buildStylePreprocessor(options), @@ -1015,6 +1080,34 @@ export function angular(options?: PluginOptions): Plugin[] { configResolved(config) { resolvedConfig = config; + if (typeof pluginOptions.enableSelectorless === 'undefined') { + // Preserve the explicit option as the authoritative value. The route + // scan is only a default because Angular applies selectorless to the + // whole program once the compiler option is enabled. We still keep + // the heuristic default for compatibility with checked-in Analog apps + // and tests that use selectorless route components without an + // explicit flag. + const selectorlessRouteUsage = detectSelectorlessRouteUsage( + config.root, + pluginOptions.workspaceRoot, + pluginOptions.include, + ); + pluginOptions.enableSelectorless = + angularFullVersion >= 200000 && selectorlessRouteUsage.enabled; + debugCompilationApi('selectorless default resolved', { + angularVersion: angularFullVersion, + root: config.root, + routeFileCount: selectorlessRouteUsage.routeFileCount, + selectorlessEnabled: pluginOptions.enableSelectorless, + }); + } else { + debugCompilationApi('selectorless resolved from explicit option', { + angularVersion: angularFullVersion, + root: config.root, + selectorlessEnabled: pluginOptions.enableSelectorless, + }); + } + if (pluginOptions.hasTailwindCss) { validateTailwindConfig(config, watchMode); } @@ -2797,6 +2890,7 @@ export function angular(options?: PluginOptions): Plugin[] { shouldExternalize: shouldExternalizeStyles(), externalRuntimeStyles: !!tsCompilerOptions['externalRuntimeStyles'], hmrEnabled: !!tsCompilerOptions['_enableHmr'], + selectorlessEnabled: pluginOptions.enableSelectorless, }); if (tsCompilerOptions.compilationMode === 'partial') { @@ -2805,6 +2899,13 @@ export function angular(options?: PluginOptions): Plugin[] { tsCompilerOptions['supportJitMode'] = true; } + if (pluginOptions.enableSelectorless) { + // Angular reads selectorless as a compiler-wide mode rather than a + // per-route transform, so keep this mutation in sync with every + // compilation path that feeds Angular compiler options. + tsCompilerOptions['_enableSelectorless'] = true; + } + if (!isTest && config.build?.lib) { tsCompilerOptions['declaration'] = true; tsCompilerOptions['declarationMap'] = watchMode; @@ -3092,6 +3193,7 @@ export function angular(options?: PluginOptions): Plugin[] { shouldExternalize: shouldExternalizeStyles(), externalRuntimeStyles: !!tsCompilerOptions['externalRuntimeStyles'], hmrEnabled: !!tsCompilerOptions['_enableHmr'], + selectorlessEnabled: pluginOptions.enableSelectorless, }); if (tsCompilerOptions['compilationMode'] === 'partial') { @@ -3100,6 +3202,12 @@ export function angular(options?: PluginOptions): Plugin[] { tsCompilerOptions['supportJitMode'] = true; } + if (pluginOptions.enableSelectorless) { + // Keep the legacy NgtscProgram path aligned with the Compilation API + // path above. Selectorless is still compiler-wide here. + tsCompilerOptions['_enableSelectorless'] = true; + } + if (!isTest && config.build?.lib) { tsCompilerOptions['declaration'] = true; tsCompilerOptions['declarationMap'] = watchMode;