From 5a10fc4f8287d448c88ce2f6c97c48307af34af1 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Thu, 31 Mar 2022 11:54:55 -0700 Subject: [PATCH] feat(core): implement standalone directives, components, and pipes (#45687) This commit exposes the `standalone` flag on `@Directive`, `@Component`, and `@Pipe`, effectively making standalone components a part of Angular's public API. As part of this operation, it also implements JIT compilation for standalone types. Standalone types are Angular-decorated types which act as their own "declarations", where they would otherwise be declared in an NgModule. Marking an Angular type as standalone means that it can be used directly in other standalone components and in NgModules, without needing an associated NgModule to depend on it. In the case of a standalone component, template dependencies which would otherwise be specified by an NgModule are instead specified directly on the component itself, via the `imports` field. Other standalone types can be imported, as well as NgModules. PR Close #45687 --- goldens/circular-deps/packages.json | 4 - goldens/public-api/core/index.md | 8 + packages/core/src/metadata/directives.ts | 9 ++ packages/core/src/render3/jit/directive.ts | 81 +++++++++- packages/core/src/render3/jit/module.ts | 90 ++++++++--- packages/core/src/render3/jit/pipe.ts | 4 +- .../core/test/acceptance/standalone_spec.ts | 153 ++++++++++++++++++ 7 files changed, 315 insertions(+), 34 deletions(-) create mode 100644 packages/core/test/acceptance/standalone_spec.ts diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json index c9e6612e2d6..903dc3160eb 100644 --- a/goldens/circular-deps/packages.json +++ b/goldens/circular-deps/packages.json @@ -81,10 +81,6 @@ "packages/core/src/linker/component_factory.ts", "packages/core/src/linker/ng_module_factory.ts" ], - [ - "packages/core/src/linker/component_factory_resolver.ts", - "packages/core/src/linker/ng_module_factory.ts" - ], [ "packages/core/src/metadata/directives.ts", "packages/core/src/render3/jit/directive.ts" diff --git a/goldens/public-api/core/index.md b/goldens/public-api/core/index.md index f070eb78942..1dec55ef901 100644 --- a/goldens/public-api/core/index.md +++ b/goldens/public-api/core/index.md @@ -181,9 +181,13 @@ export interface Component extends Directive { encapsulation?: ViewEncapsulation; // @deprecated entryComponents?: Array | any[]>; + // (undocumented) + imports?: (Type | any[])[]; interpolation?: [string, string]; moduleId?: string; preserveWhitespaces?: boolean; + // (undocumented) + standalone?: boolean; styles?: string[]; styleUrls?: string[]; template?: string; @@ -407,6 +411,8 @@ export interface Directive { [key: string]: any; }; selector?: string; + // (undocumented) + standalone?: boolean; } // @public @@ -954,6 +960,8 @@ export const PACKAGE_ROOT_URL: InjectionToken; export interface Pipe { name: string; pure?: boolean; + // (undocumented) + standalone?: boolean; } // @public (undocumented) diff --git a/packages/core/src/metadata/directives.ts b/packages/core/src/metadata/directives.ts index 6d1977badc6..ad989a2fc48 100644 --- a/packages/core/src/metadata/directives.ts +++ b/packages/core/src/metadata/directives.ts @@ -287,6 +287,8 @@ export interface Directive { * To ensure the correct behavior, the app must import `@angular/compiler`. */ jit?: true; + + standalone?: boolean; } /** @@ -553,6 +555,11 @@ export interface Component extends Directive { * overridden in compiler options. */ preserveWhitespaces?: boolean; + + standalone?: boolean; + + // TODO: better type here! + imports?: (Type|any[])[]; } /** @@ -624,6 +631,8 @@ export interface Pipe { * even if the arguments have not changed. */ pure?: boolean; + + standalone?: boolean; } /** diff --git a/packages/core/src/render3/jit/directive.ts b/packages/core/src/render3/jit/directive.ts index 80e48132fd3..ac9d8862f45 100644 --- a/packages/core/src/render3/jit/directive.ts +++ b/packages/core/src/render3/jit/directive.ts @@ -7,7 +7,7 @@ */ import {getCompilerFacade, JitCompilerUsage, R3DirectiveMetadataFacade} from '../../compiler/compiler_facade'; -import {R3ComponentMetadataFacade, R3QueryMetadataFacade} from '../../compiler/compiler_facade_interface'; +import {R3ComponentMetadataFacade, R3QueryMetadataFacade, R3TemplateDependencyFacade, R3TemplateDependencyKind} from '../../compiler/compiler_facade_interface'; import {resolveForwardRef} from '../../di/forward_ref'; import {getReflect, reflectDependencies} from '../../di/jit/util'; import {Type} from '../../interface/type'; @@ -15,9 +15,11 @@ import {Query} from '../../metadata/di'; import {Component, Directive, Input} from '../../metadata/directives'; import {componentNeedsResolution, maybeQueueResolutionOfComponentResources} from '../../metadata/resource_loading'; import {ViewEncapsulation} from '../../metadata/view'; +import {NgModuleDef} from '../../r3_symbols'; +import {flatten} from '../../util/array_utils'; import {EMPTY_ARRAY, EMPTY_OBJ} from '../../util/empty'; import {initNgDevMode} from '../../util/ng_dev_mode'; -import {getComponentDef, getDirectiveDef} from '../definition'; +import {getComponentDef, getDirectiveDef, getNgModuleDef, getPipeDef} from '../definition'; import {NG_COMP_DEF, NG_DIR_DEF, NG_FACTORY_DEF} from '../fields'; import {ComponentType} from '../interfaces/definition'; import {stringifyForError} from '../util/stringify_utils'; @@ -105,6 +107,26 @@ export function compileComponent(type: Type, metadata: Component): void { } } + let declarations: R3TemplateDependencyFacade[]; + + if (metadata.standalone) { + // Standalone components always have themselves in scope. + declarations = [{ + kind: R3TemplateDependencyKind.Directive, + type, + }]; + + // And might have other dependencies in scope, depending on `imports`. + if (metadata.imports) { + declarations.push(...getStandaloneDependencies(flatten(metadata.imports))); + } + } else { + // NgModule-based components are processed first with empty `declarations`, since when the + // component is evaluated the NgModule has not yet been executed. The real scope for the + // component will be patched on after the NgModule is fully evaluated. + declarations = []; + } + const templateUrl = metadata.templateUrl || `ng:///${type.name}/template.html`; const meta: R3ComponentMetadataFacade = { ...directiveMetadata(type, metadata), @@ -113,11 +135,12 @@ export function compileComponent(type: Type, metadata: Component): void { preserveWhitespaces, styles: metadata.styles || EMPTY_ARRAY, animations: metadata.animations, - declarations: [], + declarations, changeDetection: metadata.changeDetection, encapsulation, interpolation: metadata.interpolation, viewProviders: metadata.viewProviders || null, + isStandalone: !!metadata.standalone, }; compilationDepth++; @@ -156,6 +179,54 @@ export function compileComponent(type: Type, metadata: Component): void { }); } +function getStandaloneDependencies(imports: Type[]): R3TemplateDependencyFacade[] { + const dependencies: R3TemplateDependencyFacade[] = []; + for (const rawDep of imports) { + const dep = resolveForwardRef(rawDep); + if (!dep) { + // TODO: real error + throw new Error(`ForwardRef issue?`); + } + const ngModuleDef: NgModuleDef|null = getNgModuleDef(dep); + if (ngModuleDef) { + dependencies.push({ + kind: R3TemplateDependencyKind.NgModule, + type: dep as any, + } as R3TemplateDependencyFacade); + + const scopes = transitiveScopesFor(ngModuleDef.type); + for (const dir of scopes.exported.directives) { + dependencies.push({ + kind: R3TemplateDependencyKind.Directive, + type: dir, + } as R3TemplateDependencyFacade); + } + + for (const dir of scopes.exported.pipes) { + dependencies.push({ + kind: R3TemplateDependencyKind.Pipe, + type: dir, + } as R3TemplateDependencyFacade); + } + } + + const dirDef = getComponentDef(dep) || getDirectiveDef(dep); + const anyDef = dirDef || getPipeDef(dep); + if (anyDef) { + if (!anyDef.standalone) { + // TODO: real error + throw new Error(`What are you doing? You imported a non-standalone thing!`); + } + + dependencies.push({ + kind: dirDef ? R3TemplateDependencyKind.Directive : R3TemplateDependencyKind.Pipe, + type: dep, + } as R3TemplateDependencyFacade); + } + } + return dependencies; +} + function hasSelectorScope(component: Type): component is Type& {ngSelectorScope: Type} { return (component as {ngSelectorScope?: any}).ngSelectorScope !== undefined; @@ -256,9 +327,7 @@ export function directiveMetadata(type: Type, metadata: Directive): R3Direc exportAs: extractExportAs(metadata.exportAs), providers: metadata.providers || null, viewQueries: extractQueriesMetadata(type, propMetadata, isViewQuery), - // TODO(alxhub): pass through the standalone flag from the directive metadata once standalone - // functionality is fully rolled out. - isStandalone: false, + isStandalone: !!metadata.standalone, }; } diff --git a/packages/core/src/render3/jit/module.ts b/packages/core/src/render3/jit/module.ts index fc3643dc603..8731c94d2bc 100644 --- a/packages/core/src/render3/jit/module.ts +++ b/packages/core/src/render3/jit/module.ts @@ -195,10 +195,19 @@ export function compileNgModuleDefs( }); } +function isStandalone(type: Type) { + const def = getComponentDef(type) || getDirectiveDef(type) || getPipeDef(type); + return def !== null ? def.standalone : false; +} + function verifySemanticsOfNgModuleDef( moduleType: NgModuleType, allowDuplicateDeclarationsInRoot: boolean, importingModule?: NgModuleType): void { if (verifiedNgModule.get(moduleType)) return; + + // skip verifications of standalone components, direcrtives and pipes + if (isStandalone(moduleType)) return; + verifiedNgModule.set(moduleType, true); moduleType = resolveForwardRef(moduleType); let ngModuleDef: NgModuleDef; @@ -214,9 +223,9 @@ function verifySemanticsOfNgModuleDef( const errors: string[] = []; const declarations = maybeUnwrapFn(ngModuleDef.declarations); const imports = maybeUnwrapFn(ngModuleDef.imports); - flatten(imports).map(unwrapModuleWithProvidersImports).forEach(mod => { - verifySemanticsOfNgModuleImport(mod, moduleType); - verifySemanticsOfNgModuleDef(mod, false, moduleType); + flatten(imports).map(unwrapModuleWithProvidersImports).forEach(modOrStandaloneCmpt => { + verifySemanticsOfNgModuleImport(modOrStandaloneCmpt, moduleType); + verifySemanticsOfNgModuleDef(modOrStandaloneCmpt, false, moduleType); }); const exports = maybeUnwrapFn(ngModuleDef.exports); declarations.forEach(verifyDeclarationsHaveDefinitions); @@ -331,12 +340,14 @@ function verifySemanticsOfNgModuleDef( function verifySemanticsOfNgModuleImport(type: Type, importingModule: Type) { type = resolveForwardRef(type); - if (getComponentDef(type) || getDirectiveDef(type)) { + const directiveDef = getComponentDef(type) || getDirectiveDef(type); + if (directiveDef !== null && !directiveDef.standalone) { throw new Error(`Unexpected directive '${type.name}' imported by the module '${ importingModule.name}'. Please add an @NgModule annotation.`); } - if (getPipeDef(type)) { + const pipeDef = getPipeDef(type); + if (pipeDef !== null && !pipeDef.standalone) { throw new Error(`Unexpected pipe '${type.name}' imported by the module '${ importingModule.name}'. Please add an @NgModule annotation.`); } @@ -399,7 +410,13 @@ export function resetCompiledComponents(): void { */ function computeCombinedExports(type: Type): Type[] { type = resolveForwardRef(type); - const ngModuleDef = getNgModuleDef(type, true); + const ngModuleDef = getNgModuleDef(type); + + // a standalone component, directive or pipe + if (ngModuleDef === null) { + return [type]; + } + return [...flatten(maybeUnwrapFn(ngModuleDef.exports).map((type) => { const ngModuleDef = getNgModuleDef(type); if (ngModuleDef) { @@ -458,6 +475,49 @@ export function patchComponentDefWithScope( componentDef.tView = null; } +/** + * Compute the pair of transitive scopes (compilation scope and exported scope) for a given type + * (eaither a NgModule or a standalone component / directive / pipe). + */ +export function transitiveScopesFor(type: Type): NgModuleTransitiveScopes { + if (isNgModule(type)) { + return transitiveScopesForNgModule(type); + } else if (isStandalone(type)) { + const directiveDef = getComponentDef(type) || getDirectiveDef(type); + if (directiveDef !== null) { + return { + schemas: null, + compilation: { + directives: new Set(), + pipes: new Set(), + }, + exported: { + directives: new Set([type]), + pipes: new Set(), + }, + }; + } + + const pipeDef = getPipeDef(type); + if (pipeDef !== null) { + return { + schemas: null, + compilation: { + directives: new Set(), + pipes: new Set(), + }, + exported: { + directives: new Set(), + pipes: new Set([type]), + }, + }; + } + } + + // TODO: change the error message to be more user-facing and take standalone into account + throw new Error(`${type.name} does not have a module def (ɵmod property)`); +} + /** * Compute the pair of transitive scopes (compilation scope and exported scope) for a given module. * @@ -467,11 +527,8 @@ export function patchComponentDefWithScope( * * @param moduleType module that transitive scope should be calculated for. */ -export function transitiveScopesFor(moduleType: Type): NgModuleTransitiveScopes { - if (!isNgModule(moduleType)) { - throw new Error(`${moduleType.name} does not have a module def (ɵmod property)`); - } - const def = getNgModuleDef(moduleType)!; +export function transitiveScopesForNgModule(moduleType: Type): NgModuleTransitiveScopes { + const def = getNgModuleDef(moduleType, true); if (def.transitiveCompileScopes !== null) { return def.transitiveCompileScopes; @@ -490,18 +547,9 @@ export function transitiveScopesFor(moduleType: Type): NgModuleTransitiveS }; maybeUnwrapFn(def.imports).forEach((imported: Type) => { - const importedType = imported as Type& { - // If imported is an @NgModule: - ɵmod?: NgModuleDef; - }; - - if (!isNgModule(importedType)) { - throw new Error(`Importing ${importedType.name} which does not have a ɵmod property`); - } - // When this module imports another, the imported module's exported directives and pipes are // added to the compilation scope of this module. - const importedScope = transitiveScopesFor(importedType); + const importedScope = transitiveScopesFor(imported); importedScope.exported.directives.forEach(entry => scopes.compilation.directives.add(entry)); importedScope.exported.pipes.forEach(entry => scopes.compilation.pipes.add(entry)); }); diff --git a/packages/core/src/render3/jit/pipe.ts b/packages/core/src/render3/jit/pipe.ts index ca5294b533b..c5dc68001ba 100644 --- a/packages/core/src/render3/jit/pipe.ts +++ b/packages/core/src/render3/jit/pipe.ts @@ -60,8 +60,6 @@ function getPipeMetadata(type: Type, meta: Pipe): R3PipeMetadataFacade { name: type.name, pipeName: meta.name, pure: meta.pure !== undefined ? meta.pure : true, - // TODO(alxhub): pass through the standalone flag from the pipe metadata once standalone - // functionality is fully rolled out. - isStandalone: false, + isStandalone: !!meta.standalone, }; } diff --git a/packages/core/test/acceptance/standalone_spec.ts b/packages/core/test/acceptance/standalone_spec.ts new file mode 100644 index 00000000000..ec184f0842c --- /dev/null +++ b/packages/core/test/acceptance/standalone_spec.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CommonModule} from '@angular/common'; +import {Component, Directive, Input, NgModule, Pipe, PipeTransform} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; + +describe('standalone components, directives and pipes', () => { + it('should render a standalone component', () => { + @Component({ + standalone: true, + template: 'Look at me, no NgModule!', + }) + class StandaloneCmp { + } + + const fixture = TestBed.createComponent(StandaloneCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML).toEqual('Look at me, no NgModule!'); + }); + + it('should render a recursive standalone component', () => { + @Component({ + selector: 'tree', + standalone: true, + template: + `({{level}})`, + imports: [CommonModule] + }) + class TreeCmp { + @Input() level = 0; + } + + @Component({standalone: true, template: '', imports: [TreeCmp]}) + class StandaloneCmp { + } + + const fixture = TestBed.createComponent(StandaloneCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('(3)(2)(1)(0)'); + }); + + it('should render a standalone component with a standalone dependency', () => { + @Component({ + standalone: true, + selector: 'inner-cmp', + template: 'Look at me, no NgModule!', + }) + class InnerCmp { + } + + @Component({ + standalone: true, + template: '', + imports: [InnerCmp], + }) + class StandaloneCmp { + } + + const fixture = TestBed.createComponent(StandaloneCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML) + .toEqual('Look at me, no NgModule!'); + }); + + + it('should render a standalone component with an NgModule-based dependency', () => { + @Component({ + selector: 'inner-cmp', + template: 'Look at me, no NgModule (kinda)!', + }) + class InnerCmp { + } + + @NgModule({ + declarations: [InnerCmp], + exports: [InnerCmp], + }) + class Module { + } + + @Component({ + standalone: true, + template: '', + imports: [Module], + }) + class StandaloneCmp { + } + + const fixture = TestBed.createComponent(StandaloneCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML) + .toEqual('Look at me, no NgModule (kinda)!'); + }); + + it('should allow exporting standalone components, directives and pipes from NgModule', () => { + @Component({ + selector: 'standalone-cmp', + standalone: true, + template: `standalone`, + }) + class StandaloneCmp { + } + + @Directive({ + selector: '[standalone-dir]', + host: { + '[attr.id]': '"standalone"', + }, + standalone: true + }) + class StandaloneDir { + } + + @Pipe({name: 'standalonePipe', standalone: true}) + class StandalonePipe implements PipeTransform { + transform(value: any) { + return `|${value}`; + } + } + + @NgModule({ + imports: [StandaloneCmp, StandaloneDir, StandalonePipe], + exports: [StandaloneCmp, StandaloneDir, StandalonePipe], + }) + class LibModule { + } + + @Component({ + selector: 'app-cmpt', + template: `{{'standalone' | standalonePipe}}`, + }) + class AppComponent { + } + + TestBed.configureTestingModule({ + imports: [LibModule], + declarations: [AppComponent], + }); + + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('standalone|standalone'); + expect(fixture.nativeElement.querySelector('standalone-cmp').getAttribute('id')) + .toBe('standalone'); + }); +});