Skip to content
Draft

test #70189

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions dev-app/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -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: [
Expand Down
5 changes: 5 additions & 0 deletions goldens/public-api/compiler-cli/error_code.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ export interface Component extends Directive {
animations?: any[];
changeDetection?: ChangeDetectionStrategy;
encapsulation?: ViewEncapsulation;
hostless?: boolean;
imports?: (Type<any> | ReadonlyArray<any>)[];
preserveWhitespaces?: boolean;
schemas?: SchemaMetadata[];
Expand Down
52 changes: 52 additions & 0 deletions hostless_features.md
Original file line number Diff line number Diff line change
@@ -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 `<ng-container>`).

### Styling & Encapsulation

- [x] children hostless components do not inherit from their parent, same as regular components

### Component Features & APIs

- [x] Content projection (`<ng-content>`) 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.
4 changes: 2 additions & 2 deletions integration/platform-server-hydration/size.json
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -974,6 +995,58 @@ 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) {
if (style.includes(':host')) {
if (diagnostics === undefined) {
diagnostics = [];
}
diagnostics.push(
makeDiagnostic(
ErrorCode.HOSTLESS_COMPONENT_HOST_STYLE,
component.get('styles') ??
component.get('styleUrl') ??
component.get('styleUrls') ??
component.get('hostless')!,
'Hostless components cannot use :host or :host-context in their styles.',
undefined,
ts.DiagnosticCategory.Warning,
),
);
break; // only need to warn once per component
}
}
}

// Collect all explicitly deferred symbols from the `@Component.deferredImports` field
// (if it exists) and populate the `DeferredSymbolTracker` state. These operations are safe
// for the local compilation mode, since they don't require accessing/resolving symbols
Expand Down Expand Up @@ -1012,6 +1085,7 @@ export class ComponentDecoratorHandler implements DecoratorHandler<
template,
encapsulation,
changeDetection,
isHostless,
styles,
externalStyles,
legacyOptionalChaining: this.legacyOptionalChaining,
Expand Down Expand Up @@ -1096,6 +1170,7 @@ export class ComponentDecoratorHandler implements DecoratorHandler<
name: node.name.text,
selector: analysis.meta.selector,
exportAs: analysis.meta.exportAs,
isHostless: analysis.meta.isHostless,
inputs: analysis.inputs,
inputFieldNamesFromMetadataArray: analysis.inputFieldNamesFromMetadataArray,
outputs: analysis.outputs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ import {
ClassMemberKind,
Decorator,
ReflectionHost,
reflectObjectLiteral,
} from '../../../reflection';
import {LocalModuleScopeRegistry, TypeCheckScopeRegistry} from '../../../scope';
import {
Expand All @@ -73,13 +72,11 @@ import {
getUndecoratedClassWithAngularFeaturesDiagnostic,
InjectableClassRegistry,
isAngularDecorator,
parseStandaloneOption,
readBaseClass,
ReferencesRegistry,
resolveProvidersRequiringFactory,
toFactoryMetadata,
UndecoratedMetadataExtractor,
unwrapExpression,
validateHostDirectives,
} from '../../common';

Expand Down Expand Up @@ -311,6 +308,7 @@ export class DirectiveDecoratorHandler implements DecoratorHandler<
name: node.name.text,
selector: analysis.meta.selector,
exportAs: analysis.meta.exportAs,
isHostless: false,
inputs: analysis.inputs,
inputFieldNamesFromMetadataArray: analysis.inputFieldNamesFromMetadataArray,
outputs: analysis.outputs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ runInEachFileSystem(() => {
inputs: analysis.inputs,
outputs: analysis.outputs,
isComponent: false,
isHostless: false,
name: 'Dir',
selector: '[dir]',
isStructural: false,
Expand Down
25 changes: 25 additions & 0 deletions packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions packages/compiler-cli/src/ngtsc/indexer/test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
*/

import {
BoundTarget,
ClassPropertyMapping,
CssSelector,
DirectiveMatcher,
Expand All @@ -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 {
Expand Down Expand Up @@ -64,6 +63,7 @@ export function getBoundTemplate(
selector,
name: declaration.name.getText(),
isComponent: true,
isHostless: false,
inputs: ClassPropertyMapping.fromMappedObject({}),
outputs: ClassPropertyMapping.fromMappedObject({}),
exportAs: null,
Expand Down
4 changes: 4 additions & 0 deletions packages/compiler-cli/src/ngtsc/metadata/src/dts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand All @@ -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,
Expand Down
55 changes: 55 additions & 0 deletions packages/compiler-cli/src/ngtsc/metadata/test/dts_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HostlessCmp, "hostless-cmp", never, {}, {}, never, never, true, never, false, true>;
}

export declare class RegularCmp {
static ɵcmp: i0.ɵɵComponentDeclaration<RegularCmp, "regular-cmp", never, {}, {}, never, never, true, never, false, false>;
}

export declare class DefaultCmp {
static ɵcmp: i0.ɵɵComponentDeclaration<DefaultCmp, "default-cmp", never, {}, {}, never, never, true>;
}
`,
},
],
{
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);
});
});
1 change: 1 addition & 0 deletions packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ function fakeDirective(ref: Reference<ClassDeclaration>): DirectiveMeta {
name,
selector: `[${ref.debugName}]`,
isComponent: name.startsWith('Cmp'),
isHostless: false,
inputs: ClassPropertyMapping.fromMappedObject({}),
outputs: ClassPropertyMapping.fromMappedObject({}),
exportAs: null,
Expand Down
Loading
Loading