From c855a5689f24e683edc3a2a06bd61fed96dab27f Mon Sep 17 00:00:00 2001 From: Pawel Kozlowski Date: Wed, 22 Jul 2026 16:42:58 +0200 Subject: [PATCH 001/161] release: bump the next branch to v22.2.0-next.0 --- package.json | 2 +- renovate.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a2cf8e5c4e10..d6ea1c5397e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angular-srcs", - "version": "22.1.0-next.6", + "version": "22.2.0-next.0", "private": true, "description": "Angular - a web framework for modern web apps", "homepage": "https://github.com/angular/angular", diff --git a/renovate.json b/renovate.json index 6b178edc01aa..dbb8a238c562 100644 --- a/renovate.json +++ b/renovate.json @@ -1,7 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["github>angular/dev-infra//renovate-presets/default.json5"], - "baseBranchPatterns": ["main", "22.0.x"], + "baseBranchPatterns": ["main", "22.1.x"], "ignoreDeps": [ "@types/selenium-webdriver", "angular-1.5", From 3bf24306be2ad639fa6bbe4a45dee546534f3a58 Mon Sep 17 00:00:00 2001 From: Pawel Kozlowski Date: Wed, 22 Jul 2026 16:42:59 +0200 Subject: [PATCH 002/161] docs: release notes for the v22.1.0-rc.0 release --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3f5ad21e62..f75394a22c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ + +# 22.1.0-rc.0 (2026-07-22) +### common +| Commit | Type | Description | +| -- | -- | -- | +| [d14696e430](https://github.com/angular/angular/commit/d14696e4308c5cd95cf2184cae3582b52f7c7cf3) | fix | preserve crossorigin on image preloads | +### core +| Commit | Type | Description | +| -- | -- | -- | +| [3499a1321b](https://github.com/angular/angular/commit/3499a1321bfe4801c38744ddb500715486680366) | fix | ensure SVG animation attributeName is checked case-insensitively | +### forms +| Commit | Type | Description | +| -- | -- | -- | +| [3497c9b943](https://github.com/angular/angular/commit/3497c9b943748d0f21587fcdc1482215f857fa28) | fix | ensure `pending` status propagates to the root form in signal forms | +### http +| Commit | Type | Description | +| -- | -- | -- | +| [3192dccaa3](https://github.com/angular/angular/commit/3192dccaa3e1488f8bf351864ea48e44a21246aa) | fix | prevent transfer cache key collisions | +### migrations +| Commit | Type | Description | +| -- | -- | -- | +| [49672c437b](https://github.com/angular/angular/commit/49672c437b440564e9c20b77a7ff151fbf316706) | fix | correctly migrate ngClass with mixed space-separated keys | +| [223e40279f](https://github.com/angular/angular/commit/223e40279f3f6df979a323f79cb370528366c24a) | fix | preserve NgClass import on partial migration | + + + # 22.0.8 (2026-07-22) ### common From e77930993020da05d351d63860b165096a79b2af Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 23 Jul 2026 11:46:39 -0700 Subject: [PATCH 003/161] release: bump Angular DevTools version to 1.18.0 --- .../projects/shell-browser/src/manifest/manifest.chrome.json | 2 +- .../projects/shell-browser/src/manifest/manifest.firefox.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/devtools/projects/shell-browser/src/manifest/manifest.chrome.json b/devtools/projects/shell-browser/src/manifest/manifest.chrome.json index 193f0bfb0efb..2fc96332bda5 100644 --- a/devtools/projects/shell-browser/src/manifest/manifest.chrome.json +++ b/devtools/projects/shell-browser/src/manifest/manifest.chrome.json @@ -3,7 +3,7 @@ "short_name": "Angular DevTools", "name": "Angular DevTools", "description": "Angular DevTools extends Chrome DevTools adding Angular specific debugging and profiling capabilities.", - "version": "1.17.0", + "version": "1.18.0", "minimum_chrome_version": "102", "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" diff --git a/devtools/projects/shell-browser/src/manifest/manifest.firefox.json b/devtools/projects/shell-browser/src/manifest/manifest.firefox.json index 960486595b37..d4bf15c3210f 100644 --- a/devtools/projects/shell-browser/src/manifest/manifest.firefox.json +++ b/devtools/projects/shell-browser/src/manifest/manifest.firefox.json @@ -3,7 +3,7 @@ "short_name": "Angular DevTools", "name": "Angular DevTools", "description": "Angular DevTools extends Firefox DevTools adding Angular specific debugging and profiling capabilities.", - "version": "1.17.0", + "version": "1.18.0", "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", "icons": { "16": "assets/icon16.png", From d79b3b65e9536accd825620331185706c3339714 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 24 Jul 2026 09:54:04 +0200 Subject: [PATCH 004/161] refactor(core): align navigation types with built in ones Aligns our clone of the navigation API types with the built-in TypeScript types. This is related to an internal issue. --- .../dom-navigation/src/navigation_types.ts | 16 ++++---- .../dom-navigation/testing/fake_navigation.ts | 41 ++++++++++++------- .../test/fake_platform_navigation.spec.ts | 16 ++++---- packages/core/src/core_private_export.ts | 2 +- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/packages/core/primitives/dom-navigation/src/navigation_types.ts b/packages/core/primitives/dom-navigation/src/navigation_types.ts index d9c49dd2e7e2..8ff38ff84cc3 100644 --- a/packages/core/primitives/dom-navigation/src/navigation_types.ts +++ b/packages/core/primitives/dom-navigation/src/navigation_types.ts @@ -64,7 +64,7 @@ export declare class Navigation extends EventTarget { } export declare class NavigationTransition { - readonly navigationType: NavigationTypeString; + readonly navigationType: NavigationType; readonly from: NavigationHistoryEntry; readonly to: NavigationDestination; readonly finished: Promise; @@ -108,7 +108,7 @@ export declare class NavigationHistoryEntry extends EventTarget { ): void; } -export type NavigationTypeString = 'reload' | 'push' | 'replace' | 'traverse'; +export type NavigationType = 'push' | 'reload' | 'replace' | 'traverse'; export interface NavigationUpdateCurrentEntryOptions { state: unknown; @@ -130,19 +130,19 @@ export interface NavigationReloadOptions extends NavigationOptions { export declare class NavigationCurrentEntryChangeEvent extends Event { constructor(type: string, eventInit?: NavigationCurrentEntryChangeEventInit); - readonly navigationType: NavigationTypeString | null; + readonly navigationType: NavigationType | null; readonly from: NavigationHistoryEntry; } export interface NavigationCurrentEntryChangeEventInit extends EventInit { - navigationType?: NavigationTypeString | null; + navigationType?: NavigationType | null; from: NavigationHistoryEntry; } export declare class NavigateEvent extends Event { constructor(type: string, eventInit?: NavigateEventInit); - readonly navigationType: NavigationTypeString; + readonly navigationType: NavigationType; readonly canIntercept: boolean; readonly userInitiated: boolean; readonly hashChange: boolean; @@ -157,7 +157,7 @@ export declare class NavigateEvent extends Event { } export interface NavigateEventInit extends EventInit { - navigationType?: NavigationTypeString; + navigationType?: NavigationType; canIntercept?: boolean; userInitiated?: boolean; hashChange?: boolean; @@ -176,8 +176,8 @@ export interface NavigationInterceptOptions { export declare class NavigationDestination { readonly url: string; - readonly key: string | null; - readonly id: string | null; + readonly key: string; + readonly id: string; readonly index: number; readonly sameDocument: boolean; diff --git a/packages/core/primitives/dom-navigation/testing/fake_navigation.ts b/packages/core/primitives/dom-navigation/testing/fake_navigation.ts index a556713ea49a..f2311bf064b2 100644 --- a/packages/core/primitives/dom-navigation/testing/fake_navigation.ts +++ b/packages/core/primitives/dom-navigation/testing/fake_navigation.ts @@ -9,7 +9,7 @@ // 3p-only-start import { NavigationNavigateOptions, - NavigationTypeString, + NavigationType, NavigationOptions, NavigateEvent, NavigationCurrentEntryChangeEvent, @@ -80,6 +80,8 @@ export class FakeNavigation implements Navigation { */ eventTarget: EventTarget; + readonly activation: NavigationActivation | null = null; + /** The next unique id for created entries. Replace recreates this id. */ private nextId = 0; @@ -179,7 +181,7 @@ export class FakeNavigation implements Navigation { const fromUrl = new URL(this.currentEntry.url!); const toUrl = new URL(url, this.currentEntry.url!); - let navigationType: NavigationTypeString; + let navigationType: NavigationType; if (!options?.history || options.history === 'auto') { // Auto defaults to push, but if the URLs are the same, is a replace. if (fromUrl.toString() === toUrl.toString()) { @@ -231,7 +233,7 @@ export class FakeNavigation implements Navigation { } private pushOrReplaceState( - navigationType: NavigationTypeString, + navigationType: NavigationType, data: unknown, _title: string, url?: string, @@ -617,13 +619,15 @@ export class FakeNavigation implements Navigation { } set oncurrententrychange( - _handler: // tslint:disable-next-line:no-any + _handler: + // tslint:disable-next-line:no-any ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any) | null, ) { throw new Error('unimplemented'); } - get oncurrententrychange(): // tslint:disable-next-line:no-any + get oncurrententrychange(): + // tslint:disable-next-line:no-any ((this: Navigation, ev: NavigationCurrentEntryChangeEvent) => any) | null { throw new Error('unimplemented'); } @@ -816,7 +820,7 @@ function dispatchNavigateEvent({ canIntercept: boolean; userInitiated: boolean; hashChange: boolean; - navigationType: NavigationTypeString; + navigationType: NavigationType; destination: FakeNavigationDestination; info: unknown; sameDocument: boolean; @@ -846,7 +850,7 @@ function dispatchNavigateEvent({ event.sameDocument = sameDocument; let precommitHandlers: Array<(controller: NavigationPrecommitController) => Promise> = []; - let handlers: Array<() => Promise> = []; + let handlers: Array<() => PromiseLike | void> = []; // https://whatpr.org/html/10919/nav-history-apis.html#dom-navigateevent-intercept event.intercept = function ( @@ -962,7 +966,14 @@ function dispatchNavigateEvent({ } } (navigation.transition as InternalNavigationTransition)?.committedResolve(); - const promisesList: Array> = handlers.map((handler) => handler()); + const promisesList: Array> = []; + for (const handler of handlers) { + const handlerResult = handler(); + + if (handlerResult) { + promisesList.push(handlerResult); + } + } promisesList.push(result.committed); Promise.all(promisesList) .then(() => { @@ -1134,7 +1145,7 @@ function createFakeNavigationCurrentEntryChangeEvent({ navigationType, }: { from: FakeNavigationHistoryEntry; - navigationType: NavigationTypeString; + navigationType: NavigationType; }) { const event = new Event('currententrychange', { bubbles: false, @@ -1176,8 +1187,8 @@ function createHashChangeEvent(newURL: string, oldURL: string) { export class FakeNavigationDestination implements NavigationDestination { url: string; readonly sameDocument: boolean; - readonly key: string | null; - readonly id: string | null; + readonly key: string; + readonly id: string; readonly index: number; state?: unknown; @@ -1204,8 +1215,8 @@ export class FakeNavigationDestination implements NavigationDestination { this.sameDocument = sameDocument; this.state = state; this.historyState = historyState; - this.key = key; - this.id = id; + this.key = key ?? ''; + this.id = id ?? ''; this.index = index; } @@ -1238,7 +1249,7 @@ class InternalNavigationTransition implements NavigationTransition { constructor( readonly from: NavigationHistoryEntry, readonly to: NavigationDestination, - readonly navigationType: NavigationTypeString, + readonly navigationType: NavigationType, ) { this.finished = new Promise((resolve, reject) => { this.finishedReject = reject; @@ -1303,7 +1314,7 @@ class InternalNavigationResult { /** Internal options for performing a navigate. */ interface InternalNavigateOptions { - navigationType: NavigationTypeString; + navigationType: NavigationType; cancelable: boolean; canIntercept: boolean; userInitiated: boolean; diff --git a/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts b/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts index a274dd96801d..b41b8557e29c 100644 --- a/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts +++ b/packages/core/primitives/dom-navigation/testing/test/fake_platform_navigation.spec.ts @@ -162,8 +162,8 @@ describe('navigation', () => { signal: jasmine.any(AbortSignal), destination: jasmine.objectContaining({ url: 'https://test.com/test', - key: null, - id: null, + key: '', + id: '', index: -1, sameDocument: false, }), @@ -235,8 +235,8 @@ describe('navigation', () => { signal: jasmine.any(AbortSignal), destination: jasmine.objectContaining({ url: 'https://test.com/test', - key: null, - id: null, + key: '', + id: '', index: -1, sameDocument: false, }), @@ -1515,8 +1515,8 @@ describe('navigation', () => { signal: jasmine.any(AbortSignal), destination: jasmine.objectContaining({ url: 'https://test.com/test', - key: null, - id: null, + key: '', + id: '', index: -1, sameDocument: true, }), @@ -1571,8 +1571,8 @@ describe('navigation', () => { signal: jasmine.any(AbortSignal), destination: jasmine.objectContaining({ url: 'https://test.com/test', - key: null, - id: null, + key: '', + id: '', index: -1, sameDocument: true, }), diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 2f73109420b5..7b8e5854c203 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -17,7 +17,7 @@ export { type NavigationReloadOptions as ɵNavigationReloadOptions, type NavigationResult as ɵNavigationResult, type NavigationTransition as ɵNavigationTransition, - type NavigationTypeString as ɵNavigationTypeString, + type NavigationType as ɵNavigationType, type NavigationUpdateCurrentEntryOptions as ɵNavigationUpdateCurrentEntryOptions, } from '../primitives/dom-navigation'; export {maybeUnwrapDefaultExport as ɵmaybeUnwrapDefaultExport} from './util/default_export'; From 25dbe795079d4b57ba7f334013a399aff640f5e2 Mon Sep 17 00:00:00 2001 From: Jens Kuehlers Date: Thu, 16 Jul 2026 08:21:57 +0000 Subject: [PATCH 005/161] docs: add v23 release and change to yearly release cycle ## Summary This PR adds v22.x and v23 release dates. It also changes Angular's release cadence to a yearly cycle. ## Why we are making this change The community has long requested less frequent major releases due to the impact of breaking changes and upgrades for their projects as well as for enterprise customers. Additionally, a longer release cycle provides increased API stability for developers using agentic workflows, while still delivering a reasonable cadence of API upgrades and migrations. --- adev/src/content/reference/releases.md | 37 +++++++++++--------- contributing-docs/branches-and-versioning.md | 19 +++++----- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/adev/src/content/reference/releases.md b/adev/src/content/reference/releases.md index 6a47894e8144..5c6f65c53a75 100644 --- a/adev/src/content/reference/releases.md +++ b/adev/src/content/reference/releases.md @@ -60,44 +60,47 @@ HELPFUL: Dates are offered as general guidance and are subject to change. In general, expect the following release cycle: -- A major release every 6 months -- 1-3 minor releases for each major release +- A major release every 12 months +- 4-6 minor releases for each major release - A patch release and pre-release \(`next` or `rc`\) build almost every week This cadence of releases gives eager developers access to new features as soon as they are fully developed and pass through our code review and integration testing processes, while maintaining the stability and reliability of the platform for production users that prefer to receive features after they have been validated by Google and other developers that use the pre-release builds. +HELPFUL: Until Angular v22, Angular had a 6-month major release cycle, with 1-3 minor releases for each major release + ## Support policy and schedule HELPFUL: Approximate dates are offered as general guidance and are subject to change. ### Release schedule -Release schedule for upcoming versions will be announced soon. - - +| v22.1 | Week of 2026-07-27 | +| v22.2 | ~ September 2026 | +| v22.3 | ~ November 2026 | +| v22.4 | ~ January 2027 | +| v22.5 | ~ March 2027 | +| v23.0 | ~ June 2027 | ### Support window -All major releases are typically supported for 18 months. +All major releases are typically supported for 24 months. | Support stage | Support Timing | Details | | :---------------- | :------------- | :------------------------------------------------------------------ | -| Active | 6 months | Regularly-scheduled updates and patches are released | +| Active | 12 months | Regularly-scheduled updates and patches are released | | Long-term \(LTS\) | 12 months | Only [critical fixes and security patches](#lts-fixes) are released | ### Actively supported versions The following table provides the status for Angular versions under support. -| Version | Status | Released | Active ends | LTS ends | -| :------ | :----- | :--------- | :-------------- | :-------------- | -| ^22.0.0 | Active | 2026-06-03 | To be announced | To be announced | -| ^21.0.0 | LTS | 2025-11-19 | 2026-06-03 | To be announced | -| ^20.0.0 | LTS | 2025-05-28 | 2025-11-19 | 2026-11-28 | +| Version | Status | Released | Active ends | LTS ends | +| :------ | :----- | :--------- | :---------- | :--------- | +| ^22.0.0 | Active | 2026-06-03 | 2027-06 | 2028-06 | +| ^21.0.0 | LTS | 2025-11-19 | 2026-06-03 | 2027-06 | +| ^20.0.0 | LTS | 2025-05-28 | 2025-11-19 | 2026-11-28 | Angular versions v2 to v19 are no longer supported. @@ -110,14 +113,14 @@ As a general rule, a fix is considered for an LTS version if it resolves one of: ## Deprecation policy -When the Angular team intends to remove an API or feature, it will be marked as _deprecated_. This occurs when an API is obsolete, superseded by another API, or otherwise discontinued. Deprecated API remain available through their deprecated phase, which lasts a minimum two major versions (approximately one year). +When the Angular team intends to remove an API or feature, it will be marked as _deprecated_. This occurs when an API is obsolete, superseded by another API, or otherwise discontinued. Deprecated API remain available through their deprecated phase, which lasts a minimum one major version (approximately one year). To help ensure that you have sufficient time and a clear path to update, this is our deprecation policy: | Deprecation stages | Details | | :----------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Announcement | We announce deprecated APIs and features in the [change log](https://github.com/angular/angular/blob/main/CHANGELOG.md 'Angular change log'). Deprecated APIs appear in the [documentation](api?status=8) with ~~strikethrough~~. When we announce a deprecation, we also announce a recommended update path. Additionally, all deprecated APIs are annotated with `@deprecated` in the corresponding documentation, which enables text editors and IDEs to provide hints if your project depends on them. | -| Deprecation period | When an API or a feature is deprecated, it is still present in at least the next two major releases (period of at least 12 months). After that, deprecated APIs and features are candidates for removal. A deprecation can be announced in any release, but the removal of a deprecated API or feature happens only in major release. Until a deprecated API or feature is removed, it is maintained according to the LTS support policy, meaning that only critical and security issues are fixed. | +| Deprecation period | When an API or a feature is deprecated, it is still present in at least the next major release (period of at least 12 months). After that, deprecated APIs and features are candidates for removal. A deprecation can be announced in any release, but the removal of a deprecated API or feature happens only in major release. Until a deprecated API or feature is removed, it is maintained according to the LTS support policy, meaning that only critical and security issues are fixed. | | npm dependencies | We only make npm dependency updates that require changes to your applications in a major release. In minor releases, we update peer dependencies by expanding the supported versions, but we do not require projects to update these dependencies until a future major version. This means that during minor Angular releases, npm dependency updates within Angular applications and libraries are optional. | ## Compatibility policy diff --git a/contributing-docs/branches-and-versioning.md b/contributing-docs/branches-and-versioning.md index 9a5adec81e7f..b73fb7180b85 100644 --- a/contributing-docs/branches-and-versioning.md +++ b/contributing-docs/branches-and-versioning.md @@ -28,23 +28,26 @@ referred to as the **active patch branch**. ## Major releases lifecycle -Angular releases a major version roughly every six months. Following a major release, we move +Angular releases a major version roughly every twelve months. Following a major release, we move through a consistent lifecycle to the next major release, and repeat. At a high level, this process proceeds as follows: - A major release occurs. The `main` branch now represents the next minor version. -- Six weeks later, a minor release occurs. The `main` branch now represents the next minor - version. -- Six weeks later, a second minor release occurs. The `main` branch now represents the next major +- Eight weeks later, a minor release occurs. The `main` branch now represents the next minor + version. This process repeats 4 times. +- Eight weeks later, the last minor release occurs. The `main` branch now represents the next major version. - Three months later, a major release occurs and the process repeats. +NOTE: Until Angular v22, Angular had a major release every six months, with 1-3 minor releases for each major release. + ### Example -- Angular publishes `11.0.0`. At this point in time, the `main` branch represents `11.1.0`. -- Six weeks later, we publish `11.1.0` and `main` represents `11.2.0`. -- Six weeks later, we publish `11.2.0` and `main` represents `12.0.0`. -- Three months later, this cycle repeats with the publication of `12.0.0`. +- Angular publishes `22.0.0`. At this point in time, the `main` branch represents `22.1.0`. +- Eight weeks later, we publish `22.1.0` and `main` represents `22.2.0`. +- The last step repeats until 22.4.0 is published and `main` represents `22.5.0`. +- Eight weeks later, we publish `22.5.0` and `main` represents `23.0.0`. +- Three months later, this cycle repeats with the publication of `23.0.0`. ### Feature freeze and release candidates From 738b8fe9d2a9c164f3601b123cbdbb33bb9a0a04 Mon Sep 17 00:00:00 2001 From: mfstapert Date: Tue, 21 Jul 2026 23:15:11 +0200 Subject: [PATCH 006/161] docs: update attribute testing guide to include canonical example with local component --- .../guide/testing/attribute-directives.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/adev/src/content/guide/testing/attribute-directives.md b/adev/src/content/guide/testing/attribute-directives.md index 1613929010cd..439c7ce9c251 100644 --- a/adev/src/content/guide/testing/attribute-directives.md +++ b/adev/src/content/guide/testing/attribute-directives.md @@ -154,3 +154,35 @@ A few techniques are noteworthy: The test for the default color uses the injector of the second `

