Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3de0537
test(vite-plugin-nitro): add snapshot test for Vite config mutations
benpsnyder Apr 13, 2026
8826989
feat(vite-plugin-angular): enhance debugging documentation and improv…
benpsnyder Apr 13, 2026
6f72fa1
feat: add error handling for @reference in comments and improve direc…
benpsnyder Apr 13, 2026
43a5438
chore: update dependencies and configurations for Docusaurus and Angu…
benpsnyder Apr 13, 2026
70263db
chore: update debugging documentation with additional package overrid…
benpsnyder Apr 13, 2026
efcb7f2
chore: update debugging documentation to clarify usage of catalog and…
benpsnyder Apr 13, 2026
e76e35a
docs: enhance documentation on duplicate analog() registrations and S…
benpsnyder Apr 14, 2026
9f39aa7
Merge remote-tracking branch 'origin/alpha' into feat/support-snyder-…
benpsnyder Apr 14, 2026
18312c0
Merge remote-tracking branch 'origin/alpha' into feat/support-snyder-…
benpsnyder Apr 14, 2026
229b9e4
chore: update comments for clarity in Angular and Nitro plugins, and …
benpsnyder Apr 14, 2026
3155d19
feat: enhance configuration handling and improve test stability by cl…
benpsnyder Apr 14, 2026
e0b491b
chore: refine comments and enhance test stability in Angular and Nitr…
benpsnyder Apr 14, 2026
71b31a9
feat: improve handling of CSS comments and references, ensuring quote…
benpsnyder Apr 14, 2026
69af095
feat: add function to detect @reference text in comments and refine C…
benpsnyder Apr 14, 2026
ac4a4ca
feat: refactor CSS directive handling by extracting utility functions…
benpsnyder Apr 14, 2026
ac2dd07
feat: implement TailwindReferenceError handling in JIT plugin and aug…
benpsnyder Apr 14, 2026
00a43bd
fix: update console warning implementation in JIT plugin tests to ret…
benpsnyder Apr 14, 2026
49e660b
feat: add support for selectorless compilation in Angular plugin, enh…
benpsnyder Apr 15, 2026
6679136
Merge remote-tracking branch 'analogjs/alpha' into feat/snyder-suppor…
benpsnyder Apr 15, 2026
cb5feab
feat(vite-plugin-angular): enhance live reload plugin with Windows pa…
benpsnyder Apr 15, 2026
638d30e
feat(vite-plugin-angular): improve selectorless compilation handling …
benpsnyder Apr 15, 2026
756a1de
Merge remote-tracking branch 'analogjs/alpha' into feat/selectorless-…
benpsnyder Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/platform/src/lib/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginOptions['experimental']>['enableSelectorless']
: boolean;

/**
* Enable typed route table generation for type-safe navigation.
*
Expand Down
37 changes: 37 additions & 0 deletions packages/platform/src/lib/platform-plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
9 changes: 9 additions & 0 deletions packages/platform/src/lib/platform-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -132,6 +140,7 @@ export function platformPlugin(opts: Options = {}): Plugin[] {
experimental: {
...(viteExperimental ?? {}),
useAngularCompilationAPI,
enableSelectorless,
},
}),
)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ async function setupLiveReloadPlugin(options: {
filename: string;
}>;
include?: string[];
experimental?: {
enableSelectorless?: boolean;
};
stylePreprocessor?: (
code: string,
filename: string,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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: '<p>Home</p>',
})
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<string, unknown>) => Record<string, unknown>)
| 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<string, unknown>) => Record<string, unknown>)
| 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: '<p>Home</p>',
})
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<string, unknown>) => Record<string, unknown>)
| 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}`;
Expand Down
108 changes: 108 additions & 0 deletions packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
};
Comment on lines +320 to +367
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Route auto-detection now outruns the selectorless safety gate.

This new heuristic enables selectorless mode for src/app/routes / app/routes, but the later validation path still only exempts /pages/ and .page.* in isLikelyPageOnlyComponent(). A selectorless route entry component will therefore get _enableSelectorless and then still fail with the explicit-selector error. Reuse the same route-file predicate in both places so route entries stay supported.

Possible fix
 function isLikelyPageOnlyComponent(id: string): boolean {
   return (
+    isSelectorlessRouteFile(id) ||
     id.includes('/pages/') ||
     /\.page\.[cm]?[jt]sx?$/i.test(id) ||
     /\([^/]+\)\.page\.[cm]?[jt]sx?$/i.test(id)
   );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts` around lines 320
- 367, The selectorless detection enables routes under src/app/routes and
app/routes via detectSelectorlessRouteUsage but isLikelyPageOnlyComponent still
only treats /pages/ and .page.* as route-like; update isLikelyPageOnlyComponent
to reuse the same route-file predicate (isSelectorlessRouteFile) or extract the
predicate into a shared helper so files matching '/src/app/routes/' or
'/app/routes/' are considered route entries as well, ensuring components that
triggered _enableSelectorless won't then fail the explicit-selector check.

}
const classNames = new Map();
export function evictDeletedFileMetadata(
file: string,
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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') {
Expand All @@ -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;
Expand Down Expand Up @@ -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') {
Expand All @@ -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;
Expand Down
Loading