diff --git a/goldens/public-api/compiler-cli/error_code.api.md b/goldens/public-api/compiler-cli/error_code.api.md index 4585782eda9..858dbeeb1c2 100644 --- a/goldens/public-api/compiler-cli/error_code.api.md +++ b/goldens/public-api/compiler-cli/error_code.api.md @@ -109,6 +109,7 @@ export enum ErrorCode { PIPE_MISSING_NAME = 2002, SCHEMA_INVALID_ATTRIBUTE = 8002, SCHEMA_INVALID_ELEMENT = 8001, + SERVICE_CONSTRUCTOR_DI = 2028, SKIP_HYDRATION_NOT_STATIC = 8108, SPLIT_TWO_WAY_BINDING = 8007, SUFFIX_NOT_SUPPORTED = 8106, diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 6554c62ab8c..c440ad9f433 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -1764,6 +1764,27 @@ export interface SelfDecorator { new (): Self; } +// @public +export interface Service { + autoProvided?: boolean; + factory?: () => unknown; +} + +// @public +export const Service: ServiceDecorator; + +// @public +export interface ServiceDecorator { + (): TypeDecorator; + (options?: { + autoProvided: false; + }): TypeDecorator; + (options?: { + autoProvided?: true; + factory?: () => unknown; + }): TypeDecorator; +} + // @public export function setTestabilityGetter(getter: GetTestability): void; diff --git a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker_selector.ts b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker_selector.ts index 4da9a8e8c04..9e9af24a80a 100644 --- a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker_selector.ts +++ b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_linker_selector.ts @@ -23,6 +23,7 @@ import {PartialNgModuleLinkerVersion1} from './partial_ng_module_linker_1'; import {PartialPipeLinkerVersion1} from './partial_pipe_linker_1'; import {PLACEHOLDER_VERSION} from './util'; import {AbsoluteFsPath} from '../../../../src/ngtsc/file_system/src/types'; +import {PartialServiceLinkerVersion1} from './partial_service_linker_1'; export const ɵɵngDeclareDirective = 'ɵɵngDeclareDirective'; export const ɵɵngDeclareClassMetadata = 'ɵɵngDeclareClassMetadata'; @@ -33,6 +34,7 @@ export const ɵɵngDeclareInjector = 'ɵɵngDeclareInjector'; export const ɵɵngDeclareNgModule = 'ɵɵngDeclareNgModule'; export const ɵɵngDeclarePipe = 'ɵɵngDeclarePipe'; export const ɵɵngDeclareClassMetadataAsync = 'ɵɵngDeclareClassMetadataAsync'; +export const ɵɵngDeclareService = 'ɵɵngDeclareService'; export const declarationFunctions = [ ɵɵngDeclareDirective, ɵɵngDeclareClassMetadata, @@ -43,6 +45,7 @@ export const declarationFunctions = [ ɵɵngDeclareNgModule, ɵɵngDeclarePipe, ɵɵngDeclareClassMetadataAsync, + ɵɵngDeclareService, ]; export interface LinkerRange { @@ -119,6 +122,9 @@ export function createLinkerMap( linkers.set(ɵɵngDeclarePipe, [ {range: LATEST_VERSION_RANGE, linker: new PartialPipeLinkerVersion1()}, ]); + linkers.set(ɵɵngDeclareService, [ + {range: LATEST_VERSION_RANGE, linker: new PartialServiceLinkerVersion1()}, + ]); return linkers; } diff --git a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_service_linker_1.ts b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_service_linker_1.ts new file mode 100644 index 00000000000..d71aff5d344 --- /dev/null +++ b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_service_linker_1.ts @@ -0,0 +1,59 @@ +/** + * @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.dev/license + */ +import { + compileService, + ConstantPool, + R3DeclareServiceMetadata, + R3PartialDeclaration, + R3ServiceMetadata, +} from '@angular/compiler'; + +import {AstObject} from '../../ast/ast_value'; +import {FatalLinkerError} from '../../fatal_linker_error'; + +import {LinkedDefinition, PartialLinker} from './partial_linker'; +import {wrapReference} from './util'; + +/** + * A `PartialLinker` that is designed to process `ɵɵngDeclareService()` call expressions. + */ +export class PartialServiceLinkerVersion1 implements PartialLinker { + linkPartialDeclaration( + constantPool: ConstantPool, + metaObj: AstObject, + ): LinkedDefinition { + const meta = toR3ServiceMeta(metaObj); + return compileService(meta, /* resolveForwardRefs */ false); + } +} + +/** + * Derives the `R3ServiceMetadata` structure from the AST object. + */ +export function toR3ServiceMeta( + metaObj: AstObject, +): R3ServiceMetadata { + const typeExpr = metaObj.getValue('type'); + const typeName = typeExpr.getSymbolName(); + if (typeName === null) { + throw new FatalLinkerError( + typeExpr.expression, + 'Unsupported type, its name could not be determined', + ); + } + + const meta: R3ServiceMetadata = { + name: typeName, + type: wrapReference(typeExpr.getOpaque()), + typeArgumentCount: 0, + autoProvided: metaObj.has('autoProvided') ? metaObj.getBoolean('autoProvided') : undefined, + factory: metaObj.has('factory') ? metaObj.getOpaque('factory') : undefined, + }; + + return meta; +} diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/service.ts b/packages/compiler-cli/src/ngtsc/annotations/src/service.ts new file mode 100644 index 00000000000..25b3db29af3 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/annotations/src/service.ts @@ -0,0 +1,318 @@ +/** + * @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.dev/license + */ + +import { + compileClassMetadata, + CompileClassMetadataFn, + compileDeclareClassMetadata, + compileDeclareServiceFromMetadata, + compileService, + FactoryTarget, + R3ClassMetadata, + R3CompiledExpression, + R3ServiceMetadata, + WrappedNodeExpr, +} from '@angular/compiler'; +import ts from 'typescript'; + +import {ErrorCode, FatalDiagnosticError, makeDiagnostic} from '../../diagnostics'; +import {PartialEvaluator} from '../../partial_evaluator'; +import {PerfEvent, PerfRecorder} from '../../perf'; +import {ClassDeclaration, Decorator, ReflectionHost, reflectObjectLiteral} from '../../reflection'; +import { + AnalysisOutput, + CompilationMode, + CompileResult, + DecoratorHandler, + DetectResult, + HandlerPrecedence, + ResolveResult, +} from '../../transform'; +import { + compileDeclareFactory, + CompileFactoryFn, + compileNgFactoryDefField, + extractClassMetadata, + findAngularDecorator, + readBaseClass, + toFactoryMetadata, + wrapTypeReference, +} from '../common'; + +export interface ServiceHandlerData { + meta: R3ServiceMetadata; + classMetadata: R3ClassMetadata | null; +} + +/** + * Adapts the `compileService` compiler for `@Service` decorators to the Ivy compiler. + */ +export class ServiceDecoratorHandler implements DecoratorHandler< + Decorator, + ServiceHandlerData, + null, + unknown +> { + constructor( + private reflector: ReflectionHost, + private evaluator: PartialEvaluator, + private isCore: boolean, + private perf: PerfRecorder, + private includeClassMetadata: boolean, + private readonly compilationMode: CompilationMode, + ) {} + + readonly precedence = HandlerPrecedence.SHARED; + readonly name = 'ServiceDecoratorHandler'; + + detect( + node: ClassDeclaration, + decorators: Decorator[] | null, + ): DetectResult | undefined { + if (!decorators) { + return undefined; + } + const decorator = findAngularDecorator(decorators, 'Service', this.isCore); + if (decorator !== undefined) { + return { + trigger: decorator.node, + decorator: decorator, + metadata: decorator, + }; + } else { + return undefined; + } + } + + analyze( + node: ClassDeclaration, + decorator: Readonly, + ): AnalysisOutput { + this.perf.eventCount(PerfEvent.AnalyzeService); + + const decorators = this.reflector.getDecoratorsOfDeclaration(node); + let diagnostics: ts.Diagnostic[] | undefined; + + if (decorators !== null && decorators.length > 1) { + const nonServiceDecorator = decorators.find( + (decorator) => decorator.import?.from === '@angular/core' && decorator.name !== 'Service', + ); + + if (nonServiceDecorator) { + diagnostics ??= []; + diagnostics.push( + makeDiagnostic( + ErrorCode.DECORATOR_COLLISION, + nonServiceDecorator.node, + 'Cannot apply more than one Angular decorator on an @Service class.', + ), + ); + } + } + + return { + analysis: { + meta: this.extractServiceMetadata(node, decorator), + classMetadata: this.includeClassMetadata + ? extractClassMetadata(node, this.reflector, this.isCore) + : null, + }, + diagnostics, + }; + } + + symbol(): null { + return null; + } + + resolve(node: ClassDeclaration): ResolveResult { + const diagnostics = this.getDependencyInjectionDiagnostics(node); + return diagnostics === null ? {} : {diagnostics}; + } + + compileFull(node: ClassDeclaration, analysis: Readonly): CompileResult[] { + return this.compile( + compileNgFactoryDefField, + (meta) => compileService(meta, false), + compileClassMetadata, + node, + analysis, + ); + } + + compilePartial(node: ClassDeclaration, analysis: Readonly): CompileResult[] { + return this.compile( + compileDeclareFactory, + compileDeclareServiceFromMetadata, + compileDeclareClassMetadata, + node, + analysis, + ); + } + + compileLocal(node: ClassDeclaration, analysis: Readonly): CompileResult[] { + return this.compile( + compileNgFactoryDefField, + (meta) => compileService(meta, false), + compileClassMetadata, + node, + analysis, + ); + } + + private compile( + compileFactoryFn: CompileFactoryFn, + compileServiceFn: (meta: R3ServiceMetadata) => R3CompiledExpression, + compileClassMetadataFn: CompileClassMetadataFn, + node: ClassDeclaration, + analysis: Readonly, + ): CompileResult[] { + const results: CompileResult[] = []; + const meta = analysis.meta; + const factoryRes = compileFactoryFn( + toFactoryMetadata({...meta, deps: []}, FactoryTarget.Service), + ); + if (analysis.classMetadata !== null) { + factoryRes.statements.push(compileClassMetadataFn(analysis.classMetadata).toStmt()); + } + results.push(factoryRes); + + const ɵprov = this.reflector.getMembersOfClass(node).find((member) => member.name === 'ɵprov'); + + if (ɵprov !== undefined) { + throw new FatalDiagnosticError( + ErrorCode.INJECTABLE_DUPLICATE_PROV, + ɵprov.nameNode || ɵprov.node || node, + 'Services cannot contain a static ɵprov property, because the compiler is going to generate one.', + ); + } + + if (ɵprov === undefined) { + // Only add a new ɵprov if there is not one already + const res = compileServiceFn(analysis.meta); + results.push({ + name: 'ɵprov', + initializer: res.expression, + statements: res.statements, + type: res.type, + deferrableImports: null, + }); + } + + return results; + } + + /** + * Read metadata from the `@Service` decorator and produce the metadata needed to run + * `compileService`. + * + * A `null` return value indicates this is `@Service` has invalid data. + */ + private extractServiceMetadata(clazz: ClassDeclaration, decorator: Decorator): R3ServiceMetadata { + const name = clazz.name.text; + const type = wrapTypeReference(clazz); + const typeArgumentCount = this.reflector.getGenericArityOfClass(clazz) || 0; + if (decorator.args === null) { + throw new FatalDiagnosticError( + ErrorCode.DECORATOR_NOT_CALLED, + decorator.node, + '@Service must be called', + ); + } + if (decorator.args.length === 0) { + return { + name, + type, + typeArgumentCount, + }; + } else if (decorator.args.length === 1) { + const metaNode = decorator.args[0]; + + if (!ts.isObjectLiteralExpression(metaNode)) { + throw new FatalDiagnosticError( + ErrorCode.DECORATOR_ARG_NOT_LITERAL, + metaNode, + '@Service argument must be an object literal', + ); + } + + // Resolve the fields of the literal into a map of field name to expression. + const meta = reflectObjectLiteral(metaNode); + let autoProvided: boolean | undefined; + + if (meta.has('autoProvided')) { + const value = meta.get('autoProvided')!; + + if (value.kind !== ts.SyntaxKind.TrueKeyword && value.kind !== ts.SyntaxKind.FalseKeyword) { + throw new FatalDiagnosticError( + ErrorCode.VALUE_HAS_WRONG_TYPE, + metaNode, + '`autoProvided` property must be a boolean literal', + ); + } + + autoProvided = value.kind === ts.SyntaxKind.TrueKeyword; + } + + const result: R3ServiceMetadata = {name, type, typeArgumentCount, autoProvided}; + + if (meta.has('factory')) { + result.factory = new WrappedNodeExpr(meta.get('factory')!); + } + + return result; + } else { + throw new FatalDiagnosticError( + ErrorCode.DECORATOR_ARITY_WRONG, + decorator.args[2], + 'Too many arguments to @Service', + ); + } + } + + private getDependencyInjectionDiagnostics(node: ClassDeclaration): ts.Diagnostic[] | null { + const ownCtorParams = this.reflector.getConstructorParameters(node); + + if (ownCtorParams !== null) { + return ownCtorParams.length > 0 ? [makeConstructorDependencyInjectionDiagnostic(node)] : null; + } + + // Don't traverse parent classes in local mode. + if (this.compilationMode === CompilationMode.LOCAL) { + return null; + } + + let baseClass = readBaseClass(node, this.reflector, this.evaluator); + + while (baseClass !== null) { + if (baseClass === 'dynamic') { + return null; + } + + const baseCtorParams = this.reflector.getConstructorParameters(baseClass.node); + + if (baseCtorParams !== null) { + return baseCtorParams.length > 0 + ? [makeConstructorDependencyInjectionDiagnostic(node)] + : null; + } + + baseClass = readBaseClass(baseClass.node, this.reflector, this.evaluator); + } + + return null; + } +} + +function makeConstructorDependencyInjectionDiagnostic(node: ClassDeclaration): ts.Diagnostic { + return makeDiagnostic( + ErrorCode.SERVICE_CONSTRUCTOR_DI, + node.name, + '@Service class cannot use constructor dependency injection. Use the `inject` function instead.', + ); +} diff --git a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts index 4c1cb47c286..17df0f53ac5 100644 --- a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts +++ b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts @@ -127,6 +127,7 @@ import {DiagnosticCategoryLabel, NgCompilerAdapter, NgCompilerOptions} from '../ import {untagAllTsFiles} from '../../shims'; import {angularJitApplicationTransform} from '../../transform/jit'; import {coreVersionSupportsFeature} from './feature_detection'; +import {ServiceDecoratorHandler} from '../../annotations/src/service'; /** * State information about a compilation which is only generated once some data is requested from @@ -1579,6 +1580,14 @@ export class NgCompiler { !!this.options.strictStandalone, this.implicitStandaloneValue, ), + new ServiceDecoratorHandler( + reflector, + evaluator, + isCore, + this.delegatingPerfRecorder, + supportTestBed, + compilationMode, + ), new InjectableDecoratorHandler( reflector, evaluator, diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts index f83dedbfb5b..b64e1901d91 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -189,6 +189,11 @@ export enum ErrorCode { */ COMPONENT_ANIMATIONS_CONFLICT = 2027, + /** + * Raised when an `@Service` class is using constructor dependency injection. + */ + SERVICE_CONSTRUCTOR_DI = 2028, + SYMBOL_NOT_EXPORTED = 3001, /** * Raised when a relationship between directives and/or pipes would cause a cyclic import to be diff --git a/packages/compiler-cli/src/ngtsc/perf/src/api.ts b/packages/compiler-cli/src/ngtsc/perf/src/api.ts index cfbf575193b..060fc81b820 100644 --- a/packages/compiler-cli/src/ngtsc/perf/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/perf/src/api.ts @@ -234,6 +234,11 @@ export enum PerfEvent { */ AnalyzePipe, + /** + * An `@Service` class was analyzed. + */ + AnalyzeService, + /** * A trait was analyzed. * @@ -298,7 +303,7 @@ export enum PerfEvent { EmitSourceFile, /** - * Tracks the number of `PrefEvent`s, and must appear at the end of the list. + * Tracks the number of `PerfEvent`s, and must appear at the end of the list. */ LAST, } diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/service_decorator/GOLDEN_PARTIAL.js new file mode 100644 index 00000000000..686178c642f --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/GOLDEN_PARTIAL.js @@ -0,0 +1,123 @@ +/**************************************************************************************************** + * PARTIAL FILE: basic_service.js + ****************************************************************************************************/ +import { Service } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyService { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, deps: [], target: i0.ɵɵFactoryTarget.Service }); + static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, decorators: [{ + type: Service + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: basic_service.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyService { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵprov: i0.ɵɵInjectableDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: service_with_factory.js + ****************************************************************************************************/ +import { Service } from '@angular/core'; +import * as i0 from "@angular/core"; +class Alternate { +} +export class MyService { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, deps: [], target: i0.ɵɵFactoryTarget.Service }); + static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, factory: () => new Alternate() }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, decorators: [{ + type: Service, + args: [{ factory: () => new Alternate() }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: service_with_factory.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyService { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵprov: i0.ɵɵInjectableDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: not_provided_service.js + ****************************************************************************************************/ +import { Service } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyService { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, deps: [], target: i0.ɵɵFactoryTarget.Service }); + static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, autoProvided: false }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, decorators: [{ + type: Service, + args: [{ autoProvided: false }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: not_provided_service.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyService { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵprov: i0.ɵɵInjectableDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: explicitly_provided_service.js + ****************************************************************************************************/ +import { Service } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyService { + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, deps: [], target: i0.ɵɵFactoryTarget.Service }); + static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, decorators: [{ + type: Service, + args: [{ autoProvided: true }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: explicitly_provided_service.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyService { + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵprov: i0.ɵɵInjectableDeclaration; +} + +/**************************************************************************************************** + * PARTIAL FILE: generic_service.js + ****************************************************************************************************/ +import { Service } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyService { + getOne() { + return null; + } + getTwo() { + return null; + } + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, deps: [], target: i0.ɵɵFactoryTarget.Service }); + static ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyService, decorators: [{ + type: Service + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: generic_service.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyService { + getOne(): T; + getTwo(): V; + static ɵfac: i0.ɵɵFactoryDeclaration, never>; + static ɵprov: i0.ɵɵInjectableDeclaration>; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/service_decorator/TEST_CASES.json new file mode 100644 index 00000000000..3d7736292c8 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/TEST_CASES.json @@ -0,0 +1,80 @@ +{ + "$schema": "../test_case_schema.json", + "cases": [ + { + "description": "should compile a basic service", + "inputFiles": ["basic_service.ts"], + "expectations": [ + { + "files": [ + { + "expected": "basic_service.js", + "generated": "basic_service.js" + } + ], + "failureMessage": "Incorrect service" + } + ] + }, + { + "description": "should compile a service with a factory", + "inputFiles": ["service_with_factory.ts"], + "expectations": [ + { + "files": [ + { + "expected": "service_with_factory.js", + "generated": "service_with_factory.js" + } + ], + "failureMessage": "Incorrect service" + } + ] + }, + { + "description": "should compile a service with `autoProvided: false`", + "inputFiles": ["not_provided_service.ts"], + "expectations": [ + { + "files": [ + { + "expected": "not_provided_service.js", + "generated": "not_provided_service.js" + } + ], + "failureMessage": "Incorrect service" + } + ] + }, + { + "description": "should compile a service with `autoProvided: true`", + "inputFiles": ["explicitly_provided_service.ts"], + "expectations": [ + { + "files": [ + { + "expected": "explicitly_provided_service.js", + "generated": "explicitly_provided_service.js" + } + ], + "failureMessage": "Incorrect service" + } + ] + }, + { + "description": "should compile a service with generic arguments", + "inputFiles": ["generic_service.ts"], + "expectations": [ + { + "files": [ + { + "expected": "generic_service.js", + "generated": "generic_service.js" + } + ], + "failureMessage": "Incorrect service" + } + ] + } + ] +} diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/basic_service.js b/packages/compiler-cli/test/compliance/test_cases/service_decorator/basic_service.js new file mode 100644 index 00000000000..b8266ebd43e --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/basic_service.js @@ -0,0 +1,8 @@ +export class MyService { + static ɵfac = function MyService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || MyService)(); }; + static ɵprov = /*@__PURE__*/ $r3$.ɵɵdefineService({ token: MyService, factory: MyService.ɵfac }); +} + +(() => { (typeof ngDevMode === "undefined" || ngDevMode) && $r3$.ɵsetClassMetadata(MyService, [{ + type: Service +}], null, null); })(); diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/basic_service.ts b/packages/compiler-cli/test/compliance/test_cases/service_decorator/basic_service.ts new file mode 100644 index 00000000000..785a8c81f1f --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/basic_service.ts @@ -0,0 +1,4 @@ +import {Service} from '@angular/core'; + +@Service() +export class MyService {} diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/explicitly_provided_service.js b/packages/compiler-cli/test/compliance/test_cases/service_decorator/explicitly_provided_service.js new file mode 100644 index 00000000000..35a8dce6494 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/explicitly_provided_service.js @@ -0,0 +1,9 @@ +export class MyService { + static ɵfac = function MyService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || MyService)(); }; + static ɵprov = /*@__PURE__*/ $r3$.ɵɵdefineService({ token: MyService, factory: MyService.ɵfac }); +} + +(() => { (typeof ngDevMode === "undefined" || ngDevMode) && $r3$.ɵsetClassMetadata(MyService, [{ + type: Service, + args: [{ autoProvided: true }] +}], null, null); })(); diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/explicitly_provided_service.ts b/packages/compiler-cli/test/compliance/test_cases/service_decorator/explicitly_provided_service.ts new file mode 100644 index 00000000000..767c036e9f6 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/explicitly_provided_service.ts @@ -0,0 +1,4 @@ +import {Service} from '@angular/core'; + +@Service({autoProvided: true}) +export class MyService {} diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/generic_service.js b/packages/compiler-cli/test/compliance/test_cases/service_decorator/generic_service.js new file mode 100644 index 00000000000..09ff0733696 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/generic_service.js @@ -0,0 +1,6 @@ +export class MyService { + … + static ɵfac = function MyService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || MyService)(); }; + static ɵprov = /*@__PURE__*/ i0.ɵɵdefineService({ token: MyService, factory: MyService.ɵfac }); +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/generic_service.ts b/packages/compiler-cli/test/compliance/test_cases/service_decorator/generic_service.ts new file mode 100644 index 00000000000..336de2b1d41 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/generic_service.ts @@ -0,0 +1,12 @@ +import {Service} from '@angular/core'; + +@Service() +export class MyService { + getOne(): T { + return null!; + } + + getTwo(): V { + return null!; + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/not_provided_service.js b/packages/compiler-cli/test/compliance/test_cases/service_decorator/not_provided_service.js new file mode 100644 index 00000000000..ed83abc0f2d --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/not_provided_service.js @@ -0,0 +1,9 @@ +export class MyService { + static ɵfac = function MyService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || MyService)(); }; + static ɵprov = /*@__PURE__*/ $r3$.ɵɵdefineService({ token: MyService, factory: MyService.ɵfac, autoProvided: false }); +} + +(() => { (typeof ngDevMode === "undefined" || ngDevMode) && $r3$.ɵsetClassMetadata(MyService, [{ + type: Service, + args: [{ autoProvided: false }] +}], null, null); })(); diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/not_provided_service.ts b/packages/compiler-cli/test/compliance/test_cases/service_decorator/not_provided_service.ts new file mode 100644 index 00000000000..a24833cf616 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/not_provided_service.ts @@ -0,0 +1,4 @@ +import {Service} from '@angular/core'; + +@Service({autoProvided: false}) +export class MyService {} diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/service_with_factory.js b/packages/compiler-cli/test/compliance/test_cases/service_decorator/service_with_factory.js new file mode 100644 index 00000000000..025baf14242 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/service_with_factory.js @@ -0,0 +1,11 @@ +class Alternate {} + +export class MyService { + static ɵfac = function MyService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || MyService)(); }; + static ɵprov = /*@__PURE__*/ $r3$.ɵɵdefineService({ token: MyService, factory: () => (() => new Alternate())() }); +} + +(() => { (typeof ngDevMode === "undefined" || ngDevMode) && $r3$.ɵsetClassMetadata(MyService, [{ + type: Service, + args: [{ factory: () => new Alternate() }] +}], null, null); })(); diff --git a/packages/compiler-cli/test/compliance/test_cases/service_decorator/service_with_factory.ts b/packages/compiler-cli/test/compliance/test_cases/service_decorator/service_with_factory.ts new file mode 100644 index 00000000000..dd77e267957 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/service_decorator/service_with_factory.ts @@ -0,0 +1,6 @@ +import {Service} from '@angular/core'; + +class Alternate {} + +@Service({factory: () => new Alternate()}) +export class MyService {} diff --git a/packages/compiler-cli/test/ngtsc/service_spec.ts b/packages/compiler-cli/test/ngtsc/service_spec.ts new file mode 100644 index 00000000000..7b04d35db98 --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/service_spec.ts @@ -0,0 +1,127 @@ +/*! + * @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.dev/license + */ + +import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing'; +import {loadStandardTestFiles} from '../../src/ngtsc/testing'; +import {NgtscTestEnvironment} from './env'; + +const testFiles = loadStandardTestFiles(); + +runInEachFileSystem(() => { + describe('@Service decorator', () => { + let env!: NgtscTestEnvironment; + + beforeEach(() => { + env = NgtscTestEnvironment.setup(testFiles); + env.tsconfig(); + }); + + // More thorough compilation tests are in `packages/compiler-cli/test/compliance/test_cases/service_decorator`. + it('should compile an @Service class', () => { + env.write( + 'test.ts', + ` + import {Service} from '@angular/core'; + + @Service() + export class TestService {} + `, + ); + env.driveMain(); + + const jsContents = env.getContents('test.js'); + const dtsContents = env.getContents('test.d.ts'); + expect(jsContents).not.toContain('__decorate'); + expect(jsContents).toContain('TestService.ɵfac ='); + expect(jsContents).toContain('TestService.ɵprov ='); + expect(dtsContents).toContain('static ɵfac: i0.ɵɵFactoryDeclaration;'); + expect(dtsContents).toContain('static ɵprov: i0.ɵɵInjectableDeclaration;'); + }); + + it('should report if an @Service class has another Angular decorator', () => { + env.write( + 'test.ts', + ` + import {Service, Pipe} from '@angular/core'; + + @Service() + @Pipe({name: 'foo'}) + export class TestService {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe( + 'Cannot apply more than one Angular decorator on an @Service class.', + ); + }); + + it('should report if an @Service class uses constructor-based DI from its own constructor', () => { + env.write( + 'test.ts', + ` + import {Service, ApplicationRef} from '@angular/core'; + + @Service() + export class TestService { + constructor(appRef: ApplicationRef) {} + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe( + '@Service class cannot use constructor dependency injection. Use the `inject` function instead.', + ); + }); + + it('should report if an @Service class uses constructor-based DI from an inherited constructor', () => { + env.write( + 'grandparent.ts', + ` + import {Injectable, ApplicationRef} from '@angular/core'; + + @Injectable() + export class GrandparentService { + constructor(appRef: ApplicationRef) {} + } + `, + ); + + env.write( + 'parent.ts', + ` + import {Injectable} from '@angular/core'; + import {GrandparentService} from './grandparent'; + + @Injectable() + export class ParentService extends GrandparentService {} + `, + ); + + env.write( + 'test.ts', + ` + import {Service} from '@angular/core'; + import {ParentService} from './parent'; + + @Service() + export class TestService extends ParentService {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toBe( + '@Service class cannot use constructor dependency injection. Use the `inject` function instead.', + ); + }); + }); +}); diff --git a/packages/compiler/src/compiler.ts b/packages/compiler/src/compiler.ts index 6486e8a9bfc..933338ca00e 100644 --- a/packages/compiler/src/compiler.ts +++ b/packages/compiler/src/compiler.ts @@ -48,6 +48,7 @@ export * from './expression_parser/lexer'; export * from './expression_parser/parser'; export * from './i18n/index'; export * from './injectable_compiler_2'; +export * from './service_compiler'; export {publishFacade} from './jit_compiler_facade'; export * from './ml_parser/ast'; export * from './ml_parser/html_parser'; @@ -142,6 +143,7 @@ export { export {compileDeclareDirectiveFromMetadata} from './render3/partial/directive'; export {compileDeclareFactoryFunction} from './render3/partial/factory'; export {compileDeclareInjectableFromMetadata} from './render3/partial/injectable'; +export {compileDeclareServiceFromMetadata} from './render3/partial/service'; export {compileDeclareInjectorFromMetadata} from './render3/partial/injector'; export {compileDeclareNgModuleFromMetadata} from './render3/partial/ng_module'; export {compileDeclarePipeFromMetadata} from './render3/partial/pipe'; diff --git a/packages/compiler/src/compiler_facade_interface.ts b/packages/compiler/src/compiler_facade_interface.ts index f0c74e137ce..a3074c628e4 100644 --- a/packages/compiler/src/compiler_facade_interface.ts +++ b/packages/compiler/src/compiler_facade_interface.ts @@ -96,6 +96,16 @@ export interface CompilerFacade { sourceMapUrl: string, meta: R3DeclareFactoryFacade, ): any; + compileService( + angularCoreEnv: CoreEnvironment, + sourceMapUrl: string, + meta: R3ServiceMetadataFacade, + ): any; + compileServiceDeclaration( + angularCoreEnv: CoreEnvironment, + sourceMapUrl: string, + meta: R3DeclareServiceFacade, + ): any; createParseSourceSpan(kind: string, typeName: string, sourceUrl: string): ParseSourceSpan; @@ -123,6 +133,7 @@ export enum FactoryTarget { Injectable = 2, Pipe = 3, NgModule = 4, + Service = 5, } export interface R3DependencyMetadataFacade { @@ -163,6 +174,14 @@ export interface R3InjectableMetadataFacade { deps?: R3DependencyMetadataFacade[]; } +export interface R3ServiceMetadataFacade { + name: string; + type: Type; + typeArgumentCount: number; + autoProvided?: boolean; + factory?: OpaqueValue; +} + export interface R3NgModuleMetadataFacade { type: Type; bootstrap: Function[]; @@ -343,6 +362,12 @@ export interface R3DeclareInjectableFacade { deps?: R3DeclareDependencyMetadataFacade[]; } +export interface R3DeclareServiceFacade { + type: Type; + autoProvided?: boolean; + factory?: OpaqueValue; +} + export enum ViewEncapsulation { Emulated = 0, // Historically the 1 value was for `Native` encapsulation which has been removed as of v11. diff --git a/packages/compiler/src/injectable_compiler_2.ts b/packages/compiler/src/injectable_compiler_2.ts index f3e24d7b69b..1ace5fe5963 100644 --- a/packages/compiler/src/injectable_compiler_2.ts +++ b/packages/compiler/src/injectable_compiler_2.ts @@ -140,20 +140,18 @@ export function compileInjectable( .callFn([injectableProps.toLiteralMap()], undefined, true); return { expression, - type: createInjectableType(meta), + type: createInjectableType(meta.type.type, meta.typeArgumentCount), statements: result.statements, }; } -export function createInjectableType(meta: R3InjectableMetadata) { +export function createInjectableType(type: o.Expression, typeArgumentCount: number) { return new o.ExpressionType( - o.importExpr(Identifiers.InjectableDeclaration, [ - typeWithParameters(meta.type.type, meta.typeArgumentCount), - ]), + o.importExpr(Identifiers.InjectableDeclaration, [typeWithParameters(type, typeArgumentCount)]), ); } -function delegateToFactory( +export function delegateToFactory( type: o.WrappedNodeExpr, useType: o.WrappedNodeExpr, unwrapForwardRefs: boolean, diff --git a/packages/compiler/src/jit_compiler_facade.ts b/packages/compiler/src/jit_compiler_facade.ts index 8b81bdcb8fc..0708b08f9fc 100644 --- a/packages/compiler/src/jit_compiler_facade.ts +++ b/packages/compiler/src/jit_compiler_facade.ts @@ -25,6 +25,7 @@ import { R3DeclarePipeDependencyFacade, R3DeclarePipeFacade, R3DeclareQueryMetadataFacade, + R3DeclareServiceFacade, R3DependencyMetadataFacade, R3DirectiveMetadataFacade, R3FactoryDefMetadataFacade, @@ -33,6 +34,7 @@ import { R3NgModuleMetadataFacade, R3PipeMetadataFacade, R3QueryMetadataFacade, + R3ServiceMetadataFacade, R3TemplateDependencyFacade, } from './compiler_facade_interface'; import {ConstantPool} from './constant_pool'; @@ -103,6 +105,7 @@ import {R3TargetBinder} from './render3/view/t2_binder'; import {makeBindingParser, parseTemplate} from './render3/view/template'; import {ResourceLoader} from './resource_loader'; import {DomElementSchemaRegistry} from './schema/dom_element_schema_registry'; +import {compileService} from './service_compiler'; import {getJitStandaloneDefaultForVersion} from './util'; export class CompilerFacadeImpl implements CompilerFacade { @@ -269,6 +272,44 @@ export class CompilerFacadeImpl implements CompilerFacade { return this.compileDirectiveFromMeta(angularCoreEnv, sourceMapUrl, meta); } + compileService( + angularCoreEnv: CoreEnvironment, + sourceMapUrl: string, + facade: R3ServiceMetadataFacade, + ): any { + const {expression, statements} = compileService( + { + name: facade.name, + type: wrapReference(facade.type), + typeArgumentCount: facade.typeArgumentCount, + autoProvided: facade.autoProvided, + factory: facade.factory ? wrapExpression(facade, 'factory') : undefined, + }, + /* resolveForwardRefs */ true, + ); + + return this.jitExpression(expression, angularCoreEnv, sourceMapUrl, statements); + } + + compileServiceDeclaration( + angularCoreEnv: CoreEnvironment, + sourceMapUrl: string, + facade: R3DeclareServiceFacade, + ): any { + const {expression, statements} = compileService( + { + name: facade.type.name, + type: wrapReference(facade.type), + typeArgumentCount: 0, + autoProvided: facade.autoProvided, + factory: facade.factory ? wrapExpression(facade, 'factory') : undefined, + }, + /* resolveForwardRefs */ true, + ); + + return this.jitExpression(expression, angularCoreEnv, sourceMapUrl, statements); + } + private compileDirectiveFromMeta( angularCoreEnv: CoreEnvironment, sourceMapUrl: string, diff --git a/packages/compiler/src/render3/partial/api.ts b/packages/compiler/src/render3/partial/api.ts index 10d11855ad0..003e39e657f 100644 --- a/packages/compiler/src/render3/partial/api.ts +++ b/packages/compiler/src/render3/partial/api.ts @@ -585,3 +585,23 @@ export interface R3DeclareHostDirectiveMetadata { inputs?: string[]; outputs?: string[]; } + +/** + * Describes the shape of the object that the `ɵɵngDeclareService()` function accepts. + * + * This interface serves primarily as documentation, as conformance to this interface is not + * enforced during linking. + */ +export interface R3DeclareServiceMetadata extends R3PartialDeclaration { + /** + * Determines whether the service should be provided automatically or if the user + * is responsible for providing it. + */ + autoProvided?: boolean; + + /** + * If provided, an expression that evaluates to a function to use when creating an instance of + * this injectable. + */ + factory?: o.Expression; +} diff --git a/packages/compiler/src/render3/partial/injectable.ts b/packages/compiler/src/render3/partial/injectable.ts index 99e9a9ccc33..af40037b16b 100644 --- a/packages/compiler/src/render3/partial/injectable.ts +++ b/packages/compiler/src/render3/partial/injectable.ts @@ -32,7 +32,7 @@ export function compileDeclareInjectableFromMetadata( const definitionMap = createInjectableDefinitionMap(meta); const expression = o.importExpr(R3.declareInjectable).callFn([definitionMap.toLiteralMap()]); - const type = createInjectableType(meta); + const type = createInjectableType(meta.type.type, meta.typeArgumentCount); return {expression, type, statements: []}; } diff --git a/packages/compiler/src/render3/partial/service.ts b/packages/compiler/src/render3/partial/service.ts new file mode 100644 index 00000000000..7dabaf4aa2f --- /dev/null +++ b/packages/compiler/src/render3/partial/service.ts @@ -0,0 +1,59 @@ +/** + * @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.dev/license + */ +import {R3ServiceMetadata} from '../../service_compiler'; +import {createInjectableType} from '../../injectable_compiler_2'; +import * as o from '../../output/output_ast'; +import {Identifiers as R3} from '../r3_identifiers'; +import {R3CompiledExpression} from '../util'; +import {DefinitionMap} from '../view/util'; +import {R3DeclareServiceMetadata} from './api'; + +/** + * Every time we make a breaking change to the declaration interface or partial-linker behavior, we + * must update this constant to prevent old partial-linkers from incorrectly processing the + * declaration. + * + * Do not include any prerelease in these versions as they are ignored. + */ +const MINIMUM_PARTIAL_LINKER_VERSION = '22.0.0'; + +/** + * Compile a Service declaration defined by the `R3ServiceMetadata`. + */ +export function compileDeclareServiceFromMetadata(meta: R3ServiceMetadata): R3CompiledExpression { + const definitionMap = createServiceDefinitionMap(meta); + + const expression = o.importExpr(R3.declareService).callFn([definitionMap.toLiteralMap()]); + const type = createInjectableType(meta.type.type, meta.typeArgumentCount); + + return {expression, type, statements: []}; +} + +/** + * Gathers the declaration fields for a Service into a `DefinitionMap`. + */ +export function createServiceDefinitionMap( + meta: R3ServiceMetadata, +): DefinitionMap { + const definitionMap = new DefinitionMap(); + + definitionMap.set('minVersion', o.literal(MINIMUM_PARTIAL_LINKER_VERSION)); + definitionMap.set('version', o.literal('0.0.0-PLACEHOLDER')); + definitionMap.set('ngImport', o.importExpr(R3.core)); + definitionMap.set('type', meta.type.value); + + if (meta.autoProvided === false) { + definitionMap.set('autoProvided', o.literal(false)); + } + + if (meta.factory !== undefined) { + definitionMap.set('factory', meta.factory); + } + + return definitionMap; +} diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index 45c1bf00473..5db2aec5c12 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -307,6 +307,9 @@ export class Identifiers { moduleName: CORE, }; + static defineService: o.ExternalReference = {name: 'ɵɵdefineService', moduleName: CORE}; + static declareService: o.ExternalReference = {name: 'ɵɵngDeclareService', moduleName: CORE}; + static resolveWindow: o.ExternalReference = {name: 'ɵɵresolveWindow', moduleName: CORE}; static resolveDocument: o.ExternalReference = {name: 'ɵɵresolveDocument', moduleName: CORE}; static resolveBody: o.ExternalReference = {name: 'ɵɵresolveBody', moduleName: CORE}; diff --git a/packages/compiler/src/service_compiler.ts b/packages/compiler/src/service_compiler.ts new file mode 100644 index 00000000000..2ae2b90a07b --- /dev/null +++ b/packages/compiler/src/service_compiler.ts @@ -0,0 +1,58 @@ +/** + * @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.dev/license + */ + +import {createInjectableType, delegateToFactory} from './injectable_compiler_2'; +import * as o from './output/output_ast'; +import {Identifiers} from './render3/r3_identifiers'; +import {R3CompiledExpression, R3Reference} from './render3/util'; +import {DefinitionMap} from './render3/view/util'; + +export interface R3ServiceMetadata { + name: string; + type: R3Reference; + typeArgumentCount: number; + autoProvided?: boolean; + factory?: o.Expression; +} + +export function compileService( + meta: R3ServiceMetadata, + resolveForwardRefs: boolean, +): R3CompiledExpression { + const def = new DefinitionMap<{ + token: o.Expression; + factory: o.Expression; + autoProvided: o.Expression; + }>(); + def.set('token', meta.type.value); + def.set( + 'factory', + meta.factory === undefined + ? delegateToFactory( + meta.type.value as o.WrappedNodeExpr, + meta.type.value as o.WrappedNodeExpr, + resolveForwardRefs, + ) + : o.arrowFn([], meta.factory.callFn([])), + ); + + // Only generate providedIn property if it's different from the default. + if (meta.autoProvided === false) { + def.set('autoProvided', o.literal(false)); + } + + const expression = o + .importExpr(Identifiers.defineService) + .callFn([def.toLiteralMap()], undefined, true); + + return { + expression, + type: createInjectableType(meta.type.type, meta.typeArgumentCount), + statements: [], + }; +} diff --git a/packages/core/src/compiler/compiler_facade.ts b/packages/core/src/compiler/compiler_facade.ts index fcef852a0be..b13b8caca50 100644 --- a/packages/core/src/compiler/compiler_facade.ts +++ b/packages/core/src/compiler/compiler_facade.ts @@ -16,7 +16,7 @@ export const enum JitCompilerUsage { interface JitCompilerUsageRequest { usage: JitCompilerUsage; - kind: 'directive' | 'component' | 'pipe' | 'injectable' | 'NgModule'; + kind: 'directive' | 'component' | 'pipe' | 'injectable' | 'NgModule' | 'service'; type: Type; } diff --git a/packages/core/src/compiler/compiler_facade_interface.ts b/packages/core/src/compiler/compiler_facade_interface.ts index 5d45064d2ca..62c7d4e8c23 100644 --- a/packages/core/src/compiler/compiler_facade_interface.ts +++ b/packages/core/src/compiler/compiler_facade_interface.ts @@ -96,6 +96,16 @@ export interface CompilerFacade { sourceMapUrl: string, meta: R3DeclareFactoryFacade, ): any; + compileService( + angularCoreEnv: CoreEnvironment, + sourceMapUrl: string, + meta: R3ServiceMetadataFacade, + ): any; + compileServiceDeclaration( + angularCoreEnv: CoreEnvironment, + sourceMapUrl: string, + meta: R3DeclareServiceFacade, + ): any; createParseSourceSpan(kind: string, typeName: string, sourceUrl: string): ParseSourceSpan; @@ -123,6 +133,7 @@ export enum FactoryTarget { Injectable = 2, Pipe = 3, NgModule = 4, + Service = 5, } export interface R3DependencyMetadataFacade { @@ -163,6 +174,14 @@ export interface R3InjectableMetadataFacade { deps?: R3DependencyMetadataFacade[]; } +export interface R3ServiceMetadataFacade { + name: string; + type: Type; + typeArgumentCount: number; + autoProvided?: boolean; + factory?: OpaqueValue; +} + export interface R3NgModuleMetadataFacade { type: Type; bootstrap: Function[]; @@ -345,6 +364,12 @@ export interface R3DeclareInjectableFacade { deps?: R3DeclareDependencyMetadataFacade[]; } +export interface R3DeclareServiceFacade { + type: Type; + autoProvided?: boolean; + factory?: OpaqueValue; +} + export enum ViewEncapsulation { Emulated = 0, // Historically the 1 value was for `Native` encapsulation which has been removed as of v11. diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index d87a81a8158..207367fb41a 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -272,6 +272,7 @@ export { ɵɵngDeclareDirective, ɵɵngDeclareFactory, ɵɵngDeclareInjectable, + ɵɵngDeclareService, ɵɵngDeclareInjector, ɵɵngDeclareNgModule, ɵɵngDeclarePipe, diff --git a/packages/core/src/di/index.ts b/packages/core/src/di/index.ts index b5e9c5adbd3..b074c0da62a 100644 --- a/packages/core/src/di/index.ts +++ b/packages/core/src/di/index.ts @@ -17,6 +17,8 @@ export {assertInInjectionContext, runInInjectionContext} from './contextual'; export {ɵɵdefineInjectable, ɵɵdefineInjector, InjectableType, InjectorType} from './interface/defs'; export {forwardRef, resolveForwardRef, ForwardRefFn} from './forward_ref'; export {Injectable, InjectableDecorator, InjectableProvider} from './injectable'; +export {Service, ServiceDecorator} from './service'; +export {ɵɵdefineService} from './interface/service'; export {Injector, DestroyableInjector} from './injector'; export {EnvironmentInjector} from './r3_injector'; export { diff --git a/packages/core/src/di/interface/service.ts b/packages/core/src/di/interface/service.ts new file mode 100644 index 00000000000..24753a6c315 --- /dev/null +++ b/packages/core/src/di/interface/service.ts @@ -0,0 +1,36 @@ +/*! + * @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.dev/license + */ + +import {Type} from '../../interface/type'; +import {ɵɵInjectableDeclaration} from './defs'; + +/** + * Construct a definition which defines how a service will be constructed by the DI system. + * + * This should be assigned to a static `ɵprov` field on a type. + * + * Options: + * * `autoProvided` determines whether the service should be provided automatically or whether + * the user should be responsible for providing it. + * * `factory` gives the zero argument function which will create an instance of the injectable. + * + * @codeGenApi + * @publicApi + */ +export function ɵɵdefineService(opts: { + token: unknown; + autoProvided?: boolean; + factory: (parent?: Type) => T; +}): ɵɵInjectableDeclaration { + return { + token: opts.token, + providedIn: opts.autoProvided === false ? null : 'root', + factory: opts.factory, + value: undefined, + }; +} diff --git a/packages/core/src/di/jit/environment.ts b/packages/core/src/di/jit/environment.ts index bc4e2df24e9..5fa85cae564 100644 --- a/packages/core/src/di/jit/environment.ts +++ b/packages/core/src/di/jit/environment.ts @@ -8,6 +8,7 @@ import {resolveForwardRef} from '../forward_ref'; import {ɵɵinject, ɵɵinvalidFactoryDep} from '../injector_compatibility'; import {ɵɵdefineInjectable, ɵɵdefineInjector} from '../interface/defs'; +import {ɵɵdefineService} from '../interface/service'; /** * A mapping of the @angular/core API surface used in generated expressions to the actual symbols. @@ -17,6 +18,7 @@ import {ɵɵdefineInjectable, ɵɵdefineInjector} from '../interface/defs'; export const angularCoreDiEnv: {[name: string]: Function} = { 'ɵɵdefineInjectable': ɵɵdefineInjectable, 'ɵɵdefineInjector': ɵɵdefineInjector, + 'ɵɵdefineService': ɵɵdefineService, 'ɵɵinject': ɵɵinject, 'ɵɵinvalidFactoryDep': ɵɵinvalidFactoryDep, 'resolveForwardRef': resolveForwardRef, diff --git a/packages/core/src/di/jit/service.ts b/packages/core/src/di/jit/service.ts new file mode 100644 index 00000000000..d810d6b7162 --- /dev/null +++ b/packages/core/src/di/jit/service.ts @@ -0,0 +1,84 @@ +/** + * @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.dev/license + */ + +import { + getCompilerFacade, + JitCompilerUsage, + R3ServiceMetadataFacade, +} from '../../compiler/compiler_facade'; +import {Type} from '../../interface/type'; +import {NG_FACTORY_DEF} from '../../render3/fields'; +import type {Service} from '../service'; +import {NG_PROV_DEF} from '../interface/defs'; +import {angularCoreDiEnv} from './environment'; +import {reflectDependencies} from './util'; + +/** + * Compile an Angular service according to its `Service` metadata, and patch the resulting + * defition (`ɵprov`) onto the type. + */ +export function compileService(type: Type, meta?: Service): void { + let def: any = null; + let factoryDef: any = null; + + // if NG_PROV_DEF is already defined on this class then don't overwrite it + if (!type.hasOwnProperty(NG_PROV_DEF)) { + Object.defineProperty(type, NG_PROV_DEF, { + get: () => { + if (def === null) { + const compiler = getCompilerFacade({ + usage: JitCompilerUsage.Decorator, + kind: 'service', + type, + }); + def = compiler.compileService( + angularCoreDiEnv, + `ng:///${type.name}/ɵprov.js`, + getServiceMetadata(type, meta), + ); + } + return def; + }, + }); + } + + if (!type.hasOwnProperty(NG_FACTORY_DEF)) { + Object.defineProperty(type, NG_FACTORY_DEF, { + get: () => { + if (factoryDef === null) { + const compiler = getCompilerFacade({ + usage: JitCompilerUsage.Decorator, + kind: 'service', + type, + }); + factoryDef = compiler.compileFactory(angularCoreDiEnv, `ng:///${type.name}/ɵfac.js`, { + name: type.name, + type, + typeArgumentCount: 0, + deps: reflectDependencies(type), + target: compiler.FactoryTarget.Service, + }); + } + return factoryDef; + }, + configurable: true, + }); + } +} + +function getServiceMetadata(type: Type, srcMeta?: Service): R3ServiceMetadataFacade { + const compilerMeta: R3ServiceMetadataFacade = { + name: type.name, + type: type, + typeArgumentCount: 0, + autoProvided: srcMeta?.autoProvided, + factory: srcMeta?.factory, + }; + + return compilerMeta; +} diff --git a/packages/core/src/di/service.ts b/packages/core/src/di/service.ts new file mode 100644 index 00000000000..b77f229c436 --- /dev/null +++ b/packages/core/src/di/service.ts @@ -0,0 +1,80 @@ +/** + * @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.dev/license + */ + +import {Type} from '../interface/type'; +import {makeDecorator, TypeDecorator} from '../util/decorators'; +import {compileService} from './jit/service'; + +/** + * Type of the Service decorator / constructor function. + * + * @publicApi + */ +export interface ServiceDecorator { + /** + * Decorator that marks a class as a service and makes it automatically available + * in the dependency injection system. + * + * @see [Introduction to Services and DI](guide/di) + * @see [Creating and using services](guide/di/creating-and-using-services) + * @see [Defining dependency providers](guide/di/defining-dependency-providers) + * + * @developerPreview 22.0 + */ + (): TypeDecorator; + + /** + * When `autoProvided` is set to `false`, the service won't be exposed to the dependency + * injection system automatically. It is up to the user to expose it in a providers list. + * + * @developerPreview 22.0 + */ + (options?: {autoProvided: false}): TypeDecorator; + + /** + * Creates a service that is automatically provided. Passing an optional + * `factory` allows for the runtime value to be replaced. + * + * @developerPreview 22.0 + */ + (options?: {autoProvided?: true; factory?: () => unknown}): TypeDecorator; +} + +/** + * Type of the Service metadata. + * + * @publicApi + * @developerPreview 22.0 + */ +export interface Service { + /** + * Determines whether the service should be provided automatically or by the user. + * Defaults to `true`. + */ + autoProvided?: boolean; + + /** + * A function to invoke to create a value for this service. + */ + factory?: () => unknown; +} + +/** + * Service decorator and metadata. + * + * @Annotation + * @publicApi + * @developerPreview 22.0 + */ +export const Service: ServiceDecorator = makeDecorator( + 'Service', + undefined, + undefined, + undefined, + (type: Type, meta: Service | undefined) => compileService(type, meta), +); diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 5274ca2936b..c50b18c6637 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -9,8 +9,8 @@ import {forwardRef, resolveForwardRef} from '../../di/forward_ref'; import {ɵɵinject, ɵɵinvalidFactoryDep} from '../../di/injector_compatibility'; import {ɵɵdefineInjectable, ɵɵdefineInjector} from '../../di/interface/defs'; +import {ɵɵdefineService} from '../../di/interface/service'; import {registerNgModuleType} from '../../linker/ng_module_registration'; -import * as iframe_attrs_validation from '../../sanitization/iframe_attrs_validation'; import * as sanitization from '../../sanitization/sanitization'; import * as r3 from '../index'; @@ -30,6 +30,7 @@ export const angularCoreEnv: {[name: string]: unknown} = (() => ({ 'ɵɵdefineInjectable': ɵɵdefineInjectable, 'ɵɵdefineInjector': ɵɵdefineInjector, 'ɵɵdefineNgModule': r3.ɵɵdefineNgModule, + 'ɵɵdefineService': ɵɵdefineService, 'ɵɵdefinePipe': r3.ɵɵdefinePipe, 'ɵɵdirectiveInject': r3.ɵɵdirectiveInject, 'ɵɵgetInheritedFactory': r3.ɵɵgetInheritedFactory, diff --git a/packages/core/src/render3/jit/partial.ts b/packages/core/src/render3/jit/partial.ts index 820524f0579..af945af9ec1 100644 --- a/packages/core/src/render3/jit/partial.ts +++ b/packages/core/src/render3/jit/partial.ts @@ -17,6 +17,7 @@ import { R3DeclareInjectorFacade, R3DeclareNgModuleFacade, R3DeclarePipeFacade, + R3DeclareServiceFacade, } from '../../compiler/compiler_facade'; import {Type} from '../../interface/type'; import {setClassMetadata, setClassMetadataAsync} from '../metadata'; @@ -128,6 +129,8 @@ function getFactoryKind(target: FactoryTarget) { return 'pipe'; case FactoryTarget.NgModule: return 'NgModule'; + case FactoryTarget.Service: + return 'service'; } } @@ -203,3 +206,21 @@ export function ɵɵngDeclarePipe(decl: R3DeclarePipeFacade): unknown { }); return compiler.compilePipeDeclaration(angularCoreEnv, `ng:///${decl.type.name}/ɵpipe.js`, decl); } + +/** + * Compiles a partial service declaration object into a full service definition object. + * + * @codeGenApi + */ +export function ɵɵngDeclareService(decl: R3DeclareServiceFacade): unknown { + const compiler = getCompilerFacade({ + usage: JitCompilerUsage.PartialDeclaration, + kind: 'service', + type: decl.type, + }); + return compiler.compileServiceDeclaration( + angularCoreEnv, + `ng:///${decl.type.name}/ɵprov.js`, + decl, + ); +} diff --git a/packages/core/test/acceptance/service_spec.ts b/packages/core/test/acceptance/service_spec.ts new file mode 100644 index 00000000000..d2200f81de4 --- /dev/null +++ b/packages/core/test/acceptance/service_spec.ts @@ -0,0 +1,206 @@ +/*! + * @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.dev/license + */ + +import {Component, inject, InjectionToken, OnDestroy, Service, ViewChild} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; + +describe('@Service decorator', () => { + it('should be able to declare a class as an @Service', () => { + @Service() + class MyService { + readonly value = 'MyService'; + } + + @Component({template: ''}) + class MyComp { + readonly service = inject(MyService); + } + + const fixture = TestBed.createComponent(MyComp); + expect(fixture.componentInstance.service.value).toBe('MyService'); + }); + + it('should be able to provide an alternate implementation using `factory`', () => { + @Service({factory: () => ({value: 'alternate'})}) + class MyService { + readonly value = 'MyService'; + } + + @Component({template: ''}) + class MyComp { + readonly service = inject(MyService); + } + + const fixture = TestBed.createComponent(MyComp); + expect(fixture.componentInstance.service.value).toBe('alternate'); + }); + + it('should be able to provide an alternate implementation using `factory` when `autoProvided` is set to true', () => { + @Service({autoProvided: true, factory: () => ({value: 'alternate'})}) + class MyService { + readonly value = 'MyService'; + } + + @Component({template: ''}) + class MyComp { + readonly service = inject(MyService); + } + + const fixture = TestBed.createComponent(MyComp); + expect(fixture.componentInstance.service.value).toBe('alternate'); + }); + + it('should be able to inject dependencies inside the factory function', () => { + const token = new InjectionToken('token'); + + @Service({factory: () => ({value: inject(token)})}) + class MyService { + readonly value = 'MyService'; + } + + @Component({template: ''}) + class MyComp { + readonly service = inject(MyService); + } + + TestBed.configureTestingModule({ + providers: [ + { + provide: token, + useValue: 'injectedValue', + }, + ], + }); + + const fixture = TestBed.createComponent(MyComp); + expect(fixture.componentInstance.service.value).toBe('injectedValue'); + }); + + it('should not provide a service automatically if `autoProvided` is set to `false`', () => { + @Service({autoProvided: false}) + class MyService {} + + @Component({template: ''}) + class App { + readonly service = inject(MyService); + } + + expect(() => TestBed.createComponent(App)).toThrowError(/No provider found for `MyService`/); + }); + + it('should be able to provide a service with `autoProvided: false`', () => { + @Service({autoProvided: false}) + class MyService { + readonly value = 'initial'; + } + + @Component({template: ''}) + class App { + readonly service = inject(MyService); + } + + TestBed.configureTestingModule({ + providers: [ + { + provide: MyService, + useValue: { + value: 'provided', + }, + }, + ], + }); + + const fixture = TestBed.createComponent(App); + expect(fixture.componentInstance.service.value).toBe('provided'); + }); + + it('should be able to override a service', () => { + @Service() + class MyService { + readonly value = 'MyService'; + } + + @Service() + class AlternateService { + readonly value = 'AlternateService'; + } + + @Component({selector: 'my-comp', template: ''}) + class MyComp { + readonly service = inject(MyService); + } + + @Component({ + template: '', + imports: [MyComp], + providers: [{provide: MyService, useClass: AlternateService}], + }) + class App { + @ViewChild(MyComp) childInstance!: MyComp; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.componentInstance.childInstance.service.value).toBe('AlternateService'); + }); + + it('should be able to override a service that has a factory', () => { + @Service({factory: () => ({value: 'factory'})}) + class MyService { + readonly value = 'MyService'; + } + + @Service() + class AlternateService { + readonly value = 'AlternateService'; + } + + @Component({selector: 'my-comp', template: ''}) + class MyComp { + readonly service = inject(MyService); + } + + @Component({ + template: '', + imports: [MyComp], + providers: [{provide: MyService, useClass: AlternateService}], + }) + class App { + @ViewChild(MyComp) childInstance!: MyComp; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.componentInstance.childInstance.service.value).toBe('AlternateService'); + }); + + it('should invoke ngOnDestroy on services', () => { + let calls = 0; + + @Service() + class MyService implements OnDestroy { + ngOnDestroy(): void { + calls++; + } + } + + @Component({template: ''}) + class MyComp { + readonly service = inject(MyService); + } + + const fixture = TestBed.createComponent(MyComp); + fixture.detectChanges(); + fixture.destroy(); + TestBed.resetTestingModule(); + + expect(calls).toBe(1); + }); +}); diff --git a/packages/core/test/render3/jit_environment_spec.ts b/packages/core/test/render3/jit_environment_spec.ts index 1184eaa4920..f895581f84d 100644 --- a/packages/core/test/render3/jit_environment_spec.ts +++ b/packages/core/test/render3/jit_environment_spec.ts @@ -57,6 +57,7 @@ const PARTIAL_ONLY = new Set([ 'ɵɵngDeclareInjector', 'ɵɵngDeclareNgModule', 'ɵɵngDeclarePipe', + 'ɵɵngDeclareService', 'ɵɵFactoryTarget', 'ChangeDetectionStrategy', 'ViewEncapsulation',