` to get its `Highlight` instance and its `defaultColor`. - `DebugElement.properties` affords access to the artificial custom property that is set by the directive + +## Testing a directive in isolation + +A directive can't be constructed through TestBed; it must be rendered through a component's template to behave correctly. +The `Highlight` directive can be tested this way, using a local test component's input to control the directive. + +```ts +@Component({ + imports: [Highlight], + template: `

{{ color() }}

`, +}) +class Test { + readonly color = input(''); +} + +describe('Highlight', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + fixture = TestBed.createComponent(Test); + await fixture.whenStable(); + }); + + it('should use the specified color once an input is provided', async () => { + fixture.componentRef.setInput('color', 'blue'); + await fixture.whenStable(); + + const p = fixture.nativeElement.querySelector('p'); + expect(p.style.backgroundColor).toBe('blue'); + }); +}); +``` From 28d59e8d6357b6f5015657f06ecd7b543f00aba0 Mon Sep 17 00:00:00 2001 From: Kam Date: Wed, 22 Jul 2026 12:48:47 +0300 Subject: [PATCH 007/161] docs: use https for external links in adev Several guides, examples, and the update-guide recommendations linked to external resources over insecure http. Switch them to https. --- adev/src/app/features/update/recommendations.ts | 14 +++++++------- adev/src/content/events/v21.md | 2 +- .../my-lib/schematics/my-service/schema.json | 8 +++----- .../projects/my-lib/schematics/ng-add/schema.json | 2 +- adev/src/content/guide/i18n/prepare.md | 4 ++-- adev/src/content/guide/i18n/translation-files.md | 4 ++-- adev/src/content/guide/image-optimization.md | 2 +- adev/src/content/guide/testing/karma.md | 2 +- adev/src/content/kitchen-sink.md | 2 +- adev/src/content/tools/cli/cli-builder.md | 4 ++-- 10 files changed, 21 insertions(+), 23 deletions(-) diff --git a/adev/src/app/features/update/recommendations.ts b/adev/src/app/features/update/recommendations.ts index 67555f23e630..ee3ef33c122d 100644 --- a/adev/src/app/features/update/recommendations.ts +++ b/adev/src/app/features/update/recommendations.ts @@ -245,7 +245,7 @@ export const RECOMMENDATIONS: Step[] = [ level: ApplicationComplexity.Basic, step: 'node 8', action: - 'Make sure you are using [Node 8 or later](http://www.hostingadvice.com/how-to/update-node-js-latest-version/)', + 'Make sure you are using [Node 8 or later](https://www.hostingadvice.com/how-to/update-node-js-latest-version/)', }, { possibleIn: 600, @@ -486,7 +486,7 @@ export const RECOMMENDATIONS: Step[] = [ level: ApplicationComplexity.Basic, step: 'node 10', action: - 'Make sure you are using [Node 10 or later](http://www.hostingadvice.com/how-to/update-node-js-latest-version/).', + 'Make sure you are using [Node 10 or later](https://www.hostingadvice.com/how-to/update-node-js-latest-version/).', }, { possibleIn: 800, @@ -579,7 +579,7 @@ export const RECOMMENDATIONS: Step[] = [ level: ApplicationComplexity.Basic, step: 'node 10.13', action: - 'Make sure you are using [Node 10.13 or later](http://www.hostingadvice.com/how-to/update-node-js-latest-version/).', + 'Make sure you are using [Node 10.13 or later](https://www.hostingadvice.com/how-to/update-node-js-latest-version/).', }, { possibleIn: 900, @@ -780,7 +780,7 @@ export const RECOMMENDATIONS: Step[] = [ level: ApplicationComplexity.Medium, step: '$localize', action: - "If you use [Angular's i18n support](http://angular.io/guide/i18n), you will need to begin using `@angular/localize`. Learn more about the [$localize Global Import Migration](https://v9.angular.io/guide/migration-localize).", + "If you use [Angular's i18n support](https://angular.io/guide/i18n), you will need to begin using `@angular/localize`. Learn more about the [$localize Global Import Migration](https://v9.angular.io/guide/migration-localize).", }, { @@ -981,7 +981,7 @@ export const RECOMMENDATIONS: Step[] = [ level: ApplicationComplexity.Basic, step: 'v11 browser support', action: - 'Support for IE9, IE10, and IE mobile has been removed. This was announced in the [v10 update](http://blog.angular.dev/version-10-of-angular-now-available-78960babd41#c357). ', + 'Support for IE9, IE10, and IE mobile has been removed. This was announced in the [v10 update](https://blog.angular.dev/version-10-of-angular-now-available-78960babd41#c357). ', }, { possibleIn: 1100, @@ -1348,7 +1348,7 @@ export const RECOMMENDATIONS: Step[] = [ level: ApplicationComplexity.Basic, step: 'v13 node', action: - 'Make sure you are using [Node 12.20.0 or later](http://www.hostingadvice.com/how-to/update-node-js-latest-version/)', + 'Make sure you are using [Node 12.20.0 or later](https://www.hostingadvice.com/how-to/update-node-js-latest-version/)', }, { possibleIn: 1300, @@ -1471,7 +1471,7 @@ export const RECOMMENDATIONS: Step[] = [ level: ApplicationComplexity.Basic, step: 'v14 node', action: - 'Make sure you are using [Node 14.15.0 or later](http://www.hostingadvice.com/how-to/update-node-js-latest-version/)', + 'Make sure you are using [Node 14.15.0 or later](https://www.hostingadvice.com/how-to/update-node-js-latest-version/)', }, { possibleIn: 1400, diff --git a/adev/src/content/events/v21.md b/adev/src/content/events/v21.md index 46838e632d2e..675e931ac124 100644 --- a/adev/src/content/events/v21.md +++ b/adev/src/content/events/v21.md @@ -4,7 +4,7 @@ ## Release Blog -**Angular v21 is live**: check out the [v21 release blog](http://goo.gle/angular-v21-blog) to learn about all of the amazing new features coming your way. +**Angular v21 is live**: check out the [v21 release blog](https://goo.gle/angular-v21-blog) to learn about all of the amazing new features coming your way. ## Experience the v21 Release diff --git a/adev/src/content/examples/schematics-for-libraries/projects/my-lib/schematics/my-service/schema.json b/adev/src/content/examples/schematics-for-libraries/projects/my-lib/schematics/my-service/schema.json index 5409153a867c..01aef019a12f 100644 --- a/adev/src/content/examples/schematics-for-libraries/projects/my-lib/schematics/my-service/schema.json +++ b/adev/src/content/examples/schematics-for-libraries/projects/my-lib/schematics/my-service/schema.json @@ -1,5 +1,5 @@ { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", "$id": "SchematicsMyService", "title": "My Service Schema", "type": "object", @@ -24,8 +24,6 @@ "$source": "projectName" } } - }, - "required": [ - "name" - ] + }, + "required": ["name"] } diff --git a/adev/src/content/examples/schematics-for-libraries/projects/my-lib/schematics/ng-add/schema.json b/adev/src/content/examples/schematics-for-libraries/projects/my-lib/schematics/ng-add/schema.json index 32ac585d5854..30daba998600 100644 --- a/adev/src/content/examples/schematics-for-libraries/projects/my-lib/schematics/ng-add/schema.json +++ b/adev/src/content/examples/schematics-for-libraries/projects/my-lib/schematics/ng-add/schema.json @@ -1,5 +1,5 @@ { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", "$id": "SchematicsMyLibNgAdd", "title": "MyLib ng add Schema", "type": "object", diff --git a/adev/src/content/guide/i18n/prepare.md b/adev/src/content/guide/i18n/prepare.md index b2fc6175baf1..225e130d4de5 100644 --- a/adev/src/content/guide/i18n/prepare.md +++ b/adev/src/content/guide/i18n/prepare.md @@ -403,5 +403,5 @@ The following code example shows nested clauses based on the `gender` and `minut [GithubAngularAngularBlobEcffc3557fe1bff9718c01277498e877ca44588dPackagesCoreSrcI18nLocaleEnTsL14L18]: https://github.com/angular/angular/blob/ecffc3557fe1bff9718c01277498e877ca44588d/packages/core/src/i18n/locale_en.ts#L14-L18 'Line 14 to 18 - angular/packages/core/src/i18n/locale_en.ts | angular/angular | GitHub' [GithubUnicodeOrgIcuUserguideFormatParseMessages]: https://unicode-org.github.io/icu/userguide/format_parse/messages 'ICU Message Format - ICU Documentation | Unicode | GitHub' [UnicodeCldrMain]: https://cldr.unicode.org 'Unicode CLDR Project' -[UnicodeCldrIndexCldrSpecPluralRules]: http://cldr.unicode.org/index/cldr-spec/plural-rules 'Plural Rules | CLDR - Unicode Common Locale Data Repository | Unicode' -[UnicodeCldrIndexCldrSpecPluralRulesTocChoosingPluralCategoryNames]: http://cldr.unicode.org/index/cldr-spec/plural-rules#TOC-Choosing-Plural-Category-Names 'Choosing Plural Category Names - Plural Rules | CLDR - Unicode Common Locale Data Repository | Unicode' +[UnicodeCldrIndexCldrSpecPluralRules]: https://cldr.unicode.org/index/cldr-spec/plural-rules 'Plural Rules | CLDR - Unicode Common Locale Data Repository | Unicode' +[UnicodeCldrIndexCldrSpecPluralRulesTocChoosingPluralCategoryNames]: https://cldr.unicode.org/index/cldr-spec/plural-rules#TOC-Choosing-Plural-Category-Names 'Choosing Plural Category Names - Plural Rules | CLDR - Unicode Common Locale Data Repository | Unicode' diff --git a/adev/src/content/guide/i18n/translation-files.md b/adev/src/content/guide/i18n/translation-files.md index 45782f9b44d2..e684539b0851 100644 --- a/adev/src/content/guide/i18n/translation-files.md +++ b/adev/src/content/guide/i18n/translation-files.md @@ -238,6 +238,6 @@ The following example displays both translation units after translating. [GithubUnicodeOrgCldrStagingChartsLatestSupplementalLanguagePluralRulesHtml]: https://cldr.unicode.org/index/cldr-spec/plural-rules 'Language Plural Rules - CLDR Charts | Unicode | GitHub' [JsonMain]: https://www.json.org 'Introducing JSON | JSON' [OasisOpenDocsXliffXliffCoreXliffCoreHtml]: https://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html 'XLIFF Version 1.2 Specification | Oasis Open Docs' -[OasisOpenDocsXliffXliffCoreV20Cos01XliffCoreV20Cose01Html]: http://docs.oasis-open.org/xliff/xliff-core/v2.0/cos01/xliff-core-v2.0-cos01.html 'XLIFF Version 2.0 | Oasis Open Docs' -[UnicodeCldrDevelopmentDevelopmentProcessDesignProposalsXmb]: http://cldr.unicode.org/development/development-process/design-proposals/xmb 'XMB | CLDR - Unicode Common Locale Data Repository | Unicode' +[OasisOpenDocsXliffXliffCoreV20Cos01XliffCoreV20Cose01Html]: https://docs.oasis-open.org/xliff/xliff-core/v2.0/cos01/xliff-core-v2.0-cos01.html 'XLIFF Version 2.0 | Oasis Open Docs' +[UnicodeCldrDevelopmentDevelopmentProcessDesignProposalsXmb]: https://cldr.unicode.org/development/development-process/design-proposals/xmb 'XMB | CLDR - Unicode Common Locale Data Repository | Unicode' [WikipediaWikiXliff]: https://en.wikipedia.org/wiki/XLIFF 'XLIFF | Wikipedia' diff --git a/adev/src/content/guide/image-optimization.md b/adev/src/content/guide/image-optimization.md index a0987140476d..4adbbcad99cc 100644 --- a/adev/src/content/guide/image-optimization.md +++ b/adev/src/content/guide/image-optimization.md @@ -2,7 +2,7 @@ The `NgOptimizedImage` directive makes it easy to adopt performance best practices for loading images. -The directive ensures that the loading of the [Largest Contentful Paint (LCP)](http://web.dev/lcp) image is prioritized by: +The directive ensures that the loading of the [Largest Contentful Paint (LCP)](https://web.dev/lcp) image is prioritized by: - Automatically setting the `fetchpriority` attribute on the `` tag - Lazy loading other images by default diff --git a/adev/src/content/guide/testing/karma.md b/adev/src/content/guide/testing/karma.md index 95293d793c26..272b58b39ca8 100644 --- a/adev/src/content/guide/testing/karma.md +++ b/adev/src/content/guide/testing/karma.md @@ -117,7 +117,7 @@ If you want to customize Karma, you can create a `karma.conf.js` by running the ng generate config karma ``` -HELPFUL: Read more about Karma configuration in the [Karma configuration guide](http://karma-runner.github.io/6.4/config/configuration-file.html). +HELPFUL: Read more about Karma configuration in the [Karma configuration guide](https://karma-runner.github.io/6.4/config/configuration-file.html). ### Setting the Test Runner in `angular.json` diff --git a/adev/src/content/kitchen-sink.md b/adev/src/content/kitchen-sink.md index e11fc383fd74..1e200ee7958b 100644 --- a/adev/src/content/kitchen-sink.md +++ b/adev/src/content/kitchen-sink.md @@ -341,7 +341,7 @@ Embedded videos are created with `docs-video` and just need a `src` and `alt`: ## Charts & Graphs -Write diagrams and charts using [Mermaid](http://mermaid.js.org/) by setting the code language to `mermaid`, all theming is built-in. +Write diagrams and charts using [Mermaid](https://mermaid.js.org/) by setting the code language to `mermaid`, all theming is built-in. ```mermaid graph TD; diff --git a/adev/src/content/tools/cli/cli-builder.md b/adev/src/content/tools/cli/cli-builder.md index cf4f3d01bf30..52705a7b0d0e 100644 --- a/adev/src/content/tools/cli/cli-builder.md +++ b/adev/src/content/tools/cli/cli-builder.md @@ -106,7 +106,7 @@ You can provide the following schema for type validation of these values. ```json {header: "schema.json"} { - "$schema": "http://json-schema.org/schema", + "$schema": "https://json-schema.org/schema", "type": "object", "properties": { "source": { @@ -120,7 +120,7 @@ You can provide the following schema for type validation of these values. ``` HELPFUL: This is a minimal example, but the use of a schema for validation can be very powerful. -For more information, see the [JSON schemas website](http://json-schema.org). +For more information, see the [JSON schemas website](https://json-schema.org). To link our builder implementation with its schema and name, you need to create a _builder definition_ file, which you can point to in `package.json`. From 4e7aaa48f569d31c6a1e7831b00ca4a7b5e53d3a Mon Sep 17 00:00:00 2001 From: Ben Hong Date: Mon, 8 Jun 2026 13:48:32 -0400 Subject: [PATCH 008/161] docs: smooth out directives guide narrative flow (#69822) PR Close #69822 --- .../guide/directives/attribute-directives.md | 185 +++++++++--------- adev/src/content/guide/directives/overview.md | 135 +++++-------- 2 files changed, 139 insertions(+), 181 deletions(-) diff --git a/adev/src/content/guide/directives/attribute-directives.md b/adev/src/content/guide/directives/attribute-directives.md index d6ac24320bb4..3f36ebbe16df 100644 --- a/adev/src/content/guide/directives/attribute-directives.md +++ b/adev/src/content/guide/directives/attribute-directives.md @@ -1,145 +1,141 @@ # Attribute directives -Change the appearance or behavior of DOM elements and Angular components with attribute directives. +Attribute directives change the appearance or behavior of DOM elements and Angular components. -## Building an attribute directive - -This section walks you through creating a highlight directive that sets the background color of the host element to yellow. - -1. To create a directive, use the CLI command [`ng generate directive`](tools/cli/schematics). - - ```shell - ng generate directive highlight - ``` - - The CLI creates `src/app/highlight.directive.ts`, a corresponding test file `src/app/highlight.directive.spec.ts`. +## Built-in attribute directives - ```angular-ts - import {Directive} from '@angular/core'; +Angular includes several built-in attribute directives for common tasks: - @Directive({ - selector: '[appHighlight]', - }) - export class HighlightDirective {} - ``` +| Common directives | Details | +| :----------------------------------------------------- | :------------------------------------------------- | +| [`NgClass`](#adding-and-removing-classes-with-ngclass) | Adds and removes a set of CSS classes. | +| [`NgStyle`](#setting-inline-styles-with-ngstyle) | Adds and removes a set of HTML styles. | +| [`NgModel`](guide/forms/template-driven-forms) | Adds two-way data binding to an HTML form element. | - The `@Directive()` decorator's configuration property specifies the directive's CSS attribute selector, `[appHighlight]`. +HELPFUL: Built-in directives use only public APIs. They do not have special access to any private APIs that other directives can't access. -1. Import `ElementRef` and `inject` from `@angular/core`. - `ElementRef` grants direct access to the host DOM element through its `nativeElement` property. +### Adding and removing classes with `NgClass` -1. Use [`inject`](guide/di) to obtain a reference to the host DOM element, the element to which you apply `appHighlight`. +Add or remove multiple CSS classes simultaneously by binding `[ngClass]` to an expression. To use `NgClass`, add it to the component's `imports` list: -1. Add logic to the `HighlightDirective` class that sets the background to yellow. +```angular-ts +import {NgClass} from '@angular/common'; - - -IMPORTANT: Directives _do not_ support namespaces. - -```angular-html {avoid} -

This is invalid

+@Component({ + /* ... */ + imports: [NgClass], +}) +export class AppComponent {} ``` -## Applying an attribute directive - -To use the `HighlightDirective`, add a `

` element to the HTML template with the directive as an attribute. - - - -Angular creates an instance of the `HighlightDirective` class, which uses `inject(ElementRef)` to get a reference to the `

` element and set its background style to yellow. - -## Handling user events - -This section shows you how to detect when a user mouses into or out of the element and to respond by setting or clearing the highlight color. - -1. Configure host event bindings using the `host` property in the `@Directive()` decorator. - - +To toggle a single class, bind `[ngClass]` to a conditional expression that returns the class name. In this example, `ngClass` applies the `special` class when `isSpecial` is `true`: -1. Add two event handler methods, and map host element events to them via the `host` property. + - +To toggle several classes at once, bind `[ngClass]` to an object. Each key is a class name, and Angular adds the class when its value is truthy and removes it when its value is falsy: -Subscribe to events of the DOM element that hosts an attribute directive (the `

` in this case) by configuring event listeners on the directive's [`host` property](guide/components/host-elements#binding-to-the-host-element). +```angular-html +

+ This div's classes reflect the current component state. +
+``` -HELPFUL: The handlers delegate to a helper method, `highlight()`, that sets the color on the host DOM element, `el`. +HELPFUL: To add or remove a _single_ class, use [class binding](/guide/templates/binding#css-class-and-style-property-bindings) rather than `NgClass`. -The complete directive is as follows: +### Setting inline styles with `NgStyle` - +Set multiple inline styles simultaneously by binding `[ngStyle]` to an object. To use `NgStyle`, add it to the component's `imports` list: -The background color appears when the pointer hovers over the paragraph element and disappears as the pointer moves out. +```angular-ts +import {NgStyle} from '@angular/common'; -Second Highlight - -## Passing values into an attribute directive +@Component({ + /* ... */ + imports: [NgStyle], +}) +export class AppComponent {} +``` -This section walks you through setting the highlight color while applying the `HighlightDirective`. +Each key in the object is a CSS property name and each value is the style to apply: + +```angular-html +
+ This div's styles reflect the current component state. +
+``` -1. In `highlight.directive.ts`, import `input` from `@angular/core`. +HELPFUL: To add or remove a _single_ style, use [style bindings](guide/templates/binding#css-class-and-style-property-bindings) rather than `NgStyle`. - +## Building an attribute directive -1. Add an `appHighlight` `input` property. +A custom attribute directive is a class with the `@Directive()` decorator. The decorator's `selector` defines the attribute that applies the directive. By convention, custom selectors use a prefix such as `app` and wrap the name in square brackets to form an attribute selector: - +```angular-ts +import {Directive} from '@angular/core'; - The `input()` function adds metadata to the class that makes the directive's `appHighlight` property available for binding. +@Directive({ + selector: '[appHighlight]', +}) +export class HighlightDirective {} +``` -1. In `app.component.ts`, add a `color` property to the `AppComponent`. +HELPFUL: The CLI command [`ng generate directive`](tools/cli/schematics) scaffolds a directive along with its test file. - +To change the host element, a directive needs a reference to it. Inject [`ElementRef`](guide/di) to reach the element through its `nativeElement` property. This directive sets the background to yellow when Angular creates it: -1. To simultaneously apply the directive and the color, use property binding with the `appHighlight` directive selector, setting it equal to `color`. + - +IMPORTANT: Directives _do not_ support namespaces. - The `[appHighlight]` attribute binding performs two tasks: - - Applies the highlighting directive to the `

` element - - Sets the directive's highlight color with a property binding +```angular-html {avoid} +

This is invalid

+``` -### Setting the value with user input +## Applying an attribute directive -This section guides you through adding radio buttons to bind your color choice to the `appHighlight` directive. +To apply the directive, add its selector as an attribute on an element: -1. Add markup to `app.component.html` for choosing a color as follows: + - +Angular creates an instance of `HighlightDirective` for that `

