diff --git a/dev-app/src/app/app.config.ts b/dev-app/src/app/app.config.ts index a526bd5a33c3..31f497bd3cb4 100644 --- a/dev-app/src/app/app.config.ts +++ b/dev-app/src/app/app.config.ts @@ -1,9 +1,9 @@ import {ApplicationConfig, provideBrowserGlobalErrorListeners} from '@angular/core'; import {provideRouter} from '@angular/router'; -import {routes} from './app.routes'; -import {provideClientHydration, withEventReplay} from '@angular/platform-browser'; import {provideHttpClient, withFetch} from '@angular/common/http'; +import {provideClientHydration, withEventReplay} from '@angular/platform-browser'; +import {routes} from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ diff --git a/goldens/public-api/compiler-cli/error_code.api.md b/goldens/public-api/compiler-cli/error_code.api.md index e969dfd34333..7d171bd82ca0 100644 --- a/goldens/public-api/compiler-cli/error_code.api.md +++ b/goldens/public-api/compiler-cli/error_code.api.md @@ -67,6 +67,11 @@ export enum ErrorCode { HOST_DIRECTIVE_MISSING_REQUIRED_BINDING = 2019, HOST_DIRECTIVE_NOT_STANDALONE = 2014, HOST_DIRECTIVE_UNDEFINED_BINDING = 2017, + HOSTLESS_COMPONENT_ANIMATIONS = 2031, + HOSTLESS_COMPONENT_HOST_STYLE = 2032, + HOSTLESS_COMPONENT_SHADOW_DOM = 2030, + HOSTLESS_COMPONENT_UNSUPPORTED_BINDING = 8030, + HOSTLESS_COMPONENT_WITH_HOST_BINDINGS = 2029, ILLEGAL_FOR_LOOP_TRACK_ACCESS = 8009, ILLEGAL_LET_WRITE = 8015, IMPORT_CYCLE_DETECTED = 3003, diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index d1002e1ec71a..ccaed8d1ce5f 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -275,6 +275,7 @@ export interface Component extends Directive { animations?: any[]; changeDetection?: ChangeDetectionStrategy; encapsulation?: ViewEncapsulation; + hostless?: boolean; imports?: (Type | ReadonlyArray)[]; preserveWhitespaces?: boolean; schemas?: SchemaMetadata[]; diff --git a/hostless_features.md b/hostless_features.md new file mode 100644 index 000000000000..3b85dd894fcd --- /dev/null +++ b/hostless_features.md @@ -0,0 +1,52 @@ +# Hostless Components Feature Support + +This document tracks the current support matrix for Angular hostless components. + +## Supported Features + +### Core Architecture + +- [x] Hostless component configuration (`hostless: true` in `@Component` decorator). +- [x] Compilation support (compiler propagates `hostless: true` to `ComponentDef`). +- [x] Removal of the host element from the DOM (rendered as an `ElementContainer` / comment node instead). +- [x] Support by Angular DevTools (Supported out of the box! Rendered as `ElementContainer` nodes just like ``). + +### Styling & Encapsulation + +- [x] children hostless components do not inherit from their parent, same as regular components + +### Component Features & APIs + +- [x] Content projection (``) inside hostless components. +- [x] View queries (`@ViewChild`, `@ViewChildren`) querying the hostless component itself (`ElementRef` maps to the Comment node). +- [x] Dependency Injection for `ElementRef` (Injecting `ElementRef` returns a reference to the Comment node). +- [x] Directives applied to the hostless component (Instances created; dev-mode runtime warnings emitted if they attempt to apply host bindings to the underlying Comment node). +- [x] Host Directives (`hostDirectives: [MyDir]`) (Instances created; dev-mode warnings emitted if they attempt to apply host bindings). +- [x] Change Detection (`ChangeDetectionStrategy.OnPush`, `ChangeDetectorRef.markForCheck()`, Signals) (Logical `LView` tree remains exactly the same as a normal component). +- [x] Dynamic instantiation via `ViewContainerRef.createComponent()` (seamlessly appends internal views rather than a host wrapper). +- [x] Native support in `RouterOutlet` (navigating to a hostless component successfully renders its internal views without a host wrapper). +- [x] Support for `*ngComponentOutlet` (since it relies on `ViewContainerRef.createComponent()`). + +### Error Handling & Restrictions + +- [x] Throw compiler error for host bindings and listeners (`@HostBinding`, `@HostListener`, `host: { ... }`). +- [x] Throw error on styles set from the parent component. +- [x] Throw error on regular event listeners set from the parent component (listening to outputs is still allowed). +- [x] Throw error if legacy animations are used. +- [x] Throw error if `ShadowDom` encapsulation is used. +- [x] Throw error if `:host` or `:host-context` selectors are used in the component's styles. +- [x] Animations tied to the host element throw an `NG0303` unknown property runtime error in dev-mode, since there's no DOM element to animate. + +### Server-Side Rendering & Hydration + +- [x] Works with SSR & Hydration (Rehydrates safely by properly resolving the anchor comment node and correctly claiming child nodes). +- [x] `ngSkipHydration` support (Safely skips hydration boundaries rooted at hostless components, clearing server DOM and falling back to client-side rendering). + +### Testing Utilities + +- [x] Behavior of `fixture.nativeElement` and `fixture.debugElement` when testing a hostless component directly (`fixture.nativeElement` maps to the virtual `root` wrapper created by `TestBed`, allowing easy querying of child elements). + +## Unsupported / Pending Features + +- Bootstrapping a hostless component directly as the root of the application (e.g. `bootstrapApplication(MyHostless)`). The component will currently attach to the root element in the `index.html` (ignoring `hostless: true`) because the root view requires a physical DOM anchor. +- Re-ordering or querying the `ComponentRef.location.nativeElement` for dynamically created hostless components. It currently returns the underlying `Comment` node anchor, not the component's internal DOM nodes. diff --git a/integration/platform-server-hydration/size.json b/integration/platform-server-hydration/size.json index 87835fe9d82c..00140775e94d 100644 --- a/integration/platform-server-hydration/size.json +++ b/integration/platform-server-hydration/size.json @@ -1,5 +1,5 @@ { - "dist/browser/main-[hash].js": 232419, - "dist/browser/polyfills-[hash].js": 35726, + "dist/browser/main-[hash].js": 238346, + "dist/browser/polyfills-[hash].js": 35784, "dist/browser/event-dispatch-contract.min.js": 476 } diff --git a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_component_linker_1.ts b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_component_linker_1.ts index cb78ec151349..cbb04240e454 100644 --- a/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_component_linker_1.ts +++ b/packages/compiler-cli/linker/src/file_linker/partial_linkers/partial_component_linker_1.ts @@ -248,6 +248,7 @@ export class PartialComponentLinkerVersion1< : hasOnPushByDefault ? ChangeDetectionStrategy.OnPush : ChangeDetectionStrategy.Eager, + isHostless: metaObj.has('isHostless') ? metaObj.getBoolean('isHostless') : false, animations: metaObj.has('animations') ? metaObj.getOpaque('animations') : null, relativeContextFilePath: this.sourceUrl, relativeTemplatePath: null, diff --git a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts index 1e6cf4ce9851..2ac24130f964 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/component/src/handler.ts @@ -551,6 +551,12 @@ export class ComponentDecoratorHandler implements DecoratorHandler< changeDetection = new o.WrappedNodeExpr(component.get('changeDetection')!); } + let isHostless = false; + if (component.has('hostless')) { + const expr = component.get('hostless')!; + isHostless = this.evaluator.evaluate(expr) === true; + } + let animations: o.Expression | null = null; let legacyAnimationTriggerNames: LegacyAnimationTriggerNames | null = null; if (component.has('animations')) { @@ -711,6 +717,21 @@ export class ComponentDecoratorHandler implements DecoratorHandler< schemas = []; } + if (isHostless && directiveResult.hostBindingNodes?.rawNodes?.length > 0) { + if (diagnostics === undefined) { + diagnostics = []; + } + for (const node of directiveResult.hostBindingNodes.rawNodes) { + diagnostics.push( + makeDiagnostic( + ErrorCode.HOSTLESS_COMPONENT_WITH_HOST_BINDINGS, + node, + `Hostless components cannot have host bindings.`, + ), + ); + } + } + // Parse the template. // If a preanalyze phase was executed, the template may already exist in parsed form, so check // the preanalyzeTemplateCache. @@ -974,6 +995,61 @@ export class ComponentDecoratorHandler implements DecoratorHandler< } } + if (isHostless) { + if ( + encapsulation === ViewEncapsulation.ShadowDom || + encapsulation === ViewEncapsulation.ExperimentalIsolatedShadowDom + ) { + if (diagnostics === undefined) { + diagnostics = []; + } + diagnostics.push( + makeDiagnostic( + ErrorCode.HOSTLESS_COMPONENT_SHADOW_DOM, + component.get('encapsulation') ?? component.get('hostless')!, + 'Hostless components cannot use Shadow DOM encapsulation.', + ), + ); + } + + if (component.has('animations')) { + if (diagnostics === undefined) { + diagnostics = []; + } + diagnostics.push( + makeDiagnostic( + ErrorCode.HOSTLESS_COMPONENT_ANIMATIONS, + component.get('animations')!, + 'Hostless components cannot have animations.', + ), + ); + } + + for (const style of styles) { + const cleanedStyle = style + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, ''); + if (/(? { inputs: analysis.inputs, outputs: analysis.outputs, isComponent: false, + isHostless: false, name: 'Dir', selector: '[dir]', isStructural: false, 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 bdd6e05cd8fa..c9114ce2a0aa 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -162,6 +162,26 @@ export enum ErrorCode { */ COMPONENT_UNKNOWN_DEFERRED_IMPORT = 2022, + /** + * Raised when a hostless component has host bindings. + */ + HOSTLESS_COMPONENT_WITH_HOST_BINDINGS = 2029, + + /** + * Raised when a hostless component uses Shadow DOM encapsulation. + */ + HOSTLESS_COMPONENT_SHADOW_DOM = 2030, + + /** + * Raised when a hostless component has animations. + */ + HOSTLESS_COMPONENT_ANIMATIONS = 2031, + + /** + * Raised when a hostless component uses :host or :host-context in its styles. + */ + HOSTLESS_COMPONENT_HOST_STYLE = 2032, + /** * Raised when a `standalone: false` component is declared but `strictStandalone` is set. */ @@ -485,6 +505,11 @@ export enum ErrorCode { */ CONFLICTING_CONTENT_AND_PROPERTY = 8029, + /** + * Raised when a hostless component is bound to a DOM property, attribute, class, style or event. + */ + HOSTLESS_COMPONENT_UNSUPPORTED_BINDING = 8030, + /** * A two way binding in a template has an incorrect syntax, * parentheses outside brackets. For example: diff --git a/packages/compiler-cli/src/ngtsc/indexer/test/util.ts b/packages/compiler-cli/src/ngtsc/indexer/test/util.ts index a3d0f320a3a3..723476659eef 100644 --- a/packages/compiler-cli/src/ngtsc/indexer/test/util.ts +++ b/packages/compiler-cli/src/ngtsc/indexer/test/util.ts @@ -7,7 +7,6 @@ */ import { - BoundTarget, ClassPropertyMapping, CssSelector, DirectiveMatcher, @@ -24,8 +23,8 @@ import {absoluteFrom, AbsoluteFsPath} from '../../file_system'; import {Reference} from '../../imports'; import {ClassDeclaration, DeclarationNode} from '../../reflection'; import {getDeclaration, makeProgram} from '../../testing'; -import {ComponentMeta} from '../src/context'; import {AbstractBoundTemplate} from '../src/api'; +import {ComponentMeta} from '../src/context'; /** Dummy file URL */ function getTestFilePath(): AbsoluteFsPath { @@ -64,6 +63,7 @@ export function getBoundTemplate( selector, name: declaration.name.getText(), isComponent: true, + isHostless: false, inputs: ClassPropertyMapping.fromMappedObject({}), outputs: ClassPropertyMapping.fromMappedObject({}), exportAs: null, diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts index 8b2400af9ade..afb57f182f94 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/src/dts.ts @@ -208,6 +208,9 @@ export class DtsMetadataReader implements MetadataReader { const isSignal = def.type.typeArguments.length > 9 && (readBooleanType(def.type.typeArguments[9]) ?? false); + const isHostless = + def.type.typeArguments.length > 10 && (readBooleanType(def.type.typeArguments[10]) ?? false); + // At this point in time, the `.d.ts` may not be fully extractable when // trying to resolve host directive types to their declarations. // If this cannot be done completely, the metadata is incomplete and "poisoned". @@ -219,6 +222,7 @@ export class DtsMetadataReader implements MetadataReader { ref, name: clazz.name.text, isComponent, + isHostless, selector: readStringType(def.type.typeArguments[1]), exportAs: readStringArrayType(def.type.typeArguments[2]), inputs, diff --git a/packages/compiler-cli/src/ngtsc/metadata/test/dts_spec.ts b/packages/compiler-cli/src/ngtsc/metadata/test/dts_spec.ts index 4a168a13aaa3..67e1b5183dc5 100644 --- a/packages/compiler-cli/src/ngtsc/metadata/test/dts_spec.ts +++ b/packages/compiler-cli/src/ngtsc/metadata/test/dts_spec.ts @@ -375,4 +375,59 @@ runInEachFileSystem(() => { expect(withoutOwningModule.exports.length).toBe(1); expect(withoutOwningModule.isPoisoned).toBe(true); }); + + it('should read isHostless metadata from ɵɵComponentDeclaration', () => { + const mainPath = absoluteFrom('/main.d.ts'); + const {program} = makeProgram( + [ + { + name: mainPath, + contents: ` + import * as i0 from '@angular/core'; + + export declare class HostlessCmp { + static ɵcmp: i0.ɵɵComponentDeclaration; + } + + export declare class RegularCmp { + static ɵcmp: i0.ɵɵComponentDeclaration; + } + + export declare class DefaultCmp { + static ɵcmp: i0.ɵɵComponentDeclaration; + } + `, + }, + ], + { + skipLibCheck: true, + lib: ['es6', 'dom'], + }, + ); + + const sf = getSourceFileOrError(program, mainPath); + const hostlessClazz = sf.statements[1]; + const regularClazz = sf.statements[2]; + const defaultClazz = sf.statements[3]; + + if ( + !isNamedClassDeclaration(hostlessClazz) || + !isNamedClassDeclaration(regularClazz) || + !isNamedClassDeclaration(defaultClazz) + ) { + return fail('Expected class declarations'); + } + + const typeChecker = program.getTypeChecker(); + const dtsReader = new DtsMetadataReader(typeChecker, new TypeScriptReflectionHost(typeChecker)); + + const hostlessMeta = dtsReader.getDirectiveMetadata(new Reference(hostlessClazz))!; + expect(hostlessMeta.isHostless).toBe(true); + + const regularMeta = dtsReader.getDirectiveMetadata(new Reference(regularClazz))!; + expect(regularMeta.isHostless).toBe(false); + + const defaultMeta = dtsReader.getDirectiveMetadata(new Reference(defaultClazz))!; + expect(defaultMeta.isHostless).toBe(false); + }); }); diff --git a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts index d157df205f84..b36582282a7b 100644 --- a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts +++ b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts @@ -341,6 +341,7 @@ function fakeDirective(ref: Reference): DirectiveMeta { name, selector: `[${ref.debugName}]`, isComponent: name.startsWith('Cmp'), + isHostless: false, inputs: ClassPropertyMapping.fromMappedObject({}), outputs: ClassPropertyMapping.fromMappedObject({}), exportAs: null, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts index 7830833c438a..e644a065d4e1 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts @@ -85,6 +85,11 @@ export interface PotentialDirective { */ isComponent: boolean; + /** + * `true` if this component is hostless. + */ + isHostless: boolean; + /** * `true` if this directive is a structural directive. */ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index 4393912ac337..6db2871a092c 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts @@ -9,17 +9,15 @@ import { AST, BoundTarget, - ForeignComponentMeta, CssSelector, DomElementSchemaRegistry, ExternalExpr, + ForeignComponentMeta, LiteralPrimitive, ParseSourceSpan, PropertyRead, ReferenceTarget, SafePropertyRead, - ScopedNode, - Target, TemplateEntity, TmplAstBoundAttribute, TmplAstBoundEvent, @@ -53,7 +51,7 @@ import { PipeMeta, } from '../../metadata'; import {PerfCheckpoint, PerfEvent, PerfPhase, PerfRecorder} from '../../perf'; -import {ProgramDriver, UpdateMode, InliningMode} from '../../program_driver'; +import {InliningMode, ProgramDriver, UpdateMode} from '../../program_driver'; import { ClassDeclaration, DeclarationNode, @@ -61,12 +59,12 @@ import { ReflectionHost, } from '../../reflection'; import { + ComponentScope, ComponentScopeKind, ComponentScopeReader, + LocalModuleScope, StandaloneScope, TypeCheckScopeRegistry, - LocalModuleScope, - ComponentScope, } from '../../scope'; import {isShim} from '../../shims'; import { @@ -90,7 +88,6 @@ import { PotentialImportKind, PotentialImportMode, PotentialPipe, - ReferenceSymbol, ProgramTypeCheckAdapter, SelectorlessComponentSymbol, SelectorlessDirectiveSymbol, @@ -106,19 +103,19 @@ import { } from '../api'; import {makeTemplateDiagnostic} from '../diagnostics'; +import {findAllMatchingNodes} from './comments'; import {CompletionEngine} from './completion'; import { ShimTypeCheckingData, - TypeCheckData, TypeCheckContextImpl, + TypeCheckData, TypeCheckingHost, } from './context'; import {shouldReportDiagnostic, translateDiagnostic} from './diagnostics'; import {TypeCheckShimGenerator} from './shim'; import {DirectiveSourceManager} from './source'; import {findTypeCheckBlock, getSourceMapping, TypeCheckSourceResolver} from './tcb_util'; -import {SymbolBuilder, SymbolDirectiveMeta, SymbolBoundTarget} from './template_symbol_builder'; -import {findAllMatchingNodes} from './comments'; +import {SymbolBoundTarget, SymbolBuilder, SymbolDirectiveMeta} from './template_symbol_builder'; import {TCB_FUNCTION_PREFIX} from './type_check_file'; export class TypeCheckableDirectiveMetaAdapter implements SymbolDirectiveMeta { @@ -157,6 +154,9 @@ export class TypeCheckableDirectiveMetaAdapter implements SymbolDirectiveMeta { get isComponent() { return this.meta.isComponent; } + get isHostless() { + return this.meta.isHostless; + } get inputs() { return this.meta.inputs; } @@ -1752,6 +1752,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { moduleSpecifier: dep.ref.bestGuessOwningModule?.specifier, }, isComponent: dep.isComponent, + isHostless: dep.isHostless, isStructural: dep.isStructural, selector: dep.selector, ngModule, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts index f8231eb83aa9..905ac7b2df29 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_adapter.ts @@ -6,46 +6,45 @@ * found in the LICENSE file at https://angular.dev/license */ -import {TypeCheckBlockMetadata, TypeCheckableDirectiveMeta} from '../api'; -import {Environment} from './environment'; -import { - ImportFlags, - ReferenceEmitKind, - Reference, - ReferenceEmitter, - assertSuccessfulReferenceEmit, -} from '../../imports'; -import {ImportManager, translateType} from '../../translator'; import { AbsoluteSourceSpan, - ExternalExpr, - ExpressionType, - TransplantedType, BoundTarget, - ReferenceTarget, - TmplAstElement, - TmplAstTemplate, - WrappedNodeExpr, ClassPropertyMapping, ConflictingHostDirectiveBinding, - TcbGenericContextBehavior, - TcbTypeCheckBlockMetadata, + ExpressionType, + ExternalExpr, + ReferenceTarget, + TcbComponentMetadata, TcbDirectiveMetadata, + TcbGenericContextBehavior, + TcbInputMapping, TcbPipeMetadata, - TcbTypeParameter, - TcbReferenceMetadata, TcbReferenceKey, - TcbComponentMetadata, - TcbInputMapping, + TcbReferenceMetadata, + TcbTypeCheckBlockMetadata, + TcbTypeParameter, + TmplAstElement, + TmplAstTemplate, + TransplantedType, + WrappedNodeExpr, } from '@angular/compiler'; +import ts from 'typescript'; +import {absoluteFromSourceFile} from '../../file_system'; +import { + ImportFlags, + Reference, + ReferenceEmitKind, + assertSuccessfulReferenceEmit, +} from '../../imports'; import {InputMapping} from '../../metadata'; -import {requiresInlineTypeCtor} from './type_constructor'; +import {ClassDeclaration, ReflectionHost} from '../../reflection'; +import {translateType} from '../../translator'; +import {TypeCheckBlockMetadata, TypeCheckableDirectiveMeta} from '../api'; +import {Environment} from './environment'; import {tempPrint} from './tcb_print'; import {generateTcbTypeParameters} from './tcb_util'; +import {requiresInlineTypeCtor} from './type_constructor'; import {TypeParameterEmitter} from './type_parameter_emitter'; -import {ClassDeclaration, ReflectionHost} from '../../reflection'; -import ts from 'typescript'; -import {absoluteFromSourceFile} from '../../file_system'; /** * Adapts the compiler's `TypeCheckBlockMetadata` (which includes full TS AST nodes) @@ -100,6 +99,7 @@ export function adaptTypeCheckBlockMetadata( const tcbDir: TcbDirectiveMetadata = { isComponent: dir.isComponent, + isHostless: dir.isHostless, name: dir.name, selector: dir.selector, exportAs: dir.exportAs, @@ -223,8 +223,7 @@ export function adaptTypeCheckBlockMetadata( getDeferBlocks: () => meta.boundTarget.getDeferBlocks(), getConflictingHostDirectiveBindings: (node) => meta.boundTarget.getConflictingHostDirectiveBindings(node) as - | ConflictingHostDirectiveBinding[] - | null, + ConflictingHostDirectiveBinding[] | null, }; const pipes = new Map(); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts index 113e34e67958..81db48eefe53 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts @@ -69,6 +69,7 @@ export interface SymbolDirectiveMeta { getNgModule(): ClassDeclaration | null; matchSource: MatchSource; isComponent: boolean; + isHostless: boolean; selector: string | null; isStructural: boolean; inputs: ClassPropertyMapping; @@ -303,6 +304,7 @@ export class SymbolBuilder { ref, selector: meta.selector, isComponent: meta.isComponent, + isHostless: meta.isHostless, ngModule: meta.getNgModule(), kind: SymbolKind.Directive, isStructural: meta.isStructural, @@ -317,6 +319,7 @@ export class SymbolBuilder { ref, selector: meta.selector, isComponent: meta.isComponent, + isHostless: meta.isHostless, ngModule: meta.getNgModule(), kind: SymbolKind.Directive, isStructural: meta.isStructural, @@ -505,6 +508,7 @@ export class SymbolBuilder { kind: SymbolKind.Directive, tcbLocation: this.getTcbLocationForNode(fieldAccessExpr.expression), isComponent: meta.isComponent, + isHostless: meta.isHostless, isStructural: meta.isStructural, selector: meta.selector, ngModule: meta.getNgModule(), diff --git a/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts index d880b807198d..30926ec9bc55 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts @@ -9,17 +9,20 @@ import { AST, ASTWithSource, + Binary, + BindingType, + CssSelector, ImplicitReceiver, ParsedEventType, PropertyRead, - Binary, RecursiveAstVisitor, + ThisReceiver, TmplAstBoundEvent, + TmplAstElement, TmplAstLetDeclaration, TmplAstNode, TmplAstRecursiveVisitor, TmplAstVariable, - ThisReceiver, } from '@angular/compiler'; import ts from 'typescript'; @@ -28,6 +31,41 @@ import {TemplateDiagnostic, TemplateTypeChecker} from '../../api'; import {isSignalReference} from '../../src/symbol_util'; import {TemplateSemanticsChecker} from '../api/api'; +const NG_SKIP_HYDRATION_ATTR = 'ngSkipHydration'; + +function selectorMatchesAttribute( + selectorStr: string | null, + attrName: string, + attrValue?: string, +): boolean { + if (!selectorStr) { + return false; + } + try { + const selectors = CssSelector.parse(selectorStr); + for (const sel of selectors) { + if (attrName === 'class' && sel.classNames.length > 0) { + if (attrValue) { + const classes = attrValue.split(/\s+/); + if (sel.classNames.some((c) => classes.includes(c))) { + return true; + } + } else { + return true; + } + } + for (let i = 0; i < sel.attrs.length; i += 2) { + if (sel.attrs[i] === attrName) { + return true; + } + } + } + } catch { + return false; + } + return false; +} + export class TemplateSemanticsCheckerImpl implements TemplateSemanticsChecker { constructor(private templateTypeChecker: TemplateTypeChecker) {} @@ -41,10 +79,6 @@ export class TemplateSemanticsCheckerImpl implements TemplateSemanticsChecker { /** Visitor that verifies the semantics of a template. */ class TemplateSemanticsVisitor extends TmplAstRecursiveVisitor { - private constructor(private expressionVisitor: ExpressionsSemanticsVisitor) { - super(); - } - static visit( nodes: TmplAstNode[], component: ts.ClassDeclaration, @@ -56,11 +90,87 @@ class TemplateSemanticsVisitor extends TmplAstRecursiveVisitor { component, diagnostics, ); - const templateVisitor = new TemplateSemanticsVisitor(expressionVisitor); + const templateVisitor = new TemplateSemanticsVisitor( + expressionVisitor, + templateTypeChecker, + component, + diagnostics, + ); nodes.forEach((node) => node.visit(templateVisitor)); return diagnostics; } + private constructor( + private expressionVisitor: ExpressionsSemanticsVisitor, + private templateTypeChecker: TemplateTypeChecker, + private component: ts.ClassDeclaration, + private diagnostics: TemplateDiagnostic[], + ) { + super(); + } + + override visitElement(element: TmplAstElement) { + super.visitElement(element); + + const directives = this.templateTypeChecker.getDirectivesOfNode(this.component, element); + const hostlessComponent = directives?.find((dir) => dir.isComponent && dir.isHostless); + + if (hostlessComponent !== undefined) { + for (const input of element.inputs) { + const isInputClaimed = directives?.some((dir) => + dir.inputs.hasBindingPropertyName(input.name), + ); + if ( + input.type === BindingType.Attribute || + input.type === BindingType.Class || + input.type === BindingType.Style || + input.type === BindingType.Animation + ) { + if (!isInputClaimed) { + this.reportHostlessBindingError(input); + } + } else if (input.type === BindingType.Property) { + if (!isInputClaimed) { + this.reportHostlessBindingError(input); + } + } + } + for (const output of element.outputs) { + const isOutputClaimed = directives?.some((dir) => + dir.outputs.hasBindingPropertyName(output.name), + ); + if (!isOutputClaimed) { + this.reportHostlessBindingError(output); + } + } + for (const attribute of element.attributes) { + if (attribute.name === NG_SKIP_HYDRATION_ATTR) continue; + const isClaimed = directives?.some((dir) => { + if (dir.inputs.hasBindingPropertyName(attribute.name)) return true; + if (selectorMatchesAttribute(dir.selector, attribute.name, attribute.value)) { + return true; + } + return false; + }); + if (!isClaimed) { + this.reportHostlessBindingError(attribute); + } + } + } + } + + private reportHostlessBindingError(node: TmplAstNode) { + this.diagnostics.push( + this.templateTypeChecker.makeTemplateDiagnostic( + this.component, + node.sourceSpan, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.HOSTLESS_COMPONENT_UNSUPPORTED_BINDING), + 'Hostless components cannot have DOM bindings.', + ), + ); + } + override visitBoundEvent(event: TmplAstBoundEvent): void { super.visitBoundEvent(event); event.handler.visit(this.expressionVisitor, event); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts index 8d160ccb7352..062f7e083560 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts @@ -933,6 +933,7 @@ function getDirectiveMetaFromDeclaration( queries: decl.queries || [], isStructural: false, isStandalone: !!decl.isStandalone, + isHostless: decl.isComponent ? decl.isHostless === true : false, isSignal: !!decl.isSignal, baseClass: null, animationTriggerNames: null, @@ -977,6 +978,7 @@ function makeScope(program: ts.Program, sf: ts.SourceFile, decls: TestDeclaratio baseClass: null, name: decl.name, selector: decl.selector, + isHostless: decl.isComponent ? decl.isHostless === true : false, queries: [], inputs: ClassPropertyMapping.fromMappedObject(decl.inputs || {}), outputs: ClassPropertyMapping.fromMappedObject(decl.outputs || {}), diff --git a/packages/compiler-cli/test/ngtsc/hostless_components_spec.ts b/packages/compiler-cli/test/ngtsc/hostless_components_spec.ts new file mode 100644 index 000000000000..3b2ab3f36ac2 --- /dev/null +++ b/packages/compiler-cli/test/ngtsc/hostless_components_spec.ts @@ -0,0 +1,669 @@ +/** + * @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 ts from 'typescript'; +import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing'; +import {loadStandardTestFiles} from '../../src/ngtsc/testing'; + +import {NgtscTestEnvironment} from './env'; + +const testFiles = loadStandardTestFiles(); + +runInEachFileSystem(() => { + describe('hostless components', () => { + let env!: NgtscTestEnvironment; + + beforeEach(() => { + env = NgtscTestEnvironment.setup(testFiles); + env.tsconfig(); + }); + + it('should throw an error if a hostless component has @HostBinding', () => { + env.write( + 'test.ts', + ` + import {Component, HostBinding} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp { + @HostBinding('class.active') isActive = true; + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain('Hostless components cannot have host bindings'); + }); + + it('should throw an error if a hostless component has @HostListener', () => { + env.write( + 'test.ts', + ` + import {Component, HostListener} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp { + @HostListener('click') + onClick() {} + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain('Hostless components cannot have host bindings'); + }); + + it('should throw an error if a hostless component has a host: {} block', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + host: { + 'class': 'my-class' + } + }) + export class TestCmp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain('Hostless components cannot have host bindings'); + }); + it('should throw an error if a hostless component uses ShadowDom encapsulation', () => { + env.write( + 'test.ts', + ` + import {Component, ViewEncapsulation} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + encapsulation: ViewEncapsulation.ShadowDom + }) + export class TestCmp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain( + 'Hostless components cannot use Shadow DOM encapsulation', + ); + }); + + it('should throw an error if a hostless component has animations', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + animations: [ + // Dummy animation to trigger the compiler check + { type: 0 } as any + ] + }) + export class TestCmp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain('Hostless components cannot have animations'); + }); + + it('should emit a warning if a hostless component has :host in its styles', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + styles: [' :host { display: block; } '] + }) + export class TestCmp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); + expect(diags[0].messageText).toContain( + 'Hostless components cannot use :host or :host-context in their styles', + ); + }); + + it('should emit a warning if a hostless component has :host-context in its styles', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + styles: [' :host-context(.dark-theme) { color: white; } '] + }) + export class TestCmp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); + expect(diags[0].messageText).toContain( + 'Hostless components cannot use :host or :host-context in their styles', + ); + }); + + it('should throw an error if a hostless component has a class or style binding on its usage', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp {} + + @Component({ + selector: 'app', + template: '', + imports: [TestCmp], + }) + export class App {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + expect(diags[0].messageText).toContain('Hostless components cannot have DOM bindings'); + expect(diags[1].messageText).toContain('Hostless components cannot have DOM bindings'); + }); + + it('should throw an error if style or class attributes are used', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp {} + + @Component({ + selector: 'app', + template: '', + imports: [TestCmp], + }) + export class App {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + expect(diags[0].messageText).toContain('Hostless components cannot have DOM bindings'); + expect(diags[1].messageText).toContain('Hostless components cannot have DOM bindings'); + }); + + it('should allow ngSkipHydration on the hostless component', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp {} + + @Component({ + selector: 'app', + template: '', + imports: [TestCmp], + }) + export class App {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should allow binding on directive inputs applied to hostless element', () => { + env.write( + 'test.ts', + ` + import {Component, Directive, Input} from '@angular/core'; + + @Directive({ + selector: '[my-dir]', + standalone: true, + }) + export class MyDir { + @Input() myInput: string = ''; + } + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp {} + + @Component({ + selector: 'app', + template: \` + + + \`, + imports: [TestCmp, MyDir], + }) + export class App {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should reject attributes that are only substrings of directive selectors', () => { + env.write( + 'test.ts', + ` + import {Component, Directive} from '@angular/core'; + + @Directive({ + selector: '[my-box]', + standalone: true, + }) + export class MyBoxDir {} + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp {} + + @Component({ + selector: 'app', + template: \` + + \`, + imports: [TestCmp, MyBoxDir], + }) + export class App {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain('Hostless components cannot have DOM bindings'); + }); + + it('should allow compound and multiple attribute selectors on hostless components', () => { + env.write( + 'test.ts', + ` + import {Component, Directive} from '@angular/core'; + + @Directive({ + selector: '[dirA][dirB]', + standalone: true, + }) + export class MultiAttrDir {} + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp {} + + @Component({ + selector: 'app', + template: \` + + \`, + imports: [TestCmp, MultiAttrDir], + }) + export class App {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should emit IsHostless in .d.ts declaration', () => { + env.write( + 'test.ts', + ` + import {Component, signal} from '@angular/core'; + + @Component({ + selector: 'hostless-cmp', + template: '
', + hostless: true, + }) + export class HostlessCmp {} + + @Component({ + selector: 'hostless-signal-cmp', + template: '
', + hostless: true, + }) + export class HostlessSignalCmp { + mySignal = signal(0); + } + + @Component({ + selector: 'regular-cmp', + template: '
', + }) + export class RegularCmp {} + `, + ); + + env.driveMain(); + const dtsCode = env.getContents('test.d.ts'); + expect(dtsCode).toContain( + 'static ɵcmp: i0.ɵɵComponentDeclaration;', + ); + expect(dtsCode).toContain( + 'static ɵcmp: i0.ɵɵComponentDeclaration;', + ); + }); + + it('should support host directives with aliased inputs and outputs on hostless components', () => { + env.write( + 'test.ts', + ` + import {Component, Directive, EventEmitter, Input, Output} from '@angular/core'; + + @Directive({ + standalone: true, + }) + export class MyDir { + @Input() dirIn = ''; + @Output() dirOut = new EventEmitter(); + } + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + hostDirectives: [ + { + directive: MyDir, + inputs: ['dirIn: customIn'], + outputs: ['dirOut: customOut'], + }, + ], + }) + export class TestCmp {} + + @Component({ + selector: 'app', + template: \` + + \`, + imports: [TestCmp], + }) + export class App { + onOut(event: string) {} + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should support signal inputs, required inputs, outputs, and models on hostless components', () => { + env.write( + 'test.ts', + ` + import {Component, input, model, output, signal} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp { + sigIn = input('default'); + reqIn = input.required(); + sigModel = model(false); + sigOut = output(); + } + + @Component({ + selector: 'app', + template: \` + + \`, + imports: [TestCmp], + }) + export class App { + flag = signal(true); + onOut(val: string) {} + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should emit hostless in partial declaration under local compilation mode', () => { + env.tsconfig({ + compilationMode: 'experimental-local', + }); + + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'hostless-cmp', + template: '
', + hostless: true, + }) + export class HostlessCmp {} + `, + ); + + env.driveMain(); + const jsCode = env.getContents('test.js'); + expect(jsCode).toContain('hostless: true'); + }); + + it('should throw an error if an event listener is bound without a matching directive output', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp {} + + @Component({ + selector: 'app', + template: '', + imports: [TestCmp], + }) + export class App { + onClick() {} + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain('Hostless components cannot have DOM bindings'); + }); + + it('should allow event listener if matching a directive output on hostless component', () => { + env.write( + 'test.ts', + ` + import {Component, Directive, EventEmitter, Output} from '@angular/core'; + + @Directive({ + selector: '[my-emitter]', + standalone: true, + }) + export class MyEmitterDir { + @Output() myCustomEvent = new EventEmitter(); + } + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp {} + + @Component({ + selector: 'app', + template: '', + imports: [TestCmp, MyEmitterDir], + }) + export class App { + onCustom() {} + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should throw an error if an unknown property is bound to hostless component', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp {} + + @Component({ + selector: 'app', + template: '', + imports: [TestCmp], + }) + export class App {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain('Hostless components cannot have DOM bindings'); + }); + + it('should not warn when CSS class name or variable contains substring :host', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + styles: [ + '.host-card { color: red; }', + ':root { --host-bg-color: blue; }', + '/* comment mentioning :host */', + ], + }) + export class TestCmp {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + + it('should allow class-based directive selectors on hostless components', () => { + env.write( + 'test.ts', + ` + import {Component, Directive} from '@angular/core'; + + @Directive({ + selector: '.my-class-dir', + standalone: true, + }) + export class MyClassDir {} + + @Component({ + selector: 'test-cmp', + template: '
', + hostless: true, + }) + export class TestCmp {} + + @Component({ + selector: 'app', + template: '', + imports: [TestCmp, MyClassDir], + }) + export class App {} + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + }); +}); diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index c2e2d10447bc..9a5d60b97c9d 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -9335,5 +9335,93 @@ suppress expect(diags.length).toBe(0); }); }); + + describe('hostless components', () => { + it('should report an error when a DOM binding is applied to a hostless component', () => { + env.tsconfig({strictTemplates: true}); + env.write( + 'test.ts', + ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + selector: 'my-hostless-comp', + template: '', + hostless: true, + standalone: false, + }) + export class MyHostlessComp {} + + @Component({ + selector: 'test', + standalone: false, + template: \` + + + + + + \`, + }) + export class TestCmp { + id = 'my-id'; + color = 'red'; + onClick() {} + } + + @NgModule({ + declarations: [MyHostlessComp, TestCmp], + }) + export class TestModule {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(5); + expect(diags[0].messageText).toContain('Hostless components cannot have DOM bindings.'); + expect(diags[1].messageText).toContain('Hostless components cannot have DOM bindings.'); + expect(diags[2].messageText).toContain('Hostless components cannot have DOM bindings.'); + expect(diags[3].messageText).toContain('Hostless components cannot have DOM bindings.'); + expect(diags[4].messageText).toContain('Hostless components cannot have DOM bindings.'); + }); + + it('should not report an error when an input binding or output binding is applied to a hostless component', () => { + env.tsconfig({strictTemplates: true}); + env.write( + 'test.ts', + ` + import {Component, Input, Output, EventEmitter, NgModule} from '@angular/core'; + + @Component({ + selector: 'my-hostless-comp', + template: '', + standalone: false, + hostless: true, + }) + export class MyHostlessComp { + @Input() myInput: string; + @Output() myOutput = new EventEmitter(); + } + + @Component({ + selector: 'test', + standalone: false, + template: \` + + \`, + }) + export class TestCmp { + onOutput() {} + } + + @NgModule({ + declarations: [MyHostlessComp, TestCmp], + }) + export class TestModule {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + }); }); }); diff --git a/packages/compiler/src/compiler_facade_interface.ts b/packages/compiler/src/compiler_facade_interface.ts index 81c75e56c024..43b1f262f562 100644 --- a/packages/compiler/src/compiler_facade_interface.ts +++ b/packages/compiler/src/compiler_facade_interface.ts @@ -237,13 +237,13 @@ export interface R3ComponentMetadataFacade extends R3DirectiveMetadataFacade { viewProviders: Provider[] | null; changeDetection?: ChangeDetectionStrategy; hasDirectiveDependencies: boolean; + isHostless?: boolean; } // TODO(legacy-partial-output-inputs): Remove in v18. // https://github.com/angular/angular/blob/d4b423690210872b5c32a322a6090beda30b05a3/packages/core/src/compiler/compiler_facade_interface.ts#L197-L199 export type LegacyInputPartialMapping = - | string - | [bindingPropertyName: string, classPropertyName: string, transformFunction?: Function]; + string | [bindingPropertyName: string, classPropertyName: string, transformFunction?: Function]; export interface R3DeclareDirectiveFacade { selector?: string; @@ -300,6 +300,7 @@ export interface R3DeclareComponentFacade extends R3DeclareDirectiveFacade { changeDetection?: ChangeDetectionStrategy; encapsulation?: ViewEncapsulation; preserveWhitespaces?: boolean; + isHostless?: boolean; } export type R3DeclareTemplateDependencyFacade = { diff --git a/packages/compiler/src/jit_compiler_facade.ts b/packages/compiler/src/jit_compiler_facade.ts index b87dc274749f..6088a38e2eec 100644 --- a/packages/compiler/src/jit_compiler_facade.ts +++ b/packages/compiler/src/jit_compiler_facade.ts @@ -359,6 +359,7 @@ export class CompilerFacadeImpl implements CompilerFacade { animations: facade.animations != null ? new WrappedNodeExpr(facade.animations) : null, viewProviders: facade.viewProviders != null ? new WrappedNodeExpr(facade.viewProviders) : null, + isHostless: facade.isHostless ?? false, relativeContextFilePath: '', i18nUseExternalIds: true, relativeTemplatePath: null, @@ -717,6 +718,7 @@ function convertDeclareComponentFacadeToMetadata( defer, changeDetection: decl.changeDetection ?? ChangeDetectionStrategy.OnPush, encapsulation: decl.encapsulation ?? ViewEncapsulation.Emulated, + isHostless: decl.isHostless ?? false, declarationListEmitMode: DeclarationListEmitMode.ClosureResolved, relativeContextFilePath: '', i18nUseExternalIds: true, diff --git a/packages/compiler/src/render3/partial/api.ts b/packages/compiler/src/render3/partial/api.ts index 003e39e657fc..534ad2b8d0ee 100644 --- a/packages/compiler/src/render3/partial/api.ts +++ b/packages/compiler/src/render3/partial/api.ts @@ -236,6 +236,7 @@ export interface R3DeclareComponentMetadata extends R3DeclareDirectiveMetadata { * Whether whitespace in the template should be preserved. Defaults to false. */ preserveWhitespaces?: boolean; + isHostless?: boolean; } export type R3DeclareTemplateDependencyMetadata = diff --git a/packages/compiler/src/render3/partial/component.ts b/packages/compiler/src/render3/partial/component.ts index c3f657375d4a..95ab56beff1e 100644 --- a/packages/compiler/src/render3/partial/component.ts +++ b/packages/compiler/src/render3/partial/component.ts @@ -129,6 +129,10 @@ function createComponentDefinitionMap( definitionMap.set('preserveWhitespaces', o.literal(true)); } + if (meta.isHostless === true) { + definitionMap.set('isHostless', o.literal(true)); + } + if (meta.defer.mode === DeferBlockDepsEmitMode.PerBlock) { const resolvers: o.Expression[] = []; let hasResolvers = false; diff --git a/packages/compiler/src/render3/view/api.ts b/packages/compiler/src/render3/view/api.ts index d161e8b8366f..8cbfd3514991 100644 --- a/packages/compiler/src/render3/view/api.ts +++ b/packages/compiler/src/render3/view/api.ts @@ -288,6 +288,11 @@ export interface R3ComponentMetadata< */ changeDetection: ChangeDetectionStrategy | o.Expression | null; + /** + * Whether the component is hostless. + */ + isHostless: boolean; + /** * Relative path to the component's template from the root of the project. * Used to generate debugging information. @@ -364,9 +369,7 @@ export interface R3TemplateDependency { * A dependency that's used within a component template */ export type R3TemplateDependencyMetadata = - | R3DirectiveDependencyMetadata - | R3PipeDependencyMetadata - | R3NgModuleDependencyMetadata; + R3DirectiveDependencyMetadata | R3PipeDependencyMetadata | R3NgModuleDependencyMetadata; /** * Information about a directive that is used in a component template. Only the stable, public diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 5b2b2bc0b485..225a4c505dab 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -180,6 +180,18 @@ export function compileComponentFromMetadata( constantPool: ConstantPool, bindingParser: BindingParser, ): R3CompiledExpression { + if (meta.isHostless) { + const hasBindings = + Object.keys(meta.host.attributes).length > 0 || + Object.keys(meta.host.listeners).length > 0 || + Object.keys(meta.host.properties).length > 0 || + meta.host.specialAttributes.styleAttr || + meta.host.specialAttributes.classAttr; + if (hasBindings) { + throw new Error('Hostless components cannot have host bindings.'); + } + } + const definitionMap = baseDirectiveFields(meta, constantPool, bindingParser); addFeatures(definitionMap, meta); @@ -320,6 +332,10 @@ export function compileComponentFromMetadata( } } + if (meta.isHostless) { + definitionMap.set('hostless', o.literal(true)); + } + const expression = o .importExpr(R3.defineComponent) .callFn([definitionMap.toLiteralMap()], undefined, true); @@ -340,9 +356,12 @@ export function createComponentType(meta: R3ComponentMetadata = - | {directive: DirectiveT; node: Exclude} - | Element - | Template; + {directive: DirectiveT; node: Exclude} | Element | Template; /** Entity that is local to the template and defined within the template. */ export type TemplateEntity = Reference | Variable | LetDeclaration; @@ -128,6 +126,11 @@ export interface DirectiveMeta { */ isComponent: boolean; + /** + * Whether the component is hostless. + */ + isHostless: boolean; + /** * Set of inputs which this directive claims. * diff --git a/packages/compiler/src/typecheck/api.ts b/packages/compiler/src/typecheck/api.ts index 3c3e51f0e1cc..68bb877a4942 100644 --- a/packages/compiler/src/typecheck/api.ts +++ b/packages/compiler/src/typecheck/api.ts @@ -105,6 +105,7 @@ export interface TcbDirectiveMetadata { name: string; selector: string | null; isComponent: boolean; + isHostless: boolean; isGeneric: boolean; isStructural: boolean; isStandalone: boolean; diff --git a/packages/compiler/test/render3/view/binding_spec.ts b/packages/compiler/test/render3/view/binding_spec.ts index 91105436eb5f..2863e4c09537 100644 --- a/packages/compiler/test/render3/view/binding_spec.ts +++ b/packages/compiler/test/render3/view/binding_spec.ts @@ -37,6 +37,7 @@ function makeDirectiveMeta(config: { inputs: ClassPropertyMapping.fromMappedObject(config.inputs || {}), outputs: ClassPropertyMapping.fromMappedObject(config.outputs || {}), isComponent: !!config.isComponent, + isHostless: false, isStructural: !!config.isStructural, selector: config.selector, animationTriggerNames: null, diff --git a/packages/core/src/compiler/compiler_facade_interface.ts b/packages/core/src/compiler/compiler_facade_interface.ts index 81c75e56c024..43b1f262f562 100644 --- a/packages/core/src/compiler/compiler_facade_interface.ts +++ b/packages/core/src/compiler/compiler_facade_interface.ts @@ -237,13 +237,13 @@ export interface R3ComponentMetadataFacade extends R3DirectiveMetadataFacade { viewProviders: Provider[] | null; changeDetection?: ChangeDetectionStrategy; hasDirectiveDependencies: boolean; + isHostless?: boolean; } // TODO(legacy-partial-output-inputs): Remove in v18. // https://github.com/angular/angular/blob/d4b423690210872b5c32a322a6090beda30b05a3/packages/core/src/compiler/compiler_facade_interface.ts#L197-L199 export type LegacyInputPartialMapping = - | string - | [bindingPropertyName: string, classPropertyName: string, transformFunction?: Function]; + string | [bindingPropertyName: string, classPropertyName: string, transformFunction?: Function]; export interface R3DeclareDirectiveFacade { selector?: string; @@ -300,6 +300,7 @@ export interface R3DeclareComponentFacade extends R3DeclareDirectiveFacade { changeDetection?: ChangeDetectionStrategy; encapsulation?: ViewEncapsulation; preserveWhitespaces?: boolean; + isHostless?: boolean; } export type R3DeclareTemplateDependencyFacade = { diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index 37bdf0dd4e63..0ebb52f49e2d 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -44,13 +44,13 @@ import { import {unwrapLView, unwrapRNode} from '../render3/util/view_utils'; import {TransferState} from '../transfer_state'; +import {setJSActionAttributes} from '../event_delegation_utils'; import { unsupportedProjectionOfDomNodes, validateMatchingNode, validateNodeExists, } from './error_handling'; import {collectDomEventsInfo} from './event_replay'; -import {setJSActionAttributes} from '../event_delegation_utils'; import { getOrComputeI18nChildren, isI18nHydrationEnabled, @@ -77,7 +77,11 @@ import { TEMPLATES, } from './interfaces'; import {calcPathForNode, isDisconnectedNode} from './node_lookup_utils'; -import {isInSkipHydrationBlock, SKIP_HYDRATION_ATTR_NAME} from './skip_hydration'; +import { + hasSkipHydrationAttrOnTNode, + isInSkipHydrationBlock, + SKIP_HYDRATION_ATTR_NAME, +} from './skip_hydration'; import {EVENT_REPLAY_ENABLED_DEFAULT, IS_EVENT_REPLAY_ENABLED} from './tokens'; import { convertHydrateTriggersToJsAction, @@ -688,7 +692,18 @@ function serializeLView( // Note: Let declarations that return an array are also storing an array in the LView, // we need to exclude them. const targetNode = unwrapRNode(lView[i][HOST]!); - if (!(targetNode as HTMLElement).hasAttribute(SKIP_HYDRATION_ATTR_NAME)) { + + let skipHydration = hasSkipHydrationAttrOnTNode(tNode); + if (!skipHydration) { + if ((targetNode as Node).nodeType === Node.ELEMENT_NODE) { + skipHydration = (targetNode as HTMLElement).hasAttribute(SKIP_HYDRATION_ATTR_NAME); + } else if ((targetNode as Node).nodeType === Node.COMMENT_NODE) { + skipHydration = + (targetNode as Comment).textContent?.includes(SKIP_HYDRATION_ATTR_NAME) ?? false; + } + } + + if (!skipHydration) { annotateHostElementForHydration( targetNode as RElement, lView[i], @@ -696,6 +711,18 @@ function serializeLView( context, ); } + + // A hostless component also acts as an in its parent's DOM. + if (tNode.type & TNodeType.ElementContainer) { + ngh[ELEMENT_CONTAINERS] ??= {}; + const componentLView = lView[i] as LView; + const componentTView = componentLView[TVIEW]; + ngh[ELEMENT_CONTAINERS][noOffsetIndex] = calcNumRootNodes( + componentTView, + componentLView, + componentTView.firstChild, + ); + } } else { // case if (tNode.type & TNodeType.ElementContainer) { @@ -832,12 +859,24 @@ function annotateHostElementForHydration( // - or uses ShadowDom view encapsulation, since Domino doesn't support // shadow DOM, so we can not guarantee that client and server representations // would exactly match - renderer.setAttribute(element, SKIP_HYDRATION_ATTR_NAME, ''); + if ((element as HTMLElement).nodeType === Node.COMMENT_NODE) { + ngDevMode + ? renderer.setValue(element, `ng-container ${SKIP_HYDRATION_ATTR_NAME}`) + : renderer.setValue(element, SKIP_HYDRATION_ATTR_NAME); + } else { + renderer.setAttribute(element, SKIP_HYDRATION_ATTR_NAME, ''); + } return null; } else { const ngh = serializeLView(lView, parentDeferBlockId, context); const index = context.serializedViewCollection.add(ngh); - renderer.setAttribute(element, NGH_ATTR_NAME, index.toString()); + if ((element as HTMLElement).nodeType === Node.COMMENT_NODE) { + ngDevMode + ? renderer.setValue(element, `ng-container ${NGH_ATTR_NAME}=${index}`) + : renderer.setValue(element, `${NGH_ATTR_NAME}=${index}`); + } else { + renderer.setAttribute(element, NGH_ATTR_NAME, index.toString()); + } return index; } } diff --git a/packages/core/src/hydration/node_lookup_utils.ts b/packages/core/src/hydration/node_lookup_utils.ts index afd81aeb7bc0..ca8602632b2c 100644 --- a/packages/core/src/hydration/node_lookup_utils.ts +++ b/packages/core/src/hydration/node_lookup_utils.ts @@ -244,6 +244,9 @@ function locateRNodeByPath(path: string, lView: LView): RNode { let ref: Element; if (referenceNode === REFERENCE_NODE_HOST) { ref = lView[DECLARATION_COMPONENT_VIEW][HOST] as unknown as Element; + if ((ref as Node).nodeType === Node.COMMENT_NODE) { + ref = (ref as Node).parentElement as Element; + } } else if (referenceNode === REFERENCE_NODE_BODY) { ref = ɵɵresolveBody( lView[DECLARATION_COMPONENT_VIEW][HOST] as RElement & {ownerDocument: Document}, @@ -354,6 +357,9 @@ export function calcPathForNode( // (i.e. not a DOM node), use component host element as a reference node. parentIndex = referenceNodeName = REFERENCE_NODE_HOST; parentRNode = lView[DECLARATION_COMPONENT_VIEW][HOST]!; + if ((parentRNode as Node).nodeType === Node.COMMENT_NODE) { + parentRNode = (parentRNode as Node).parentElement as RNode; + } } else { // Use parent TNode as a reference node. parentIndex = parentTNode.index; diff --git a/packages/core/src/hydration/utils.ts b/packages/core/src/hydration/utils.ts index d9518aff59d0..ef153bd6b441 100644 --- a/packages/core/src/hydration/utils.ts +++ b/packages/core/src/hydration/utils.ts @@ -1,4 +1,4 @@ -/** +/** * @license * Copyright Google LLC All Rights Reserved. * @@ -8,16 +8,24 @@ import {Injector} from '../di/injector'; import type {ViewRef} from '../linker/view_ref'; -import {getComponent} from '../render3/util/discovery_utils'; import {LContainer} from '../render3/interfaces/container'; import {getDocument} from '../render3/interfaces/document'; import {RElement, RNode} from '../render3/interfaces/renderer_dom'; import {isRootView} from '../render3/interfaces/type_checks'; import {HEADER_OFFSET, HYDRATION, LView, TVIEW, TViewType} from '../render3/interfaces/view'; +import {getComponent} from '../render3/util/discovery_utils'; import {makeStateKey, StateKey, TransferState} from '../transfer_state'; import {assertDefined, assertEqual} from '../util/assert'; import type {HydrationContext} from './annotate'; +import {hoverEventNames, interactionEventNames} from '../../primitives/defer/src/triggers'; +import {DeferBlockTrigger, HydrateTriggerDetails} from '../defer/interfaces'; +import {DEHYDRATED_BLOCK_REGISTRY} from '../defer/registry'; +import {formatRuntimeError, RuntimeError, RuntimeErrorCode} from '../errors'; +import {sharedMapFunction} from '../event_delegation_utils'; +import {isDetachedByI18n} from '../i18n/utils'; +import {TNode} from '../render3/interfaces/node'; +import {isInSkipHydrationBlock} from '../render3/state'; import { BlockSummary, CONTAINERS, @@ -34,14 +42,6 @@ import { SerializedView, } from './interfaces'; import {IS_INCREMENTAL_HYDRATION_ENABLED, JSACTION_BLOCK_ELEMENT_MAP} from './tokens'; -import {formatRuntimeError, RuntimeError, RuntimeErrorCode} from '../errors'; -import {DeferBlockTrigger, HydrateTriggerDetails} from '../defer/interfaces'; -import {hoverEventNames, interactionEventNames} from '../../primitives/defer/src/triggers'; -import {DEHYDRATED_BLOCK_REGISTRY} from '../defer/registry'; -import {sharedMapFunction} from '../event_delegation_utils'; -import {isDetachedByI18n} from '../i18n/utils'; -import {isInSkipHydrationBlock} from '../render3/state'; -import {TNode} from '../render3/interfaces/node'; /** * The name of the key used in the TransferState collection, @@ -123,7 +123,23 @@ export function retrieveHydrationInfoImpl( injector: Injector, isRootView = false, ): DehydratedView | null { - let nghAttrValue = rNode.getAttribute(NGH_ATTR_NAME); + let nghAttrValue: string | null = null; + if ((rNode as HTMLElement).nodeType === 8 /* Node.COMMENT_NODE */) { + const match = (rNode as HTMLElement).textContent?.match(/ngh=([a-z0-9|]+)/); + if (match) { + nghAttrValue = match[1]; + } + } else if (typeof (rNode as HTMLElement).getAttribute === 'function') { + nghAttrValue = (rNode as HTMLElement).getAttribute(NGH_ATTR_NAME); + } else { + console.error( + 'rNode does not have getAttribute. nodeType:', + (rNode as any).nodeType, + 'nodeName:', + (rNode as any).nodeName, + ); + } + if (nghAttrValue == null) return null; // For cases when a root component also acts as an anchor node for a ViewContainerRef @@ -187,11 +203,22 @@ export function retrieveHydrationInfoImpl( if (remainingNgh) { // If we have only used one of the ngh ids, store the remaining one // back on this RNode. - rNode.setAttribute(NGH_ATTR_NAME, remainingNgh); + if ((rNode as any).nodeType === Node.COMMENT_NODE) { + (rNode as any).textContent = (rNode as any).textContent?.replace( + /ngh=[a-z0-9|]+/, + `ngh=${remainingNgh}`, + ); + } else { + rNode.setAttribute(NGH_ATTR_NAME, remainingNgh); + } } else { // The `ngh` attribute is cleared from the DOM node now // that the data has been retrieved for all indices. - rNode.removeAttribute(NGH_ATTR_NAME); + if ((rNode as any).nodeType === Node.COMMENT_NODE) { + (rNode as any).textContent = (rNode as any).textContent?.replace(/ ?ngh=[a-z0-9|]+/, ''); + } else { + rNode.removeAttribute(NGH_ATTR_NAME); + } } // Note: don't check whether this node was claimed for hydration, @@ -375,7 +402,10 @@ export function markRNodeAsHavingHydrationMismatch( // The RNode can be a standard HTMLElement (not an Angular component or directive) // The devtools component tree only displays Angular components & directives // Therefore we attach the debug info to the closest component/directive - while (node && !getComponent(node as Element)) { + while ( + node && + ((node as Node).nodeType !== 1 /* Node.ELEMENT_NODE */ || !getComponent(node as Element)) + ) { node = node?.parentNode as RNode; } diff --git a/packages/core/src/metadata/directives.ts b/packages/core/src/metadata/directives.ts index ab1f8cd50b97..042ed6dff238 100644 --- a/packages/core/src/metadata/directives.ts +++ b/packages/core/src/metadata/directives.ts @@ -555,6 +555,14 @@ export interface Component extends Directive { */ changeDetection?: ChangeDetectionStrategy; + /** + * If `true`, the component will not render a host element in the DOM. + * Instead, the component will act as a logical container, and any + * CSS style encapsulation classes will be applied to the top-level + * elements in its template. + */ + hostless?: boolean; + /** * Defines the set of injectable objects that are visible to its view DOM children. * See [example](#injecting-a-class-with-a-view-provider). diff --git a/packages/core/src/render3/collect_native_nodes.ts b/packages/core/src/render3/collect_native_nodes.ts index 97480a567429..c7f1191f306c 100644 --- a/packages/core/src/render3/collect_native_nodes.ts +++ b/packages/core/src/render3/collect_native_nodes.ts @@ -11,7 +11,7 @@ import {icuContainerIterate} from './i18n/i18n_tree_shaking'; import {CONTAINER_HEADER_OFFSET, LContainer, LContainerFlags, NATIVE} from './interfaces/container'; import {TIcuContainerNode, TNode, TNodeType} from './interfaces/node'; import {RNode} from './interfaces/renderer_dom'; -import {isLContainer} from './interfaces/type_checks'; +import {isComponentHost, isLContainer} from './interfaces/type_checks'; import { DECLARATION_COMPONENT_VIEW, FLAGS, @@ -23,7 +23,7 @@ import { } from './interfaces/view'; import {assertTNodeType} from './node_assert'; import {getProjectionNodes} from './node_manipulation'; -import {getLViewParent, unwrapRNode} from './util/view_utils'; +import {getComponentLViewByIndex, getLViewParent, unwrapRNode} from './util/view_utils'; export function collectNativeNodes( tView: TView, @@ -79,14 +79,27 @@ export function collectNativeNodes( // The container's anchor comment node is always physically positioned after any views // rendered inside the container, so we always push it here at the end. result.push(anchor); - } else { + } else if (!(tNode.type & TNodeType.ElementContainer)) { result.push(unwrapRNode(lNode)); } } const tNodeType = tNode.type; if (tNodeType & TNodeType.ElementContainer) { - collectNativeNodes(tView, lView, tNode.child, result); + if (isComponentHost(tNode)) { + const componentLView = getComponentLViewByIndex(tNode.index, lView); + collectNativeNodes( + componentLView[TVIEW], + componentLView, + componentLView[TVIEW].firstChild, + result, + ); + } else { + collectNativeNodes(tView, lView, tNode.child, result); + } + if (lNode !== null && !isLContainer(lNode)) { + result.push(unwrapRNode(lNode)); + } } else if (tNodeType & TNodeType.Icu) { const nextRNode = icuContainerIterate(tNode as TIcuContainerNode, lView); let rNode: RNode | null; diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 7459010045be..144643d799a2 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -16,7 +16,7 @@ import { import {Injector} from '../di/injector'; import {EnvironmentInjector} from '../di/r3_injector'; import {RuntimeError, RuntimeErrorCode} from '../errors'; -import {AbstractType, Type} from '../interface/type'; +import {Type} from '../interface/type'; import {ComponentRef as AbstractComponentRef} from '../linker/component_factory'; import {createElementRef, ElementRef} from '../linker/element_ref'; import {NgModuleRef} from '../linker/ng_module_factory'; @@ -65,7 +65,7 @@ import {retrieveHydrationInfo} from '../hydration/utils'; import {getComponentName} from '../internal/get_closest_component_name'; import {NG_REFLECT_ATTRS_FLAG, NG_REFLECT_ATTRS_FLAG_DEFAULT} from '../ng_reflect'; import {ChainedInjector} from './chained_injector'; -import {createElementNode, setupStaticAttributes} from './dom_node_manipulation'; +import {createCommentNode, createElementNode, setupStaticAttributes} from './dom_node_manipulation'; import {BINDING, Binding, BindingInternal, DirectiveWithBindings} from './dynamic_bindings'; import {getDocument} from './interfaces/document'; import {unregisterLView} from './interfaces/lview_tracking'; @@ -315,9 +315,16 @@ export class ComponentFactory { const rootTView = createRootTView(rootSelectorOrNode, cmpDef, componentBindings, directives); const hostRenderer = environment.rendererFactory.createRenderer(null, cmpDef); + const isHostless = cmpDef.hostless === true && !rootSelectorOrNode; + const hostElement = rootSelectorOrNode ? locateHostElement(hostRenderer, rootSelectorOrNode, cmpDef.encapsulation, rootViewInjector) - : createHostElement(cmpDef, hostRenderer); + : isHostless + ? (createCommentNode( + hostRenderer, + ngDevMode ? `hostless ${getComponentName(cmpDef)}` : '', + ) as RElement) + : createHostElement(cmpDef, hostRenderer); assertNotScriptHostElement(hostElement); const sharedStylesHost = rootViewInjector.get(SHARED_STYLES_HOST, null); @@ -374,7 +381,7 @@ export class ComponentFactory { const hostTNode = directiveHostFirstCreatePass( HEADER_OFFSET, rootLView, - TNodeType.Element, + isHostless ? TNodeType.ElementContainer : TNodeType.Element, TNodeName.DynamicHost, () => rootTView.directiveRegistry, true, diff --git a/packages/core/src/render3/definition.ts b/packages/core/src/render3/definition.ts index ea28776033c5..9094d193d5d3 100644 --- a/packages/core/src/render3/definition.ts +++ b/packages/core/src/render3/definition.ts @@ -215,6 +215,7 @@ interface DirectiveDefinition { } interface ComponentDefinition extends Omit, 'features'> { + hostless?: boolean; /** * The number of nodes, local refs, and pipes in this component template. * @@ -367,6 +368,7 @@ export function ɵɵdefineComponent( schemas: componentDefinition.schemas || null, tView: null, id: '', + hostless: componentDefinition.hostless === true, }; // TODO: Do we still need/want this ? diff --git a/packages/core/src/render3/instructions/element.ts b/packages/core/src/render3/instructions/element.ts index 7d5caf585d3b..44b9f6e7d9f4 100644 --- a/packages/core/src/render3/instructions/element.ts +++ b/packages/core/src/render3/instructions/element.ts @@ -12,13 +12,14 @@ import { validateMatchingNode, validateNodeExists, } from '../../hydration/error_handling'; -import {locateNextRNode} from '../../hydration/node_lookup_utils'; +import {locateNextRNode, siblingAfter} from '../../hydration/node_lookup_utils'; import { hasSkipHydrationAttrOnRElement, hasSkipHydrationAttrOnTNode, } from '../../hydration/skip_hydration'; import { canHydrateNode, + getNgContainerSize, getSerializedContainerViews, markRNodeAsClaimedByHydration, markRNodeAsSkippedByHydration, @@ -27,10 +28,15 @@ import { import {getComponentName} from '../../internal/get_closest_component_name'; import {assertDefined} from '../../util/assert'; import {assertTNodeCreationIndex} from '../assert'; -import {clearElementContents, createElementNode} from '../dom_node_manipulation'; +import { + clearElementContents, + createCommentNode, + createElementNode, + nativeRemoveNode, +} from '../dom_node_manipulation'; import {ComponentDef} from '../interfaces/definition'; import {hasClassInput, hasStyleInput, TElementNode, TNode, TNodeType} from '../interfaces/node'; -import {RElement} from '../interfaces/renderer_dom'; +import {RComment, RElement, RNode} from '../interfaces/renderer_dom'; import {isComponentHost, isDirectiveHost} from '../interfaces/type_checks'; import { ENVIRONMENT, @@ -111,6 +117,17 @@ export function ɵɵelementStart( ) : (tView.data[adjustedIndex] as TElementNode); + let isHostless = false; + if (isComponentHost(tNode)) { + const def = tView.data[tNode.directiveStart + tNode.componentOffset] as ComponentDef<{}>; + if (def.hostless) { + isHostless = true; + if (tView.firstCreatePass) { + tNode.type = TNodeType.ElementContainer; + } + } + } + // If the node is a component host and we have a tracing service, we need to wrap the init logic. if (isComponentHost(tNode)) { const tracingService = lView[ENVIRONMENT].tracingService; @@ -119,13 +136,13 @@ export function ɵɵelementStart( const def = tView.data[tNode.directiveStart + tNode.componentOffset] as ComponentDef<{}>; return tracingService.componentCreate(getComponentName(def), () => { - initializeElement(index, name, lView, tNode, localRefsIndex); + initializeElement(index, name, lView, tNode, localRefsIndex, isHostless); return ɵɵelementStart; }); } } - initializeElement(index, name, lView, tNode, localRefsIndex); + initializeElement(index, name, lView, tNode, localRefsIndex, isHostless); return ɵɵelementStart; } @@ -135,8 +152,15 @@ function initializeElement( lView: LView, tNode: TElementNode, localRefsIndex: number | undefined, + isHostless: boolean = false, ) { - elementLikeStartShared(tNode, lView, index, name, _locateOrCreateElementNode); + elementLikeStartShared( + tNode, + lView, + index, + name, + isHostless ? _locateOrCreateCommentNode : _locateOrCreateElementNode, + ); if (isDirectiveHost(tNode)) { const tView = lView[TVIEW]; @@ -165,7 +189,7 @@ export function ɵɵelementEnd(): typeof ɵɵelementEnd { ngDevMode && assertDefined(initialTNode, 'No parent node to close.'); const currentTNode = elementLikeEndShared(initialTNode); - ngDevMode && assertTNodeType(currentTNode, TNodeType.AnyRNode); + ngDevMode && assertTNodeType(currentTNode, TNodeType.AnyRNode | TNodeType.ElementContainer); if (tView.firstCreatePass) { directiveHostEndFirstCreatePass(tView, currentTNode); @@ -417,4 +441,70 @@ function locateOrCreateElementNodeImpl( export function enableLocateOrCreateElementNodeImpl() { _locateOrCreateElementNode = locateOrCreateElementNodeImpl; + _locateOrCreateCommentNode = locateOrCreateCommentNodeImpl; +} + +let _locateOrCreateCommentNode: typeof locateOrCreateCommentNodeImpl = ( + tView: TView, + lView: LView, + tNode: TNode, + name: string, + index: number, +) => { + lastNodeWasCreated(true); + return createCommentNode(lView[RENDERER], ngDevMode ? `hostless ${name}` : ''); +}; + +function locateOrCreateCommentNodeImpl( + tView: TView, + lView: LView, + tNode: TNode, + name: string, + index: number, +) { + const isNodeCreationMode = !canHydrateNode(lView, tNode); + + lastNodeWasCreated(isNodeCreationMode); + + // Regular creation mode. + if (isNodeCreationMode) { + return createCommentNode(lView[RENDERER], ngDevMode ? `hostless ${name}` : ''); + } + + // Hydration mode, looking up existing elements in DOM. + const hydrationInfo = lView[HYDRATION]!; + const currentRNode = locateNextRNode(hydrationInfo, tView, lView, tNode)!; + + // A hostless component acts as an ElementContainer, meaning `currentRNode` + // is the first child of the component. We need to find the anchor comment node. + const ngContainerSize = getNgContainerSize(hydrationInfo, index) as number; + setSegmentHead(hydrationInfo, index, currentRNode); + const comment = siblingAfter(ngContainerSize, currentRNode)!; + + if (ngDevMode) { + validateMatchingNode(comment, Node.COMMENT_NODE, null, lView, tNode); + markRNodeAsClaimedByHydration(comment); + } + + if (hydrationInfo && hasSkipHydrationAttrOnTNode(tNode)) { + if (isComponentHost(tNode)) { + enterSkipHydrationBlock(tNode); + + // Since this isn't hydratable, we need to empty the container's contents + // so there's no duplicate content after render. + const renderer = lView[RENDERER]; + let nodeToRemove: RNode | null = currentRNode; + while (nodeToRemove && nodeToRemove !== comment) { + const next: RNode | null = (nodeToRemove as Node).nextSibling as RNode | null; + nativeRemoveNode(renderer, nodeToRemove); + nodeToRemove = next; + } + + ngDevMode && markRNodeAsSkippedByHydration(comment); + } else if (ngDevMode) { + throw invalidSkipHydrationHost(comment); + } + } + + return comment; } diff --git a/packages/core/src/render3/instructions/render.ts b/packages/core/src/render3/instructions/render.ts index 9e054e7e9af3..c176b2134cd2 100644 --- a/packages/core/src/render3/instructions/render.ts +++ b/packages/core/src/render3/instructions/render.ts @@ -6,17 +6,21 @@ * found in the LICENSE file at https://angular.dev/license */ -import {retrieveHydrationInfo} from '../../hydration/utils'; +import {getSegmentHead, retrieveHydrationInfo} from '../../hydration/utils'; import {assertEqual, assertNotReactive} from '../../util/assert'; import {RenderFlags} from '../interfaces/definition'; +import {TNode, TNodeType} from '../interfaces/node'; +import {isComponentHost} from '../interfaces/type_checks'; import { CONTEXT, FLAGS, + HEADER_OFFSET, HOST, HYDRATION, INJECTOR, LView, LViewFlags, + PARENT, QUERIES, TVIEW, TView, @@ -28,8 +32,9 @@ import {enterView, leaveView} from '../state'; import {getComponentLViewByIndex, isCreationMode} from '../util/view_utils'; import {executeTemplate} from './shared'; - export function renderComponent(hostLView: LView, componentHostIdx: number) { + const hostTNode = hostLView[TVIEW].data[componentHostIdx] as TNode; + ngDevMode && assertEqual(isCreationMode(hostLView), true, 'Should be run in creation mode'); const componentView = getComponentLViewByIndex(componentHostIdx, hostLView); const componentTView = componentView[TVIEW]; @@ -39,6 +44,12 @@ export function renderComponent(hostLView: LView, componentHostIdx: number) { // Populate an LView with hydration info retrieved from the DOM via TransferState. if (hostRNode !== null && componentView[HYDRATION] === null) { componentView[HYDRATION] = retrieveHydrationInfo(hostRNode, componentView[INJECTOR]); + if (hostTNode && isComponentHost(hostTNode) && hostTNode.type === TNodeType.ElementContainer) { + if (componentView[HYDRATION] && hostLView[HYDRATION]) { + const noOffsetIndex = hostTNode.index - HEADER_OFFSET; + componentView[HYDRATION].firstChild = getSegmentHead(hostLView[HYDRATION], noOffsetIndex); + } + } } profiler(ProfilerEvent.ComponentStart); diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 3ae9b3cd732b..c7b40c2e5bde 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -390,7 +390,7 @@ function instantiateAllDirectives(tView: TView, lView: LView, tNode: TDirectiveH // The component view needs to be created before creating the node injector // since it is used to inject some special symbols like `ChangeDetectorRef`. if (isComponentHost(tNode)) { - ngDevMode && assertTNodeType(tNode, TNodeType.AnyRNode); + ngDevMode && assertTNodeType(tNode, TNodeType.AnyRNode | TNodeType.ElementContainer); createComponentLView( lView, tNode as TElementNode, diff --git a/packages/core/src/render3/interfaces/definition.ts b/packages/core/src/render3/interfaces/definition.ts index d2bc1e7729b2..1d3b625b72c6 100644 --- a/packages/core/src/render3/interfaces/definition.ts +++ b/packages/core/src/render3/interfaces/definition.ts @@ -367,6 +367,9 @@ export interface ComponentDef extends DirectiveDef { /** Whether or not this component is signal-based. */ readonly signals: boolean; + /** Whether the component is hostless and should not render a host element. */ + readonly hostless?: boolean; + /** * Registry of directives and components that may be found in this view. * diff --git a/packages/core/src/render3/interfaces/public_definitions.ts b/packages/core/src/render3/interfaces/public_definitions.ts index d001916d8bd4..77ac52149f12 100644 --- a/packages/core/src/render3/interfaces/public_definitions.ts +++ b/packages/core/src/render3/interfaces/public_definitions.ts @@ -54,6 +54,7 @@ export type ɵɵComponentDeclaration< IsStandalone extends boolean = false, HostDirectives = never, IsSignal extends boolean = false, + IsHostless extends boolean = false, > = unknown; /** diff --git a/packages/core/src/render3/jit/directive.ts b/packages/core/src/render3/jit/directive.ts index a848a98bdf12..579c2da7de1f 100644 --- a/packages/core/src/render3/jit/directive.ts +++ b/packages/core/src/render3/jit/directive.ts @@ -147,6 +147,7 @@ export function compileComponent(type: Type, metadata: Component): void { // * for standalone components, they're set just below, after `compileComponent`. declarations: [], changeDetection: metadata.changeDetection, + isHostless: metadata.hostless === true, encapsulation, viewProviders: metadata.viewProviders || null, // We can't inspect whether any of the dependencies are actually directives, because they diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index e89ea4df97ff..3cc0d9f529fa 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -20,6 +20,9 @@ import { assertNumber, } from '../util/assert'; +import {ProfilerEvent} from '../../primitives/devtools'; +import {cancelLeavingNodes, reusedNodes, trackLeavingNodes} from '../animation/utils'; +import {Injector} from '../di'; import {isDetachedByI18n} from '../i18n/utils'; import { assertLContainer, @@ -57,6 +60,7 @@ import {Renderer} from './interfaces/renderer'; import {RElement, RNode} from './interfaces/renderer_dom'; import {isComponentHost, isDestroyed, isLContainer, isLView} from './interfaces/type_checks'; import { + ANIMATIONS, CHILD_HEAD, CLEANUP, DECLARATION_COMPONENT_VIEW, @@ -68,7 +72,7 @@ import { HookData, HookFn, HOST, - ANIMATIONS, + INJECTOR, LView, LViewFlags, NEXT, @@ -81,16 +85,16 @@ import { TVIEW, TView, TViewType, - INJECTOR, - ID, } from './interfaces/view'; +import {maybeQueueEnterAnimation, runLeaveAnimationsWithCallback} from './node_animations'; import {assertTNodeType} from './node_assert'; import {profiler} from './profiler'; -import {ProfilerEvent} from '../../primitives/devtools'; -import {getLViewParent, getNativeByTNode, unwrapRNode} from './util/view_utils'; -import {cancelLeavingNodes, reusedNodes, trackLeavingNodes} from '../animation/utils'; -import {Injector} from '../di'; -import {maybeQueueEnterAnimation, runLeaveAnimationsWithCallback} from './node_animations'; +import { + getComponentLViewByIndex, + getLViewParent, + getNativeByTNode, + unwrapRNode, +} from './util/view_utils'; export const enum WalkTNodeTreeAction { /** node create in the native environment. Run on initial creation. */ @@ -516,6 +520,7 @@ export function getClosestRElement( // corresponding DOM node at all. while ( parentTNode !== null && + !isComponentHost(parentTNode) && parentTNode.type & (TNodeType.ElementContainer | TNodeType.Icu | TNodeType.LetDeclaration) ) { tNode = parentTNode; @@ -527,9 +532,16 @@ export function getClosestRElement( if (parentTNode === null) { // We are inserting a root element of the component view into the component host element and // it should always be eager. + const hostTNode = lView[T_HOST]; + if (hostTNode && hostTNode.type & TNodeType.ElementContainer) { + // The host is an ElementContainer (hostless component), so we can't append to it. + // Append to its parent node instead. + const renderer = lView[RENDERER]; + return renderer.parentNode(lView[HOST] as RNode); + } return lView[HOST]; } else { - ngDevMode && assertTNodeType(parentTNode, TNodeType.AnyRNode | TNodeType.Container); + ngDevMode && assertTNodeType(parentTNode, TNodeType.AnyRNode | TNodeType.AnyContainer); if (isComponentHost(parentTNode)) { ngDevMode && assertTNodeForLView(parentTNode, lView); const {encapsulation} = tView.data[ @@ -588,6 +600,9 @@ export function getInsertInFrontOfRNodeWithNoI18n( lView: LView, ): RNode | null { if (parentTNode.type & (TNodeType.ElementContainer | TNodeType.Icu)) { + if (parentTNode === lView[T_HOST]) { + return lView[HOST] as RElement; + } return getNativeByTNode(parentTNode, lView); } return null; @@ -690,16 +705,33 @@ export function getFirstNativeNode(lView: LView, tNode: TNode | null): RNode | n } else if (tNodeType & TNodeType.Container) { return getBeforeNodeForView(-1, lView[tNode.index]); } else if (tNodeType & TNodeType.ElementContainer) { - const elIcuContainerChild = tNode.child; - if (elIcuContainerChild !== null) { - return getFirstNativeNode(lView, elIcuContainerChild); - } else { + if (isComponentHost(tNode)) { + const componentLView = getComponentLViewByIndex(tNode.index, lView); + const firstChild = componentLView[TVIEW].firstChild; + if (firstChild !== null) { + const firstNode = getFirstNativeNode(componentLView, firstChild); + if (firstNode !== null) { + return firstNode; + } + } const rNodeOrLContainer = lView[tNode.index]; if (isLContainer(rNodeOrLContainer)) { return getBeforeNodeForView(-1, rNodeOrLContainer); } else { return unwrapRNode(rNodeOrLContainer); } + } else { + const elIcuContainerChild = tNode.child; + if (elIcuContainerChild !== null) { + return getFirstNativeNode(lView, elIcuContainerChild); + } else { + const rNodeOrLContainer = lView[tNode.index]; + if (isLContainer(rNodeOrLContainer)) { + return getBeforeNodeForView(-1, rNodeOrLContainer); + } else { + return unwrapRNode(rNodeOrLContainer); + } + } } } else if (tNodeType & TNodeType.LetDeclaration) { return getFirstNativeNode(lView, tNode.next); @@ -781,8 +813,10 @@ function applyNodes( tNode, TNodeType.AnyRNode | TNodeType.AnyContainer | TNodeType.Projection | TNodeType.Icu, ); + const rawSlotValue = lView[tNode.index]; const tNodeType = tNode.type; + if (isProjection) { if (action === WalkTNodeTreeAction.Create) { rawSlotValue && attachPatchData(unwrapRNode(rawSlotValue), lView); @@ -791,7 +825,20 @@ function applyNodes( } if (!isDetachedByI18n(tNode)) { if (tNodeType & TNodeType.ElementContainer) { - applyNodes(renderer, action, tNode.child, lView, parentRElement, beforeNode, false); + if (isComponentHost(tNode)) { + const componentLView = getComponentLViewByIndex(tNode.index, lView); + applyNodes( + renderer, + action, + componentLView[TVIEW].firstChild, + componentLView, + parentRElement, + beforeNode, + false, + ); + } else { + applyNodes(renderer, action, tNode.child, lView, parentRElement, beforeNode, false); + } applyToElementOrContainer( action, renderer, diff --git a/packages/core/test/acceptance/hostless_components_spec.ts b/packages/core/test/acceptance/hostless_components_spec.ts new file mode 100644 index 000000000000..19c3bf609a4c --- /dev/null +++ b/packages/core/test/acceptance/hostless_components_spec.ts @@ -0,0 +1,1421 @@ +/** + * @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 {NgIf} from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ComponentRef, + DestroyRef, + Directive, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + inject, + Injectable, + Input, + OnChanges, + OnDestroy, + OnInit, + Optional, + Output, + QueryList, + Self, + signal, + SimpleChanges, + ViewChild, + ViewChildren, + ViewContainerRef, +} from '@angular/core'; +import {DeferBlockBehavior, TestBed} from '@angular/core/testing'; +import {isBrowser} from '@angular/private/testing'; +import {provideRouter, Router, RouterOutlet} from '@angular/router'; + +describe('hostless components', () => { + it('should not render a host element', async () => { + @Component({ + selector: 'my-hostless', + hostless: true, + template: ` +
Child 1
+
Child 2
+ `, + }) + class HostlessCmp {} + + @Component({ + template: ` +
Before
+ +
After
+ `, + imports: [HostlessCmp], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const html = fixture.nativeElement.innerHTML; + expect(html).toContain('
Before
'); + expect(html).not.toContain(''); + expect(html).not.toContain('_nghost'); + }); + + it('should handle self-closing hostless components', async () => { + @Component({ + selector: 'my-hostless', + hostless: true, + template: ` +
Child 1
+
Child 2
+ `, + }) + class HostlessCmp {} + + @Component({ + template: ` +
Before
+ +
After
+ `, + imports: [HostlessCmp], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const html = fixture.nativeElement.innerHTML; + expect(html).toContain('
Before
'); + expect(html).toContain('
Child 1
'); + expect(html).toContain('
Child 2
'); + expect(html).toContain('
After
'); + expect(html).not.toContain(' { + @Component({ + selector: 'my-hostless', + hostless: true, + template: ` +
Child 1
+
Child 2
+ `, + styles: ` + div { + color: red; + } + `, + }) + class HostlessCmp {} + + @Component({ + template: `
Before
+ +
After
`, + imports: [HostlessCmp], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + const html = fixture.nativeElement as HTMLElement; + + const divs = Array.from(html.querySelectorAll('div')); + expect(getComputedStyle(divs[0]).color).toBe('rgb(0, 0, 0)'); + expect(getComputedStyle(divs[1]).color).toBe('rgb(255, 0, 0)'); + expect(getComputedStyle(divs[2]).color).toBe('rgb(255, 0, 0)'); + expect(getComputedStyle(divs[3]).color).toBe('rgb(0, 0, 0)'); + }); + + isBrowser && + it('should not inherit styles from parent component', async () => { + @Component({ + selector: 'my-hostless', + hostless: true, + template: `
Child 1
`, + }) + class HostlessCmp {} + + @Component({ + template: `
Before
+ +
After
`, + styles: ` + div { + color: blue; + } + `, + imports: [HostlessCmp], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + const html = fixture.nativeElement as HTMLElement; + + const divs = Array.from(html.querySelectorAll('div')); + expect(getComputedStyle(divs[0]).color).toBe('rgb(0, 0, 255)'); + expect(getComputedStyle(divs[1]).color).toBe('rgb(0, 0, 0)'); + expect(getComputedStyle(divs[2]).color).toBe('rgb(0, 0, 255)'); + }); + + it('should project content correctly inside a hostless component', async () => { + @Component({ + selector: 'my-hostless-proj', + hostless: true, + template: ` +
+ +
+ `, + }) + class HostlessCmp {} + + @Component({ + template: ` + + Projected Content + + `, + imports: [HostlessCmp], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const html = fixture.nativeElement.innerHTML; + expect(html).toContain('Projected Content'); + }); + + describe('View Queries', () => { + it('should query hostless component instance and ElementRef successfully', async () => { + @Component({ + selector: 'my-hostless', + template: '
Hostless Content
', + hostless: true, + }) + class MyHostless {} + + @Component({ + template: '', + imports: [MyHostless], + }) + class App { + @ViewChild('ref1') hostlessChild!: MyHostless; + @ViewChildren(MyHostless) hostlessChildren!: QueryList; + @ViewChild('ref2', {read: ElementRef}) hostlessEl!: ElementRef; + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const app = fixture.componentInstance; + + expect(app.hostlessChild).toBeInstanceOf(MyHostless); + expect(app.hostlessChildren.length).toBe(2); + expect(app.hostlessChildren.first).toBeInstanceOf(MyHostless); + + // Querying for ElementRef should return the Comment node that replaces the host + expect(app.hostlessEl.nativeElement instanceof Comment).toBeTrue(); + expect(app.hostlessEl.nativeElement.textContent).toBe('hostless my-hostless'); + }); + }); + + describe('Dependency Injection', () => { + it('should inject ElementRef pointing to the comment node', async () => { + @Component({ + selector: 'my-hostless-di', + template: '
DI Test
', + hostless: true, + }) + class MyHostlessDi { + constructor(public elementRef: ElementRef) {} + } + + @Component({ + template: '', + imports: [MyHostlessDi], + }) + class App { + @ViewChild(MyHostlessDi) hostless!: MyHostlessDi; + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const app = fixture.componentInstance; + const injectedRef = app.hostless.elementRef; + + expect(injectedRef).toBeDefined(); + expect(injectedRef.nativeElement instanceof Comment).toBeTrue(); + expect(injectedRef.nativeElement.textContent).toBe('hostless my-hostless-di'); + }); + }); + + describe('Directives on Hostless Components', () => { + it('should attach directive to the hostless component and inject ElementRef correctly', async () => { + @Directive({ + selector: '[my-dir]', + }) + class MyDirective { + constructor(public elementRef: ElementRef) {} + } + + @Component({ + selector: 'my-hostless-dir', + template: '
Dir Test
', + hostless: true, + }) + class MyHostlessDir {} + + @Component({ + template: '', + imports: [MyHostlessDir, MyDirective], + }) + class App { + @ViewChild(MyDirective) myDir!: MyDirective; + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const app = fixture.componentInstance; + + // The directive should be instantiated + expect(app.myDir).toBeInstanceOf(MyDirective); + + // ElementRef inside the directive should point to the Comment node + expect(app.myDir.elementRef.nativeElement instanceof Comment).toBeTrue(); + expect(app.myDir.elementRef.nativeElement.textContent).toBe('hostless my-hostless-dir'); + + // We no longer have a @HostBinding here because it would throw an error + const html = fixture.nativeElement.innerHTML; + }); + + it('should attach hostDirectives to the hostless component and inject ElementRef correctly', async () => { + @Directive({ + standalone: true, + }) + class MyHostDirective { + constructor(public elementRef: ElementRef) {} + } + + @Component({ + selector: 'my-hostless-host-dir', + template: '
Host Dir Test
', + hostless: true, + hostDirectives: [MyHostDirective], + }) + class MyHostlessHostDir {} + + @Component({ + template: '', + imports: [MyHostlessHostDir], + }) + class App { + @ViewChild(MyHostDirective) myHostDir!: MyHostDirective; + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const app = fixture.componentInstance; + + // The host directive should be instantiated + expect(app.myHostDir).toBeInstanceOf(MyHostDirective); + + // ElementRef inside the host directive should point to the Comment node + expect(app.myHostDir.elementRef.nativeElement instanceof Comment).toBeTrue(); + expect(app.myHostDir.elementRef.nativeElement.textContent).toBe( + 'hostless my-hostless-host-dir', + ); + }); + }); + + describe('Change Detection', () => { + it('should run change detection normally including OnPush and Signals', async () => { + @Component({ + selector: 'my-onpush', + template: '
OnPush: {{value}}
', + hostless: true, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class MyOnPush { + @Input() value = 'initial'; + + constructor(public cdr: ChangeDetectorRef) {} + + updateValue(newVal: string) { + this.value = newVal; + this.cdr.markForCheck(); + } + } + + @Component({ + selector: 'my-signal', + template: '
Signal: {{sig()}}
', + hostless: true, + }) + class MySignal { + sig = signal('initial'); + } + + @Component({ + template: '', + imports: [MyOnPush, MySignal], + }) + class App { + appValue = signal('app-initial'); + @ViewChild(MyOnPush) myOnPush!: MyOnPush; + @ViewChild(MySignal) mySignal!: MySignal; + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toContain('OnPush: app-initial'); + expect(fixture.nativeElement.textContent).toContain('Signal: initial'); + + const app = fixture.componentInstance; + + // Update app value and re-render + app.appValue.set('app-updated'); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('OnPush: app-updated'); + + // Update OnPush component internally and markForCheck + app.myOnPush.updateValue('internal-updated'); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('OnPush: internal-updated'); + + // Update Signal + app.mySignal.sig.set('signal-updated'); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('Signal: signal-updated'); + }); + }); + + it('should throw a compiler error when host bindings are present on a hostless component', () => { + @Component({ + selector: 'my-hostless-binding', + template: '
Hostless Content
', + hostless: true, + }) + class MyHostlessBinding { + @HostBinding('class.active') isActive = true; + @HostBinding('attr.role') role = 'presentation'; + } + + @Component({ + template: '', + imports: [MyHostlessBinding], + }) + class App {} + + expect(() => TestBed.createComponent(App)).toThrowError( + 'Hostless components cannot have host bindings.', + ); + }); + + it('should throw a compiler error when host listeners are present on a hostless component', () => { + @Component({ + selector: 'my-hostless-listener', + template: '
Hostless Content
', + hostless: true, + }) + class MyHostlessListener { + @HostListener('click') + onClick() {} + } + + @Component({ + template: '', + imports: [MyHostlessListener], + }) + class App {} + + expect(() => TestBed.createComponent(App)).toThrowError( + 'Hostless components cannot have host bindings.', + ); + }); + + it('should project content with select correctly on hostless component', async () => { + @Component({ + selector: 'my-hostless-proj', + template: ` +
+
+ `, + hostless: true, + }) + class MyHostlessProj {} + + @Component({ + template: ` + + A + B + + `, + imports: [MyHostlessProj], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const html = fixture.nativeElement.innerHTML; + expect(html).toContain('
A
'); + expect(html).toContain('
B
'); + }); + + it('should allow to listen to output', () => { + @Component({ + selector: 'my-hostless', + template: '
Hostless Content
', + hostless: true, + }) + class MyHostless { + @Output() testOutput = new EventEmitter(); + } + + @Component({ + template: '', + imports: [MyHostless], + }) + class App { + @ViewChild(MyHostless) hostless!: MyHostless; + onTestOutput() {} + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + const app = fixture.componentInstance; + spyOn(app, 'onTestOutput'); + app.hostless.testOutput.emit(); + + expect(app.onTestOutput).toHaveBeenCalled(); + }); + + it('should create a virtual host for tests', () => { + @Component({ + hostless: true, + template: '
hello world
hello world
', + }) + class MyHostless {} + + const fixture = TestBed.createComponent(MyHostless); + expect(fixture.debugElement).toBeTruthy(); + expect(fixture.nativeElement).toBeTruthy(); + // The virtual host + expect(fixture.nativeElement.tagName).toBe('DIV'); + expect(fixture.nativeElement.id).toBe('root-hostless'); + // The actual content of our hostless component + expect(fixture.nativeElement.innerHTML).toContain( + '
hello world
hello world
', + ); + }); + + it('should work fine with structural directives', async () => { + @Component({ + template: '
', + imports: [MyHostless, NgIf], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + expect(fixture.nativeElement.innerHTML).toContain( + '
Hostless Content
', + ); + }); + + it('should correctly insert hostless components dynamically created via ViewContainerRef', async () => { + let compRef!: ComponentRef; + @Component({ + template: '
', + }) + class App { + @ViewChild('container', {read: ViewContainerRef}) vcr!: ViewContainerRef; + ngAfterViewInit() { + compRef = this.vcr.createComponent(MyHostless); + } + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + expect(fixture.nativeElement.innerHTML).toContain( + '
Hostless Content
', + ); + + expect(compRef.location.nativeElement.nodeType).toBe(Node.COMMENT_NODE); + }); + + it('should be supported by the router outlet', async () => { + @Component({ + selector: 'my-hostless', + hostless: true, + template: '
Hostless Content
', + }) + class MyHostless {} + + @Component({ + imports: [RouterOutlet], + template: '', + }) + class App {} + + TestBed.configureTestingModule({ + providers: [provideRouter([{path: '', component: MyHostless}])], + }); + + const fixture = TestBed.createComponent(App); + const router = TestBed.inject(Router); + await router.navigateByUrl('/'); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toContain( + '
Hostless Content
', + ); + }); + + it('should work smealessly with the SVG namespace', async () => { + @Component({ + selector: 'my-hostless-svg', + hostless: true, + template: '', + }) + class MyHostlessSvg {} + + @Component({ + template: '', + imports: [MyHostlessSvg], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + expect(fixture.nativeElement.innerHTML).toContain( + '', + ); + }); + + it('should render nested svg hostless components', async () => { + @Component({ + selector: 'my-hostless-svg', + hostless: true, + template: '', + }) + class MyHostlessSvg {} + + @Component({ + selector: 'my-svg-g', + hostless: true, + template: ``, + imports: [MyHostlessSvg], + }) + class MySvgGroup {} + + @Component({ + template: '', + imports: [MySvgGroup], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + expect(fixture.nativeElement.innerHTML).toContain( + ` + + + + + `.replaceAll(/\s{2,}/g, ''), + ); + }); + + it('should support directives on hostless component', async () => { + @Directive({ + selector: '[testDir]', + }) + class TestDir { + @Input('testDir') val!: string; + } + + @Component({ + selector: 'my-hostless-dir', + template: '
Hostless Content
', + hostless: true, + }) + class MyHostlessDir { + constructor(@Self() @Optional() public testDir: TestDir) {} + } + + @Component({ + template: '', + imports: [MyHostlessDir, TestDir], + }) + class App { + @ViewChild(MyHostlessDir) hostlessDir!: MyHostlessDir; + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + expect(fixture.componentInstance.hostlessDir.testDir).toBeTruthy(); + expect(fixture.componentInstance.hostlessDir.testDir.val).toBe('1'); + }); + + isBrowser && + it('should apply styling when top level nodes are wrapped by @blocks', async () => { + @Component({ + selector: 'my-hostless-blocks', + template: '@if(true) {
Hostless Content
}', + hostless: true, + styles: ` + div { + color: red; + } + `, + }) + class MyHostlessBlocks {} + + @Component({ + template: '', + imports: [MyHostlessBlocks], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + const div = fixture.nativeElement.querySelector('div'); + expect(getComputedStyle(div).color).toBe('rgb(255, 0, 0)'); + }); + + describe('Control Flow (@for, @if, @switch)', () => { + it('should support dynamic mutations in @for loops with hostless components', async () => { + @Component({ + selector: 'item-hostless', + template: '{{id}}: {{name}}', + hostless: true, + }) + class ItemHostless { + @Input() id: number = 0; + @Input() name: string = ''; + } + + @Component({ + template: ` +
+ @for (item of items(); track item.id) { + + } +
+ `, + imports: [ItemHostless], + }) + class App { + items = signal([ + {id: 1, name: 'First'}, + {id: 2, name: 'Second'}, + {id: 3, name: 'Third'}, + ]); + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const getTexts = () => + Array.from(fixture.nativeElement.querySelectorAll('.item')).map( + (el: any) => el.textContent, + ); + + expect(getTexts()).toEqual(['1: First', '2: Second', '3: Third']); + + // Reverse list + fixture.componentInstance.items.set([ + {id: 3, name: 'Third'}, + {id: 2, name: 'Second'}, + {id: 1, name: 'First'}, + ]); + await fixture.whenStable(); + expect(getTexts()).toEqual(['3: Third', '2: Second', '1: First']); + + // Remove middle item and add new item + fixture.componentInstance.items.set([ + {id: 3, name: 'Third'}, + {id: 4, name: 'Fourth'}, + {id: 1, name: 'First'}, + ]); + await fixture.whenStable(); + expect(getTexts()).toEqual(['3: Third', '4: Fourth', '1: First']); + }); + + it('should support dynamic @if / @else and @switch / @case with hostless components', async () => { + @Component({ + selector: 'hostless-a', + template: '
View A
', + hostless: true, + }) + class HostlessA {} + + @Component({ + selector: 'hostless-b', + template: '
View B
', + hostless: true, + }) + class HostlessB {} + + @Component({ + template: ` +
+ @if (showA()) { + + } @else { + + } + + @switch (tab()) { + @case ('a') { + + } + @case ('b') { + + } + @default { + Default + } + } +
+ `, + imports: [HostlessA, HostlessB], + }) + class App { + showA = signal(true); + tab = signal('a'); + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('.view-a')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.view-b')).toBeFalsy(); + + // Toggle @if + fixture.componentInstance.showA.set(false); + fixture.componentInstance.tab.set('b'); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('.view-a')).toBeFalsy(); + expect(fixture.nativeElement.querySelectorAll('.view-b').length).toBe(2); + + // Switch to default + fixture.componentInstance.tab.set('other'); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('.default-view')).toBeTruthy(); + }); + }); + + describe('@defer blocks', () => { + it('should correctly render hostless components inside @defer blocks', async () => { + @Component({ + selector: 'deferred-hostless', + template: '
Deferred Hostless Content
', + hostless: true, + }) + class DeferredHostless {} + + @Component({ + template: ` + @defer (when isLoaded()) { + + } @placeholder { +
Loading placeholder
+ } + `, + imports: [DeferredHostless], + }) + class App { + isLoaded = signal(false); + } + + const fixture = TestBed.configureTestingModule({ + deferBlockBehavior: DeferBlockBehavior.Playthrough, + }).createComponent(App); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('.placeholder')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.deferred-content')).toBeFalsy(); + + // Trigger defer block + fixture.componentInstance.isLoaded.set(true); + // Yes we need both... + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.deferred-content')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.placeholder')).toBeFalsy(); + }); + + it('should support @defer blocks within hostless component templates', async () => { + @Component({ + selector: 'hostless-with-defer', + template: ` +
Top
+ @defer (when showInner()) { +
Inner Defer
+ } @placeholder { +
Inner Placeholder
+ } + `, + hostless: true, + }) + class HostlessWithDefer { + showInner = signal(false); + } + + @Component({ + template: '', + imports: [HostlessWithDefer], + }) + class App { + @ViewChild(HostlessWithDefer) child!: HostlessWithDefer; + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('.top-hostless')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.inner-placeholder')).toBeTruthy(); + + fixture.componentInstance.child.showInner.set(true); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('.inner-defer')).toBeTruthy(); + }); + }); + + describe('Two-Way Data Binding', () => { + it('should support two-way binding on hostless components', async () => { + @Component({ + selector: 'hostless-counter', + template: '', + hostless: true, + }) + class HostlessCounter { + @Input() count: number = 0; + @Output() countChange = new EventEmitter(); + + increment() { + this.countChange.emit(this.count + 1); + } + } + + @Component({ + template: ` + +
Parent: {{ parentCount() }}
+ `, + imports: [HostlessCounter], + }) + class App { + parentCount = signal(10); + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.componentInstance.parentCount()).toBe(10); + expect(fixture.nativeElement.querySelector('.inc-btn').textContent).toBe('Count: 10'); + expect(fixture.nativeElement.querySelector('.parent-val').textContent).toBe('Parent: 10'); + + // Click button inside hostless component + fixture.nativeElement.querySelector('.inc-btn').click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.parentCount()).toBe(11); + expect(fixture.nativeElement.querySelector('.inc-btn').textContent).toBe('Count: 11'); + expect(fixture.nativeElement.querySelector('.parent-val').textContent).toBe('Parent: 11'); + + // Update parent value + fixture.componentInstance.parentCount.set(42); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.inc-btn').textContent).toBe('Count: 42'); + expect(fixture.nativeElement.querySelector('.parent-val').textContent).toBe('Parent: 42'); + }); + }); + + describe('ViewContainerRef dynamic lifecycle and cleanup', () => { + it('should properly insert at index, move, and clean up DOM on destroy', async () => { + @Component({ + selector: 'dynamic-item', + template: '
Item {{label}}
', + hostless: true, + }) + class DynamicItem { + label = ''; + } + + @Component({ + template: '
', + }) + class App { + @ViewChild('vcr', {read: ViewContainerRef}) vcr!: ViewContainerRef; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + const vcr = fixture.componentInstance.vcr; + + // Create item 1 + const ref1 = vcr.createComponent(DynamicItem); + ref1.instance.label = '1'; + ref1.changeDetectorRef.detectChanges(); + + // Create item 2 + const ref2 = vcr.createComponent(DynamicItem); + ref2.instance.label = '2'; + ref2.changeDetectorRef.detectChanges(); + + let items = Array.from(fixture.nativeElement.querySelectorAll('.dyn-item')).map( + (el: any) => el.textContent, + ); + expect(items).toEqual(['Item 1', 'Item 2']); + + // Move ref2 to index 0 + vcr.move(ref2.hostView, 0); + fixture.detectChanges(); + + items = Array.from(fixture.nativeElement.querySelectorAll('.dyn-item')).map( + (el: any) => el.textContent, + ); + expect(items).toEqual(['Item 2', 'Item 1']); + + // Destroy ref1 and verify DOM cleanup + ref1.destroy(); + fixture.detectChanges(); + + items = Array.from(fixture.nativeElement.querySelectorAll('.dyn-item')).map( + (el: any) => el.textContent, + ); + expect(items).toEqual(['Item 2']); + expect(fixture.nativeElement.innerHTML).not.toContain('Item 1'); + }); + }); + + describe('Multi-slot and nested content projection', () => { + it('should project multi-slot content and nested hostless projections correctly', async () => { + @Component({ + selector: 'hostless-card', + template: ` +
+
+ + `, + hostless: true, + }) + class HostlessCard {} + + @Component({ + selector: 'hostless-badge', + template: '', + hostless: true, + }) + class HostlessBadge {} + + @Component({ + template: ` + +

Card Title New

+

Body Content

+
Footer Content
+
+ `, + imports: [HostlessCard, HostlessBadge], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const html = fixture.nativeElement.innerHTML; + expect(html).toContain( + '

Card Title New

', + ); + expect(html).toContain('

Body Content

'); + expect(html).toContain( + '', + ); + }); + }); + + describe('Lifecycle Hooks', () => { + it('should invoke lifecycle hooks in correct order for hostless components', async () => { + const hooks: string[] = []; + + @Component({ + selector: 'lifecycle-hostless', + template: '
Lifecycle: {{val}}
', + hostless: true, + }) + class LifecycleHostless implements OnInit, OnChanges, AfterViewInit, OnDestroy { + @Input() val = ''; + private destroyRef = inject(DestroyRef); + + constructor() { + hooks.push('constructor'); + this.destroyRef.onDestroy(() => hooks.push('destroyRef')); + } + + ngOnChanges(changes: SimpleChanges) { + hooks.push(`ngOnChanges:${changes['val'].currentValue}`); + } + + ngOnInit() { + hooks.push('ngOnInit'); + } + + ngAfterViewInit() { + hooks.push('ngAfterViewInit'); + } + + ngOnDestroy() { + hooks.push('ngOnDestroy'); + } + } + + @Component({ + template: ` + @if (show()) { + + } + `, + imports: [LifecycleHostless], + }) + class App { + show = signal(true); + val = signal('v1'); + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + expect(hooks).toEqual(['constructor', 'ngOnChanges:v1', 'ngOnInit', 'ngAfterViewInit']); + + // Update input + fixture.componentInstance.val.set('v2'); + await fixture.whenStable(); + + expect(hooks).toContain('ngOnChanges:v2'); + + // Destroy + fixture.componentInstance.show.set(false); + await fixture.whenStable(); + + expect(hooks).toContain('ngOnDestroy'); + expect(hooks).toContain('destroyRef'); + }); + }); + + describe('Content Projection Fallback', () => { + it('should render fallback content when no projected nodes are provided, and override when provided', async () => { + @Component({ + selector: 'fallback-hostless', + hostless: true, + template: ` +
+ Default Fallback Content +
+ `, + }) + class FallbackHostless {} + + @Component({ + template: ` +
+ @if (showCustom()) { + + Custom Projected Content + + } @else { + + } +
+ `, + imports: [FallbackHostless], + }) + class App { + showCustom = signal(false); + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + expect(fixture.nativeElement.innerHTML).toContain('Default Fallback Content'); + expect(fixture.nativeElement.innerHTML).not.toContain('Custom Projected Content'); + + fixture.componentInstance.showCustom.set(true); + await fixture.whenStable(); + + expect(fixture.nativeElement.innerHTML).toContain( + 'Custom Projected Content', + ); + expect(fixture.nativeElement.innerHTML).not.toContain('Default Fallback Content'); + }); + }); + + describe('Dependency Injection with Component Providers', () => { + it('should provide services to child components and directives within its template', async () => { + @Injectable() + class CounterService { + val = 42; + } + + @Component({ + selector: 'child-consumer', + template: 'Value: {{ counter.val }}', + }) + class ChildConsumer { + counter = inject(CounterService); + } + + @Component({ + selector: 'provider-hostless', + hostless: true, + providers: [CounterService], + template: ` +
+ +
+ `, + imports: [ChildConsumer], + }) + class ProviderHostless {} + + @Component({ + template: '', + imports: [ProviderHostless], + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toContain('Value: 42'); + }); + }); + + describe('Dynamic creation with projectable nodes', () => { + it('should project nodes into dynamically created hostless component', async () => { + @Component({ + selector: 'dynamic-proj-hostless', + hostless: true, + template: ` +
+
+ `, + }) + class DynamicProjHostless {} + + @Component({ + template: '
', + }) + class App { + @ViewChild('vcr', {read: ViewContainerRef}) vcr!: ViewContainerRef; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + const headerNode = document.createElement('h2'); + headerNode.setAttribute('header', ''); + headerNode.textContent = 'Dynamic Header'; + + const bodyNode = document.createElement('p'); + bodyNode.textContent = 'Dynamic Body'; + + const compRef = fixture.componentInstance.vcr.createComponent(DynamicProjHostless, { + projectableNodes: [[headerNode], [bodyNode]], + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toContain( + '

Dynamic Header

', + ); + expect(fixture.nativeElement.innerHTML).toContain( + '

Dynamic Body

', + ); + + compRef.destroy(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.dyn-header')).toBeFalsy(); + }); + }); + + describe('Edge cases and potential undefined behaviors', () => { + it('should not render unprojected content passed to hostless component without ng-content', async () => { + @Component({ + selector: 'no-proj-hostless', + hostless: true, + template: '
Only internal content
', + }) + class NoProjHostless {} + + @Component({ + imports: [NoProjHostless], + template: + '

Should not appear in DOM

', + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('#unprojected')).toBeFalsy(); + expect(fixture.nativeElement.innerHTML).toBe( + '
Only internal content
', + ); + }); + + it('should only render projected slots and omit unprojected content in hostless multi-slot projection', async () => { + @Component({ + selector: 'multi-slot-hostless', + hostless: true, + template: ` +
+ + `, + }) + class MultiSlotHostless {} + + @Component({ + imports: [MultiSlotHostless], + template: ` + + Header Content +

Unprojected middle content

+ Footer Content +
Unprojected trailing content
+
+ `, + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('#unprojected-middle')).toBeFalsy(); + expect(fixture.nativeElement.querySelector('#unprojected-end')).toBeFalsy(); + expect(fixture.nativeElement.querySelector('.projected-header').textContent.trim()).toBe( + 'Header Content', + ); + expect(fixture.nativeElement.querySelector('.projected-footer').textContent.trim()).toBe( + 'Footer Content', + ); + }); + + it('should handle nested hostless components with unprojected content', async () => { + @Component({ + selector: 'inner-hostless', + hostless: true, + template: 'Inner Hostless', + }) + class InnerHostless {} + + @Component({ + selector: 'outer-hostless', + hostless: true, + imports: [InnerHostless], + template: ` +
+ + Inner Unprojected + +
+ `, + }) + class OuterHostless {} + + @Component({ + imports: [OuterHostless], + template: ` + + Outer Unprojected + + `, + }) + class App {} + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('#outer-unprojected')).toBeFalsy(); + expect(fixture.nativeElement.querySelector('#inner-unprojected')).toBeFalsy(); + expect(fixture.nativeElement.textContent).toContain('Inner Hostless'); + }); + + it('should maintain style encapsulation boundary when parent uses child combinators', async () => { + @Component({ + selector: 'hostless-target', + hostless: true, + template: 'Child text', + }) + class HostlessTarget {} + + @Component({ + selector: 'regular-target', + template: 'Child text', + }) + class RegularTarget {} + + @Component({ + imports: [HostlessTarget], + styles: ['div > span.target { color: rgb(255, 0, 0); }'], + template: '
', + }) + class HostlessApp {} + + @Component({ + imports: [RegularTarget], + styles: ['div > span.target { color: rgb(255, 0, 0); }'], + template: '
', + }) + class RegularApp {} + + const regFixture = TestBed.createComponent(RegularApp); + await regFixture.whenStable(); + const regSpan = regFixture.nativeElement.querySelector('span.target'); + const regColor = window.getComputedStyle(regSpan).color; + + const hostlessFixture = TestBed.createComponent(HostlessApp); + await hostlessFixture.whenStable(); + const hostlessSpan = hostlessFixture.nativeElement.querySelector('span.target'); + const hostlessColor = window.getComputedStyle(hostlessSpan).color; + // it is important to compute the style while the element is attached to the document + // else we could simply get empty string returned. + + expect(hostlessColor).toBe(regColor); + }); + }); +}); + +@Component({ + selector: 'my-hostless', + template: '
Hostless Content
', + hostless: true, +}) +class MyHostless {} diff --git a/packages/core/test/acceptance/template_ref_spec.ts b/packages/core/test/acceptance/template_ref_spec.ts index 08cfdaf9f976..caf162cc8abe 100644 --- a/packages/core/test/acceptance/template_ref_spec.ts +++ b/packages/core/test/acceptance/template_ref_spec.ts @@ -156,8 +156,8 @@ describe('TemplateRef', () => { `); expect(rootNodes.length).toBe(2); - expect(rootNodes[0].nodeType).toBe(Node.COMMENT_NODE); - expect(rootNodes[1].nodeType).toBe(Node.TEXT_NODE); + expect(rootNodes[0].nodeType).toBe(Node.TEXT_NODE); + expect(rootNodes[1].nodeType).toBe(Node.COMMENT_NODE); }); xit('should descend into ICU containers', () => { diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 54cd560acc34..779da1bce850 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -47,6 +47,9 @@ "CHILD_TAIL", "CIRCULAR", "CLEANUP", + "COMMENT_DELIMITER", + "COMMENT_DELIMITER_ESCAPED", + "COMMENT_DISALLOWED", "COMPLETE_NOTIFICATION", "COMPONENT_REGEX", "COMPONENT_VARIABLE", @@ -306,6 +309,7 @@ "_injectImplementation", "_isRefreshingViews", "_keyMap", + "_locateOrCreateCommentNode", "_locateOrCreateElementNode", "_ngOnChangesFeatureImpl", "_platformInjector", @@ -392,6 +396,7 @@ "copyAnimationEvent", "couldBeInjectableType", "createAnimationFailed", + "createCommentNode", "createComponentLView", "createDirectivesInstances", "createElementNode", @@ -460,6 +465,7 @@ "errorHandler", "errorHandlerEnvironmentInitializer", "errorNotification", + "escapeCommentText", "execFinalizer", "executeCheckHooks", "executeContentQueries", @@ -548,6 +554,7 @@ "getPromiseCtor", "getRootTViewTemplate", "getRuntimeErrorCode", + "getSegmentHead", "getSelectedIndex", "getSelectedTNode", "getStyleHost", diff --git a/packages/core/test/bundling/create_component/bundle.golden_symbols.json b/packages/core/test/bundling/create_component/bundle.golden_symbols.json index 4f747dc0989e..eab9d1ef9eea 100644 --- a/packages/core/test/bundling/create_component/bundle.golden_symbols.json +++ b/packages/core/test/bundling/create_component/bundle.golden_symbols.json @@ -24,6 +24,9 @@ "CHILD_TAIL", "CIRCULAR", "CLEANUP", + "COMMENT_DELIMITER", + "COMMENT_DELIMITER_ESCAPED", + "COMMENT_DISALLOWED", "COMPLETE_NOTIFICATION", "COMPONENT_REGEX", "COMPONENT_VARIABLE", @@ -305,6 +308,7 @@ "convertToInjectOptions", "couldBeInjectableType", "createAnchorNode", + "createCommentNode", "createComponentLView", "createContainerRef", "createDirectivesInstances", @@ -367,6 +371,7 @@ "errorHandler", "errorHandlerEnvironmentInitializer", "errorNotification", + "escapeCommentText", "execFinalizer", "executeCheckHooks", "executeContentQueries", @@ -453,6 +458,7 @@ "getPromiseCtor", "getRootTViewTemplate", "getRuntimeErrorCode", + "getSegmentHead", "getSelectedIndex", "getSelectedTNode", "getStyleHost", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index f119ff5f0b45..a90f10dad2a7 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -78,6 +78,9 @@ "CHILD_TAIL", "CIRCULAR", "CLEANUP", + "COMMENT_DELIMITER", + "COMMENT_DELIMITER_ESCAPED", + "COMMENT_DISALLOWED", "COMPLETE_NOTIFICATION", "CONTAINER_HEADER_OFFSET", "CONTEXT", @@ -275,6 +278,7 @@ "_icuContainerIterate", "_injectImplementation", "_isRefreshingViews", + "_locateOrCreateCommentNode", "_locateOrCreateContainerAnchor", "_locateOrCreateElementNode", "_locateOrCreateTextNode", @@ -348,6 +352,7 @@ "convertToInjectOptions", "couldBeInjectableType", "createAndRenderEmbeddedLView", + "createCommentNode", "createComponentLView", "createContainerAnchorImpl", "createDeferBlockInjector", @@ -409,6 +414,7 @@ "errorContext", "errorHandlerEnvironmentInitializer", "errorNotification", + "escapeCommentText", "execFinalizer", "executeCheckHooks", "executeContentQueries", @@ -498,6 +504,7 @@ "getPromiseCtor", "getRootTViewTemplate", "getRuntimeErrorCode", + "getSegmentHead", "getSelectedIndex", "getSelectedTNode", "getStyleHost", diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index ea1786afdd63..1124fd22c02f 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -38,6 +38,9 @@ "CHILD_TAIL", "CIRCULAR", "CLEANUP", + "COMMENT_DELIMITER", + "COMMENT_DELIMITER_ESCAPED", + "COMMENT_DISALLOWED", "COMPILER_OPTIONS", "COMPLETE_NOTIFICATION", "COMPONENT_REGEX", @@ -347,6 +350,7 @@ "_keyMap", "_lastDefined", "_locateOrCreateAnchorNode", + "_locateOrCreateCommentNode", "_locateOrCreateContainerAnchor", "_locateOrCreateElementNode", "_locateOrCreateTextNode", @@ -456,6 +460,7 @@ "couldBeInjectableType", "createAnchorNode", "createAndRenderEmbeddedLView", + "createCommentNode", "createComponentLView", "createComputed", "createContainerAnchorImpl", @@ -536,6 +541,7 @@ "errorHandler", "errorHandlerEnvironmentInitializer", "errorNotification", + "escapeCommentText", "execFinalizer", "executeCheckHooks", "executeContentQueries", @@ -650,6 +656,7 @@ "getPromiseCtor", "getRootTViewTemplate", "getRuntimeErrorCode", + "getSegmentHead", "getSelectedIndex", "getSelectedTNode", "getSimpleChangesStore", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index b3e44d84dae7..ee6aa3e4fab4 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -37,6 +37,9 @@ "CHILD_TAIL", "CIRCULAR", "CLEANUP", + "COMMENT_DELIMITER", + "COMMENT_DELIMITER_ESCAPED", + "COMMENT_DISALLOWED", "COMPILER_OPTIONS", "COMPLETE_NOTIFICATION", "COMPONENT_REGEX", @@ -348,6 +351,7 @@ "_keyMap", "_lastDefined", "_locateOrCreateAnchorNode", + "_locateOrCreateCommentNode", "_locateOrCreateContainerAnchor", "_locateOrCreateElementNode", "_locateOrCreateTextNode", @@ -453,6 +457,7 @@ "couldBeInjectableType", "createAnchorNode", "createAndRenderEmbeddedLView", + "createCommentNode", "createComponentLView", "createComputed", "createContainerAnchorImpl", @@ -533,6 +538,7 @@ "errorHandler", "errorHandlerEnvironmentInitializer", "errorNotification", + "escapeCommentText", "execFinalizer", "executeCheckHooks", "executeContentQueries", @@ -646,6 +652,7 @@ "getPromiseCtor", "getRootTViewTemplate", "getRuntimeErrorCode", + "getSegmentHead", "getSelectedIndex", "getSelectedTNode", "getSimpleChangesStore", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index d0b6ed54c0d5..0db68972e199 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -332,6 +332,7 @@ "_isRefreshingViews", "_keyMap", "_locateOrCreateAnchorNode", + "_locateOrCreateCommentNode", "_locateOrCreateContainerAnchor", "_locateOrCreateElementContainerNode", "_locateOrCreateElementNode", @@ -763,6 +764,7 @@ "locateI18nRNodeByIndex", "locateNextRNode", "locateOrCreateAnchorNode", + "locateOrCreateCommentNodeImpl", "locateOrCreateContainerAnchorImpl", "locateOrCreateElementContainerNode", "locateOrCreateElementNodeImpl", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 403c184cff3d..0a9e0e8f31a6 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -38,6 +38,9 @@ "CHILD_TAIL", "CIRCULAR", "CLEANUP", + "COMMENT_DELIMITER", + "COMMENT_DELIMITER_ESCAPED", + "COMMENT_DISALLOWED", "COMPLETE_NOTIFICATION", "COMPONENT_REGEX", "COMPONENT_VARIABLE", @@ -385,6 +388,7 @@ "_isRefreshingViews", "_keyMap", "_locateOrCreateAnchorNode", + "_locateOrCreateCommentNode", "_locateOrCreateElementNode", "_locateOrCreateTextNode", "_ngOnChangesFeatureImpl", @@ -494,6 +498,7 @@ "createAnchorNode", "createAndRenderEmbeddedLView", "createChildrenForEmptyPaths", + "createCommentNode", "createComponentLView", "createComputed", "createContainerRef", @@ -608,6 +613,7 @@ "errorHandler", "errorHandlerEnvironmentInitializer", "errorNotification", + "escapeCommentText", "exactMatchOptions", "execFinalizer", "executeCheckHooks", @@ -740,6 +746,7 @@ "getSanitizationBypassType", "getSanitizer", "getSecurityContext", + "getSegmentHead", "getSelectedIndex", "getSelectedTNode", "getSimpleChangesStore", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 35f8283fd048..c790c8c5cc3c 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -24,6 +24,9 @@ "CHILD_TAIL", "CIRCULAR", "CLEANUP", + "COMMENT_DELIMITER", + "COMMENT_DELIMITER_ESCAPED", + "COMMENT_DISALLOWED", "COMPLETE_NOTIFICATION", "COMPONENT_REGEX", "COMPONENT_VARIABLE", @@ -281,6 +284,7 @@ "convertToBitFlags", "convertToInjectOptions", "couldBeInjectableType", + "createCommentNode", "createComponentLView", "createDirectivesInstances", "createElementNode", @@ -339,6 +343,7 @@ "errorHandler", "errorHandlerEnvironmentInitializer", "errorNotification", + "escapeCommentText", "execFinalizer", "executeCheckHooks", "executeContentQueries", @@ -415,6 +420,7 @@ "getPromiseCtor", "getRootTViewTemplate", "getRuntimeErrorCode", + "getSegmentHead", "getSelectedIndex", "getStyleHost", "getTNode", diff --git a/packages/core/testing/src/test_bed.ts b/packages/core/testing/src/test_bed.ts index a841ad4013e5..a215b7a41c93 100644 --- a/packages/core/testing/src/test_bed.ts +++ b/packages/core/testing/src/test_bed.ts @@ -693,7 +693,7 @@ export class TestBedImpl implements TestBed { const testComponentRenderer = this.inject(TestComponentRenderer); const shouldInferTagName = options?.inferTagName ?? this._instanceInferTagName ?? false; const componentDef = getComponentDef(type); - const rootElId = `root${_nextRootElementId++}`; + const rootElId = componentDef?.hostless ? 'root-hostless' : `root${_nextRootElementId++}`; if (!componentDef) { throw new Error(`It looks like '${stringify(type)}' has not been compiled.`); diff --git a/packages/platform-browser/src/dom/dom_renderer.ts b/packages/platform-browser/src/dom/dom_renderer.ts index fa212895d3b1..4e6efc62d6ac 100644 --- a/packages/platform-browser/src/dom/dom_renderer.ts +++ b/packages/platform-browser/src/dom/dom_renderer.ts @@ -8,27 +8,27 @@ import {DOCUMENT, ɵgetDOM as getDOM} from '@angular/common'; import { + ɵallLeavingAnimations as allLeavingAnimations, APP_ID, CSP_NONCE, Inject, Injectable, InjectionToken, + makeEnvironmentProviders, NgZone, OnDestroy, + Optional, Renderer2, RendererFactory2, RendererStyleFlags2, RendererType2, - ViewEncapsulation, ɵRuntimeError as RuntimeError, - type ListenerOptions, + ɵSHARED_STYLES_HOST as SHARED_STYLES_HOST, ɵTracingService as TracingService, ɵTracingSnapshot as TracingSnapshot, - Optional, - ɵallLeavingAnimations as allLeavingAnimations, - ɵSHARED_STYLES_HOST as SHARED_STYLES_HOST, - makeEnvironmentProviders, + ViewEncapsulation, type EnvironmentProviders, + type ListenerOptions, } from '@angular/core'; import {RuntimeErrorCode} from '../errors'; @@ -726,7 +726,9 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer { applyToHost(element: any): void { this.applyStyles(); - this.setAttribute(element, this.hostAttr, ''); + if (element?.nodeType === 1 /* Node.ELEMENT_NODE */) { + this.setAttribute(element, this.hostAttr, ''); + } } override createElement(parent: any, name: string): Element { diff --git a/packages/platform-server/test/full_app_hydration_spec.ts b/packages/platform-server/test/full_app_hydration_spec.ts index ca0c1c58924a..ff6311310309 100644 --- a/packages/platform-server/test/full_app_hydration_spec.ts +++ b/packages/platform-server/test/full_app_hydration_spec.ts @@ -39,7 +39,9 @@ import { EnvironmentInjector, ErrorHandler, inject, + Injectable, Input, + input, NgZone, PendingTasks, Pipe, @@ -87,6 +89,7 @@ import { verifyHasNoLog, verifyNodeHasMismatchInfo, verifyNodeHasSkipHydrationMarker, + verifyNodeWasHydrated, verifyNoNodesWereClaimedForHydration, withDebugConsole, withNoopErrorHandler, @@ -148,6 +151,723 @@ describe('platform-server full application hydration integration', () => { expect(ssrContents).toContain(` { + @Component({ + selector: 'hostless', + hostless: true, + template: 'This is a hostless component.', + }) + class HostlessComponent {} + + @Component({ + selector: 'app', + imports: [HostlessComponent], + template: ` `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain(`This is a hostless component.`); + expect(ssrContents).toMatch(//); + + resetTViewsFor(SimpleComponent, HostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/, ''), clientRootNode); + }); + + it('should support nested hostless components during hydration', async () => { + @Component({ + selector: 'inner-hostless', + hostless: true, + template: 'Inner content', + }) + class InnerHostlessComponent {} + + @Component({ + selector: 'outer-hostless', + hostless: true, + imports: [InnerHostlessComponent], + template: ` + Outer before + + Outer after + `, + }) + class OuterHostlessComponent {} + + @Component({ + selector: 'app', + imports: [OuterHostlessComponent], + template: ` +
App header
+ +
App footer
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Outer before'); + expect(ssrContents).toContain('Inner content'); + expect(ssrContents).toContain('Outer after'); + + resetTViewsFor(SimpleComponent, OuterHostlessComponent, InnerHostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should support multiple sibling hostless components during hydration', async () => { + @Component({ + selector: 'hostless-a', + hostless: true, + template: 'Hostless A', + }) + class HostlessAComponent {} + + @Component({ + selector: 'hostless-b', + hostless: true, + template: 'Hostless B', + }) + class HostlessBComponent {} + + @Component({ + selector: 'app', + imports: [HostlessAComponent, HostlessBComponent], + template: ` +
Header
+ + +
Footer
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Hostless A'); + expect(ssrContents).toContain('Hostless B'); + + resetTViewsFor(SimpleComponent, HostlessAComponent, HostlessBComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should support empty hostless components during hydration', async () => { + @Component({ + selector: 'empty-hostless', + hostless: true, + template: '', + }) + class EmptyHostlessComponent {} + + @Component({ + selector: 'app', + imports: [EmptyHostlessComponent], + template: ` +
Before
+ +
After
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + resetTViewsFor(SimpleComponent, EmptyHostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should skip hydration when ngSkipHydration is set on hostless component in template', async () => { + @Component({ + selector: 'hostless', + hostless: true, + template: '

Skipped hostless content

', + }) + class HostlessComponent {} + + @Component({ + selector: 'app', + imports: [HostlessComponent], + template: ` +
Header
+ +
Footer
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Skipped hostless content'); + expect(ssrContents).not.toMatch(/

Skipped hostless content/); + + resetTViewsFor(SimpleComponent, HostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + expect(ngDevMode!.componentsSkippedHydration).toBe(1); + + const clientRootNode = compRef.location.nativeElement as HTMLElement; + const header = clientRootNode.querySelector('header')!; + const footer = clientRootNode.querySelector('footer')!; + verifyNodeWasHydrated(header); + verifyNodeWasHydrated(footer); + + const commentNode = Array.from(clientRootNode.childNodes).find( + (n) => n.nodeType === Node.COMMENT_NODE, + ) as HTMLElement; + expect(commentNode).toBeDefined(); + verifyNodeHasSkipHydrationMarker(commentNode); + + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should skip hydration when ngSkipHydration="true" attribute is set on hostless component', async () => { + @Component({ + selector: 'hostless', + hostless: true, + template: '

Attr skipped content

', + }) + class HostlessComponent {} + + @Component({ + selector: 'app', + imports: [HostlessComponent], + template: ` +
Header
+ +
Footer
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Attr skipped content'); + + resetTViewsFor(SimpleComponent, HostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + expect(ngDevMode!.componentsSkippedHydration).toBe(1); + + const clientRootNode = compRef.location.nativeElement as HTMLElement; + const header = clientRootNode.querySelector('header')!; + const footer = clientRootNode.querySelector('footer')!; + verifyNodeWasHydrated(header); + verifyNodeWasHydrated(footer); + + const commentNode = Array.from(clientRootNode.childNodes).find( + (n) => n.nodeType === Node.COMMENT_NODE, + ) as HTMLElement; + expect(commentNode).toBeDefined(); + verifyNodeHasSkipHydrationMarker(commentNode); + + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should skip hydration of entire subtree when ngSkipHydration is on outer hostless component', async () => { + @Component({ + selector: 'inner-hostless', + hostless: true, + template: 'Inner content', + }) + class InnerHostlessComponent {} + + @Component({ + selector: 'outer-hostless', + hostless: true, + imports: [InnerHostlessComponent], + template: ` +
Outer header
+ +
Outer footer
+ `, + }) + class OuterHostlessComponent {} + + @Component({ + selector: 'app', + imports: [OuterHostlessComponent], + template: ` +
App top
+ +
App bottom
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Outer header'); + expect(ssrContents).toContain('Inner content'); + + resetTViewsFor(SimpleComponent, OuterHostlessComponent, InnerHostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + expect(ngDevMode!.componentsSkippedHydration).toBe(1); + + const clientRootNode = compRef.location.nativeElement as HTMLElement; + const header = clientRootNode.querySelector('header')!; + const footer = clientRootNode.querySelector('footer')!; + verifyNodeWasHydrated(header); + verifyNodeWasHydrated(footer); + + const commentNodes = Array.from(clientRootNode.childNodes).filter( + (n) => n.nodeType === Node.COMMENT_NODE, + ) as HTMLElement[]; + expect(commentNodes.length).toBeGreaterThan(0); + const outerCommentNode = commentNodes[commentNodes.length - 1]; + verifyNodeHasSkipHydrationMarker(outerCommentNode); + + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should skip hydration of only inner component when ngSkipHydration is on inner hostless component', async () => { + @Component({ + selector: 'inner-hostless', + hostless: true, + template: 'Inner skipped', + }) + class InnerHostlessComponent {} + + @Component({ + selector: 'outer-hostless', + hostless: true, + imports: [InnerHostlessComponent], + template: ` +
Outer header
+ +
Outer footer
+ `, + }) + class OuterHostlessComponent {} + + @Component({ + selector: 'app', + imports: [OuterHostlessComponent], + template: ` +
App top
+ +
App bottom
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Outer header'); + expect(ssrContents).toContain('Inner skipped'); + + resetTViewsFor(SimpleComponent, OuterHostlessComponent, InnerHostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + expect(ngDevMode!.componentsSkippedHydration).toBe(1); + + const clientRootNode = compRef.location.nativeElement as HTMLElement; + const header = clientRootNode.querySelector('header')!; + const footer = clientRootNode.querySelector('footer')!; + verifyNodeWasHydrated(header); + verifyNodeWasHydrated(footer); + + const outerDivs = clientRootNode.querySelectorAll('div'); + expect(outerDivs.length).toBe(2); + verifyNodeWasHydrated(outerDivs[0]); + verifyNodeWasHydrated(outerDivs[1]); + + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should hydrate hostless component containing @if and @for blocks', async () => { + @Component({ + selector: 'control-flow-hostless', + hostless: true, + template: ` + @if (show()) { +
Conditional text
+ } + @for (item of items(); track item) { + {{ item }} + } + `, + }) + class ControlFlowHostlessComponent { + show = signal(true); + items = signal(['A', 'B', 'C']); + } + + @Component({ + selector: 'app', + imports: [ControlFlowHostlessComponent], + template: ` +
+ +
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Conditional text'); + expect(ssrContents).toContain('ABC'); + + resetTViewsFor(SimpleComponent, ControlFlowHostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should hydrate hostless component rendered inside an @if block', async () => { + @Component({ + selector: 'hostless-child', + hostless: true, + template: 'Hostless inside if block', + }) + class HostlessChildComponent {} + + @Component({ + selector: 'app', + imports: [HostlessChildComponent], + template: ` + @if (show()) { +
Before
+ +
After
+ } + `, + }) + class SimpleComponent { + show = signal(true); + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Hostless inside if block'); + + resetTViewsFor(SimpleComponent, HostlessChildComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should hydrate hostless component containing ', async () => { + @Component({ + selector: 'projecting-hostless', + hostless: true, + template: ` +
Before projection
+ +
After projection
+ `, + }) + class ProjectingHostlessComponent {} + + @Component({ + selector: 'app', + imports: [ProjectingHostlessComponent], + template: ` + +

Projected content

+
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Before projection'); + expect(ssrContents).toContain('Projected content'); + expect(ssrContents).toContain('After projection'); + + resetTViewsFor(SimpleComponent, ProjectingHostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should hydrate hostless components inside an @for loop correctly', async () => { + @Component({ + selector: 'item-hostless', + hostless: true, + template: 'Item {{ id }}', + }) + class ItemHostlessComponent { + @Input() id: number = 0; + } + + @Component({ + selector: 'app', + imports: [ItemHostlessComponent], + template: ` +
+ @for (item of items(); track item) { + + } +
+ `, + }) + class SimpleComponent { + items = signal([1, 2, 3]); + } + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Item 1'); + expect(ssrContents).toContain('Item 2'); + expect(ssrContents).toContain('Item 3'); + + resetTViewsFor(SimpleComponent, ItemHostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement as HTMLElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + + // Mutate list dynamically post-hydration + compRef.instance.items.set([3, 4]); + appRef.tick(); + + const updatedItems = clientRootNode.querySelectorAll('.item') as NodeListOf; + expect(updatedItems.length).toBe(2); + expect(updatedItems[0].textContent).toBe('Item 3'); + expect(updatedItems[1].textContent).toBe('Item 4'); + }); + + it('should hydrate hostless component with content projection fallback correctly', async () => { + @Component({ + selector: 'fallback-hostless', + hostless: true, + template: ` +
Fallback Header
+ Default fallback text +
Fallback Footer
+ `, + }) + class FallbackHostlessComponent {} + + @Component({ + selector: 'app', + imports: [FallbackHostlessComponent], + template: ` +
+ +
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Fallback Header'); + expect(ssrContents).toContain('Default fallback text'); + expect(ssrContents).toContain('Fallback Footer'); + + resetTViewsFor(SimpleComponent, FallbackHostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement as HTMLElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should hydrate hostless component providing services via providers', async () => { + @Injectable() + class HydratedService { + msg = 'Service from Hostless Provider'; + } + + @Component({ + selector: 'consumer-child', + template: '{{ service.msg }}', + }) + class ConsumerChildComponent { + service = inject(HydratedService); + } + + @Component({ + selector: 'provider-hostless', + hostless: true, + providers: [HydratedService], + imports: [ConsumerChildComponent], + template: ` +
+ +
+ `, + }) + class ProviderHostlessComponent {} + + @Component({ + selector: 'app', + imports: [ProviderHostlessComponent], + template: ` +
+ +
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Service from Hostless Provider'); + + resetTViewsFor(SimpleComponent, ProviderHostlessComponent, ConsumerChildComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement as HTMLElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should hydrate hostless component that receives unprojected content (no ng-content)', async () => { + @Component({ + selector: 'hostless-no-project', + hostless: true, + template: 'Hostless text', + }) + class HostlessNoProjectComponent {} + + @Component({ + selector: 'app', + imports: [HostlessNoProjectComponent], + template: ` +
Before
+ +

Unprojected content

+
+
After
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + expect(ssrContents).toContain('Hostless text'); + expect(ssrContents).not.toContain('Unprojected content'); + + resetTViewsFor(SimpleComponent, HostlessNoProjectComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement as HTMLElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + + it('should hydrate empty hostless component alongside siblings', async () => { + @Component({ + selector: 'empty-hostless', + hostless: true, + template: '', + }) + class EmptyHostlessComponent {} + + @Component({ + selector: 'app', + imports: [EmptyHostlessComponent], + template: ` +
Before
+ +
After
+ `, + }) + class SimpleComponent {} + + const html = await ssr(SimpleComponent); + const ssrContents = getAppContents(html); + + resetTViewsFor(SimpleComponent, EmptyHostlessComponent); + + const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); + const compRef = getComponentRef(appRef); + appRef.tick(); + + const clientRootNode = compRef.location.nativeElement as HTMLElement; + verifyAllNodesClaimedForHydration(clientRootNode); + verifyClientAndSSRContentsMatch(ssrContents.replace(/ ngh=\d+/g, ''), clientRootNode); + }); + it('should skip local ref slots while producing hydration annotations', async () => { @Component({ selector: 'nested',