Skip to content

Commit f115390

Browse files
fix(vite-plugin-angular): resolve non-standalone component scope in fastCompile (#2390)
1 parent 6f93fa1 commit f115390

6 files changed

Lines changed: 459 additions & 9 deletions

File tree

packages/vite-plugin-angular/src/lib/compiler/compile.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,100 @@ export function compile(
631631
});
632632
}
633633

634+
// Non-standalone components get their directive/pipe scope from the
635+
// @NgModule that declares them. Whole-program ngtsc resolves this; a
636+
// per-file engine can't read the module from the component file, but the
637+
// project-wide registry (populated at buildStart) records every module's
638+
// declarations + imports. We resolve the owning module's transitive scope
639+
// here and inline it into the component's own `dependencies` — matching
640+
// ngtsc, and surviving tree-shaking (unlike a separate, droppable
641+
// `ɵɵsetComponentScope` call).
642+
//
643+
// The real "non-standalone" signal is the owning-module lookup below:
644+
// a component listed in an @NgModule's `declarations` is non-standalone
645+
// by definition (Angular forbids declaring a standalone component). The
646+
// `meta.standalone` flag can't be trusted to detect this — the metadata
647+
// default is `true`, so an *omitted* flag (the common pre-v19 form,
648+
// where it defaulted to false) is indistinguishable from explicit
649+
// `true`. So gate cheaply: on v19+ a declared component must set
650+
// `standalone: false`, so only run the lookup for those; pre-v19 the
651+
// flag is unreliable, so run it for every component (the lookup itself
652+
// filters out genuinely standalone ones — they aren't declared).
653+
const mightBeNonStandalone =
654+
meta.standalone === false || ANGULAR_MAJOR < 19;
655+
if (!isPartial && mightBeNonStandalone && registry) {
656+
let owningModule:
657+
| { declarations?: string[]; imports?: string[] }
658+
| undefined;
659+
for (const entry of registry.values()) {
660+
if (
661+
entry.kind === 'ngmodule' &&
662+
entry.declarations?.includes(className)
663+
) {
664+
owningModule = entry;
665+
break;
666+
}
667+
}
668+
if (owningModule) {
669+
// Transitive scope = the module's own declarations + the exported
670+
// declarations of every imported module (recursively) + tuple members.
671+
const scopeNames = new Set<string>();
672+
const addExportsOf = (modName: string, visited: Set<string>) => {
673+
if (visited.has(modName)) return;
674+
visited.add(modName);
675+
const e = registry.get(modName);
676+
if (!e) return;
677+
if (e.kind === 'ngmodule') {
678+
for (const exp of e.exports ?? []) addExportsOf(exp, visited);
679+
} else if (e.kind === 'tuple') {
680+
for (const m of e.members ?? []) scopeNames.add(m);
681+
} else {
682+
scopeNames.add(modName);
683+
}
684+
};
685+
for (const d of owningModule.declarations ?? [])
686+
scopeNames.add(d);
687+
for (const imp of owningModule.imports ?? [])
688+
addExportsOf(imp, new Set());
689+
690+
for (const name of scopeNames) {
691+
if (seenDeclarationNames.has(name)) continue;
692+
const entry = registry.get(name);
693+
if (
694+
!entry ||
695+
entry.kind === 'ngmodule' ||
696+
entry.kind === 'tuple'
697+
)
698+
continue;
699+
if (!importedNames.has(name)) {
700+
const spec = resolveSyntheticImportSpecifier(
701+
fileName,
702+
entry,
703+
importSpecifierByName.get(name),
704+
);
705+
if (spec) syntheticImports.set(name, spec);
706+
}
707+
const kind = entry.kind === 'pipe' ? 1 : 0;
708+
const decl: CompileDeclaration = {
709+
type: new o.WrappedNodeExpr(name),
710+
selector: entry.selector || `_unresolved-${name}`,
711+
kind,
712+
...(kind === 1 ? { name: entry.pipeName } : {}),
713+
};
714+
if (entry.inputs) {
715+
decl.inputs = Object.values(entry.inputs).map(
716+
(i) => i.bindingPropertyName,
717+
);
718+
}
719+
if (entry.outputs) {
720+
decl.outputs = Object.values(entry.outputs) as string[];
721+
}
722+
seenDeclarationNames.add(name);
723+
declarations.push(decl);
724+
}
725+
}
726+
}
727+
634728
let templateContent = meta.template || '';
635729
if (!templateContent && meta.templateUrl) {
636730
try {

packages/vite-plugin-angular/src/lib/compiler/dts-reader.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function scanDtsFile(code: string, fileName: string): RegistryEntry[] {
3939
export function scanPackageDts(
4040
packageName: string,
4141
basePath: string,
42+
visited?: Set<string>,
4243
): RegistryEntry[] {
4344
// Walk up from basePath to find the nearest node_modules containing the package.
4445
// This handles monorepos where node_modules is at the workspace root,
@@ -67,6 +68,11 @@ export function scanPackageDts(
6768

6869
const dtsFiles = collectDtsFiles(searchDir);
6970
const entries: RegistryEntry[] = [];
71+
// Packages re-exported by this package's NgModule declarations. A library
72+
// NgModule can re-export another package's module (e.g. BrowserModule exports
73+
// CommonModule), so those packages must be scanned too or their directives
74+
// (NgIf, NgForOf, …) never reach the registry.
75+
const referencedPackages = new Set<string>();
7076

7177
for (const file of dtsFiles) {
7278
try {
@@ -79,11 +85,31 @@ export function scanPackageDts(
7985
packageName;
8086
}
8187
entries.push(...fileEntries);
88+
// Only follow cross-package imports of declaration files that actually
89+
// contain an NgModule — those are the ones whose exports can pull in
90+
// another package's directives/pipes. This keeps the walk bounded.
91+
if (visited && fileEntries.some((e) => e.kind === 'ngmodule')) {
92+
for (const p of collectImportedPackages(code, file)) {
93+
referencedPackages.add(p);
94+
}
95+
}
8296
} catch {
8397
// Skip unreadable files
8498
}
8599
}
86100

101+
if (visited) {
102+
for (const p of referencedPackages) {
103+
if (p === packageName || visited.has(p)) continue;
104+
visited.add(p);
105+
try {
106+
entries.push(...scanPackageDts(p, basePath, visited));
107+
} catch {
108+
// Package may not have .d.ts or not be Angular
109+
}
110+
}
111+
}
112+
87113
return entries;
88114
}
89115

@@ -328,6 +354,35 @@ export function collectRelativeReExports(
328354
return result;
329355
}
330356

357+
/**
358+
* Parse a source file with OXC and return every module specifier it imports or
359+
* re-exports from (relative AND bare), including `import`, `export * from`, and
360+
* `export { … } from`. Used to walk the transitive import graph at startup so
361+
* the registry contains components/directives/pipes/modules from files not
362+
* directly listed in tsconfig `files`/`include` (transitively-imported app
363+
* sources) or behind wildcard `paths` (workspace libraries).
364+
*/
365+
export function collectAllImports(code: string, fileName: string): string[] {
366+
let program: any;
367+
try {
368+
program = parseSync(fileName, code).program;
369+
} catch {
370+
return [];
371+
}
372+
const result: string[] = [];
373+
for (const stmt of program.body || []) {
374+
if (
375+
stmt.type === 'ImportDeclaration' ||
376+
stmt.type === 'ExportAllDeclaration' ||
377+
stmt.type === 'ExportNamedDeclaration'
378+
) {
379+
const specifier: string | undefined = stmt.source?.value;
380+
if (specifier) result.push(specifier);
381+
}
382+
}
383+
return result;
384+
}
385+
331386
// ---------------------------------------------------------------------------
332387
// Internal helpers
333388
// ---------------------------------------------------------------------------

packages/vite-plugin-angular/src/lib/compiler/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export {
1010
scanPackageDts,
1111
collectImportedPackages,
1212
collectRelativeReExports,
13+
collectAllImports,
1314
} from './dts-reader.js';
1415
export { jitTransform, type JitTransformResult } from './jit-transform.js';
1516
export { generateHmrCode } from './hmr.js';

packages/vite-plugin-angular/src/lib/compiler/ngmodule.spec.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import { compileCode as compile } from './test-helpers';
33
import { expectCompiles, buildRegistry } from './test-helpers';
4+
import { ANGULAR_MAJOR } from './angular-version';
45

56
describe('@NgModule', () => {
67
it('compiles a basic NgModule', () => {
@@ -448,3 +449,166 @@ describe('Dependency list deduplication', () => {
448449
expect(countRefs(deps, 'SelfCmp')).toBe(1);
449450
});
450451
});
452+
453+
describe('NgModule scope for declared (non-standalone) components', () => {
454+
function depsArrayFor(result: string): string {
455+
const m = result.match(/dependencies:\s*\(\)\s*=>\s*\[([\s\S]*?)\]/);
456+
expect(
457+
m,
458+
'compiled output should contain a dependencies array',
459+
).not.toBeNull();
460+
return m![1];
461+
}
462+
463+
it("inlines the declaring module's own declarations and transitive imported-module exports into a non-standalone component", () => {
464+
const componentSrc = `
465+
import { Component } from '@angular/core';
466+
@Component({
467+
selector: 'app-panel',
468+
standalone: false,
469+
template: '<div highlight shared>x</div>',
470+
})
471+
export class PanelComponent {}
472+
`;
473+
const highlightSrc = `
474+
import { Directive } from '@angular/core';
475+
@Directive({ selector: '[highlight]', standalone: false })
476+
export class HighlightDirective {}
477+
`;
478+
const sharedDirectiveSrc = `
479+
import { Directive } from '@angular/core';
480+
@Directive({ selector: '[shared]' })
481+
export class SharedDirective {}
482+
`;
483+
const sharedModuleSrc = `
484+
import { NgModule } from '@angular/core';
485+
import { SharedDirective } from './shared.directive';
486+
@NgModule({ declarations: [SharedDirective], exports: [SharedDirective] })
487+
export class SharedModule {}
488+
`;
489+
const moduleSrc = `
490+
import { NgModule } from '@angular/core';
491+
import { PanelComponent } from './panel.component';
492+
import { HighlightDirective } from './highlight.directive';
493+
import { SharedModule } from './shared.module';
494+
@NgModule({
495+
declarations: [PanelComponent, HighlightDirective],
496+
imports: [SharedModule],
497+
})
498+
export class PanelModule {}
499+
`;
500+
501+
const registry = buildRegistry({
502+
'panel.component.ts': componentSrc,
503+
'highlight.directive.ts': highlightSrc,
504+
'shared.directive.ts': sharedDirectiveSrc,
505+
'shared.module.ts': sharedModuleSrc,
506+
'panel.module.ts': moduleSrc,
507+
});
508+
509+
const result = compile(componentSrc, 'panel.component.ts', registry);
510+
expectCompiles(result);
511+
512+
const deps = depsArrayFor(result);
513+
// sibling declaration from the owning module
514+
expect(deps).toContain('HighlightDirective');
515+
// directive re-exported by an imported module (transitive scope)
516+
expect(deps).toContain('SharedDirective');
517+
// every dependency resolved to a real selector, none left unresolved
518+
expect(result).not.toContain('_unresolved-');
519+
});
520+
521+
it('resolves ModuleWithProviders (forRoot) imports in declared-component scope', () => {
522+
const widgetSrc = `
523+
import { Component } from '@angular/core';
524+
@Component({
525+
selector: 'app-widget',
526+
standalone: false,
527+
template: '<i config></i>',
528+
})
529+
export class WidgetComponent {}
530+
`;
531+
const configDirectiveSrc = `
532+
import { Directive } from '@angular/core';
533+
@Directive({ selector: '[config]' })
534+
export class ConfigDirective {}
535+
`;
536+
const configModuleSrc = `
537+
import { NgModule, ModuleWithProviders } from '@angular/core';
538+
import { ConfigDirective } from './config.directive';
539+
@NgModule({ declarations: [ConfigDirective], exports: [ConfigDirective] })
540+
export class ConfigModule {
541+
static forRoot(): ModuleWithProviders<ConfigModule> {
542+
return { ngModule: ConfigModule };
543+
}
544+
}
545+
`;
546+
const widgetModuleSrc = `
547+
import { NgModule } from '@angular/core';
548+
import { WidgetComponent } from './widget.component';
549+
import { ConfigModule } from './config.module';
550+
@NgModule({
551+
declarations: [WidgetComponent],
552+
imports: [ConfigModule.forRoot()],
553+
})
554+
export class WidgetModule {}
555+
`;
556+
557+
const registry = buildRegistry({
558+
'widget.component.ts': widgetSrc,
559+
'config.directive.ts': configDirectiveSrc,
560+
'config.module.ts': configModuleSrc,
561+
'widget.module.ts': widgetModuleSrc,
562+
});
563+
564+
const result = compile(widgetSrc, 'widget.component.ts', registry);
565+
expectCompiles(result);
566+
567+
const deps = depsArrayFor(result);
568+
// directive exported by the module imported via `ConfigModule.forRoot()`
569+
expect(deps).toContain('ConfigDirective');
570+
expect(result).not.toContain('_unresolved-');
571+
});
572+
573+
it('treats a declared component that omits `standalone` per the Angular version', () => {
574+
// Angular 19+ defaults `standalone` to true, so a component must set
575+
// `standalone: false` to be NgModule-declared. On v17/v18 the flag is
576+
// usually omitted (it defaulted to false), so the owning-module scope must
577+
// still apply there. This asserts both sides of that version gate.
578+
const componentSrc = `
579+
import { Component } from '@angular/core';
580+
@Component({ selector: 'app-bare', template: '<div sibling></div>' })
581+
export class BareComponent {}
582+
`;
583+
const siblingSrc = `
584+
import { Directive } from '@angular/core';
585+
@Directive({ selector: '[sibling]' })
586+
export class SiblingDirective {}
587+
`;
588+
const moduleSrc = `
589+
import { NgModule } from '@angular/core';
590+
import { BareComponent } from './bare.component';
591+
import { SiblingDirective } from './sibling.directive';
592+
@NgModule({ declarations: [BareComponent, SiblingDirective] })
593+
export class BareModule {}
594+
`;
595+
596+
const registry = buildRegistry({
597+
'bare.component.ts': componentSrc,
598+
'sibling.directive.ts': siblingSrc,
599+
'bare.module.ts': moduleSrc,
600+
});
601+
602+
const result = compile(componentSrc, 'bare.component.ts', registry);
603+
expectCompiles(result);
604+
const deps = depsArrayFor(result);
605+
606+
if (ANGULAR_MAJOR < 19) {
607+
// pre-v19: omitted flag means non-standalone, so module scope applies
608+
expect(deps).toContain('SiblingDirective');
609+
} else {
610+
// v19+: omitted flag means standalone, so no module scope is forced
611+
expect(deps).not.toContain('SiblingDirective');
612+
}
613+
});
614+
});

0 commit comments

Comments
 (0)