` element, injects a reference to the element, and sets its background to yellow. -2. Revise the `AppComponent.color` so that it has no initial value. +## Handling user events - +To respond to user interaction, bind host element events to handler methods through the `host` property of the `@Directive()` decorator. The following directive highlights the host element while the pointer is over it and clears the highlight when the pointer leaves: -3. In `highlight.directive.ts`, revise `onMouseEnter` method so that it first tries to highlight with `appHighlight` and falls back to `red` if `appHighlight` is `undefined`. - + -4. Serve your application to verify that the user can choose the color with the radio buttons. +The `host` property maps the `mouseenter` and `mouseleave` events to the `onMouseEnter()` and `onMouseLeave()` methods, which delegate to a `highlight()` helper that sets the background color on the host element. For more on host event bindings, see [binding to the host element](guide/components/host-elements#binding-to-the-host-element). - Animated gif of the refactored highlight directive changing color according to the radio button the user selects +Second Highlight -## Binding to a second property +## Accepting input values -This section guides you through configuring your application so the developer can set the default color. +Like components, directives accept inputs through the [`input()`](guide/components/inputs) function. Give an input the same name as the selector so that a single binding both applies the directive and passes a value to it: -1. Add a second `input()` property to `HighlightDirective` called `defaultColor`. + - +Read the input by calling its signal, and fall back to a default when no value is bound: -2. Revise the directive's `onMouseEnter` so that it first tries to highlight with the `appHighlight`, then with the `defaultColor`, and falls back to `red` if both properties are `undefined`. + - +In the template, bind the value to the selector. Because the input shares the selector's name, `[appHighlight]` both applies the directive and sets its value: -3. To bind to the `AppComponent.color` and fall back to "violet" as the default color, add the following HTML. - In this case, the `defaultColor` binding doesn't use square brackets, `[]`, because the value is a static string, not a dynamic expression. + - +A directive can declare more than one input. The following directive adds a `defaultColor` input, then falls back through `appHighlight`, `defaultColor`, and finally `red`: - As with components, you can add multiple directive property bindings to a host element. + -The default color is red if there is no default color binding. -When the user chooses a color the selected color becomes the active highlight color. +Bind both inputs on the same element. Because `defaultColor` takes a static string rather than a dynamic expression, it doesn't need square brackets: -Animated gif of final highlight directive that shows red color with no binding and violet with the default color set. When user selects color, the selection takes precedence. + ## Deactivating Angular processing with `NgNonBindable` @@ -157,3 +153,10 @@ In the following example, the `appHighlight` directive is still active but Angul If you apply `ngNonBindable` to a parent element, Angular disables interpolation and binding of any sort, such as property binding or event binding, for the element's children. + +## What's next + + + + + diff --git a/adev/src/content/guide/directives/overview.md b/adev/src/content/guide/directives/overview.md index a30d7929df27..a640831663b4 100644 --- a/adev/src/content/guide/directives/overview.md +++ b/adev/src/content/guide/directives/overview.md @@ -1,118 +1,73 @@ - -Directives are classes that add additional behavior to elements in your Angular applications. + +Directives add behavior to elements and components in your Angular applications. -Use Angular's built-in directives to manage forms, lists, styles, and what users see. +A directive can change how an element looks, how it behaves, or how it fits into the DOM. Angular ships with several built-in directives, and you can write your own. -The different types of Angular directives are as follows: +## When to use a directive -| Directive Types | Details | -| :--------------------------------------------------------------- | :-------------------------------------------------------------------------------- | -| [Components](guide/components) | Used with a template. This type of directive is the most common directive type. | -| [Attribute directives](#built-in-attribute-directives) | Change the appearance or behavior of an element, component, or another directive. | -| [Structural directives](/guide/directives/structural-directives) | Change the DOM layout by adding and removing DOM elements. | +Directives are most effective when they encapsulate **reusable** behavior that you want to apply to an existing element or component. -This guide covers built-in [attribute directives](#built-in-attribute-directives). +Common examples include: -## Built-in attribute directives +- Applying the same appearance or behavior across many elements, such as autofocus or a tooltip. +- Reading from or writing to the host element's DOM, attributes, or classes. +- Adding behavior to a component you don't own without changing its source. -Attribute directives listen to and modify the behavior of other HTML elements, attributes, properties, and components. +If you need to render your own markup or manage a piece of UI with its own template, reach for a [component](guide/components) rather than a directive. -The most common attribute directives are as follows: +## A quick example -| Common directives | Details | -| :----------------------------------------------------- | :------------------------------------------------- | -| [`NgClass`](#adding-and-removing-classes-with-ngclass) | Adds and removes a set of CSS classes. | -| [`NgStyle`](#setting-inline-styles-with-ngstyle) | Adds and removes a set of HTML styles. | -| [`NgModel`](guide/forms/template-driven-forms) | Adds two-way data binding to an HTML form element. | +Suppose you want elements to highlight when the user hovers over them, changing their background color to yellow. Rather than repeat the same event-handling logic on every element, you can package that behavior in a directive and apply it wherever you need it. -HELPFUL: Built-in directives use only public APIs. They do not have special access to any private APIs that other directives can't access. - -## Adding and removing classes with `NgClass` - -Add or remove multiple CSS classes simultaneously with `ngClass`. - -HELPFUL: To add or remove a _single_ class, use [class binding](/guide/templates/binding#css-class-and-style-property-bindings) rather than `NgClass`. - -### Import `NgClass` in the component - -To use `NgClass`, add it to the component's `imports` list. +The following `appHighlight` directive sets the host element's background color when the pointer enters and clears it when the pointer leaves: ```angular-ts -import {NgClass} from '@angular/common'; - -@Component({ - /* ... */ - imports: [NgClass], +import {Directive, ElementRef, inject} from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', + host: { + '(mouseenter)': 'onMouseEnter()', + '(mouseleave)': 'onMouseLeave()', + }, }) -export class AppComponent {} -``` - -### Using `NgClass` with an expression - -On the element you'd like to style, add `[ngClass]` and set it equal to an expression. -In this case, `isSpecial` is a boolean set to `true` in `app.component.ts`. -Because `isSpecial` is true, `ngClass` applies the class of `special` to the `

`. - - - -### Using `NgClass` with a method - -1. To use `NgClass` with a method, add the method to the component class. - In the following example, `setCurrentClasses()` sets the property `currentClasses` with an object that adds or removes three classes based on the `true` or `false` state of three other component properties. - - Each key of the object is a CSS class name. - If a key is `true`, `ngClass` adds the class. - If a key is `false`, `ngClass` removes the class. +export class HighlightDirective { + private el = inject(ElementRef); - + onMouseEnter() { + this.el.nativeElement.style.backgroundColor = 'yellow'; + } -1. In the template, add the `ngClass` property binding to `currentClasses` to set the element's classes: - - - -For this use case, Angular applies the classes on initialization and in case of changes caused by reassigning the `currentClasses` object. -The full example calls `setCurrentClasses()` initially with `ngOnInit()` when the user clicks on the `Refresh currentClasses` button. -These steps are not necessary to implement `ngClass`. - -## Setting inline styles with `NgStyle` - -HELPFUL: To add or remove a _single_ style, use [style bindings](guide/templates/binding#css-class-and-style-property-bindings) rather than `NgStyle`. - -### Import `NgStyle` in the component - -To use `NgStyle`, add it to the component's `imports` list. - -```angular-ts -import {NgStyle} from '@angular/common'; - -@Component({ - /* ... */ - imports: [NgStyle], -}) -export class AppComponent {} + onMouseLeave() { + this.el.nativeElement.style.backgroundColor = ''; + } +} ``` -Use `NgStyle` to set multiple inline styles simultaneously, based on the state of the component. +Apply the directive by adding its selector as an attribute on an element: -1. To use `NgStyle`, add a method to the component class. - - In the following example, `setCurrentStyles()` sets the property `currentStyles` with an object that defines three styles, based on the state of three other component properties. +```angular-html +

Highlight me!

+``` - +Every element that carries the `appHighlight` attribute gains the same hover behavior, with the logic defined in one place. -1. To set the element's styles, add an `ngStyle` property binding to `currentStyles`. +## Types of directives - +Angular has two primary types of directives: -For this use case, Angular applies the styles upon initialization and in case of changes. -To do this, the full example calls `setCurrentStyles()` initially with `ngOnInit()` and when the dependent properties change through a button click. -However, these steps are not necessary to implement `ngStyle` on its own. +| Directive type | Details | +| :-------------------------------------------------------------- | :-------------------------------------------------------------------------------- | +| [Attribute directives](guide/directives/attribute-directives) | Change the appearance or behavior of an element, component, or another directive. | +| [Structural directives](guide/directives/structural-directives) | Change the DOM layout by adding and removing DOM elements. | ## What's next +Learn more about each type of directive in the following guides. + - - + + From 17845308ea4efc2d3f4b6886b7f46c2aed05b7c2 Mon Sep 17 00:00:00 2001 From: Ben Hong Date: Thu, 16 Jul 2026 11:24:16 -0400 Subject: [PATCH 009/161] docs: modernize directives guides (#69822) Co-authored-by: Matthieu Riegler PR Close #69822 --- .../guide/directives/attribute-directives.md | 86 ++----------- .../directives/directive-composition-api.md | 120 +++++++----------- adev/src/content/guide/directives/overview.md | 30 ++--- .../guide/directives/structural-directives.md | 67 ++++------ 4 files changed, 100 insertions(+), 203 deletions(-) diff --git a/adev/src/content/guide/directives/attribute-directives.md b/adev/src/content/guide/directives/attribute-directives.md index 3f36ebbe16df..ad50cc276f2e 100644 --- a/adev/src/content/guide/directives/attribute-directives.md +++ b/adev/src/content/guide/directives/attribute-directives.md @@ -2,81 +2,21 @@ Attribute directives change the appearance or behavior of DOM elements and Angular components. -## Built-in attribute directives +## Use template bindings for one-off behavior -Angular includes several built-in attribute directives for common tasks: +Angular's template syntax already covers changing a single element's classes, styles, properties, and events: -| Common directives | Details | -| :----------------------------------------------------- | :------------------------------------------------- | -| [`NgClass`](#adding-and-removing-classes-with-ngclass) | Adds and removes a set of CSS classes. | -| [`NgStyle`](#setting-inline-styles-with-ngstyle) | Adds and removes a set of HTML styles. | -| [`NgModel`](guide/forms/template-driven-forms) | Adds two-way data binding to an HTML form element. | +- [Class and style bindings](guide/templates/binding#css-class-and-style-property-bindings) add and remove CSS classes and inline styles. +- [Property and attribute bindings](guide/templates/binding) set DOM properties and HTML attributes. +- [Event listeners](guide/templates/event-listeners) respond to user interaction. -HELPFUL: Built-in directives use only public APIs. They do not have special access to any private APIs that other directives can't access. - -### Adding and removing classes with `NgClass` - -Add or remove multiple CSS classes simultaneously by binding `[ngClass]` to an expression. To use `NgClass`, add it to the component's `imports` list: - -```angular-ts -import {NgClass} from '@angular/common'; - -@Component({ - /* ... */ - imports: [NgClass], -}) -export class AppComponent {} -``` - -To toggle a single class, bind `[ngClass]` to a conditional expression that returns the class name. In this example, `ngClass` applies the `special` class when `isSpecial` is `true`: - - - -To toggle several classes at once, bind `[ngClass]` to an object. Each key is a class name, and Angular adds the class when its value is truthy and removes it when its value is falsy: - -```angular-html -
- This div's classes reflect the current component state. -
-``` - -HELPFUL: To add or remove a _single_ class, use [class binding](/guide/templates/binding#css-class-and-style-property-bindings) rather than `NgClass`. - -### Setting inline styles with `NgStyle` - -Set multiple inline styles simultaneously by binding `[ngStyle]` to an object. To use `NgStyle`, add it to the component's `imports` list: - -```angular-ts -import {NgStyle} from '@angular/common'; - -@Component({ - /* ... */ - imports: [NgStyle], -}) -export class AppComponent {} -``` - -Each key in the object is a CSS property name and each value is the style to apply: - -```angular-html -
- This div's styles reflect the current component state. -
-``` - -HELPFUL: To add or remove a _single_ style, use [style bindings](guide/templates/binding#css-class-and-style-property-bindings) rather than `NgStyle`. +Attribute directives are useful when you want to package this kind of behavior into a reusable unit that you can apply to any element or component. ## Building an attribute directive -A custom attribute directive is a class with the `@Directive()` decorator. The decorator's `selector` defines the attribute that applies the directive. By convention, custom selectors use a prefix such as `app` and wrap the name in square brackets to form an attribute selector: +A custom attribute directive is a JavaScript class with the `@Directive()` decorator. The decorator's `selector` defines the attribute that applies the directive. The square brackets make this an attribute selector, so the directive matches elements that carry the attribute. By convention, use a prefix such as `app` to avoid naming collisions: -```angular-ts +```ts import {Directive} from '@angular/core'; @Directive({ @@ -87,7 +27,7 @@ export class HighlightDirective {} HELPFUL: The CLI command [`ng generate directive`](tools/cli/schematics) scaffolds a directive along with its test file. -To change the host element, a directive needs a reference to it. Inject [`ElementRef`](guide/di) to reach the element through its `nativeElement` property. This directive sets the background to yellow when Angular creates it: +A directive can change its host declaratively through host bindings or imperatively through a reference to the host element. This example [injects](guide/di) [`ElementRef`](api/core/ElementRef) and accesses the element through its `nativeElement` property to set the background to yellow: @@ -113,22 +53,22 @@ To respond to user interaction, bind host element events to handler methods thro The `host` property maps the `mouseenter` and `mouseleave` events to the `onMouseEnter()` and `onMouseLeave()` methods, which delegate to a `highlight()` helper that sets the background color on the host element. For more on host event bindings, see [binding to the host element](guide/components/host-elements#binding-to-the-host-element). -Second Highlight - ## Accepting input values Like components, directives accept inputs through the [`input()`](guide/components/inputs) function. Give an input the same name as the selector so that a single binding both applies the directive and passes a value to it: -Read the input by calling its signal, and fall back to a default when no value is bound: +Read the input by calling it as a signal, and fall back to a default when no color is set: -In the template, bind the value to the selector. Because the input shares the selector's name, `[appHighlight]` both applies the directive and sets its value: +In the template, bind the value to the selector. Because the input shares the selector's name, `[appHighlight]` both applies the directive and sets its value. Here the bound `color` is a property on the component: + + A directive can declare more than one input. The following directive adds a `defaultColor` input, then falls back through `appHighlight`, `defaultColor`, and finally `red`: diff --git a/adev/src/content/guide/directives/directive-composition-api.md b/adev/src/content/guide/directives/directive-composition-api.md index 8cacb56637b6..47b132a26267 100644 --- a/adev/src/content/guide/directives/directive-composition-api.md +++ b/adev/src/content/guide/directives/directive-composition-api.md @@ -1,20 +1,16 @@ # Directive composition API -Angular directives offer a great way to encapsulate reusable behaviors— directives can apply -attributes, CSS classes, and event listeners to an element. +Angular directives offer a great way to encapsulate reusable behaviors. Directives can apply attributes, CSS classes, and event listeners to an element. -The _directive composition API_ lets you apply directives to a component's host element from -_within_ the component TypeScript class. +The _directive composition API_ lets you apply directives to a component's host element from _within_ the component TypeScript class. ## Adding directives to a component -You apply directives to a component by adding a `hostDirectives` property to a component's -decorator. We call such directives _host directives_. +You apply directives to a component by adding a `hostDirectives` property to a component's decorator. Such directives are called _host directives_. -In this example, we apply the directive `MenuBehavior` to the host element of `AdminMenu`. This -works similarly to applying the `MenuBehavior` to the `` element in a template. +In this example, the `MenuBehavior` directive is applied to the host element of `AdminMenu`. This works similarly to applying the `MenuBehavior` to the `` element in a template. -```typescript +```ts @Component({ selector: 'admin-menu', templateUrl: './admin-menu.html', @@ -23,25 +19,20 @@ works similarly to applying the `MenuBehavior` to the `` element in export class AdminMenu {} ``` -When the framework renders a component, Angular also creates an instance of each host directive. The -directives' host bindings apply to the component's host element. By default, host directive inputs -and outputs are not exposed as part of the component's public API. See -[Including inputs and outputs](#including-inputs-and-outputs) below for more information. - -**Angular applies host directives statically at compile time.** You cannot dynamically add -directives at runtime. +When the framework renders a component, Angular also creates an instance of each host directive. The directives' host bindings apply to the component's host element. By default, host directive inputs and outputs are not exposed as part of the component's public API. See [Including inputs and outputs](#including-inputs-and-outputs) below for more information. -**Directives used in `hostDirectives` may not specify `standalone: false`.** +Host directives come with the following constraints: -**Angular ignores the `selector` of directives applied in the `hostDirectives` property.** +- **Angular applies host directives statically at compile time.** You cannot dynamically add + directives at runtime. +- **Directives used in `hostDirectives` may not specify `standalone: false`.** +- **Angular ignores the `selector` of directives applied in the `hostDirectives` property.** ## Including inputs and outputs -When you apply `hostDirectives` to your component, the inputs and outputs from the host directives -are not included in your component's API by default. You can explicitly include inputs and outputs -in your component's API by expanding the entry in `hostDirectives`: +When you apply `hostDirectives` to your component, the inputs and outputs from the host directives are not included in your component's API by default. You can explicitly include inputs and outputs in your component's API by expanding the entry in `hostDirectives`: -```typescript +```ts @Component({ selector: 'admin-menu', templateUrl: './admin-menu.html', @@ -56,17 +47,15 @@ in your component's API by expanding the entry in `hostDirectives`: export class AdminMenu {} ``` -By explicitly specifying the inputs and outputs, consumers of the component with `hostDirective` can -bind them in a template: +By explicitly specifying the inputs and outputs, consumers of the component with `hostDirectives` can bind them in a template: ```angular-html ``` -Furthermore, you can alias inputs and outputs from `hostDirective` to customize the API of your -component: +Furthermore, you can alias inputs and outputs from a host directive to customize the API of your component: -```typescript +```ts @Component({ selector: 'admin-menu', templateUrl: './admin-menu.html', @@ -87,26 +76,17 @@ export class AdminMenu {} ## Adding directives to another directive -You can also add `hostDirectives` to other directives, in addition to components. This enables the -transitive aggregation of multiple behaviors. +You can also add `hostDirectives` to other directives, in addition to components. This enables the transitive aggregation of multiple behaviors. -In the following example, we define two directives, `Menu` and `Tooltip`. We then compose the behavior -of these two directives in `MenuWithTooltip`. Finally, we apply `MenuWithTooltip` -to `SpecializedMenuWithTooltip`. +The following example defines two directives, `Menu` and `Tooltip`, then composes their behavior in `MenuWithTooltip`. Finally, it applies `MenuWithTooltip` to `SpecializedMenuWithTooltip`. -When `SpecializedMenuWithTooltip` is used in a template, it creates instances of all of `Menu` -, `Tooltip`, and `MenuWithTooltip`. Each of these directives' host bindings apply to the host -element of `SpecializedMenuWithTooltip`. +When `SpecializedMenuWithTooltip` is used in a template, it creates instances of all of `Menu`, `Tooltip`, and `MenuWithTooltip`. Each of these directives' host bindings apply to the host element of `SpecializedMenuWithTooltip`. ```ts -@Directive({ - /* ... */ -}) +@Directive({/* ... */}) export class Menu {} -@Directive({ - /* ... */ -}) +@Directive({/* ... */}) export class Tooltip {} // MenuWithTooltip can compose behaviors from multiple other directives @@ -126,12 +106,11 @@ export class SpecializedMenuWithTooltip {} ### Directive execution order -Host directives go through the same lifecycle as components and directives used directly in a -template. However, host directives always execute their constructor, lifecycle hooks, and bindings _before_ the component or directive on which they are applied. +Host directives go through the same lifecycle as components and directives used directly in a template. However, host directives always execute their constructor, lifecycle hooks, and bindings _before_ the component or directive on which they are applied. The following example shows minimal use of a host directive: -```typescript +```ts @Component({ selector: 'admin-menu', templateUrl: './admin-menu.html', @@ -149,13 +128,11 @@ The order of execution here is: 5. `MenuBehavior` applies host bindings 6. `AdminMenu` applies host bindings -This order of operations means that components with `hostDirectives` can override any host bindings -specified by a host directive. +This order of operations means that components with `hostDirectives` can override any host bindings specified by a host directive. -This order of operations extends to nested chains of host directives, as shown in the following -example. +This order of operations extends to nested chains of host directives, as shown in the following example. -```typescript +```ts @Directive({...}) export class Tooltip { } @@ -184,15 +161,11 @@ In the example above, the order of execution is: ### Dependency injection -A component or directive that specifies `hostDirectives` can inject the instances of those host -directives and vice versa. +A component or directive that specifies `hostDirectives` can inject the instances of those host directives and vice versa. -When applying host directives to a component, both the component and host directives can define -providers. +When applying host directives to a component, both the component and host directives can define providers. -If a component or directive with `hostDirectives` and those host directives both provide the same -injection token, the providers defined by class with `hostDirectives` take precedence over providers -defined by the host directives. +If a component or directive with `hostDirectives` and those host directives both provide the same injection token, the providers defined by class with `hostDirectives` take precedence over providers defined by the host directives. ### Host directive de-duplication @@ -200,17 +173,14 @@ When the same directive appears more than once in the resolved host directive tr #### Template match takes precedence -If a directive matches an element once through a **template selector** and also appears as a -**host directive**, Angular keeps only the template match and discards all host directive matches. +If a directive matches an element once through a **template selector** and also appears as a **host directive**, Angular keeps only the template match and discards all host directive matches. -The mental model is that a host directive match represents `Partial` , a partial -application where only the inputs and outputs explicitly listed in `hostDirectives` are exposed, -while a template match represents the full directive with its complete public API. +The mental model is that a host directive match represents `Partial`, a partial application where only the inputs and outputs explicitly listed in `hostDirectives` are exposed, while a template match represents the full directive with its complete public API. -```ts @Directive({selector: '[hoverable]'}) export class Hoverable {} +```ts @Component({ selector: 'app-button', hostDirectives: [Hoverable], @@ -226,10 +196,7 @@ export class Button {} #### Multiple host directive matches are merged -If the same directive appears **more than once as a host directive** , for example, when two -directives both declare a common dependency in their `hostDirectives` , Angular merges all -instances into a single directive instance. The input and output mappings from all instances are -combined. +If the same directive appears **more than once as a host directive**, for example, when two directives both declare a common dependency in their `hostDirectives`, Angular merges all instances into a single directive instance. The input and output mappings from all instances are combined. This resolves the classic [diamond problem](https://en.wikipedia.org/wiki/Multiple_inheritance#The_diamond_problem) in host directive composition: @@ -237,11 +204,11 @@ This resolves the classic [diamond problem](https://en.wikipedia.org/wiki/Multip // A shared behavior that both triggers need @Directive({ host: { - '[attr.data-trigger-id]': 'triggerId', + '[attr.data-trigger-id]': 'triggerId()', }, }) export class TriggerRef { - readonly triggerId = `trigger-${crypto.randomUUID()}`; + readonly triggerId = input(`trigger-${crypto.randomUUID()}`); } // Two separate triggers, each declaring TriggerRef as a host directive @@ -267,14 +234,13 @@ export class DropdownTrigger { ``` -HELPFUL: Because Angular produces only one instance of the shared directive, both `PopoverTrigger` -and `DropdownTrigger` receive the same `TriggerRef` instance when they inject it. +HELPFUL: Because Angular produces only one instance of the shared directive, both `PopoverTrigger` and `DropdownTrigger` receive the same `TriggerRef` instance when they inject it. #### Conflicting aliases When Angular merges duplicate host directive matches it also merges their input and output mappings. -If two instances of the same host directive expose the **same input or output under different -aliases**, Angular throws an error at compile time ([NG8024](errors/NG8024)) + +If two instances of the same host directive expose the **same input or output under different aliases**, Angular throws an error at compile time ([NG8024](errors/NG8024)). ```ts @Directive({ @@ -297,5 +263,11 @@ export class DropdownTrigger {} ``` -To resolve this, ensure that both paths expose the shared input or output under the same alias, or -do not expose it at all. +To resolve this, ensure that both paths expose the shared input or output under the same alias, or do not expose it at all. + +## What's next + + + + + diff --git a/adev/src/content/guide/directives/overview.md b/adev/src/content/guide/directives/overview.md index a640831663b4..0a9050dc4824 100644 --- a/adev/src/content/guide/directives/overview.md +++ b/adev/src/content/guide/directives/overview.md @@ -14,37 +14,32 @@ Common examples include: - Reading from or writing to the host element's DOM, attributes, or classes. - Adding behavior to a component you don't own without changing its source. -If you need to render your own markup or manage a piece of UI with its own template, reach for a [component](guide/components) rather than a directive. +If you need to render your own markup or manage a piece of UI with its own template, reach for a [component](guide/components), a specialized directive with its own template. ## A quick example -Suppose you want elements to highlight when the user hovers over them, changing their background color to yellow. Rather than repeat the same event-handling logic on every element, you can package that behavior in a directive and apply it wherever you need it. +Suppose you want elements to highlight when the user hovers over them with a mouse, changing their background color to yellow. Rather than repeat the same event-handling logic on every element, you can package that behavior in a directive and apply it wherever you need it. -The following `appHighlight` directive sets the host element's background color when the pointer enters and clears it when the pointer leaves: +The following `appHighlight` directive sets the host element's background color when the mouse enters and clears it when the mouse leaves: -```angular-ts -import {Directive, ElementRef, inject} from '@angular/core'; +```ts +import {Directive, signal} from '@angular/core'; @Directive({ selector: '[appHighlight]', host: { - '(mouseenter)': 'onMouseEnter()', - '(mouseleave)': 'onMouseLeave()', + '(mouseenter)': 'isHovered.set(true)', + '(mouseleave)': 'isHovered.set(false)', + '[style.background-color]': 'isHovered() ? "yellow" : null', }, }) export class HighlightDirective { - private el = inject(ElementRef); - - onMouseEnter() { - this.el.nativeElement.style.backgroundColor = 'yellow'; - } - - onMouseLeave() { - this.el.nativeElement.style.backgroundColor = ''; - } + protected isHovered = signal(false); } ``` +The `host` metadata listens for mouse events to update the `isHovered` signal, and binds the host element's `background-color` style to the signal's value. + Apply the directive by adding its selector as an attribute on an element: ```angular-html @@ -55,10 +50,11 @@ Every element that carries the `appHighlight` attribute gains the same hover beh ## Types of directives -Angular has two primary types of directives: +Angular has three primary types of directives: | Directive type | Details | | :-------------------------------------------------------------- | :-------------------------------------------------------------------------------- | +| [Components](guide/components) | Define reusable UI with their own template. | | [Attribute directives](guide/directives/attribute-directives) | Change the appearance or behavior of an element, component, or another directive. | | [Structural directives](guide/directives/structural-directives) | Change the DOM layout by adding and removing DOM elements. | diff --git a/adev/src/content/guide/directives/structural-directives.md b/adev/src/content/guide/directives/structural-directives.md index b834cbb4cc01..13ce47f57f88 100644 --- a/adev/src/content/guide/directives/structural-directives.md +++ b/adev/src/content/guide/directives/structural-directives.md @@ -2,13 +2,15 @@ Structural directives are directives applied to an `` element that conditionally or repeatedly render the content of that ``. +For everyday conditional and repeated rendering, use Angular's built-in [control flow blocks](guide/templates/control-flow) (`@if`, `@for`, and `@switch`). Write a structural directive when you need reusable rendering behavior that control flow doesn't cover, such as gating content behind a permission check or providing a template with data from an external source. + ## Example use case -In this guide you'll build a structural directive which fetches data from a given data source and renders its template when that data is available. This directive is called `SelectDirective`, after the SQL keyword `SELECT`, and match it with an attribute selector `[select]`. +This guide uses a directive called `SelectDirective` as its running example. The directive fetches data from a given data source and renders its template when that data is available. It is named after the SQL keyword `SELECT` and matched with an attribute selector, `[select]`. -`SelectDirective` will have an input naming the data source to be used, which you will call `selectFrom`. The `select` prefix for this input is important for the [shorthand syntax](#structural-directive-shorthand). The directive will instantiate its `` with a template context providing the selected data. +`SelectDirective` has an input naming the data source to use, called `selectFrom`. The `select` prefix for this input is important for the [shorthand syntax](#structural-directive-shorthand). The directive instantiates its `` with a template context providing the selected data. -The following is an example of using this directive directly on an `` would look like: +Using this directive directly on an `` looks like this: ```angular-html @@ -18,7 +20,7 @@ The following is an example of using this directive directly on an ``. -HELPFUL: Note that Angular's `` element defines a template that doesn't render anything by default, if you just wrap elements in an `` without applying a structural directive those elements will not be rendered. +HELPFUL: Angular's `` element defines a template that doesn't render anything by default. If you wrap elements in an `` without applying a structural directive, those elements are not rendered. For more information, see the [ng-template API](api/core/ng-template) documentation. @@ -60,20 +62,12 @@ You can only apply one structural directive per element when using the shorthand ## Creating a structural directive -This section guides you through creating the `SelectDirective`. - - - -Using the Angular CLI, run the following command, where `select` is the name of the directive: +A structural directive is a directive class that injects two dependencies: -```shell -ng generate directive select -``` +- [`TemplateRef`](api/core/TemplateRef) gives the directive access to the content of the `` it is applied to. +- [`ViewContainerRef`](api/core/ViewContainerRef) represents the location in the DOM where the directive can render that template. -Angular creates the directive class and specifies the CSS selector, `[select]`, that identifies the directive in a template. - - -Import `TemplateRef`, `ViewContainerRef`, and `input`. Inject `TemplateRef` and `ViewContainerRef` in the directive as private properties. +The directive controls rendering by creating, or not creating, embedded views from the template in the view container. The complete `SelectDirective` looks like this: ```ts import {Directive, TemplateRef, ViewContainerRef, inject, input} from '@angular/core'; @@ -88,27 +82,9 @@ export interface DataSource { export class SelectDirective { private templateRef = inject(TemplateRef); private viewContainerRef = inject(ViewContainerRef); -} -``` - - -Add a `selectFrom` `input()` property. - -```ts -export class SelectDirective { - // ... selectFrom = input.required>(); -} -``` - - - -With `SelectDirective` now scaffolded as a structural directive with its input, you can now add the logic to fetch the data and render the template with it: -```ts -export class SelectDirective { - // ... async ngOnInit() { const data = await this.selectFrom().load(); this.viewContainerRef.createEmbeddedView(this.templateRef, { @@ -120,10 +96,15 @@ export class SelectDirective { } ``` - - +The `selectFrom` input names the data source the directive reads from. It uses [`input.required()`](guide/components/inputs#required-inputs) because the directive can't do anything useful without a data source. + +When Angular initializes the directive, it loads the data and then renders the template by calling `createEmbeddedView()`. The second argument is the template's _context object_: values the template can bind to with `let` declarations. Assigning the data to the `$implicit` key makes it the default value that `let-data` (or `let data` in shorthand) receives. -That's it - `SelectDirective` is up and running. A follow-up step might be to [add template type-checking support](#typing-the-directives-context). +NOTE: This example renders its template once, when the directive initializes. It does not re-render when the bound data source changes. + +Once the directive works, consider [adding template type-checking support](#typing-the-directives-context). + +HELPFUL: The CLI command [`ng generate directive`](tools/cli/schematics) scaffolds a directive along with its test file. ## Structural directive syntax reference @@ -167,8 +148,8 @@ The following table provides shorthand examples: | :-------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------ | | `*myDir="let item of [1,2,3]"` | `` | | `*myDir="let item of [1,2,3] as items; trackBy: myTrack; index as i"` | `` | -| `*ngComponentOutlet="componentClass";` | `` | -| `*ngComponentOutlet="componentClass; inputs: myInputs";` | `` | +| `*ngComponentOutlet="componentClass"` | `` | +| `*ngComponentOutlet="componentClass; inputs: myInputs"` | `` | | `*myDir="exp as value"` | `` | ## Improving template type checking for custom directives @@ -251,3 +232,11 @@ export class SelectDirective { } } ``` + +## What's next + + + + + + From 7cbf5e3c2b5539fee35b3848a61a9070df44267a Mon Sep 17 00:00:00 2001 From: Angular Robot Date: Thu, 23 Jul 2026 21:16:05 +0000 Subject: [PATCH 010/161] build: update all github actions See associated pull request for more information. --- .github/workflows/adev-preview-deploy.yml | 2 +- .github/workflows/assistant-to-the-branch-manager.yml | 2 +- .github/workflows/benchmark-compare.yml | 2 +- .github/workflows/cross-repo-adev-docs.yml | 2 +- .github/workflows/google-internal-tests.yml | 2 +- .github/workflows/scorecard.yml | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/adev-preview-deploy.yml b/.github/workflows/adev-preview-deploy.yml index 0e95143906f5..d0cc86127874 100644 --- a/.github/workflows/adev-preview-deploy.yml +++ b/.github/workflows/adev-preview-deploy.yml @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: token: '${{secrets.GITHUB_TOKEN}}' persist-credentials: false diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml index f2cd2939db4f..2a2e87dd3023 100644 --- a/.github/workflows/assistant-to-the-branch-manager.yml +++ b/.github/workflows/assistant-to-the-branch-manager.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest if: github.event.repository.fork == false steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: angular/dev-infra/github-actions/branch-manager@442c2fcbf06a321b5196b4c5fc70e78a49242958 diff --git a/.github/workflows/benchmark-compare.yml b/.github/workflows/benchmark-compare.yml index 8aed70f9db92..cc5cffa83620 100644 --- a/.github/workflows/benchmark-compare.yml +++ b/.github/workflows/benchmark-compare.yml @@ -28,7 +28,7 @@ jobs: - uses: alessbell/pull-request-comment-branch@ef3408c9757d05f89cb525036383033a313758a0 # v2.1.0 id: comment-branch - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Specify repository as the PR branch might be from a fork. repository: ${{steps.comment-branch.outputs.head_owner}}/${{steps.comment-branch.outputs.head_repo}} diff --git a/.github/workflows/cross-repo-adev-docs.yml b/.github/workflows/cross-repo-adev-docs.yml index be1d00dd0c14..eecfceb78c1d 100644 --- a/.github/workflows/cross-repo-adev-docs.yml +++ b/.github/workflows/cross-repo-adev-docs.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout the repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Setting `persist-credentials: false` prevents the github-action account from being the # account that is attempted to be used for authentication, instead the remote is set to diff --git a/.github/workflows/google-internal-tests.yml b/.github/workflows/google-internal-tests.yml index 2cb129d58cdf..dfcdb3375cd0 100644 --- a/.github/workflows/google-internal-tests.yml +++ b/.github/workflows/google-internal-tests.yml @@ -13,7 +13,7 @@ jobs: statuses: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: angular/dev-infra/github-actions/google-internal-tests@442c2fcbf06a321b5196b4c5fc70e78a49242958 with: run-tests-guide-url: http://go/angular-g3sync-start diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index b7663824b711..634fbeef0339 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -25,12 +25,12 @@ jobs: steps: - name: 'Checkout code' - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: 'Run analysis' - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif results_format: sarif @@ -47,6 +47,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: 'Upload to code-scanning' - uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: sarif_file: results.sarif From e606a020e90cf130f3ab532a7be553bf899313c2 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 23 Jul 2026 10:35:26 +0200 Subject: [PATCH 011/161] fix(language-service): account for strictTemplates being enabled by default We were raising the suggestion about enabling `strictTemplates` when `strictTemplates` is ommitted, however the option is now enabled by default. Fixes #69905. --- .../language-service/src/language_service.ts | 2 +- .../test/legacy/language_service_spec.ts | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/language-service/src/language_service.ts b/packages/language-service/src/language_service.ts index fa836a4dda0f..dcd1fa703959 100644 --- a/packages/language-service/src/language_service.ts +++ b/packages/language-service/src/language_service.ts @@ -899,7 +899,7 @@ export class LanguageService { project.readFile(path), ); - if (!this.options.strictTemplates) { + if (this.options.strictTemplates === false) { diagnostics.push({ messageText: 'Some language features are not available. ' + diff --git a/packages/language-service/test/legacy/language_service_spec.ts b/packages/language-service/test/legacy/language_service_spec.ts index cfb967214824..cdc50c3e2c26 100644 --- a/packages/language-service/test/legacy/language_service_spec.ts +++ b/packages/language-service/test/legacy/language_service_spec.ts @@ -107,9 +107,11 @@ describe('language service adapter', () => { }); describe('compiler options diagnostics', () => { - it('suggests turning on strict flag', () => { + it('suggests turning on strict flag when strictTemplates is explicitly false', () => { configFileFs.overwriteConfigFile(TSCONFIG, { - angularCompilerOptions: {}, + angularCompilerOptions: { + strictTemplates: false, + }, }); const diags = ngLS.getCompilerOptionsDiagnostics(); const diag = diags.find(isSuggestStrictTemplatesDiag); @@ -128,6 +130,16 @@ describe('language service adapter', () => { const diag = diags.find(isSuggestStrictTemplatesDiag); expect(diag).toBeUndefined(); }); + + it('does not suggest turning on strict mode is strictTemplates flag is ommitted', () => { + configFileFs.overwriteConfigFile(TSCONFIG, { + angularCompilerOptions: {}, + }); + const diags = ngLS.getCompilerOptionsDiagnostics(); + const diag = diags.find(isSuggestStrictTemplatesDiag); + expect(diag).toBeUndefined(); + }); + function isSuggestStrictTemplatesDiag(diag: ts.Diagnostic) { return diag.code === ngErrorCode(ErrorCode.SUGGEST_STRICT_TEMPLATES); } From 5245ca5ba72fd165b839d0c45b98f7b50d13a4d5 Mon Sep 17 00:00:00 2001 From: Matthew Beck Date: Thu, 23 Jul 2026 10:21:44 -0700 Subject: [PATCH 012/161] test(compiler-cli): format compliance TEST_CASES.json with prettier Reformats the TEST_CASES.json files touched by the following change so they satisfy the repo's prettier check (short inputFiles/files arrays collapsed to a single line). Pure formatting; the parsed JSON is unchanged. Split into its own commit so the coverage change that follows is easy to review. --- .../content_projection/TEST_CASES.json | 76 +++------ .../elements/TEST_CASES.json | 60 ++----- .../r3_view_compiler_di/di/TEST_CASES.json | 82 +++------- .../r3_view_compiler_i18n/TEST_CASES.json | 22 +-- .../blocks/TEST_CASES.json | 36 +---- .../icu_logic/TEST_CASES.json | 153 ++++-------------- .../TEST_CASES.json | 17 +- .../host_bindings/TEST_CASES.json | 48 ++---- .../test_cases/signal_inputs/TEST_CASES.json | 5 +- 9 files changed, 117 insertions(+), 382 deletions(-) diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/content_projection/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/content_projection/TEST_CASES.json index d38021991ef4..1f2d13e4ec20 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/content_projection/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/content_projection/TEST_CASES.json @@ -3,9 +3,7 @@ "cases": [ { "description": "should support content projection in root template", - "inputFiles": [ - "root_template.ts" - ], + "inputFiles": ["root_template.ts"], "expectations": [ { "failureMessage": "Incorrect SimpleComponent definition", @@ -29,29 +27,21 @@ }, { "description": "should support multi-slot content projection with multiple wildcard slots", - "inputFiles": [ - "multiple_wildcards.ts" - ], + "inputFiles": ["multiple_wildcards.ts"], "expectations": [ { "failureMessage": "Invalid content projection instructions generated", - "files": [ - "multiple_wildcards.js" - ] + "files": ["multiple_wildcards.js"] } ] }, { "description": "should support content projection in nested templates", - "inputFiles": [ - "nested_template.ts" - ], + "inputFiles": ["nested_template.ts"], "expectations": [ { "failureMessage": "Invalid content projection instructions generated", - "files": [ - "nested_template.js" - ] + "files": ["nested_template.js"] }, { "failureMessage": "Invalid content projection instructions generated", @@ -66,15 +56,11 @@ }, { "description": "should support content projection in both the root and nested templates", - "inputFiles": [ - "root_and_nested.ts" - ], + "inputFiles": ["root_and_nested.ts"], "expectations": [ { "failureMessage": "Invalid content projection instructions generated", - "files": [ - "root_and_nested.js" - ] + "files": ["root_and_nested.js"] }, { "failureMessage": "Invalid content projection instructions generated", @@ -89,85 +75,61 @@ }, { "description": "should parse the selector that is passed into ngProjectAs", - "inputFiles": [ - "ng_project_as_selector.ts" - ], + "inputFiles": ["ng_project_as_selector.ts"], "expectations": [ { "failureMessage": "Incorrect SimpleComponent definition", - "files": [ - "ng_project_as_selector.js" - ] + "files": ["ng_project_as_selector.js"] } ] }, { "description": "should take the first selector if multiple values are passed into ngProjectAs", - "inputFiles": [ - "ng_project_as_compound_selector.ts" - ], + "inputFiles": ["ng_project_as_compound_selector.ts"], "expectations": [ { "failureMessage": "Incorrect SimpleComponent definition", - "files": [ - "ng_project_as_compound_selector.js" - ] + "files": ["ng_project_as_compound_selector.js"] } ] }, { "description": "should include parsed ngProjectAs selectors into template attrs", - "inputFiles": [ - "ng_project_as_attribute.ts" - ], + "inputFiles": ["ng_project_as_attribute.ts"], "expectations": [ { "failureMessage": "Incorrect MyApp definition", - "files": [ - "ng_project_as_attribute.js" - ] + "files": ["ng_project_as_attribute.js"] } ] }, { "description": "should capture the node name of ng-content with a structural directive", - "inputFiles": [ - "ng_content_with_structural_dir.ts" - ], + "inputFiles": ["ng_content_with_structural_dir.ts"], "expectations": [ { "failureMessage": "Incorrect SimpleComponent definition", - "files": [ - "ng_content_with_structural_dir.js" - ] + "files": ["ng_content_with_structural_dir.js"] } ] }, { "description": "support projectAs on ng-content", - "inputFiles": [ - "project_as_ng_content.ts" - ], + "inputFiles": ["project_as_ng_content.ts"], "expectations": [ { "failureMessage": "Incorrect projection", - "files": [ - "project_as_ng_content.js" - ] + "files": ["project_as_ng_content.js"] } ] }, { "description": "should support fallback content in ng-content", - "inputFiles": [ - "ng_content_fallback.ts" - ], + "inputFiles": ["ng_content_fallback.ts"], "expectations": [ { "failureMessage": "Incorrect projection", - "files": [ - "ng_content_fallback.js" - ] + "files": ["ng_content_fallback.js"] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/elements/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/elements/TEST_CASES.json index 31f925ce05bd..757bfe320076 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/elements/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/elements/TEST_CASES.json @@ -3,9 +3,7 @@ "cases": [ { "description": "should handle SVG", - "inputFiles": [ - "svg.ts" - ], + "inputFiles": ["svg.ts"], "expectations": [ { "files": [ @@ -29,9 +27,7 @@ }, { "description": "should handle SVG with an embedded ng-template", - "inputFiles": [ - "svg_embedded_view.ts" - ], + "inputFiles": ["svg_embedded_view.ts"], "expectations": [ { "files": [ @@ -46,9 +42,7 @@ }, { "description": "should handle MathML", - "inputFiles": [ - "mathml.ts" - ], + "inputFiles": ["mathml.ts"], "expectations": [ { "files": [ @@ -72,9 +66,7 @@ }, { "description": "should translate DOM structure", - "inputFiles": [ - "dom.ts" - ], + "inputFiles": ["dom.ts"], "expectations": [ { "files": [ @@ -98,9 +90,7 @@ }, { "description": "should support namespaced attributes", - "inputFiles": [ - "namespace.ts" - ], + "inputFiles": ["namespace.ts"], "expectations": [ { "files": [ @@ -124,9 +114,7 @@ }, { "description": "should support ", - "inputFiles": [ - "ng-container.ts" - ], + "inputFiles": ["ng-container.ts"], "expectations": [ { "failureMessage": "Incorrect generated template." @@ -135,9 +123,7 @@ }, { "description": "should generate self-closing elementContainer instruction for empty ", - "inputFiles": [ - "empty_ng-container.ts" - ], + "inputFiles": ["empty_ng-container.ts"], "expectations": [ { "failureMessage": "Incorrect generated template." @@ -146,9 +132,7 @@ }, { "description": "should bind to element properties", - "inputFiles": [ - "properties.ts" - ], + "inputFiles": ["properties.ts"], "expectations": [ { "files": [ @@ -172,9 +156,7 @@ }, { "description": "should reserve slots for pure functions", - "inputFiles": [ - "property_pure_functions.ts" - ], + "inputFiles": ["property_pure_functions.ts"], "expectations": [ { "files": [ @@ -198,9 +180,7 @@ }, { "description": "should reserve slots for pure functions in host binding function", - "inputFiles": [ - "host_binding_pure_functions.ts" - ], + "inputFiles": ["host_binding_pure_functions.ts"], "expectations": [ { "failureMessage": "Incorrect `hostBindings` function." @@ -209,9 +189,7 @@ }, { "description": "should bind to class and style names", - "inputFiles": [ - "class_style_bindings.ts" - ], + "inputFiles": ["class_style_bindings.ts"], "expectations": [ { "files": [ @@ -235,9 +213,7 @@ }, { "description": "should de-duplicate attribute arrays", - "inputFiles": [ - "deduplicate_attributes.ts" - ], + "inputFiles": ["deduplicate_attributes.ts"], "expectations": [ { "failureMessage": "Incorrect generated template." @@ -246,9 +222,7 @@ }, { "description": "should specify security-sensitive constant attributes as template literals", - "inputFiles": [ - "security_sensitive_constant_attributes.ts" - ], + "inputFiles": ["security_sensitive_constant_attributes.ts"], "expectations": [ { "failureMessage": "Incorrect generated template." @@ -257,9 +231,7 @@ }, { "description": "should validate iframe attributes", - "inputFiles": [ - "iframe_attrs.ts" - ], + "inputFiles": ["iframe_attrs.ts"], "expectations": [ { "failureMessage": "Incorrect generated template." @@ -268,9 +240,7 @@ }, { "description": "should support namespaces on attributes", - "inputFiles": [ - "namespace_attr.ts" - ], + "inputFiles": ["namespace_attr.ts"], "expectations": [ { "failureMessage": "Incorrect generated template." diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_di/di/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_di/di/TEST_CASES.json index 33a537c242d6..d3639b78853f 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_di/di/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_di/di/TEST_CASES.json @@ -3,23 +3,17 @@ "cases": [ { "description": "should create factory methods", - "inputFiles": [ - "component_factory.ts" - ], + "inputFiles": ["component_factory.ts"], "expectations": [ { "failureMessage": "Incorrect factory", - "files": [ - "component_factory.js" - ] + "files": ["component_factory.js"] } ] }, { "description": "should create a factory definition for an injectable", - "inputFiles": [ - "injectable_factory.ts" - ], + "inputFiles": ["injectable_factory.ts"], "expectations": [ { "failureMessage": "Incorrect factory definition", @@ -43,9 +37,7 @@ }, { "description": "should create a factory definition for an injectable with an overloaded constructor", - "inputFiles": [ - "ctor_overload.ts" - ], + "inputFiles": ["ctor_overload.ts"], "expectations": [ { "failureMessage": "Incorrect factory definition", @@ -69,93 +61,67 @@ }, { "description": "should delegate directly to the alternate factory when setting `useFactory` without `deps`", - "inputFiles": [ - "usefactory_without_deps.ts" - ], + "inputFiles": ["usefactory_without_deps.ts"], "expectations": [ { "failureMessage": "Incorrect injectable definition", - "files": [ - "usefactory_without_deps.js" - ] + "files": ["usefactory_without_deps.js"] } ] }, { "description": "should not delegate directly to the alternate factory when setting `useFactory` with `deps`", - "inputFiles": [ - "usefactory_with_deps.ts" - ], + "inputFiles": ["usefactory_with_deps.ts"], "expectations": [ { "failureMessage": "Incorrect injectable definition", - "files": [ - "usefactory_with_deps.js" - ] + "files": ["usefactory_with_deps.js"] } ] }, { "description": "should delegate directly to the alternate class factory when setting `useClass` without `deps`", - "inputFiles": [ - "useclass_without_deps.ts" - ], + "inputFiles": ["useclass_without_deps.ts"], "expectations": [ { "failureMessage": "Incorrect factory definition", - "files": [ - "useclass_without_deps.js" - ] + "files": ["useclass_without_deps.js"] } ] }, { "description": "should not delegate directly to the alternate class when setting `useClass` with `deps`", - "inputFiles": [ - "useclass_with_deps.ts" - ], + "inputFiles": ["useclass_with_deps.ts"], "expectations": [ { "failureMessage": "Incorrect factory definition", - "files": [ - "useclass_with_deps.js" - ] + "files": ["useclass_with_deps.js"] } ] }, { "description": "should unwrap forward refs when delegating to a different class", - "inputFiles": [ - "useclass_forwardref.ts" - ], + "inputFiles": ["useclass_forwardref.ts"], "expectations": [ { "failureMessage": "Incorrect factory definition", - "files": [ - "useclass_forwardref.js" - ] + "files": ["useclass_forwardref.js"] } ] }, { "description": "should support forward refs in a providedIn clause", - "inputFiles": [ - "providedin_forwardref.ts" - ], + "inputFiles": ["providedin_forwardref.ts"], "expectations": [ { "failureMessage": "Incorrect factory definition", - "files": [ - "providedin_forwardref.js" - ] + "files": ["providedin_forwardref.js"] } ] }, { "description": "should have the pipe factory take precedence over the injectable factory, if a class has multiple decorators", - "inputFiles": [ - "pipe_and_injectable.ts" - ], + "inputFiles": ["pipe_and_injectable.ts"], "expectations": [ { "failureMessage": "Invalid pipe factory function", @@ -165,12 +131,7 @@ "generated": "pipe_and_injectable.js" } ], - "extraChecks": [ - [ - "verifyUniqueFactory", - "MyPipe" - ] - ] + "extraChecks": [["verifyUniqueFactory", "MyPipe"]] }, { "failureMessage": "Invalid pipe factory function", @@ -180,12 +141,7 @@ "generated": "pipe_and_injectable.js" } ], - "extraChecks": [ - [ - "verifyUniqueFactory", - "MyOtherPipe" - ] - ] + "extraChecks": [["verifyUniqueFactory", "MyOtherPipe"]] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/TEST_CASES.json index e12040bd2b61..55e40ebac998 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/TEST_CASES.json @@ -3,37 +3,25 @@ "cases": [ { "description": "should allow repeating the same placeholder", - "inputFiles": [ - "repeated_placeholder.ts" - ], + "inputFiles": ["repeated_placeholder.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should support i18n message with multiple pipes", - "inputFiles": [ - "multiple_pipes.ts" - ], + "inputFiles": ["multiple_pipes.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should support i18n nodes with ICUs inside", - "inputFiles": [ - "icu_and_i18n.ts" - ] + "inputFiles": ["icu_and_i18n.ts"] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/blocks/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/blocks/TEST_CASES.json index b37e37bd4ec8..0b65390b5458 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/blocks/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/blocks/TEST_CASES.json @@ -3,9 +3,7 @@ "cases": [ { "description": "should support @if blocks", - "inputFiles": [ - "conditional.ts" - ], + "inputFiles": ["conditional.ts"], "expectations": [ { "files": [ @@ -14,18 +12,13 @@ "expected": "conditional_template.js" } ], - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should support @switch blocks", - "inputFiles": [ - "switch.ts" - ], + "inputFiles": ["switch.ts"], "expectations": [ { "files": [ @@ -34,18 +27,13 @@ "expected": "switch_template.js" } ], - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should support @for blocks", - "inputFiles": [ - "for.ts" - ], + "inputFiles": ["for.ts"], "expectations": [ { "files": [ @@ -54,18 +42,13 @@ "expected": "for_template.js" } ], - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should support @defer blocks", - "inputFiles": [ - "defer.ts" - ], + "inputFiles": ["defer.ts"], "expectations": [ { "files": [ @@ -74,10 +57,7 @@ "expected": "defer_template.js" } ], - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/icu_logic/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/icu_logic/TEST_CASES.json index f3b3bbc10b44..15da8d7471af 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/icu_logic/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_i18n/icu_logic/TEST_CASES.json @@ -3,51 +3,34 @@ "cases": [ { "description": "should handle single icus", - "inputFiles": [ - "single_icu.ts" - ], + "inputFiles": ["single_icu.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should properly escape quotes in content", - "inputFiles": [ - "escape_quotes.ts" - ], + "inputFiles": ["escape_quotes.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should support ICU-only templates", - "inputFiles": [ - "icu_only.ts" - ], + "inputFiles": ["icu_only.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should generate i18n instructions for icus generated outside of i18n blocks", - "inputFiles": [ - "bare_icu.ts" - ], + "inputFiles": ["bare_icu.ts"], "expectations": [ { "files": [ @@ -56,192 +39,124 @@ "expected": "bare_icu.js" } ], - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should handle icus with html", - "inputFiles": [ - "html_content.ts" - ], + "inputFiles": ["html_content.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should handle icus with expressions", - "inputFiles": [ - "expressions.ts" - ], + "inputFiles": ["expressions.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should handle multiple icus in one block", - "inputFiles": [ - "multiple_icus.ts" - ], + "inputFiles": ["multiple_icus.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should handle multiple icus that share same placeholder", - "inputFiles": [ - "shared_placeholder.ts" - ], + "inputFiles": ["shared_placeholder.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should handle nested icus", - "inputFiles": [ - "nested_icus.ts" - ], + "inputFiles": ["nested_icus.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "nested with interpolations in \"other\" blocks", - "inputFiles": [ - "nested_icu_in_other_block.ts" - ], + "inputFiles": ["nested_icu_in_other_block.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should handle icus in different contexts", - "inputFiles": [ - "different_contexts.ts" - ], + "inputFiles": ["different_contexts.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should handle icus with interpolations", - "inputFiles": [ - "icu_with_interpolations.ts" - ], + "inputFiles": ["icu_with_interpolations.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should handle icus with named interpolations", - "inputFiles": [ - "named_interpolations.ts" - ], + "inputFiles": ["named_interpolations.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should attach metadata in case an ICU represents the whole message", - "inputFiles": [ - "metadata.ts" - ], + "inputFiles": ["metadata.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should produce proper messages when `select` or `plural` keywords have spaces after them", - "inputFiles": [ - "keyword_spaces.ts" - ], + "inputFiles": ["keyword_spaces.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should handle ICUs with element tags, as the root message", - "inputFiles": [ - "root_icu_with_elements.ts" - ], + "inputFiles": ["root_icu_with_elements.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] }, { "description": "should handles ICUs with html content that has interpolated attributes", - "inputFiles": [ - "attribute_interpolation.ts" - ], + "inputFiles": ["attribute_interpolation.ts"], "expectations": [ { - "extraChecks": [ - "verifyPlaceholdersIntegrity", - "verifyUniqueConsts" - ] + "extraChecks": ["verifyPlaceholdersIntegrity", "verifyUniqueConsts"] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/TEST_CASES.json index 73d514d206b2..7c84dfc85b9f 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_input_outputs/TEST_CASES.json @@ -3,9 +3,7 @@ "cases": [ { "description": "should declare inputs/outputs on a component", - "inputFiles": [ - "component.ts" - ], + "inputFiles": ["component.ts"], "expectations": [ { "files": [ @@ -20,9 +18,7 @@ }, { "description": "should declare inputs/outputs on a directive", - "inputFiles": [ - "directive.ts" - ], + "inputFiles": ["directive.ts"], "expectations": [ { "files": [ @@ -37,9 +33,7 @@ }, { "description": "should declare inputs with transform functions", - "inputFiles": [ - "input_transform.ts" - ], + "inputFiles": ["input_transform.ts"], "expectations": [ { "files": [ @@ -51,10 +45,7 @@ "failureMessage": "Incorrect directive definition" } ], - "compilationModeFilter": [ - "full compile", - "linked compile" - ] + "compilationModeFilter": ["full compile", "linked compile"] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/TEST_CASES.json index 10dbdc5a5439..22b46dd6918e 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/TEST_CASES.json @@ -3,37 +3,27 @@ "cases": [ { "description": "should generate style/class instructions for a host component creation definition", - "inputFiles": [ - "static_and_dynamic.ts" - ], + "inputFiles": ["static_and_dynamic.ts"], "expectations": [ { "failureMessage": "Incorrect template", - "files": [ - "static_and_dynamic.js" - ] + "files": ["static_and_dynamic.js"] } ] }, { "description": "should generate style/class instructions for multiple host binding definitions", - "inputFiles": [ - "multiple_dynamic.ts" - ], + "inputFiles": ["multiple_dynamic.ts"], "expectations": [ { "failureMessage": "Incorrect template", - "files": [ - "multiple_dynamic.js" - ] + "files": ["multiple_dynamic.js"] } ] }, { "description": "should generate override instructions for only single-level styling bindings when !important is present", - "inputFiles": [ - "important.ts" - ], + "inputFiles": ["important.ts"], "expectations": [ { "failureMessage": "Incorrect template", @@ -57,51 +47,37 @@ }, { "description": "should support class interpolation", - "inputFiles": [ - "class_interpolation.ts" - ], + "inputFiles": ["class_interpolation.ts"], "expectations": [ { "failureMessage": "Incorrect template", - "files": [ - "class_interpolation.js" - ] + "files": ["class_interpolation.js"] } ] }, { "description": "should support style interpolation", - "inputFiles": [ - "style_interpolation.ts" - ], + "inputFiles": ["style_interpolation.ts"], "expectations": [ { "failureMessage": "Incorrect template", - "files": [ - "style_interpolation.js" - ] + "files": ["style_interpolation.js"] } ] }, { "description": "should generate styling instructions for multiple directives that contain host binding definitions", - "inputFiles": [ - "multiple_directives.ts" - ], + "inputFiles": ["multiple_directives.ts"], "expectations": [ { "failureMessage": "Incorrect template", - "files": [ - "multiple_directives.js" - ] + "files": ["multiple_directives.js"] } ] }, { "description": "should not mangle css custom property names", - "inputFiles": [ - "css_custom_properties.ts" - ], + "inputFiles": ["css_custom_properties.ts"], "expectations": [ { "failureMessage": "Invalid host binding code" diff --git a/packages/compiler-cli/test/compliance/test_cases/signal_inputs/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/signal_inputs/TEST_CASES.json index 86df9bd374ae..7010f0a3db2a 100644 --- a/packages/compiler-cli/test/compliance/test_cases/signal_inputs/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/signal_inputs/TEST_CASES.json @@ -45,10 +45,7 @@ "failureMessage": "Incorrect definition" } ], - "compilationModeFilter": [ - "full compile", - "linked compile" - ] + "compilationModeFilter": ["full compile", "linked compile"] }, { "description": "should not capture the transform function in the compiled JS for signal inputs", From 5ad823139758b4d3a8a021d378b008c3457f8689 Mon Sep 17 00:00:00 2001 From: Suraj Yadav Date: Fri, 24 Jul 2026 00:18:28 +0530 Subject: [PATCH 013/161] fix(migrations): correctly detect `then`/`else` keywords in control flow migration The control flow migration determines whether an `*ngIf` uses a `then` and/or `else` clause by regex matching the raw microsyntax string for the literal keywords `then`/`else`. The regexes only checked that the keyword was preceded by a non-word character, but not that it was followed by one. As a result, a template reference name that merely starts with `then` (e.g. `else thenBlock`) or `else` was misidentified as the `then`/`else` keyword itself. This caused the migration to take the wrong code path (e.g. then+else instead of else-only), which in turn made `getTemplateName()` compute a `slice(start, end)` with `start > end`, producing an empty template name. That empty placeholder was never resolved and was silently emitted as an invalid ``, dropping the original template content without any warning. Add a negative lookahead `(?![\w\d])` to both regexes so `then`/`else` are only matched as whole keywords, not as a prefix of a longer template reference name. Fixes #69914 --- .../ng-generate/control-flow-migration/ifs.ts | 11 +- .../test/control_flow_migration_spec.ts | 119 ++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/packages/core/schematics/ng-generate/control-flow-migration/ifs.ts b/packages/core/schematics/ng-generate/control-flow-migration/ifs.ts index 9bfc983cd492..b6e0f95076a0 100644 --- a/packages/core/schematics/ng-generate/control-flow-migration/ifs.ts +++ b/packages/core/schematics/ng-generate/control-flow-migration/ifs.ts @@ -75,8 +75,15 @@ export function migrateIf(template: string): { } function migrateNgIf(etm: ElementToMigrate, tmpl: string, offset: number): Result { - const matchThen = etm.attr.value.match(/[^\w\d];?\s*then/gm); - const matchElse = etm.attr.value.match(/[^\w\d];?\s*else/gm); + // The negative lookahead (?![\w$]) ensures `then`/`else` are matched only as + // whole keywords. Without it, an else/then template reference name that merely + // *starts* with `then`/`else` (e.g. `else thenBlock`) is misidentified as the + // `then` keyword, since `[^\w$];?\s*then` also matches the `then` prefix of + // `thenBlock`. `$` is included alongside `\w` (which already covers digits) + // since it is a valid identifier character in JS/template reference names + // (e.g. `#then$`), but is not part of `\w`. + const matchThen = etm.attr.value.match(/[^\w$];?\s*then(?![\w$])/gm); + const matchElse = etm.attr.value.match(/[^\w$];?\s*else(?![\w$])/gm); if (etm.thenAttr !== undefined || etm.elseAttr !== undefined) { // bound if then / if then else diff --git a/packages/core/schematics/test/control_flow_migration_spec.ts b/packages/core/schematics/test/control_flow_migration_spec.ts index 9b6a7afa8352..b2a1ca899f2b 100644 --- a/packages/core/schematics/test/control_flow_migration_spec.ts +++ b/packages/core/schematics/test/control_flow_migration_spec.ts @@ -465,6 +465,125 @@ describe('control flow migration (ng update)', () => { ); }); + it('should migrate an if else case where the else template reference name starts with `then`', async () => { + writeFile( + '/comp.ts', + ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + data: any; + } + `, + ); + + writeFile( + '/comp.html', + [ + ``, + `

Hello team member!

`, + `
`, + ``, + `

No team member

`, + `
`, + ].join('\n'), + ); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe( + [ + `@if (data.teamMember) {`, + `

Hello team member!

`, + `} @else {`, + `

No team member

`, + `}\n`, + ].join('\n'), + ); + }); + + it('should migrate an if then case where the then template reference name starts with `else`', async () => { + writeFile( + '/comp.ts', + ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + data: any; + } + `, + ); + + writeFile( + '/comp.html', + [ + ``, + ``, + `

Then content

`, + `
`, + ].join('\n'), + ); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe( + [`@if (data.teamMember) {`, `

Then content

`, `}\n`].join('\n'), + ); + }); + + it('should migrate an if then else case where template reference names start with `then`/`else`', async () => { + writeFile( + '/comp.ts', + ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + data: any; + } + `, + ); + + writeFile( + '/comp.html', + [ + ``, + ``, + `

Then content

`, + `
`, + ``, + `

Else content

`, + `
`, + ].join('\n'), + ); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe( + [ + `@if (data.teamMember) {`, + `

Then content

`, + `} @else {`, + `

Else content

`, + `}\n`, + ].join('\n'), + ); + }); + it('should migrate an if case on a container', async () => { writeFile( '/comp.ts', From d06e3748b7b9e03c197694c5888556ffae54b366 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:25:17 -0500 Subject: [PATCH 014/161] fix(core): sanitize host bindings on concrete hosts (#69558) Host binding sanitization previously used the declaring directive or component selector to choose a compile-time security context. The same host binding can execute on a different concrete element through hostDirectives, inherited host bindings, dynamic directives, or createComponent hostElement usage. Compute host binding security contexts against possible concrete hosts and defer URL versus ResourceURL selection to runtime when necessary. Resolve dynamic root host TNodes to their native tag before sanitizer and security-sensitive attribute checks. Fixes angular#69550 PR Close #69558 --- .../host_bindings/GOLDEN_PARTIAL.js | 57 +++ .../host_bindings/sanitization.js | 26 +- .../host_bindings/sanitization.ts | 30 ++ .../sanitization_isolated.golden.d.ts | 16 +- .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 42 +- .../src/template/pipeline/src/ingest.ts | 60 ++- .../pipeline/src/phases/resolve_sanitizers.ts | 57 ++- packages/core/src/render3/component_ref.ts | 3 +- packages/core/src/render3/interfaces/node.ts | 8 + .../core/src/sanitization/sanitization.ts | 103 ++++- .../core/test/acceptance/security_spec.ts | 371 ++++++++++++++++++ .../router/bundle.golden_symbols.json | 7 +- .../test/sanitization/sanitization_spec.ts | 20 +- 13 files changed, 747 insertions(+), 53 deletions(-) diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/GOLDEN_PARTIAL.js index 993805dc69ac..9328636dca1f 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/GOLDEN_PARTIAL.js @@ -981,6 +981,48 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE }, }] }] }); +export class HostBindingCustomSrcdocDir { + evil = 'evil'; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcdocDir, deps: [], target: i0.ɵɵFactoryTarget.Directive }); + static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomSrcdocDir, isStandalone: true, selector: "safe-srcdoc-carrier", host: { properties: { "attr.srcdoc": "evil" } }, ngImport: i0 }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcdocDir, decorators: [{ + type: Directive, + args: [{ + selector: 'safe-srcdoc-carrier', + host: { + '[attr.srcdoc]': 'evil', + }, + }] + }] }); +export class HostBindingCustomSrcDir { + evil = 'evil'; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcDir, deps: [], target: i0.ɵɵFactoryTarget.Directive }); + static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomSrcDir, isStandalone: true, selector: "safe-src-carrier", host: { properties: { "attr.src": "evil" } }, ngImport: i0 }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomSrcDir, decorators: [{ + type: Directive, + args: [{ + selector: 'safe-src-carrier', + host: { + '[attr.src]': 'evil', + }, + }] + }] }); +export class HostBindingCustomDataDir { + evil = 'evil'; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomDataDir, deps: [], target: i0.ɵɵFactoryTarget.Directive }); + static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: HostBindingCustomDataDir, isStandalone: true, selector: "safe-data-carrier", host: { properties: { "attr.data": "evil" } }, ngImport: i0 }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: HostBindingCustomDataDir, decorators: [{ + type: Directive, + args: [{ + selector: 'safe-data-carrier', + host: { + '[attr.data]': 'evil', + }, + }] + }] }); /**************************************************************************************************** * PARTIAL FILE: sanitization.d.ts @@ -1008,6 +1050,21 @@ export declare class HostBindingSvgAnimateDir { static ɵfac: i0.ɵɵFactoryDeclaration; static ɵdir: i0.ɵɵDirectiveDeclaration; } +export declare class HostBindingCustomSrcdocDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class HostBindingCustomSrcDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class HostBindingCustomDataDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} /**************************************************************************************************** * PARTIAL FILE: security_sensitive_constant_attributes.js diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.js index 9ea142fafedc..073c1eac4008 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.js @@ -1,21 +1,21 @@ hostBindings: function HostBindingLinkDir_HostBindings(rf, ctx) { if (rf & 2) { - $r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("href", ctx.evil, $r3$.ɵɵsanitizeUrl); + $r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("href", ctx.evil, $r3$.ɵɵsanitizeUrlOrResourceUrl); $r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle); } } … hostBindings: function HostBindingImageDir_HostBindings(rf, ctx) { if (rf & 2) { - i0.ɵɵdomProperty("innerHTML", ctx.evil, i0.ɵɵsanitizeHtml)("src", ctx.nonEvil, i0.ɵɵsanitizeUrl); + i0.ɵɵdomProperty("innerHTML", ctx.evil, i0.ɵɵsanitizeHtml)("src", ctx.nonEvil, i0.ɵɵsanitizeUrlOrResourceUrl); i0.ɵɵattribute("style", ctx.evil, i0.ɵɵsanitizeStyle); } } … hostBindings: function HostBindingIframeDir_HostBindings(rf, ctx) { if (rf & 2) { - $r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("src", ctx.evil, i0.ɵɵsanitizeResourceUrl)("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute); - $r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle)("attributeName", ctx.nonEvil); + $r3$.ɵɵdomProperty("innerHTML", ctx.evil, $r3$.ɵɵsanitizeHtml)("src", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl)("sandbox", ctx.evil, $r3$.ɵɵvalidateAttribute); + $r3$.ɵɵattribute("style", ctx.evil, $r3$.ɵɵsanitizeStyle)("attributeName", ctx.nonEvil, i0.ɵɵvalidateAttribute); } } … @@ -24,3 +24,21 @@ hostBindings: function HostBindingSvgAnimateDir_HostBindings(rf, ctx) { i0.ɵɵattribute("attributeName", ctx.evil, i0.ɵɵvalidateAttribute); } } +… +hostBindings: function HostBindingCustomSrcdocDir_HostBindings(rf, ctx) { + if (rf & 2) { + i0.ɵɵattribute("srcdoc", ctx.evil, i0.ɵɵsanitizeHtml); + } +} +… +hostBindings: function HostBindingCustomSrcDir_HostBindings(rf, ctx) { + if (rf & 2) { + i0.ɵɵattribute("src", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl); + } +} +… +hostBindings: function HostBindingCustomDataDir_HostBindings(rf, ctx) { + if (rf & 2) { + i0.ɵɵattribute("data", ctx.evil, i0.ɵɵsanitizeUrlOrResourceUrl); + } +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.ts index 92a17ab2e3f5..54669e19872f 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization.ts @@ -49,3 +49,33 @@ export class HostBindingIframeDir { export class HostBindingSvgAnimateDir { evil = 'evil'; } + +@Directive({ + selector: 'safe-srcdoc-carrier', + host: { + '[attr.srcdoc]': 'evil', + }, +}) +export class HostBindingCustomSrcdocDir { + evil = 'evil'; +} + +@Directive({ + selector: 'safe-src-carrier', + host: { + '[attr.src]': 'evil', + }, +}) +export class HostBindingCustomSrcDir { + evil = 'evil'; +} + +@Directive({ + selector: 'safe-data-carrier', + host: { + '[attr.data]': 'evil', + }, +}) +export class HostBindingCustomDataDir { + evil = 'evil'; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization_isolated.golden.d.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization_isolated.golden.d.ts index f0fae600478d..8ee2476afe40 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization_isolated.golden.d.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/host_bindings/sanitization_isolated.golden.d.ts @@ -21,4 +21,18 @@ export declare class HostBindingSvgAnimateDir { static ɵfac: i0.ɵɵFactoryDeclaration; static ɵdir: i0.ɵɵDirectiveDeclaration; } - +export declare class HostBindingCustomSrcdocDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class HostBindingCustomSrcDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class HostBindingCustomDataDir { + evil: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; +} diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index e8d53ab0c475..291cbd11754b 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -8614,7 +8614,7 @@ runInEachFileSystem((os: string) => { hostVars: 6, hostBindings: function UnsafeAttrsDirective_HostBindings(rf, ctx) { if (rf & 2) { - i0.ɵɵattribute("href", ctx.attrHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.attrSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.attrAction, i0.ɵɵsanitizeUrl)("profile", ctx.attrProfile)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle); + i0.ɵɵattribute("href", ctx.attrHref, i0.ɵɵsanitizeUrlOrResourceUrl)("src", ctx.attrSrc, i0.ɵɵsanitizeUrlOrResourceUrl)("action", ctx.attrAction, i0.ɵɵsanitizeUrlOrResourceUrl)("profile", ctx.attrProfile)("innerHTML", ctx.attrInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.attrSafeTitle); } } `; @@ -8660,14 +8660,14 @@ runInEachFileSystem((os: string) => { hostVars: 3, hostBindings: function UnsafePropsDirective_HostBindings(rf, ctx) { if (rf & 2) { - i0.ɵɵdomProperty("href", ctx.propHref, i0.ɵɵsanitizeUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle); + i0.ɵɵdomProperty("href", ctx.propHref, i0.ɵɵsanitizeUrlOrResourceUrl)("innerHTML", ctx.propInnerHTML, i0.ɵɵsanitizeHtml)("title", ctx.propSafeTitle); } } `; expect(trim(jsContents)).toContain(trim(hostBindingsFn)); }); - it('should not generate sanitizers for URL properties in hostBindings fn in Component', () => { + it('should generate concrete-host URL sanitizers in hostBindings fn in Component', () => { env.write( `test.ts`, ` @@ -8700,8 +8700,40 @@ runInEachFileSystem((os: string) => { hostVars: 5, hostBindings: function FooCmp_HostBindings(rf, ctx) { if (rf & 2) { - i0.ɵɵdomProperty("href", ctx.hrefProp, i0.ɵɵsanitizeUrl)("title", ctx.titleProp); - i0.ɵɵattribute("src", ctx.srcAttr)("href", ctx.hrefAttr, i0.ɵɵsanitizeUrl)("title", ctx.titleAttr); + i0.ɵɵdomProperty("href", ctx.hrefProp, i0.ɵɵsanitizeUrlOrResourceUrl)("title", ctx.titleProp); + i0.ɵɵattribute("src", ctx.srcAttr, i0.ɵɵsanitizeUrlOrResourceUrl)("href", ctx.hrefAttr, i0.ɵɵsanitizeUrlOrResourceUrl)("title", ctx.titleAttr); + } + } + `; + expect(trim(jsContents)).toContain(trim(hostBindingsFn)); + }); + + it('should generate sanitizers for pure :not selector host bindings', () => { + env.write( + `test.ts`, + ` + import {Component} from '@angular/core'; + + @Component({ + selector: ':not(iframe)', + template: '', + host: { + '[attr.srcdoc]': 'srcdoc', + } + }) + class FooCmp { + srcdoc: any; + } + `, + ); + + env.driveMain(); + const jsContents = env.getContents('test.js'); + const hostBindingsFn = ` + hostVars: 1, + hostBindings: function FooCmp_HostBindings(rf, ctx) { + if (rf & 2) { + i0.ɵɵattribute("srcdoc", ctx.srcdoc, i0.ɵɵsanitizeHtml); } } `; diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 9b63a8584191..db2b2069a630 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -21,7 +21,7 @@ import { } from '../../../render3/view/api'; import {icuFromI18nMessage} from '../../../render3/view/i18n/util'; import {DomElementSchemaRegistry} from '../../../schema/dom_element_schema_registry'; -import {BindingParser} from '../../../template_parser/binding_parser'; +import {BindingParser, calcPossibleSecurityContexts} from '../../../template_parser/binding_parser'; import * as ir from '../ir'; import { @@ -125,19 +125,21 @@ export function ingestHostBinding( if (property.isAnimation) { bindingKind = ir.BindingKind.Animation; } - const securityContexts = bindingParser - .calcPossibleSecurityContexts( - input.componentSelector, - property.name, - bindingKind === ir.BindingKind.Attribute, - ) - .filter((context) => context !== SecurityContext.NONE); + const securityContexts = calcHostBindingSecurityContexts( + bindingParser, + input.componentSelector, + property.name, + bindingKind === ir.BindingKind.Attribute, + ); ingestDomProperty(job, property, bindingKind, securityContexts); } for (const [name, expr] of Object.entries(input.attributes) ?? []) { - const securityContexts = bindingParser - .calcPossibleSecurityContexts(input.componentSelector, name, true) - .filter((context) => context !== SecurityContext.NONE); + const securityContexts = calcHostBindingSecurityContexts( + bindingParser, + input.componentSelector, + name, + true, + ); ingestHostAttribute(job, name, expr, securityContexts); } for (const event of input.events ?? []) { @@ -146,6 +148,42 @@ export function ingestHostBinding( return job; } +function calcHostBindingSecurityContexts( + bindingParser: BindingParser, + selector: string, + name: string, + isAttribute: boolean, +): SecurityContext[] { + const declaringSelectorContexts = bindingParser.calcPossibleSecurityContexts( + selector, + name, + isAttribute, + ); + const concreteHostContexts = calcPossibleSecurityContexts( + domSchema, + null, + domSchema.getMappedPropName(name), + isAttribute, + ); + const concreteHostNonNoneContexts = concreteHostContexts.filter( + (context) => context !== SecurityContext.NONE, + ); + const concreteHostNonNoneCount = concreteHostNonNoneContexts.length; + const hasConcreteHostNoneContext = concreteHostNonNoneCount !== concreteHostContexts.length; + + // Host bindings can run against a concrete host whose element name differs from the declaring + // selector, including dynamic root components whose TNode name is `#host`. + if (hasConcreteHostNoneContext && concreteHostNonNoneCount > 0) { + return concreteHostContexts; + } + + if (concreteHostNonNoneContexts.some((context) => !declaringSelectorContexts.includes(context))) { + return concreteHostContexts; + } + + return declaringSelectorContexts.filter((context) => context !== SecurityContext.NONE); +} + // TODO: We should refactor the parser to use the same types and structures for host bindings as // with ordinary components. This would allow us to share a lot more ingestion code. export function ingestDomProperty( diff --git a/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts b/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts index c1b9ba3c6f44..1012a3beefae 100644 --- a/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts +++ b/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts @@ -58,16 +58,11 @@ export function resolveSanitizers(job: CompilationJob): void { case ir.OpKind.DomProperty: case ir.OpKind.TwoWayProperty: let sanitizerFn: o.ExternalReference | null = null; - if ( - Array.isArray(op.securityContext) && - op.securityContext.length === 2 && - op.securityContext.includes(SecurityContext.URL) && - op.securityContext.includes(SecurityContext.RESOURCE_URL) - ) { - // When the host element isn't known, some URL attributes (such as "src" and "href") may - // be part of multiple different security contexts. In this case we use special - // sanitization function and select the actual sanitizer at runtime based on a tag name - // that is provided while invoking sanitization function. + if (isUrlOrResourceUrlSecurityContext(op.securityContext)) { + // When the host element isn't known, attributes such as `href`, `src`, `data`, + // `action`, and `codebase` may be part of multiple security contexts. In this case we + // use a special sanitization function and select the actual behavior at runtime based + // on the concrete host element. sanitizerFn = Identifiers.sanitizeUrlOrResourceUrl; } else { sanitizerFn = sanitizerFns.get(getOnlySecurityContext(op.securityContext)) ?? null; @@ -81,21 +76,57 @@ export function resolveSanitizers(job: CompilationJob): void { } } +function isUrlOrResourceUrlSecurityContext( + securityContext: SecurityContext | SecurityContext[], +): boolean { + if (!Array.isArray(securityContext)) { + return false; + } + + let hasUrlContext = false; + let hasResourceUrlContext = false; + let hasNoneContext = false; + + for (const context of securityContext) { + switch (context) { + case SecurityContext.URL: + hasUrlContext = true; + break; + case SecurityContext.RESOURCE_URL: + hasResourceUrlContext = true; + break; + case SecurityContext.NONE: + hasNoneContext = true; + break; + default: + return false; + } + } + + return ( + ((hasUrlContext || hasResourceUrlContext) && hasNoneContext) || + (hasUrlContext && hasResourceUrlContext) + ); +} + /** - * Asserts that there is only a single security context and returns it. + * Asserts that there is only a single non-NONE security context and returns it. */ function getOnlySecurityContext( securityContext: SecurityContext | SecurityContext[], ): SecurityContext { if (Array.isArray(securityContext)) { - if (securityContext.length > 1) { + const nonNoneSecurityContexts = securityContext.filter( + (context) => context !== SecurityContext.NONE, + ); + if (nonNoneSecurityContexts.length > 1) { // TODO: What should we do here? TDB just took the first one, but this feels like something we // would want to know about and create a special case for like we did for Url/ResourceUrl. My // guess is that, outside of the Url/ResourceUrl case, this never actually happens. If there // do turn out to be other cases, throwing an error until we can address it feels safer. throw Error(`AssertionError: Ambiguous security context`); } - return securityContext[0] || SecurityContext.NONE; + return nonNoneSecurityContexts[0] || SecurityContext.NONE; } return securityContext; } diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 71c5056cc0de..50e10b53d058 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -42,6 +42,7 @@ import { TElementContainerNode, TElementNode, TNode, + TNodeName, TNodeType, } from './interfaces/node'; import {RElement, RNode} from './interfaces/renderer_dom'; @@ -369,7 +370,7 @@ export class ComponentFactory { HEADER_OFFSET, rootLView, TNodeType.Element, - '#host', + TNodeName.DynamicHost, () => rootTView.directiveRegistry, true, 0, diff --git a/packages/core/src/render3/interfaces/node.ts b/packages/core/src/render3/interfaces/node.ts index d6cca96f020b..74898f95f7d7 100644 --- a/packages/core/src/render3/interfaces/node.ts +++ b/packages/core/src/render3/interfaces/node.ts @@ -16,6 +16,14 @@ import {CssSelector} from './projection'; import {RNode} from './renderer_dom'; import type {LView, TView} from './view'; +/** + * Internal tag name used for a root host `TNode` when Angular creates a component against an + * existing host element. The concrete DOM tag is resolved from the native element at runtime. + */ +export const enum TNodeName { + DynamicHost = '#host', +} + /** * TNodeType corresponds to the {@link TNode} `type` property. * diff --git a/packages/core/src/sanitization/sanitization.ts b/packages/core/src/sanitization/sanitization.ts index 253b77da016b..f1c969c06cd5 100644 --- a/packages/core/src/sanitization/sanitization.ts +++ b/packages/core/src/sanitization/sanitization.ts @@ -10,7 +10,7 @@ import {XSS_SECURITY_URL} from '../error_details_base_url'; import {RuntimeError, RuntimeErrorCode} from '../errors'; import {getTemplateLocationDetails} from '../render3/instructions/element_validation'; import {getDocument} from '../render3/interfaces/document'; -import {TNode, TNodeType} from '../render3/interfaces/node'; +import {TNode, TNodeName, TNodeType} from '../render3/interfaces/node'; import {RElement} from '../render3/interfaces/renderer_dom'; import {ENVIRONMENT} from '../render3/interfaces/view'; import {getLView, getSelectedIndex, getSelectedTNode} from '../render3/state'; @@ -46,7 +46,19 @@ import {_sanitizeUrl} from './url_sanitizer'; * * @codeGenApi */ -export function ɵɵsanitizeHtml(unsafeHtml: any): TrustedHTML | string { +export function ɵɵsanitizeHtml( + unsafeHtml: any, + tagName?: string, + propName?: string, +): TrustedHTML | string { + if ( + tagName !== undefined && + propName !== undefined && + getSecurityContext(tagName, propName) !== SecurityContext.HTML + ) { + return unsafeHtml; + } + const sanitizer = getSanitizer(); if (sanitizer) { return trustedHTMLFromStringBypass(sanitizer.sanitize(SecurityContext.HTML, unsafeHtml) || ''); @@ -213,7 +225,20 @@ export function ɵɵtrustConstantResourceUrl(url: TemplateStringsArray): Trusted return trustedScriptURLFromString(url[0]); } -// Define sets outside the function for O(1) lookups and memory efficiency +const HTML_MAP: Record | undefined> = { + '*': {'innerhtml': true, 'outerhtml': true}, + 'iframe': {'srcdoc': true}, +}; + +const URL_MAP: Record | undefined> = { + '*': {'formaction': true}, + 'area': {'href': true}, + 'a': {'href': true, 'xlink:href': true}, + 'form': {'action': true}, + 'img': {'src': true}, + 'video': {'src': true}, +}; + const RESOURCE_MAP: Record | undefined> = { 'embed': {'src': true}, 'frame': {'src': true}, @@ -228,14 +253,19 @@ const RESOURCE_MAP: Record | undefined> /** * Detects which sanitizer to use for URL property, based on tag name and prop name. * - * The rules are based on the RESOURCE_URL context config from + * The rules are based on the URL and RESOURCE_URL context config from * `packages/compiler/src/schema/dom_security_schema.ts`. - * If tag and prop names don't match Resource URL schema, use URL sanitizer. + * If tag and prop names don't match URL or Resource URL schema, no sanitizer is required. */ export function getUrlSanitizer(tag: string, prop: string) { - const isResource = RESOURCE_MAP[tag.toLowerCase()]?.[prop.toLowerCase()] === true; - - return isResource ? ɵɵsanitizeResourceUrl : ɵɵsanitizeUrl; + switch (getSecurityContext(tag, prop)) { + case SecurityContext.RESOURCE_URL: + return ɵɵsanitizeResourceUrl; + case SecurityContext.URL: + return ɵɵsanitizeUrl; + default: + return null; + } } /** @@ -254,7 +284,52 @@ export function getUrlSanitizer(tag: string, prop: string) { * @codeGenApi */ export function ɵɵsanitizeUrlOrResourceUrl(unsafeUrl: any, tag: string, prop: string): any { - return getUrlSanitizer(tag, prop)(unsafeUrl); + const sanitizer = getUrlSanitizer(tag, prop); + return sanitizer === null ? unsafeUrl : sanitizer(unsafeUrl); +} + +function getSecurityContext(tagName: string, propName: string): SecurityContext { + tagName = resolveHostTagName(tagName).toLowerCase(); + propName = propName.toLowerCase(); + + if (hasSecurityContext(RESOURCE_MAP, tagName, propName)) { + return SecurityContext.RESOURCE_URL; + } + + if (hasSecurityContext(URL_MAP, tagName, propName)) { + return SecurityContext.URL; + } + + if (hasSecurityContext(HTML_MAP, tagName, propName)) { + return SecurityContext.HTML; + } + + return SecurityContext.NONE; +} + +function hasSecurityContext( + map: Record | undefined>, + tagName: string, + propName: string, +): boolean { + return map[tagName]?.[propName] === true || map['*']?.[propName] === true; +} + +function resolveHostTagName(tagName: string): string { + if (tagName !== TNodeName.DynamicHost) { + return tagName; + } + + const index = getSelectedIndex(); + const tNode = index === -1 ? null : getSelectedTNode(); + if (tNode !== null && tNode.type & TNodeType.Element) { + const element = getNativeByTNode(tNode, getLView()) as RElement; + if (element.tagName) { + return element.tagName.toLowerCase(); + } + } + + return tagName; } export function validateAgainstEventProperties(name: string) { @@ -314,15 +389,19 @@ const SECURITY_SENSITIVE_ELEMENTS: Record< * @param attributeName The name of the attribute. */ export function ɵɵvalidateAttribute(value: T, tagName: string, attributeName: string): T { - const lowerCaseTagName = tagName.toLowerCase(); - const lowerCaseAttrName = attributeName.toLowerCase(); - const index = getSelectedIndex(); const tNode: TNode | null = index === -1 ? null : getSelectedTNode(); if (tNode && tNode.type !== TNodeType.Element) { return value; } + if (tagName === TNodeName.DynamicHost && tNode !== null) { + tagName = ((getNativeByTNode(tNode, getLView()) as RElement).tagName || tagName).toLowerCase(); + } + + const lowerCaseTagName = tagName.toLowerCase(); + const lowerCaseAttrName = attributeName.toLowerCase(); + // Leverage tNode.namespace if active, otherwise check both namespaced and base variants. const fullTagName = lowerCaseTagName[0] !== ':' && tNode?.namespace diff --git a/packages/core/test/acceptance/security_spec.ts b/packages/core/test/acceptance/security_spec.ts index a317064ac1bb..0651a362d56b 100644 --- a/packages/core/test/acceptance/security_spec.ts +++ b/packages/core/test/acceptance/security_spec.ts @@ -9,11 +9,15 @@ import {NgIf} from '@angular/common'; import {DomSanitizer} from '@angular/platform-browser'; import { + ApplicationRef, Component, + ComponentRef, createComponent, Directive, EnvironmentInjector, inject, + inputBinding, + Input, provideZoneChangeDetection, TemplateRef, Type, @@ -872,6 +876,373 @@ describe('innerHTML processing', () => { }); }); +describe('host binding sanitization', () => { + const HOST_BINDING_URL = 'http://server/asset'; + const HOST_BINDING_UNSAFE_URL = 'javascript:custom-data'; + const UNSAFE_HTML = `` + '

safe

'; + const SANITIZED_HTML = '

safe

'; + const resourceUrlError = /NG0904: unsafe value used in a resource URL context.*/; + let hostBindingValue = ''; + + @Component({ + selector: 'dynamic-host', + template: '', + }) + class DynamicHostComponent {} + + @Directive({ + selector: 'safe-data-carrier', + host: {'[attr.data]': 'url'}, + }) + class DataCarrierDirective { + url = hostBindingValue; + } + + @Component({ + selector: 'host-srcdoc-carrier', + template: '', + host: {'[attr.srcdoc]': 'srcdoc'}, + }) + class SrcdocHostComponent { + srcdoc = hostBindingValue; + } + + @Component({ + selector: 'host-action-carrier', + template: '', + host: {'[attr.action]': 'action'}, + }) + class ActionHostComponent { + action = hostBindingValue; + } + + let dynamicHostElement: Element; + let dynamicHostDirective: Type; + + @Component({ + template: '', + }) + class DynamicHostTestApp { + componentRef: ComponentRef; + + private appRef = inject(ApplicationRef); + private environmentInjector = inject(EnvironmentInjector); + + constructor() { + this.componentRef = createComponent(DynamicHostComponent, { + hostElement: dynamicHostElement, + environmentInjector: this.environmentInjector, + directives: [dynamicHostDirective], + }); + this.appRef.attachView(this.componentRef.hostView); + } + } + + async function expectDynamicHostAttribute( + tagName: string, + attrName: string, + value: string, + expected: string, + ): Promise { + hostBindingValue = value; + dynamicHostElement = document.createElement(tagName); + dynamicHostDirective = DataCarrierDirective; + const fixture = TestBed.createComponent(DynamicHostTestApp); + + try { + await fixture.whenStable(); + expect(dynamicHostElement.getAttribute(attrName)).toBe(expected); + } finally { + fixture.componentInstance.componentRef.destroy(); + } + } + + async function expectDynamicHostResourceUrlRejection( + tagName: string, + value: string, + ): Promise { + hostBindingValue = value; + dynamicHostElement = document.createElement(tagName); + dynamicHostDirective = DataCarrierDirective; + const fixture = TestBed.createComponent(DynamicHostTestApp); + + try { + await expectAsync(fixture.whenStable()).toBeRejectedWithError(resourceUrlError); + } finally { + fixture.componentInstance.componentRef.destroy(); + } + } + + async function expectComponentHostAttribute( + type: Type, + tagName: string, + attrName: string, + value: string, + expected: string, + ): Promise { + hostBindingValue = value; + const hostElement = document.createElement(tagName); + const appRef = TestBed.inject(ApplicationRef); + const componentRef = createComponent(type, { + hostElement, + environmentInjector: TestBed.inject(EnvironmentInjector), + }); + + try { + appRef.attachView(componentRef.hostView); + await appRef.whenStable(); + + expect(hostElement.getAttribute(attrName)).toBe(expected); + } finally { + componentRef.destroy(); + } + } + + it('should not sanitize resource URL attribute names on non-resource concrete hosts', async () => { + await expectDynamicHostAttribute('div', 'data', HOST_BINDING_URL, HOST_BINDING_URL); + await expectDynamicHostAttribute( + 'div', + 'data', + HOST_BINDING_UNSAFE_URL, + HOST_BINDING_UNSAFE_URL, + ); + }); + + it('should sanitize a dynamic directive host binding against the concrete host element', async () => { + @Component({ + selector: 'iframe', + template: '', + }) + class DynamicIframeHostComponent {} + + @Directive({ + selector: 'safe-srcdoc-carrier', + host: {'[attr.srcdoc]': 'srcdoc'}, + }) + class SafeSrcdocCarrierDirective { + @Input() srcdoc = ''; + } + + @Component({ + template: '', + imports: [DynamicIframeHostComponent], + }) + class App { + componentRef: ComponentRef; + + private viewContainerRef = inject(ViewContainerRef); + private environmentInjector = inject(EnvironmentInjector); + + constructor() { + this.componentRef = this.viewContainerRef.createComponent(DynamicIframeHostComponent, { + environmentInjector: this.environmentInjector, + directives: [ + { + type: SafeSrcdocCarrierDirective, + bindings: [inputBinding('srcdoc', () => UNSAFE_HTML)], + }, + ], + }); + } + } + + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + const iframe = fixture.componentInstance.componentRef.location + .nativeElement as HTMLIFrameElement; + expect(iframe.getAttribute('srcdoc')).toBe(SANITIZED_HTML); + expect(iframe.getAttribute('srcdoc')).not.toContain('

safe

'; + + expect(ɵɵsanitizeHtml(html, 'div', 'srcdoc')).toBe(html); + expect(ɵɵsanitizeHtml(html, 'iframe', 'srcdoc').toString()).toBe('

safe

'); + }); + it('should sanitize url', () => { expect(ɵɵsanitizeUrl('http://server')).toEqual('http://server'); expect(ɵɵsanitizeUrl(new Wrap('http://server'))).toEqual('http://server'); @@ -119,6 +126,10 @@ describe('sanitization', () => { for (const [prop, nsSchema] of Object.entries(schema)) { for (const [ns, tagSchema] of Object.entries(nsSchema)) { + if (ns !== '') { + continue; + } + for (const [tag, context] of Object.entries(tagSchema)) { if (context !== SecurityContext.URL && context !== SecurityContext.RESOURCE_URL) { continue; @@ -143,7 +154,8 @@ describe('sanitization', () => { expect(getUrlSanitizer('IFRAME', 'SRC')).toEqual(ɵɵsanitizeResourceUrl); expect(getUrlSanitizer('IFRAME', 'src')).toEqual(ɵɵsanitizeResourceUrl); expect(getUrlSanitizer('iframe', 'SRC')).toEqual(ɵɵsanitizeResourceUrl); - expect(getUrlSanitizer('ScRiPt', 'xLiNk:HrEf')).toEqual(ɵɵsanitizeUrl); + + expect(getUrlSanitizer('DiV', 'DaTa')).toBeNull(); expect(getUrlSanitizer('A', 'HREF')).toEqual(ɵɵsanitizeUrl); }); @@ -156,10 +168,6 @@ describe('sanitization', () => { expect(() => ɵɵsanitizeUrlOrResourceUrl('http://server', 'iframe', 'SRC')).toThrowError(ERROR); - expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'ScRiPt', 'xLiNk:HrEf')).toEqual( - 'unsafe:javascript:true', - ); - expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'A', 'HREF')).toEqual( 'unsafe:javascript:true', ); @@ -200,6 +208,8 @@ describe('sanitization', () => { expect( ɵɵsanitizeUrlOrResourceUrl(bypassSanitizationTrustUrl('javascript:true'), 'a', 'href'), ).toEqual('javascript:true'); + + expect(ɵɵsanitizeUrlOrResourceUrl('javascript:true', 'div', 'data')).toBe('javascript:true'); }); it('should only trust constant strings from template literal tags without interpolation', () => { From f57d5d5c8c34b0c3307c08e827d4593a80a4e82b Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:15:14 -0500 Subject: [PATCH 015/161] fix(core): account for namespaces in host binding sanitization (#69558) Make runtime URL sanitizer selection namespace-aware so SVG and MathML host bindings match the security schema. Cover SVG href/xlink:href and MathML href host binding cases, including dynamic hostElement resolution. PR Close #69558 --- .../src/schema/dom_security_schema.ts | 4 +- .../pipeline/src/phases/resolve_sanitizers.ts | 44 ++- packages/core/src/render3/component_ref.ts | 2 +- packages/core/src/render3/i18n/i18n_parse.ts | 33 +- packages/core/src/render3/namespaces.ts | 8 +- packages/core/src/render3/state.ts | 2 +- packages/core/src/render3/util/tags.ts | 32 ++ .../src/sanitization/dom_security_schema.ts | 4 +- .../core/src/sanitization/sanitization.ts | 213 ++++------ .../core/test/acceptance/security_spec.ts | 373 +++++++++--------- .../router/bundle.golden_symbols.json | 17 +- .../test/sanitization/sanitization_spec.ts | 17 +- 12 files changed, 358 insertions(+), 391 deletions(-) create mode 100644 packages/core/src/render3/util/tags.ts diff --git a/packages/compiler/src/schema/dom_security_schema.ts b/packages/compiler/src/schema/dom_security_schema.ts index fae14f75b5db..fbd3c81f1368 100644 --- a/packages/compiler/src/schema/dom_security_schema.ts +++ b/packages/compiler/src/schema/dom_security_schema.ts @@ -56,8 +56,8 @@ type SecuritySchema = Record< >; let _SECURITY_SCHEMA!: SecuritySchema; -const SVG_NAMESPACE = 'svg'; -const MATH_ML_NAMESPACE = 'math'; +export const SVG_NAMESPACE = 'svg'; +export const MATH_ML_NAMESPACE = 'math'; const NO_NAMESPACE = ''; const MATCH_ALL_ELEMENTS = '*'; const createNullObj = () => Object.create(null); diff --git a/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts b/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts index 1012a3beefae..d2c3731598ca 100644 --- a/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts +++ b/packages/compiler/src/template/pipeline/src/phases/resolve_sanitizers.ts @@ -58,7 +58,10 @@ export function resolveSanitizers(job: CompilationJob): void { case ir.OpKind.DomProperty: case ir.OpKind.TwoWayProperty: let sanitizerFn: o.ExternalReference | null = null; - if (isUrlOrResourceUrlSecurityContext(op.securityContext)) { + if ( + Array.isArray(op.securityContext) && + hasCompositeUrlSecurityContext(op.securityContext) + ) { // When the host element isn't known, attributes such as `href`, `src`, `data`, // `action`, and `codebase` may be part of multiple security contexts. In this case we // use a special sanitization function and select the actual behavior at runtime based @@ -76,13 +79,7 @@ export function resolveSanitizers(job: CompilationJob): void { } } -function isUrlOrResourceUrlSecurityContext( - securityContext: SecurityContext | SecurityContext[], -): boolean { - if (!Array.isArray(securityContext)) { - return false; - } - +function hasCompositeUrlSecurityContext(securityContext: SecurityContext[]): boolean { let hasUrlContext = false; let hasResourceUrlContext = false; let hasNoneContext = false; @@ -115,18 +112,23 @@ function isUrlOrResourceUrlSecurityContext( function getOnlySecurityContext( securityContext: SecurityContext | SecurityContext[], ): SecurityContext { - if (Array.isArray(securityContext)) { - const nonNoneSecurityContexts = securityContext.filter( - (context) => context !== SecurityContext.NONE, - ); - if (nonNoneSecurityContexts.length > 1) { - // TODO: What should we do here? TDB just took the first one, but this feels like something we - // would want to know about and create a special case for like we did for Url/ResourceUrl. My - // guess is that, outside of the Url/ResourceUrl case, this never actually happens. If there - // do turn out to be other cases, throwing an error until we can address it feels safer. - throw Error(`AssertionError: Ambiguous security context`); - } - return nonNoneSecurityContexts[0] || SecurityContext.NONE; + if (!Array.isArray(securityContext)) { + return securityContext; + } + + if (securityContext.length < 2) { + return securityContext[0] ?? SecurityContext.NONE; + } + + const nonNoneSecurityContexts = securityContext.filter( + (context) => context !== SecurityContext.NONE, + ); + if (nonNoneSecurityContexts.length > 1) { + // TODO: What should we do here? TDB just took the first one, but this feels like something we + // would want to know about and create a special case for like we did for Url/ResourceUrl. My + // guess is that, outside of the Url/ResourceUrl case, this never actually happens. If there + // do turn out to be other cases, throwing an error until we can address it feels safer. + throw Error(`AssertionError: Ambiguous security context`); } - return securityContext; + return nonNoneSecurityContexts[0] ?? SecurityContext.NONE; } diff --git a/packages/core/src/render3/component_ref.ts b/packages/core/src/render3/component_ref.ts index 50e10b53d058..7693c877baf0 100644 --- a/packages/core/src/render3/component_ref.ts +++ b/packages/core/src/render3/component_ref.ts @@ -21,6 +21,7 @@ import {ComponentRef as AbstractComponentRef} from '../linker/component_factory' import {createElementRef, ElementRef} from '../linker/element_ref'; import {NgModuleRef} from '../linker/ng_module_factory'; import {RendererFactory2} from '../render/api'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../sanitization/dom_security_schema'; import {Sanitizer} from '../sanitization/sanitizer'; import {attachPatchData} from './context_discovery'; @@ -56,7 +57,6 @@ import { TVIEW, TViewType, } from './interfaces/view'; -import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces'; import {ProfilerEvent} from '../../primitives/devtools'; import {TracingService} from '../application/tracing'; diff --git a/packages/core/src/render3/i18n/i18n_parse.ts b/packages/core/src/render3/i18n/i18n_parse.ts index b2a9bae32d4c..1aaf0df3479c 100644 --- a/packages/core/src/render3/i18n/i18n_parse.ts +++ b/packages/core/src/render3/i18n/i18n_parse.ts @@ -73,6 +73,8 @@ import { setTIcu, setTNodeInsertBeforeIndex, } from './i18n_util'; +import {splitNsName} from '../util/tags'; +import {NAMESPACE_URIS} from '../namespaces'; const BINDING_REGEXP = /�(\d+):?\d*�/gi; const ICU_REGEXP = /({\s*�\d+:?\d*�\s*,\s*\S{6}\s*,[\s\S]*})/gi; @@ -658,7 +660,7 @@ function parseICUBlock(pattern: string): IcuExpression { const parts = i18nParseTextIntoPartsAndICU(pattern) as string[]; // Looking for (key block)+ sequence. One of the keys has to be "other". - for (let pos = 0; pos < parts.length; ) { + for (let pos = 0; pos < parts.length;) { let key = parts[pos++].trim(); if (icuType === IcuType.plural) { // Key can be "=x", we just want "x" @@ -814,13 +816,10 @@ function walkIcuTree( const attr = elAttrs.item(i)!; const lowerAttrName = attr.name.toLowerCase(); const hasBinding = !!attr.value.match(BINDING_REGEXP); - const elementNS = element.namespaceURI; - const tagNameWithNamespace = - elementNS === 'http://www.w3.org/2000/svg' - ? `:svg:${tagName}` - : elementNS === 'http://www.w3.org/1998/Math/MathML' - ? `:math:${tagName}` - : tagName; + const namespaceUri = element.namespaceURI; + const namespace = namespaceUri && NAMESPACE_URIS[namespaceUri]; + const tagNameWithNamespace = namespace ? `:${namespace}:${tagName}` : tagName; + if (hasBinding) { if (VALID_ATTRS.hasOwnProperty(lowerAttrName)) { generateBindingUpdateOpCodes( @@ -984,24 +983,6 @@ function addCreateAttribute( create.push((newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr, attrName, attrValue); } -function splitNsName(elementName: string, fatal: boolean = true): [string | null, string] { - if (elementName[0] != ':') { - return [null, elementName]; - } - - const colonIndex = elementName.indexOf(':', 1); - - if (colonIndex === -1) { - if (fatal) { - throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`); - } else { - return [null, elementName]; - } - } - - return [elementName.slice(1, colonIndex), elementName.slice(colonIndex + 1)]; -} - function i18nResolveSanitizer(attrName: string, tagName?: string): SanitizerFn | null { let schemaContext: SecurityContext; diff --git a/packages/core/src/render3/namespaces.ts b/packages/core/src/render3/namespaces.ts index 56ccf98f1029..ad1268061a23 100644 --- a/packages/core/src/render3/namespaces.ts +++ b/packages/core/src/render3/namespaces.ts @@ -6,5 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -export const SVG_NAMESPACE = 'svg'; -export const MATH_ML_NAMESPACE = 'math'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../sanitization/dom_security_schema'; + +export const NAMESPACE_URIS: Record = { + 'http://www.w3.org/2000/svg': SVG_NAMESPACE, + 'http://www.w3.org/1998/Math/MathML': MATH_ML_NAMESPACE, +}; diff --git a/packages/core/src/render3/state.ts b/packages/core/src/render3/state.ts index 0731b607a644..b0dfd8ee33fc 100644 --- a/packages/core/src/render3/state.ts +++ b/packages/core/src/render3/state.ts @@ -7,6 +7,7 @@ */ import {InternalInjectFlags} from '../di/interface/injector'; +import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../sanitization/dom_security_schema'; import { assertDefined, assertEqual, @@ -31,7 +32,6 @@ import { TView, TViewType, } from './interfaces/view'; -import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from './namespaces'; import {getTNode, walkUpViews} from './util/view_utils'; /** diff --git a/packages/core/src/render3/util/tags.ts b/packages/core/src/render3/util/tags.ts new file mode 100644 index 000000000000..b9c57f5d0b23 --- /dev/null +++ b/packages/core/src/render3/util/tags.ts @@ -0,0 +1,32 @@ +/** + * @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 + */ + +/** + * Splits an element name into its namespace and local name. + * + * @param elementName The element name to split, in the format ":namespace:name". + * @param fatal If true, throws an error if the element name is not in the correct format. + * @returns A tuple containing the namespace and local name. + */ +export function splitNsName(elementName: string, fatal: boolean = true): [string | null, string] { + if (elementName[0] != ':') { + return [null, elementName]; + } + + const colonIndex = elementName.indexOf(':', 1); + + if (colonIndex === -1) { + if (fatal) { + throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`); + } else { + return [null, elementName]; + } + } + + return [elementName.slice(1, colonIndex), elementName.slice(colonIndex + 1)]; +} diff --git a/packages/core/src/sanitization/dom_security_schema.ts b/packages/core/src/sanitization/dom_security_schema.ts index fae14f75b5db..fbd3c81f1368 100644 --- a/packages/core/src/sanitization/dom_security_schema.ts +++ b/packages/core/src/sanitization/dom_security_schema.ts @@ -56,8 +56,8 @@ type SecuritySchema = Record< >; let _SECURITY_SCHEMA!: SecuritySchema; -const SVG_NAMESPACE = 'svg'; -const MATH_ML_NAMESPACE = 'math'; +export const SVG_NAMESPACE = 'svg'; +export const MATH_ML_NAMESPACE = 'math'; const NO_NAMESPACE = ''; const MATCH_ALL_ELEMENTS = '*'; const createNullObj = () => Object.create(null); diff --git a/packages/core/src/sanitization/sanitization.ts b/packages/core/src/sanitization/sanitization.ts index f1c969c06cd5..4c4fee794ea2 100644 --- a/packages/core/src/sanitization/sanitization.ts +++ b/packages/core/src/sanitization/sanitization.ts @@ -16,6 +16,7 @@ import {ENVIRONMENT} from '../render3/interfaces/view'; import {getLView, getSelectedIndex, getSelectedTNode} from '../render3/state'; import {renderStringify} from '../render3/util/stringify_utils'; import {getNativeByTNode} from '../render3/util/view_utils'; +import {NAMESPACE_URIS} from '../render3/namespaces'; import {TrustedHTML, TrustedScript, TrustedScriptURL} from '../util/security/trusted_type_defs'; import {trustedHTMLFromString, trustedScriptURLFromString} from '../util/security/trusted_types'; import { @@ -28,8 +29,9 @@ import {allowSanitizationBypassAndThrow, BypassType, unwrapSafeValue} from './by import {_sanitizeHtml} from './html_sanitizer'; import {enforceIframeSecurity} from './iframe_attrs_validation'; import {Sanitizer} from './sanitizer'; -import {SecurityContext} from './dom_security_schema'; +import {checkSecurityContext, SecurityContext, SVG_NAMESPACE} from './dom_security_schema'; import {_sanitizeUrl} from './url_sanitizer'; +import {splitNsName} from '../render3/util/tags'; /** * An `html` sanitizer which converts untrusted `html` **string** into trusted string by removing @@ -225,31 +227,6 @@ export function ɵɵtrustConstantResourceUrl(url: TemplateStringsArray): Trusted return trustedScriptURLFromString(url[0]); } -const HTML_MAP: Record | undefined> = { - '*': {'innerhtml': true, 'outerhtml': true}, - 'iframe': {'srcdoc': true}, -}; - -const URL_MAP: Record | undefined> = { - '*': {'formaction': true}, - 'area': {'href': true}, - 'a': {'href': true, 'xlink:href': true}, - 'form': {'action': true}, - 'img': {'src': true}, - 'video': {'src': true}, -}; - -const RESOURCE_MAP: Record | undefined> = { - 'embed': {'src': true}, - 'frame': {'src': true}, - 'iframe': {'src': true}, - 'media': {'src': true}, - - 'base': {'href': true}, - 'link': {'href': true}, - 'object': {'data': true, 'codebase': true}, -}; - /** * Detects which sanitizer to use for URL property, based on tag name and prop name. * @@ -283,69 +260,55 @@ export function getUrlSanitizer(tag: string, prop: string) { * * @codeGenApi */ -export function ɵɵsanitizeUrlOrResourceUrl(unsafeUrl: any, tag: string, prop: string): any { - const sanitizer = getUrlSanitizer(tag, prop); - return sanitizer === null ? unsafeUrl : sanitizer(unsafeUrl); +export function ɵɵsanitizeUrlOrResourceUrl(unsafeUrl: any, tag: string, prop: string) { + return getUrlSanitizer(tag, prop)?.(unsafeUrl) ?? unsafeUrl; } -function getSecurityContext(tagName: string, propName: string): SecurityContext { - tagName = resolveHostTagName(tagName).toLowerCase(); - propName = propName.toLowerCase(); - - if (hasSecurityContext(RESOURCE_MAP, tagName, propName)) { - return SecurityContext.RESOURCE_URL; - } - - if (hasSecurityContext(URL_MAP, tagName, propName)) { - return SecurityContext.URL; - } - - if (hasSecurityContext(HTML_MAP, tagName, propName)) { - return SecurityContext.HTML; +export function validateAgainstEventProperties(name: string) { + if (name.toLowerCase().startsWith('on')) { + const errorMessage = + `Binding to event property '${name}' is disallowed for security reasons, ` + + `please use (${name.slice(2)})=...` + + `\nIf '${name}' is a directive input, make sure the directive is imported by the` + + ` current module.`; + throw new RuntimeError(RuntimeErrorCode.INVALID_EVENT_BINDING, errorMessage); } +} - return SecurityContext.NONE; +function getSanitizer(): Sanitizer | null { + const lView = getLView(); + return lView && lView[ENVIRONMENT].sanitizer; } -function hasSecurityContext( - map: Record | undefined>, - tagName: string, - propName: string, -): boolean { - return map[tagName]?.[propName] === true || map['*']?.[propName] === true; +function getSecurityContext(tagName: string, propName: string): SecurityContext { + const [namespace, resolvedTagName] = resolveElement(tagName); + return checkSecurityContext(resolvedTagName, propName, namespace); } -function resolveHostTagName(tagName: string): string { - if (tagName !== TNodeName.DynamicHost) { - return tagName; +function resolveElement(tagName: string): [namespace: string | null | undefined, tagName: string] { + tagName = tagName.toLowerCase(); + const splitResult = splitNsName(tagName, false); + if (splitResult[0]) { + return splitResult; } const index = getSelectedIndex(); const tNode = index === -1 ? null : getSelectedTNode(); - if (tNode !== null && tNode.type & TNodeType.Element) { + let namespace = tNode?.namespace; + + if (tagName === TNodeName.DynamicHost && tNode?.type === TNodeType.Element) { const element = getNativeByTNode(tNode, getLView()) as RElement; if (element.tagName) { - return element.tagName.toLowerCase(); + tagName = element.tagName.toLowerCase(); } - } - return tagName; -} - -export function validateAgainstEventProperties(name: string) { - if (name.toLowerCase().startsWith('on')) { - const errorMessage = - `Binding to event property '${name}' is disallowed for security reasons, ` + - `please use (${name.slice(2)})=...` + - `\nIf '${name}' is a directive input, make sure the directive is imported by the` + - ` current module.`; - throw new RuntimeError(RuntimeErrorCode.INVALID_EVENT_BINDING, errorMessage); + if (namespace == null) { + const namespaceURI = (element as RElement & {namespaceURI?: string | null}).namespaceURI; + namespace = namespaceURI && NAMESPACE_URIS[namespaceURI]; + } } -} -function getSanitizer(): Sanitizer | null { - const lView = getLView(); - return lView && lView[ENVIRONMENT].sanitizer; + return [namespace, tagName]; } /** @@ -357,28 +320,16 @@ const SECURITY_SENSITIVE_ATTRIBUTE_NAMES: ReadonlySet = new Set(['href', * @remarks Keep this in sync with DOM Security Schema. * @see [SECURITY_SCHEMA](../../../compiler/src/schema/dom_security_schema.ts) */ -const SECURITY_SENSITIVE_ELEMENTS: Record< +const SVG_ANIMATION_SENSITIVE_STATIC_VALUES: Record< string, - Record> | undefined + Record> | undefined > = { - 'iframe': { - 'sandbox': true, - 'allow': true, - 'allowfullscreen': true, - 'referrerpolicy': true, - 'csp': true, - 'fetchpriority': true, - 'credentialless': true, - }, - ':svg:animate': { - 'attributename': true, + 'animate': { 'to': SECURITY_SENSITIVE_ATTRIBUTE_NAMES, 'values': SECURITY_SENSITIVE_ATTRIBUTE_NAMES, 'from': SECURITY_SENSITIVE_ATTRIBUTE_NAMES, }, - ':svg:set': {'attributename': true, 'to': SECURITY_SENSITIVE_ATTRIBUTE_NAMES}, - ':svg:animatemotion': {'attributename': true}, - ':svg:animatetransform': {'attributename': true}, + 'set': {'to': SECURITY_SENSITIVE_ATTRIBUTE_NAMES}, }; /** @@ -395,76 +346,52 @@ export function ɵɵvalidateAttribute(value: T, tagName: string, attrib return value; } - if (tagName === TNodeName.DynamicHost && tNode !== null) { - tagName = ((getNativeByTNode(tNode, getLView()) as RElement).tagName || tagName).toLowerCase(); - } - - const lowerCaseTagName = tagName.toLowerCase(); - const lowerCaseAttrName = attributeName.toLowerCase(); - - // Leverage tNode.namespace if active, otherwise check both namespaced and base variants. - const fullTagName = - lowerCaseTagName[0] !== ':' && tNode?.namespace - ? `:${tNode.namespace}:${lowerCaseTagName}` - : lowerCaseTagName; + const [namespace, resolvedTagName] = resolveElement(tagName); + const securityContext = checkSecurityContext(resolvedTagName, attributeName, namespace); - const validationConfig = SECURITY_SENSITIVE_ELEMENTS[fullTagName]?.[lowerCaseAttrName]; - - if (!validationConfig) { + if (securityContext !== SecurityContext.ATTRIBUTE_NO_BINDING) { return value; } const lView = getLView(); - if (tNode && lowerCaseTagName === 'iframe') { - const element = getNativeByTNode(tNode, lView) as RElement; - enforceIframeSecurity(element as HTMLIFrameElement); - } - - const displayTagName = tagName[0] === ':' ? tagName.split(':').pop()! : tagName; - - if (typeof validationConfig !== 'boolean') { - if (!tNode) { - const errorMessage = - ngDevMode && - `Angular has detected that the \`${attributeName}\` was applied ` + - `as a binding to the <${tagName}> element. ` + - `For security reasons, the \`${attributeName}\` can be set on the <${tagName}> element ` + - `as a static attribute only. \n` + - `To fix this, switch the \`${attributeName}\` binding to a static attribute ` + - `in a template or in host bindings section.`; - throw new RuntimeError(RuntimeErrorCode.UNSAFE_ATTRIBUTE_BINDING, errorMessage); + if (tNode) { + if (resolvedTagName === 'iframe') { + const element = getNativeByTNode(tNode, lView) as RElement; + enforceIframeSecurity(element as HTMLIFrameElement); + } else if (namespace === SVG_NAMESPACE) { + const config = + SVG_ANIMATION_SENSITIVE_STATIC_VALUES[resolvedTagName]?.[attributeName.toLowerCase()]; + if (config) { + const element = getNativeByTNode(tNode, lView) as SVGAnimateElement; + const attributeNameValue = getSecuritySensitiveSVGAnimationAttributeName(element, config); + + if (attributeNameValue) { + const errorMessage = + ngDevMode && + `Angular has detected that the \`${attributeName}\` was applied ` + + `as a binding to the <${resolvedTagName}> element${getTemplateLocationDetails(lView)}. ` + + `For security reasons, the \`${attributeName}\` can be set on the <${resolvedTagName}> element ` + + `as a static attribute only when the "attributeName" is set to \'${attributeNameValue}\'. \n` + + `To fix this, switch the \`${attributeNameValue}\` binding to a static attribute ` + + `in a template or in host bindings section.`; + + throw new RuntimeError(RuntimeErrorCode.UNSAFE_ATTRIBUTE_BINDING, errorMessage); + } + + return value; + } } - - const element = getNativeByTNode(tNode, lView) as SVGAnimateElement; - const attributeNameValue = getSecuritySensitiveSVGAnimationAttributeName( - element, - validationConfig, - ); - - if (attributeNameValue) { - const errorMessage = - ngDevMode && - `Angular has detected that the \`${attributeName}\` was applied ` + - `as a binding to the <${displayTagName}> element${getTemplateLocationDetails(lView)}. ` + - `For security reasons, the \`${attributeName}\` can be set on the <${displayTagName}> element ` + - `as a static attribute only when the "attributeName" is set to \'${attributeNameValue}\'. \n` + - `To fix this, switch the \`${attributeNameValue}\` binding to a static attribute ` + - `in a template or in host bindings section.`; - - throw new RuntimeError(RuntimeErrorCode.UNSAFE_ATTRIBUTE_BINDING, errorMessage); - } - - return value; } const errorMessage = ngDevMode && `Angular has detected that the \`${attributeName}\` was applied ` + - `as a binding to the <${displayTagName}> element${tNode ? getTemplateLocationDetails(lView) : ''}. ` + - `For security reasons, the \`${attributeName}\` can be set on the <${displayTagName}> element ` + + `as a binding to the <${resolvedTagName}> element${tNode ? getTemplateLocationDetails(lView) : ''}. ` + + `For security reasons, the \`${attributeName}\` can be set on the <${resolvedTagName}> element ` + `as a static attribute only. \n` + `To fix this, switch the \`${attributeName}\` binding to a static attribute ` + `in a template or in host bindings section.`; + throw new RuntimeError(RuntimeErrorCode.UNSAFE_ATTRIBUTE_BINDING, errorMessage); } diff --git a/packages/core/test/acceptance/security_spec.ts b/packages/core/test/acceptance/security_spec.ts index 0651a362d56b..352b51accf7c 100644 --- a/packages/core/test/acceptance/security_spec.ts +++ b/packages/core/test/acceptance/security_spec.ts @@ -29,6 +29,9 @@ import {RuntimeErrorCode} from '../../src/errors'; import {global} from '../../src/util/global'; import {ComponentFixture, TestBed} from '../../testing'; +const SVG_NAMESPACE_URI = 'http://www.w3.org/2000/svg'; +const MATH_ML_NAMESPACE_URI = 'http://www.w3.org/1998/Math/MathML'; + describe('comment node text escaping', () => { // see: https://html.spec.whatwg.org/multipage/syntax.html#comments [ @@ -882,130 +885,153 @@ describe('host binding sanitization', () => { const UNSAFE_HTML = `` + '

safe

'; const SANITIZED_HTML = '

safe

'; const resourceUrlError = /NG0904: unsafe value used in a resource URL context.*/; - let hostBindingValue = ''; - - @Component({ - selector: 'dynamic-host', - template: '', - }) - class DynamicHostComponent {} - - @Directive({ - selector: 'safe-data-carrier', - host: {'[attr.data]': 'url'}, - }) - class DataCarrierDirective { - url = hostBindingValue; - } - @Component({ - selector: 'host-srcdoc-carrier', - template: '', - host: {'[attr.srcdoc]': 'srcdoc'}, - }) - class SrcdocHostComponent { - srcdoc = hostBindingValue; - } + async function expectHostBinding(options: { + tagName: string; + attrName: string; + value: string; + expected?: string; + expectedError?: RegExp; + namespace?: string; + componentSelector?: string; + }): Promise { + // Avoid duplicate selector generation. + const randomIdentifier = Math.floor(Math.random() * 100); + + const { + tagName, + attrName, + value, + expected, + expectedError, + namespace, + componentSelector = `dynamic-host-${randomIdentifier}`, + } = options; - @Component({ - selector: 'host-action-carrier', - template: '', - host: {'[attr.action]': 'action'}, - }) - class ActionHostComponent { - action = hostBindingValue; - } + @Directive({ + selector: `[safe-data-carrier-${randomIdentifier}]`, + host: {[`[attr.${attrName}]`]: 'val'}, + }) + class CarrierDirective { + val = value; + } + + @Component({ + selector: componentSelector, + template: '', + }) + class DynamicComponent {} - let dynamicHostElement: Element; - let dynamicHostDirective: Type; + const hostElement = namespace + ? document.createElementNS(namespace, tagName) + : document.createElement(tagName); - @Component({ - template: '', - }) - class DynamicHostTestApp { - componentRef: ComponentRef; + let componentRef: ComponentRef | undefined; - private appRef = inject(ApplicationRef); - private environmentInjector = inject(EnvironmentInjector); + @Component({ + template: '', + }) + class AppHost { + private appRef = inject(ApplicationRef); + private environmentInjector = inject(EnvironmentInjector); - constructor() { - this.componentRef = createComponent(DynamicHostComponent, { - hostElement: dynamicHostElement, - environmentInjector: this.environmentInjector, - directives: [dynamicHostDirective], - }); - this.appRef.attachView(this.componentRef.hostView); + constructor() { + componentRef = createComponent(DynamicComponent, { + hostElement, + environmentInjector: this.environmentInjector, + directives: [CarrierDirective], + }); + this.appRef.attachView(componentRef.hostView); + } } - } - async function expectDynamicHostAttribute( - tagName: string, - attrName: string, - value: string, - expected: string, - ): Promise { - hostBindingValue = value; - dynamicHostElement = document.createElement(tagName); - dynamicHostDirective = DataCarrierDirective; - const fixture = TestBed.createComponent(DynamicHostTestApp); + const fixture = TestBed.createComponent(AppHost); try { - await fixture.whenStable(); - expect(dynamicHostElement.getAttribute(attrName)).toBe(expected); + if (expectedError) { + await expectAsync(fixture.whenStable()).toBeRejectedWithError(expectedError); + } else { + await fixture.whenStable(); + expect(hostElement.getAttribute(attrName)).toBe(expected ?? null); + } } finally { - fixture.componentInstance.componentRef.destroy(); + componentRef?.destroy(); } } - async function expectDynamicHostResourceUrlRejection( - tagName: string, - value: string, - ): Promise { - hostBindingValue = value; - dynamicHostElement = document.createElement(tagName); - dynamicHostDirective = DataCarrierDirective; - const fixture = TestBed.createComponent(DynamicHostTestApp); + it('should not sanitize resource URL attribute names on non-resource concrete hosts', async () => { + await expectHostBinding({ + tagName: 'div', + attrName: 'data', + value: HOST_BINDING_URL, + expected: HOST_BINDING_URL, + }); + await expectHostBinding({ + tagName: 'div', + attrName: 'data', + value: HOST_BINDING_UNSAFE_URL, + expected: HOST_BINDING_UNSAFE_URL, + }); + }); - try { - await expectAsync(fixture.whenStable()).toBeRejectedWithError(resourceUrlError); - } finally { - fixture.componentInstance.componentRef.destroy(); - } - } + it('should sanitize href host bindings on SVG links', async () => { + await expectHostBinding({ + tagName: 'a', + attrName: 'href', + value: HOST_BINDING_UNSAFE_URL, + expected: `unsafe:${HOST_BINDING_UNSAFE_URL}`, + namespace: SVG_NAMESPACE_URI, + }); + }); - async function expectComponentHostAttribute( - type: Type, - tagName: string, - attrName: string, - value: string, - expected: string, - ): Promise { - hostBindingValue = value; - const hostElement = document.createElement(tagName); - const appRef = TestBed.inject(ApplicationRef); - const componentRef = createComponent(type, { - hostElement, - environmentInjector: TestBed.inject(EnvironmentInjector), + it('should not sanitize href host bindings on non-link SVG elements', async () => { + await expectHostBinding({ + tagName: 'rect', + attrName: 'href', + value: HOST_BINDING_UNSAFE_URL, + expected: HOST_BINDING_UNSAFE_URL, + namespace: SVG_NAMESPACE_URI, }); + }); - try { - appRef.attachView(componentRef.hostView); - await appRef.whenStable(); + it('should sanitize xlink:href host bindings on SVG links', async () => { + await expectHostBinding({ + tagName: 'a', + attrName: 'xlink:href', + value: HOST_BINDING_UNSAFE_URL, + expected: `unsafe:${HOST_BINDING_UNSAFE_URL}`, + namespace: SVG_NAMESPACE_URI, + }); + }); - expect(hostElement.getAttribute(attrName)).toBe(expected); - } finally { - componentRef.destroy(); - } - } + it('should sanitize href host bindings on MathML elements', async () => { + await expectHostBinding({ + tagName: 'mi', + attrName: 'href', + value: HOST_BINDING_UNSAFE_URL, + expected: `unsafe:${HOST_BINDING_UNSAFE_URL}`, + namespace: MATH_ML_NAMESPACE_URI, + }); + }); - it('should not sanitize resource URL attribute names on non-resource concrete hosts', async () => { - await expectDynamicHostAttribute('div', 'data', HOST_BINDING_URL, HOST_BINDING_URL); - await expectDynamicHostAttribute( - 'div', - 'data', - HOST_BINDING_UNSAFE_URL, - HOST_BINDING_UNSAFE_URL, - ); + it('should sanitize href host bindings on dynamic SVG hosts as URLs', async () => { + await expectHostBinding({ + tagName: 'a', + attrName: 'href', + value: HOST_BINDING_UNSAFE_URL, + expected: `unsafe:${HOST_BINDING_UNSAFE_URL}`, + namespace: SVG_NAMESPACE_URI, + }); + }); + + it('should sanitize href host bindings on dynamic MathML hosts as URLs', async () => { + await expectHostBinding({ + tagName: 'base', + attrName: 'href', + value: HOST_BINDING_UNSAFE_URL, + expected: `unsafe:${HOST_BINDING_UNSAFE_URL}`, + namespace: MATH_ML_NAMESPACE_URI, + }); }); it('should sanitize a dynamic directive host binding against the concrete host element', async () => { @@ -1056,47 +1082,42 @@ describe('host binding sanitization', () => { }); it('should not sanitize iframe-only host bindings on non-iframe concrete hosts', async () => { - await expectComponentHostAttribute( - SrcdocHostComponent, - 'div', - 'srcdoc', - UNSAFE_HTML, - UNSAFE_HTML, - ); + await expectHostBinding({ + tagName: 'div', + attrName: 'srcdoc', + value: UNSAFE_HTML, + expected: UNSAFE_HTML, + }); }); it('should not sanitize form-only URL host bindings on non-form concrete hosts', async () => { - await expectComponentHostAttribute( - ActionHostComponent, - 'div', - 'action', - HOST_BINDING_URL, - HOST_BINDING_URL, - ); - await expectComponentHostAttribute( - ActionHostComponent, - 'div', - 'action', - HOST_BINDING_UNSAFE_URL, - HOST_BINDING_UNSAFE_URL, - ); + await expectHostBinding({ + tagName: 'div', + attrName: 'action', + value: HOST_BINDING_URL, + expected: HOST_BINDING_URL, + }); + await expectHostBinding({ + tagName: 'div', + attrName: 'action', + value: HOST_BINDING_UNSAFE_URL, + expected: HOST_BINDING_UNSAFE_URL, + }); }); it('should sanitize form-only URL host bindings on form concrete hosts', async () => { - await expectComponentHostAttribute( - ActionHostComponent, - 'form', - 'action', - HOST_BINDING_URL, - HOST_BINDING_URL, - ); - await expectComponentHostAttribute( - ActionHostComponent, - 'form', - 'action', - HOST_BINDING_UNSAFE_URL, - `unsafe:${HOST_BINDING_UNSAFE_URL}`, - ); + await expectHostBinding({ + tagName: 'form', + attrName: 'action', + value: HOST_BINDING_URL, + expected: HOST_BINDING_URL, + }); + await expectHostBinding({ + tagName: 'form', + attrName: 'action', + value: HOST_BINDING_UNSAFE_URL, + expected: `unsafe:${HOST_BINDING_UNSAFE_URL}`, + }); }); it('should sanitize a host directive host binding against the concrete host element', async () => { @@ -1197,49 +1218,49 @@ describe('host binding sanitization', () => { }); it('should reject security-sensitive attribute host bindings on concrete dynamic iframe hosts', async () => { - @Directive({ - selector: 'sandbox-carrier', - host: {'[attr.sandbox]': 'sandbox'}, - }) - class SandboxCarrierDirective { - sandbox = ''; - } - - dynamicHostElement = document.createElement('iframe'); - dynamicHostDirective = SandboxCarrierDirective; - const fixture = TestBed.createComponent(DynamicHostTestApp); - - try { - await expectAsync(fixture.whenStable()).toBeRejectedWithError( + await expectHostBinding({ + tagName: 'iframe', + attrName: 'sandbox', + value: '', + expectedError: /NG0910: Angular has detected that the `sandbox` was applied as a binding to the