From 13e019a1bbb9276c7c8f80b42dad23c54fb3d537 Mon Sep 17 00:00:00 2001 From: Ben Hong Date: Fri, 20 Feb 2026 13:01:11 -0500 Subject: [PATCH 01/21] docs: add new debugging and troubleshooting di guide --- .../app/routing/navigation-entries/index.ts | 6 + .../di/debugging-and-troubleshooting-di.md | 1018 +++++++++++++++++ adev/src/content/reference/errors/NG0204.md | 59 + adev/src/content/reference/errors/NG0205.md | 76 ++ adev/src/content/reference/errors/NG0207.md | 75 ++ adev/src/content/reference/errors/overview.md | 3 + goldens/public-api/core/errors.api.md | 6 +- packages/core/src/errors.ts | 6 +- .../core/test/acceptance/destroy_ref_spec.ts | 2 +- packages/core/test/di/r3_injector_spec.ts | 8 +- .../test/linker/ng_module_integration_spec.ts | 4 +- 11 files changed, 1250 insertions(+), 13 deletions(-) create mode 100644 adev/src/content/guide/di/debugging-and-troubleshooting-di.md create mode 100644 adev/src/content/reference/errors/NG0204.md create mode 100644 adev/src/content/reference/errors/NG0205.md create mode 100644 adev/src/content/reference/errors/NG0207.md diff --git a/adev/src/app/routing/navigation-entries/index.ts b/adev/src/app/routing/navigation-entries/index.ts index 0117ceb6eaae..35be26c71ac5 100644 --- a/adev/src/app/routing/navigation-entries/index.ts +++ b/adev/src/app/routing/navigation-entries/index.ts @@ -341,6 +341,12 @@ export const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [ path: 'guide/di/di-in-action', contentPath: 'guide/di/di-in-action', }, + { + label: 'Debugging and troubleshooting DI', + path: 'guide/di/debugging-and-troubleshooting-di', + contentPath: 'guide/di/debugging-and-troubleshooting-di', + status: 'new', + }, ], }, { diff --git a/adev/src/content/guide/di/debugging-and-troubleshooting-di.md b/adev/src/content/guide/di/debugging-and-troubleshooting-di.md new file mode 100644 index 000000000000..e5f9ae964414 --- /dev/null +++ b/adev/src/content/guide/di/debugging-and-troubleshooting-di.md @@ -0,0 +1,1018 @@ +# Debugging and troubleshooting dependency injection + +Dependency injection (DI) issues typically stem from configuration mistakes, scope problems, or incorrect usage patterns. This guide helps you identify and resolve common DI problems that developers encounter. + +## Common pitfalls and solutions + +### Services not available where expected + +One of the most common DI issues occurs when you try to inject a service but Angular cannot find it in the current injector or any parent injector. This usually happens when the service is provided in the wrong scope or not provided at all. + +#### Provider scope mismatch + +When you provide a service in a component's `providers` array, Angular creates an instance in that component's injector. This instance is only available to that component and its children. Parent components and sibling components cannot access it because they use different injectors. + +```angular-ts {header: 'child-view.ts'} +import {Component} from '@angular/core'; +import {DataStore} from './data-store'; + +@Component({ + selector: 'app-child', + template: '

Child

', + providers: [DataStore], // Only available in this component and its children +}) +export class ChildView {} +``` + +```angular-ts {header: 'parent-view.ts'} +import {Component, inject} from '@angular/core'; +import {DataStore} from './data-store'; + +@Component({ + selector: 'app-parent', + template: '', +}) +export class ParentView { + private dataService = inject(DataStore); // ERROR: Not available to parent +} +``` + +Angular only searches up the hierarchy, never down. Parent components cannot access services provided in child components. + +**Solution:** Provide the service at a higher level (application or parent component). + +```ts {prefer} +import {Injectable} from '@angular/core'; + +@Injectable({providedIn: 'root'}) +export class DataStore { + // Available everywhere +} +``` + +TIP: Use `providedIn: 'root'` by default for services that don't need component-specific state. This makes services available everywhere and enables tree-shaking. + +#### Services and lazy-loaded routes + +When you provide a service in a lazy-loaded route's `providers` array, Angular creates a child injector for that route. This injector and its services only become available after the route loads. Components in the eagerly-loaded parts of your application cannot access these services because they use different injectors that exist before the lazy-loaded injector is created. + +```ts {header: 'feature.routes.ts'} +import {Routes} from '@angular/router'; +import {FeatureClient} from './feature-client'; + +export const featureRoutes: Routes = [ + { + path: 'feature', + providers: [FeatureClient], + loadComponent: () => import('./feature-view'), + }, +]; +``` + +```angular-ts {header: 'eager-view.ts'} +import {Component, inject} from '@angular/core'; +import {FeatureClient} from './feature-client'; + +@Component({ + selector: 'app-eager', + template: '

Eager Component

', +}) +export class EagerView { + private featureService = inject(FeatureClient); // ERROR: Not available yet +} +``` + +Lazy-loaded routes create child injectors that are only available after the route loads. + +NOTE: By default, route injectors and their services persist even after navigating away from the route. They are not destroyed until the application is closed. For automatic cleanup of unused route injectors, see [customizing route behavior](guide/routing/customizing-route-behavior#experimental-automatic-cleanup-of-unused-route-injectors). + +**Solution:** Use `providedIn: 'root'` for services that need to be shared across lazy boundaries. + +```ts {prefer, header: 'Provide at root for shared services'} +import {Injectable} from '@angular/core'; + +@Injectable({providedIn: 'root'}) +export class FeatureClient { + // Available everywhere, including before lazy load +} +``` + +If the service should be lazy-loaded but still available to eager components, inject it only where needed and use optional injection to handle availability. + +### Multiple instances instead of singletons + +You expect one shared instance (singleton) but get separate instances in different components. + +#### Providing in component instead of root + +When you add a service to a component's `providers` array, Angular creates a new instance of that service for each instance of the component. Each component gets its own separate service instance, which means changes in one component don't affect the service instance in other components. This is often unexpected when you want shared state across your application. + +```angular-ts {avoid, header: 'Component-level provider creates multiple instances'} +import {Component, inject} from '@angular/core'; +import {UserClient} from './user-client'; + +@Component({ + selector: 'app-profile', + template: '

Profile

', + providers: [UserClient], // Creates new instance per component! +}) +export class UserProfile { + private userService = inject(UserClient); +} + +@Component({ + selector: 'app-settings', + template: '

Settings

', + providers: [UserClient], // Different instance! +}) +export class UserSettings { + private userService = inject(UserClient); +} +``` + +Each component gets its own `UserClient` instance. Changes in one component don't affect the other. + +**Solution:** Use `providedIn: 'root'` for singletons. + +```ts {prefer, header: 'Root-level singleton'} +import {Injectable} from '@angular/core'; + +@Injectable({providedIn: 'root'}) +export class UserClient { + // Single instance shared across all components +} +``` + +#### When multiple instances are intentional + +Sometimes you want separate instances per component for component-specific state. + +```angular-ts {header: 'Intentional: Component-scoped state'} +import {Injectable, signal} from '@angular/core'; + +@Injectable() // No providedIn - must be provided explicitly +export class FormStateStore { + private formData = signal({}); + + setData(data: any) { + this.formData.set(data); + } + + getData() { + return this.formData(); + } +} + +@Component({ + selector: 'app-user-form', + template: '
...
', + providers: [FormStateStore], // Each form gets its own state +}) +export class UserForm { + private formState = inject(FormStateStore); +} +``` + +This pattern is useful for: + +- Form state management (each form has isolated state) +- Component-specific caching +- Temporary data that shouldn't be shared + +### Incorrect inject() usage + +The `inject()` function only works in specific contexts during class construction and factory execution. + +#### Using inject() in lifecycle hooks + +When you call the `inject()` function inside lifecycle hooks like `ngOnInit()`, `ngAfterViewInit()`, or `ngOnDestroy()`, Angular throws an error because these methods run outside the injection context. The injection context is only available during the synchronous execution of class construction, which happens before lifecycle hooks are called. + +```angular-ts {avoid, header: 'inject() in ngOnInit'} +import {Component, inject} from '@angular/core'; +import {UserClient} from './user-client'; + +@Component({ + selector: 'app-profile', + template: '

User: {{userName}}

', +}) +export class UserProfile { + userName = ''; + + ngOnInit() { + const userService = inject(UserClient); // ERROR: Not an injection context + this.userName = userService.getUser().name; + } +} +``` + +**Solution:** Capture dependencies and derive values in field initializers. + +```angular-ts {prefer, header: 'Derive values in field initializers'} +import {Component, inject} from '@angular/core'; +import {UserClient} from './user-client'; + +@Component({ + selector: 'app-profile', + template: '

User: {{userName}}

', +}) +export class UserProfile { + private userService = inject(UserClient); + userName = this.userService.getUser().name; +} +``` + +#### Using the Injector for deferred injection + +When you need to retrieve services outside an injection context, use the captured `Injector` directly with `injector.get()`: + +```angular-ts +import {Component, inject, Injector} from '@angular/core'; +import {UserClient} from './user-client'; + +@Component({ + selector: 'app-profile', + template: '', +}) +export class UserProfile { + private injector = inject(Injector); + + delayedLoad() { + setTimeout(() => { + const userService = this.injector.get(UserClient); + console.log(userService.getUser()); + }, 1000); + } +} +``` + +#### Using runInInjectionContext for callbacks + +Use `runInInjectionContext()` when you need to enable **other code** to call `inject()`. This is useful when accepting callbacks that might use dependency injection: + +```angular-ts +import {Component, inject, Injector, input} from '@angular/core'; + +@Component({ + selector: 'app-data-loader', + template: '', +}) +export class DataLoader { + private injector = inject(Injector); + onLoad = input<() => void>(); + + load() { + const callback = this.onLoad(); + if (callback) { + // Enable the callback to use inject() + this.injector.runInInjectionContext(callback); + } + } +} +``` + +The `runInInjectionContext()` method creates a temporary injection context, allowing code inside the callback to call `inject()`. + +IMPORTANT: Always capture dependencies at the class level when possible. Use `injector.get()` for simple deferred retrieval, and `runInInjectionContext()` only when external code needs to call `inject()`. + +TIP: Use `assertInInjectionContext()` to verify your code is running in a valid injection context. This is useful when creating reusable functions that call `inject()`. See [Asserting the context](guide/di/dependency-injection-context#asserts-the-context) for details. + +### providers vs viewProviders confusion + +The difference between `providers` and `viewProviders` affects content projection scenarios. + +#### Understanding the difference + +**providers:** Available to the component's template AND any content projected into the component (ng-content). + +**viewProviders:** Only available to the component's template, NOT to projected content. + +```angular-ts {header: 'parent-view.ts'} +import {Component, inject} from '@angular/core'; +import {ThemeStore} from './theme-store'; + +@Component({ + selector: 'app-parent', + template: ` +
+

Theme: {{ themeService.theme() }}

+ +
+ `, + providers: [ThemeStore], // Available to content children +}) +export class ParentView { + protected themeService = inject(ThemeStore); +} + +@Component({ + selector: 'app-parent-view', + template: ` +
+

Theme: {{ themeService.theme() }}

+ +
+ `, + viewProviders: [ThemeStore], // NOT available to content children +}) +export class ParentViewOnly { + protected themeService = inject(ThemeStore); +} +``` + +```angular-ts {header: 'child-view.ts'} +import {Component, inject} from '@angular/core'; +import {ThemeStore} from './theme-store'; + +@Component({ + selector: 'app-child', + template: '

Child theme: {{theme()}}

', +}) +export class ChildView { + private themeService = inject(ThemeStore, {optional: true}); + theme = () => this.themeService?.theme() ?? 'none'; +} +``` + +```angular-ts {header: 'app.ts'} +@Component({ + selector: 'app-root', + template: ` + + + + + + + + + + `, +}) +export class App {} +``` + +**When projected into `app-parent`:** The child component can inject `ThemeStore` because `providers` makes it available to projected content. + +**When projected into `app-parent-view`:** The child component cannot inject `ThemeStore` because `viewProviders` restricts it to the parent's template only. + +#### Choosing between providers and viewProviders + +Use `providers` when: + +- The service should be available to projected content +- You want content children to access the service +- You're providing general-purpose services + +Use `viewProviders` when: + +- The service should only be available to your component's template +- You want to hide implementation details from projected content +- You're providing internal services that shouldn't leak out + +**Default recommendation:** Use `providers` unless you have a specific reason to restrict access with `viewProviders`. + +### InjectionToken issues + +When using `InjectionToken` for non-class dependencies, developers often encounter problems related to token identity, type safety, and provider configuration. These issues usually stem from how JavaScript handles object identity and how TypeScript infers types. + +#### Token identity confusion + +When you create a new `InjectionToken` instance, JavaScript creates a unique object in memory. Even if you create another `InjectionToken` with the exact same description string, it's a completely different object. Angular uses the token object's identity (not its description) to match providers with injection points, so tokens with the same description but different object identities cannot access each other's values. + +```ts {header: 'config.token.ts'} +import {InjectionToken} from '@angular/core'; + +export interface AppConfig { + apiUrl: string; +} + +export const APP_CONFIG = new InjectionToken('app config'); +``` + +```ts {header: 'app.config.ts'} +import {APP_CONFIG} from './config.token'; + +export const appConfig: AppConfig = { + apiUrl: 'https://api.example.com', +}; + +bootstrapApplication(App, { + providers: [{provide: APP_CONFIG, useValue: appConfig}], +}); +``` + +```angular-ts {avoid, header: 'feature-view.ts'} +// Creating new token with same description +import {InjectionToken, inject} from '@angular/core'; +import {AppConfig} from './config.token'; + +const APP_CONFIG = new InjectionToken('app config'); + +@Component({ + selector: 'app-feature', + template: '

Feature

', +}) +export class FeatureView { + private config = inject(APP_CONFIG); // ERROR: Different token instance! +} +``` + +Even though both tokens have the description `'app config'`, they are different objects. Angular compares tokens by reference, not by description. + +**Solution:** Import the same token instance. + +```angular-ts {prefer, header: 'feature-view.ts'} +import {inject} from '@angular/core'; +import {APP_CONFIG, AppConfig} from './config.token'; + +@Component({ + selector: 'app-feature', + template: '

API: {{config.apiUrl}}

', +}) +export class FeatureView { + protected config = inject(APP_CONFIG); // Works: Same token instance +} +``` + +TIP: Always export tokens from a shared file and import them everywhere they're needed. Never create multiple `InjectionToken` instances with the same description. + +#### Trying to inject interfaces + +When you define a TypeScript interface, it only exists during compilation for type checking. TypeScript erases all interface definitions when it compiles to JavaScript, so at runtime there's no object for Angular to use as an injection token. If you try to inject an interface type, Angular has nothing to match against the provider configuration. + +```angular-ts {avoid, header: 'Can't inject interface'} +interface UserConfig { + name: string; + email: string; +} + +@Component({ + selector: 'app-profile', + template: '

Profile

', +}) +export class UserProfile { + // ERROR: Interfaces don't exist at runtime + constructor(private config: UserConfig) {} +} +``` + +**Solution:** Use `InjectionToken` for interface types. + +```angular-ts {prefer, header: 'Use InjectionToken for interfaces'} +import {InjectionToken, inject} from '@angular/core'; + +interface UserConfig { + name: string; + email: string; +} + +export const USER_CONFIG = new InjectionToken('user configuration'); + +// Provide the configuration +bootstrapApplication(App, { + providers: [ + { + provide: USER_CONFIG, + useValue: {name: 'Alice', email: 'alice@example.com'}, + }, + ], +}); + +// Inject using the token +@Component({ + selector: 'app-profile', + template: '

User: {{config.name}}

', +}) +export class UserProfile { + protected config = inject(USER_CONFIG); +} +``` + +The `InjectionToken` exists at runtime and can be used for injection, while the `UserConfig` interface provides type safety during development. + +### Circular dependencies + +Circular dependencies occur when services inject each other, creating a cycle that Angular cannot resolve. For detailed explanations and code examples, see [NG0200: Circular dependency](errors/NG0200). + +**Resolution strategies** (in order of preference): + +1. **Restructure** - Extract shared logic to a third service, breaking the cycle +2. **Use events** - Replace direct dependencies with event-based communication (such as `Subject`) +3. **Lazy injection** - Use `Injector.get()` to defer one dependency (last resort) + +NOTE: Do not use `forwardRef()` for service circular dependencies—it only solves circular imports in standalone component configurations. + +## Debugging dependency resolution + +### Understanding the resolution process + +Angular resolves dependencies by walking up the injector hierarchy. When a `NullInjectorError` occurs, understanding this search order helps you identify where to add the missing provider. + +Angular searches in this order: + +1. **Element injector** - The current component or directive +2. **Parent element injectors** - Up the DOM tree through parent components +3. **Environment injector** - The route or application injector +4. **NullInjector** - Throws `NullInjectorError` if not found + +When you see a `NullInjectorError`, the service isn't provided at any level the component can access. Check that: + +- The service has `@Injectable({providedIn: 'root'})`, or +- The service is in a `providers` array the component can reach + +You can modify this search behavior with resolution modifiers like `self`, `skipSelf`, `host`, and `optional`. For complete coverage of resolution rules and modifiers, see the [Hierarchical injectors guide](guide/di/hierarchical-dependency-injection). + +### Using Angular DevTools + +Angular DevTools includes an injector tree inspector that visualizes the entire injector hierarchy and shows which providers are available at each level. For installation and general usage, see the [Angular DevTools injector documentation](tools/devtools/injectors). + +When debugging DI issues, use DevTools to answer these questions: + +- **Is the service provided?** Select the component that fails to inject and check if the service appears in the Injector section. +- **At what level?** Walk up the component tree to find where the service is actually provided (component, route, or application level). +- **Multiple instances?** If a singleton service appears in multiple component injectors, it's likely provided in component `providers` arrays instead of using `providedIn: 'root'`. + +If a service never appears in any injector, verify it has the `@Injectable()` decorator with `providedIn: 'root'` or is listed in a `providers` array. + +### Logging and tracing injection + +When DevTools isn't enough, use logging to trace injection behavior. + +#### Logging service creation + +Add console logs to service constructors to see when services are created. + +```ts +import {Injectable} from '@angular/core'; + +@Injectable({providedIn: 'root'}) +export class UserClient { + constructor() { + console.log('UserClient created'); + console.trace(); // Shows call stack + } + + getUser() { + return {name: 'Alice'}; + } +} +``` + +When the service is created, you'll see the log message and a stack trace showing where the injection occurred. + +**What to look for:** + +- How many times is the constructor called? (should be once for singletons) +- Where in the code is it being injected? (check the stack trace) +- Is it created at the expected time? (application startup vs lazy) + +#### Checking service availability + +Use optional injection with logging to determine if a service is available. + +```angular-ts +import {Component, inject} from '@angular/core'; +import {UserClient} from './user-client'; + +@Component({ + selector: 'app-debug', + template: '

Debug Component

', +}) +export class DebugView { + private userService = inject(UserClient, {optional: true}); + + constructor() { + if (this.userService) { + console.log('UserClient available:', this.userService); + } else { + console.warn('UserClient NOT available'); + console.trace(); // Shows where we tried to inject + } + } +} +``` + +This pattern helps you verify if a service is available without crashing the application. + +#### Logging resolution modifiers + +Test different resolution strategies with logging. + +```angular-ts +import {Component, inject} from '@angular/core'; +import {UserClient} from './user-client'; + +@Component({ + selector: 'app-debug', + template: '

Debug Component

', + providers: [UserClient], +}) +export class DebugView { + // Try to get local instance + private localService = inject(UserClient, {self: true, optional: true}); + + // Try to get parent instance + private parentService = inject(UserClient, { + skipSelf: true, + optional: true, + }); + + constructor() { + console.log('Local instance:', this.localService); + console.log('Parent instance:', this.parentService); + console.log('Same instance?', this.localService === this.parentService); + } +} +``` + +This shows you which instances are available at different injector levels. + +### Debugging workflow + +When DI fails, follow this systematic approach: + +**Step 1: Read the error message** + +- Identify the error code (NG0200, NG0203, etc.) +- Read the dependency path +- Note which token failed + +**Step 2: Check the basics** + +- Does the service have `@Injectable()`? +- Is `providedIn` set correctly? +- Are imports correct? +- Is the file included in compilation? + +**Step 3: Verify injection context** + +- Is `inject()` called in a valid context? +- Check for async issues (await, setTimeout, promises) +- Verify timing (not after destroy) + +**Step 4: Use debugging tools** + +- Open Angular DevTools +- Check injector hierarchy +- Add console logs to constructors +- Use optional injection to test availability + +**Step 5: Simplify and isolate** + +- Remove dependencies one by one +- Test in a minimal component +- Check each injector level separately +- Create a reproduction case + +## DI error reference + +This section provides detailed information about specific Angular DI error codes you may encounter. Use this as a reference when you see these errors in your console. + +### NullInjectorError: No provider for [Service] + +**Error code:** None (displayed as `NullInjectorError`) + +This error occurs when Angular cannot find a provider for a token in the injector hierarchy. The error message includes a dependency path showing where the injection was attempted. + +``` +NullInjectorError: No provider for UserClient! + Dependency path: App -> AuthClient -> UserClient +``` + +The dependency path shows that `App` injected `AuthClient`, which tried to inject `UserClient`, but no provider was found. + +#### Missing @Injectable decorator + +The most common cause is forgetting the `@Injectable()` decorator on a service class. + +```ts {avoid, header: 'Missing decorator'} +export class UserClient { + getUser() { + return {name: 'Alice'}; + } +} +``` + +Angular requires the `@Injectable()` decorator to generate the metadata needed for dependency injection. + +```ts {prefer, header: 'Include @Injectable'} +import {Injectable} from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class UserClient { + getUser() { + return {name: 'Alice'}; + } +} +``` + +NOTE: Classes with zero-argument constructors can work without `@Injectable()`, but this is not recommended. Always include the decorator for consistency and to avoid issues when adding dependencies later. + +#### Missing providedIn configuration + +A service may have `@Injectable()` but not specify where it should be provided. + +```ts {avoid, header: 'No providedIn specified'} +import {Injectable} from '@angular/core'; + +@Injectable() +export class UserClient { + getUser() { + return {name: 'Alice'}; + } +} +``` + +Specify `providedIn: 'root'` to make the service available throughout your application. + +```ts {prefer, header: 'Specify providedIn'} +import {Injectable} from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class UserClient { + getUser() { + return {name: 'Alice'}; + } +} +``` + +The `providedIn: 'root'` configuration makes the service available application-wide and enables tree-shaking (the service is removed from the bundle if never injected). + +#### Standalone component missing imports + +In Angular v20+ with standalone components, you must explicitly import or provide dependencies in each component. + +```angular-ts {avoid, header: 'Missing service import'} +import {Component, inject} from '@angular/core'; +import {UserClient} from './user-client'; + +@Component({ + selector: 'app-profile', + template: '

User: {{user().name}}

', +}) +export class UserProfile { + private userService = inject(UserClient); // ERROR: No provider + user = this.userService.getUser(); +} +``` + +Ensure the service uses `providedIn: 'root'` or add it to the component's `providers` array. + +```angular-ts {prefer, header: 'Service uses providedIn: root'} +import {Component, inject} from '@angular/core'; +import {UserClient} from './user-client'; + +@Component({ + selector: 'app-profile', + template: '

User: {{user().name}}

', +}) +export class UserProfile { + private userService = inject(UserClient); // Works: providedIn: 'root' + user = this.userService.getUser(); +} +``` + +#### Debugging with the dependency path + +The dependency path in the error message shows the chain of injections that led to the failure. + +``` +NullInjectorError: No provider for LoggerStore! + Dependency path: App -> DataStore -> ApiClient -> LoggerStore +``` + +This path tells you: + +1. `App` injected `DataStore` +2. `DataStore` injected `ApiClient` +3. `ApiClient` tried to inject `LoggerStore` +4. No provider for `LoggerStore` was found + +Start your investigation at the end of the chain (`LoggerStore`) and verify it has proper configuration. + +#### Checking provider availability with optional injection + +Use optional injection to check if a provider exists without throwing an error. + +```angular-ts +import {Component, inject} from '@angular/core'; +import {UserClient} from './user-client'; + +@Component({ + selector: 'app-debug', + template: '

Service available: {{serviceAvailable}}

', +}) +export class DebugView { + private userService = inject(UserClient, {optional: true}); + serviceAvailable = this.userService !== null; +} +``` + +Optional injection returns `null` if no provider is found, allowing you to handle the absence gracefully. + +### NG0203: inject() must be called from an injection context + +**Error code:** NG0203 + +This error occurs when you call `inject()` outside of a valid injection context. Angular requires `inject()` to be called synchronously during class construction or factory execution. + +``` +NG0203: inject() must be called from an injection context such as a +constructor, a factory function, a field initializer, or a function +used with `runInInjectionContext`. +``` + +#### Valid injection contexts + +Angular allows `inject()` in these locations: + +1. **Class field initializers** + + ```angular-ts + import {Component, inject} from '@angular/core'; + import {UserClient} from './user-client'; + + @Component({ + selector: 'app-profile', + template: '

User: {{user().name}}

', + }) + export class UserProfile { + private userService = inject(UserClient); // Valid + user = this.userService.getUser(); + } + ``` + +2. **Class constructor** + + ```angular-ts + import {Component, inject} from '@angular/core'; + import {UserClient} from './user-client'; + + @Component({ + selector: 'app-profile', + template: '

User: {{user().name}}

', + }) + export class UserProfile { + private userService: UserClient; + + constructor() { + this.userService = inject(UserClient); // Valid + } + + user = this.userService.getUser(); + } + ``` + +3. **Provider factory functions** + + ```ts + import {inject, InjectionToken} from '@angular/core'; + import {UserClient} from './user-client'; + + export const GREETING = new InjectionToken('greeting', { + factory() { + const userService = inject(UserClient); // Valid + const user = userService.getUser(); + return `Hello, ${user.name}`; + }, + }); + ``` + +4. **Inside runInInjectionContext()** + + ```angular-ts + import {Component, inject, Injector} from '@angular/core'; + import {UserClient} from './user-client'; + + @Component({ + selector: 'app-profile', + template: '', + }) + export class UserProfile { + private injector = inject(Injector); + + loadUser() { + this.injector.runInInjectionContext(() => { + const userService = inject(UserClient); // Valid + console.log(userService.getUser()); + }); + } + } + ``` + +Other injection contexts that `inject()` also works in include: + +- [provideAppInitializer](api/core/provideAppInitializer) +- [provideEnvironmentInitializer](api/core/provideEnvironmentInitializer) +- Functional [route guards](guide/routing/route-guards) +- Functional [data resolvers](guide/routing/data-resolvers) + +#### When this error occurs + +This error occurs when: + +- Calling `inject()` in lifecycle hooks (`ngOnInit`, `ngAfterViewInit`, etc.) +- Calling `inject()` after `await` in async functions +- Calling `inject()` in callbacks (`setTimeout`, `Promise.then()`, etc.) +- Calling `inject()` outside of class construction phase + +See the "Incorrect inject() usage" section for detailed examples and solutions. + +#### Solutions and workarounds + +**Solution 1:** Capture dependencies in field initializers (most common) + +```ts +private userService = inject(UserClient) // Capture at class level +``` + +**Solution 2:** Use `runInInjectionContext()` for callbacks + +```ts +private injector = inject(Injector) + +someCallback() { + this.injector.runInInjectionContext(() => { + const service = inject(MyClient) + }) +} +``` + +**Solution 3:** Pass dependencies as parameters instead of injecting them + +```ts +// Instead of injecting inside a callback +setTimeout(() => { + const service = inject(MyClient) // ERROR +}, 1000) + +// Capture first, then use +private service = inject(MyClient) + +setTimeout(() => { + this.service.doSomething() // Use captured reference +}, 1000) +``` + +### NG0200: Circular dependency detected + +**Error code:** NG0200 + +This error occurs when two or more services depend on each other, creating a circular dependency that Angular cannot resolve. + +``` +NG0200: Circular dependency in DI detected for AuthClient + Dependency path: AuthClient -> UserClient -> AuthClient +``` + +The dependency path shows the cycle: `AuthClient` depends on `UserClient`, which depends back on `AuthClient`. + +#### Understanding the error + +Angular creates service instances by calling their constructors and injecting dependencies. When services depend on each other circularly, Angular cannot determine which to create first. + +#### Common causes + +- Direct circular dependency (Service A → Service B → Service A) +- Indirect circular dependency (Service A → Service B → Service C → Service A) +- Import cycles in module files that also have service dependencies + +#### Resolution strategies + +See the "Circular dependencies" section for detailed examples and solutions: + +1. **Restructure** - Extract shared logic to a third service (recommended) +2. **Use events** - Replace direct dependencies with event-based communication +3. **Lazy injection** - Use `Injector.get()` to defer one dependency (last resort) + +Do NOT use `forwardRef()` for service circular dependencies. It only solves circular imports in component configurations. + +### Other DI error codes + +For detailed explanations and solutions for these errors, see the [Angular error reference](errors): + +| Error Code | Description | +| ----------------------- | ------------------------------------------------------------------------------------------ | +| [NG0204](errors/NG0204) | Can't resolve all parameters - missing `@Injectable()` decorator | +| [NG0205](errors/NG0205) | Injector already destroyed - accessing services after component destruction | +| [NG0207](errors/NG0207) | EnvironmentProviders in wrong context - using `provideHttpClient()` in component providers | + +## Next steps + +When you encounter DI errors, remember to: + +1. Read the error message and dependency path carefully +2. Verify basic configuration (decorators, `providedIn`, imports) +3. Check injection context and timing +4. Use DevTools and logging to investigate +5. Simplify and isolate the problem + +For a deeper understanding of specific topics on dependency injection, check out: + +- [Understanding dependency injection](guide/di) - Core DI concepts and patterns +- [Hierarchical dependency injection](guide/di/hierarchical-dependency-injection) - How the injector hierarchy works +- [Testing with dependency injection](guide/testing) - Using TestBed and mocking dependencies diff --git a/adev/src/content/reference/errors/NG0204.md b/adev/src/content/reference/errors/NG0204.md new file mode 100644 index 000000000000..55077d2dda83 --- /dev/null +++ b/adev/src/content/reference/errors/NG0204.md @@ -0,0 +1,59 @@ +# Invalid Injection Token + +This error occurs when Angular cannot resolve a dependency for a class during dependency injection. This most commonly affects classes using constructor injection, where Angular relies on TypeScript metadata to determine parameter types. + +The most common causes are: + +1. A service class is missing the `@Injectable()` decorator +2. An `InjectionToken` lacks a proper provider definition +3. A constructor parameter cannot be resolved + +NOTE: The `inject()` function takes an explicit token, so the "unresolvable parameter" scenario does not apply to it directly. However, if the injected class itself is missing `@Injectable()` and has its own constructor dependencies, the error can still occur. + +## Common scenarios + +### Missing `@Injectable()` decorator + +When a class has constructor dependencies but lacks the `@Injectable()` decorator, Angular cannot resolve its parameters: + +```ts {header: 'Missing @Injectable() decorator'} +export class UserClient { + constructor(private http: HttpClient) {} // Angular can't resolve this +} +``` + +Add the `@Injectable()` decorator to fix this: + +```ts +@Injectable({providedIn: 'root'}) +export class UserClient { + constructor(private http: HttpClient) {} +} +``` + +### Unresolvable constructor parameters + +This error also appears when Angular cannot determine the type of a constructor parameter: + +```ts +@Injectable({providedIn: 'root'}) +export class DataStore { + // Angular can't resolve 'config' without a provider + constructor(private config: AppConfig) {} +} +``` + +Ensure all constructor parameters either have providers configured or use `@Optional()` for optional dependencies. + +## Debugging the error + +The error message includes details about which token could not be resolved: + +- `Can't resolve all parameters for X: (?, ?, ?)` — The `?` marks indicate unresolvable parameters. Check that the class has `@Injectable()` and all dependencies have providers. +- `Token X is missing a ɵprov definition` — An `InjectionToken` was used without configuring a provider. Register the token with a value using `{provide: TOKEN, useValue: ...}` or add a default factory to the token definition. + +Work backwards from the error's stack trace to identify where the problematic injection occurs, then verify that: + +1. The class has `@Injectable()` decorator +2. All constructor parameters have registered providers +3. Any `InjectionToken` has a configured provider or default value diff --git a/adev/src/content/reference/errors/NG0205.md b/adev/src/content/reference/errors/NG0205.md new file mode 100644 index 000000000000..f3b3b2315591 --- /dev/null +++ b/adev/src/content/reference/errors/NG0205.md @@ -0,0 +1,76 @@ +# Injector has already been destroyed + +This error occurs when you attempt to retrieve a service from an injector that has already been destroyed. This typically happens when code tries to access dependencies after a component, directive, or module has been destroyed. + +## Common scenarios + +### Accessing services in callbacks after destruction + +When a component is destroyed, its injector is also destroyed. If an async callback later tries to access services, this error occurs: + +```ts +@Component({ + /*...*/ +}) +export class UserProfile implements OnInit { + private userClient = inject(UserClient); + + ngOnInit() { + setTimeout(() => { + // ERROR: If component was destroyed before timeout fires, + // the injector is no longer available + this.userClient.fetchData(); + }, 5000); + } +} +``` + +### Accessing services after unsubscribing + +Similar issues occur with observables if cleanup happens in the wrong order: + +```ts +@Component({ + /*...*/ +}) +export class DataView implements OnDestroy { + private dataStore = inject(DataStore); + + ngOnDestroy() { + // Problematic: attempting to use the injector during destruction + // after other cleanup may have occurred + this.dataStore.cleanup(); + } +} +``` + +## Debugging the error + +To fix this error: + +1. **Check async operations** — Ensure callbacks, promises, and subscriptions are cancelled when the component is destroyed. Use `takeUntilDestroyed()` or `DestroyRef` for cleanup. + +2. **Capture dependencies early** — Store references to services in class fields rather than accessing the injector in callbacks. + +3. **Guard against destroyed state** — For operations that might outlive the component, check if the component is still active before accessing services. + +```ts +@Component({ + /*...*/ +}) +export class UserProfile implements OnInit { + private destroyRef = inject(DestroyRef); + private userClient = inject(UserClient); + + ngOnInit() { + // Use takeUntilDestroyed to automatically cancel when destroyed + interval(5000) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.userClient.fetchData(); + }); + } +} +``` + +The stack trace indicates where the destroyed injector was accessed. Work backwards to identify the async operation that outlived its component. diff --git a/adev/src/content/reference/errors/NG0207.md b/adev/src/content/reference/errors/NG0207.md new file mode 100644 index 000000000000..b02643e26a5a --- /dev/null +++ b/adev/src/content/reference/errors/NG0207.md @@ -0,0 +1,75 @@ +# EnvironmentProviders in wrong context + +This error occurs when `EnvironmentProviders` are used in a context that only accepts regular providers, such as a component's `providers` array. Environment providers are designed for application-wide configuration and can only be used in environment injectors (like the root injector configured in `bootstrapApplication` or route configurations). + +## Common scenarios + +### Using `provideHttpClient()` in component providers + +Functions like `provideHttpClient()` return `EnvironmentProviders`, which cannot be used at the component level: + +```ts +@Component({ + providers: [ + provideHttpClient(), // ERROR: EnvironmentProviders can't be used here + ], +}) +export class UserProfile {} +``` + +### Using `importProvidersFrom()` in component providers + +The `importProvidersFrom()` function also returns `EnvironmentProviders`: + +```ts +@Component({ + providers: [ + importProvidersFrom(SomeModule), // ERROR: can't be used for component providers + ], +}) +export class DataView {} +``` + +## Debugging the error + +Move the environment providers to an appropriate location: + +### For application-wide providers + +Configure environment providers in `bootstrapApplication`: + +```ts +bootstrapApplication(App, { + providers: [provideHttpClient(), importProvidersFrom(SomeModule)], +}); +``` + +### For route-specific providers + +Use the `providers` array in route configurations: + +```ts +const routes: Routes = [ + { + path: 'admin', + component: AdminView, + providers: [provideHttpClient(withInterceptors([authInterceptor]))], + }, +]; +``` + +### For component-level services + +If you need component-scoped services, use regular providers instead of environment providers: + +```ts +@Component({ + providers: [ + UserClient, // Regular provider - this works + {provide: API_URL, useValue: '/api'}, // Value provider - this works + ], +}) +export class UserProfile {} +``` + +The error message specifies which provider caused the issue. Check that all items in your component's `providers` array are regular providers, not environment providers returned by functions like `provideHttpClient()`, `provideRouter()`, or `importProvidersFrom()`. diff --git a/adev/src/content/reference/errors/overview.md b/adev/src/content/reference/errors/overview.md index c46bf90208d1..7892d2e12439 100644 --- a/adev/src/content/reference/errors/overview.md +++ b/adev/src/content/reference/errors/overview.md @@ -8,6 +8,9 @@ | `NG0200` | [Circular Dependency in DI](errors/NG0200) | | `NG0201` | [No Provider Found](errors/NG0201) | | `NG0203` | [`inject()` must be called from an injection context](errors/NG0203) | +| `NG0204` | [Invalid Injection Token](errors/NG0204) | +| `NG0205` | [Injector has already been destroyed](errors/NG0205) | +| `NG0207` | [EnvironmentProviders in wrong context](errors/NG0207) | | `NG0209` | [Invalid multi provider](errors/NG0209) | | `NG0300` | [Selector Collision](errors/NG0300) | | `NG0301` | [Export Not Found](errors/NG0301) | diff --git a/goldens/public-api/core/errors.api.md b/goldens/public-api/core/errors.api.md index 4b59e70feea2..c46cb6dfc75c 100644 --- a/goldens/public-api/core/errors.api.md +++ b/goldens/public-api/core/errors.api.md @@ -74,7 +74,7 @@ export const enum RuntimeErrorCode { // (undocumented) INFINITE_CHANGE_DETECTION = 103, // (undocumented) - INJECTOR_ALREADY_DESTROYED = 205, + INJECTOR_ALREADY_DESTROYED = -205, // (undocumented) INVALID_APP_ID = 211, // (undocumented) @@ -90,7 +90,7 @@ export const enum RuntimeErrorCode { // (undocumented) INVALID_INHERITANCE = 903, // (undocumented) - INVALID_INJECTION_TOKEN = 204, + INVALID_INJECTION_TOKEN = -204, // (undocumented) INVALID_MULTI_PROVIDER = -209, // (undocumented) @@ -152,7 +152,7 @@ export const enum RuntimeErrorCode { // (undocumented) PROVIDED_BOTH_ZONE_AND_ZONELESS = 408, // (undocumented) - PROVIDER_IN_WRONG_CONTEXT = 207, + PROVIDER_IN_WRONG_CONTEXT = -207, // (undocumented) PROVIDER_NOT_FOUND = -201, // (undocumented) diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 7403d061f017..b65f1f687782 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -37,9 +37,9 @@ export const enum RuntimeErrorCode { PROVIDER_NOT_FOUND = -201, INVALID_FACTORY_DEPENDENCY = 202, MISSING_INJECTION_CONTEXT = -203, - INVALID_INJECTION_TOKEN = 204, - INJECTOR_ALREADY_DESTROYED = 205, - PROVIDER_IN_WRONG_CONTEXT = 207, + INVALID_INJECTION_TOKEN = -204, + INJECTOR_ALREADY_DESTROYED = -205, + PROVIDER_IN_WRONG_CONTEXT = -207, MISSING_INJECTION_TOKEN = 208, INVALID_MULTI_PROVIDER = -209, MISSING_DOCUMENT = 210, diff --git a/packages/core/test/acceptance/destroy_ref_spec.ts b/packages/core/test/acceptance/destroy_ref_spec.ts index ce95627828dd..5f59421588db 100644 --- a/packages/core/test/acceptance/destroy_ref_spec.ts +++ b/packages/core/test/acceptance/destroy_ref_spec.ts @@ -73,7 +73,7 @@ describe('DestroyRef', () => { expect(() => { destroyRef.onDestroy(() => {}); - }).toThrowError('NG0205: Injector has already been destroyed.'); + }).toThrowError(/NG0205: Injector has already been destroyed./); }); }); diff --git a/packages/core/test/di/r3_injector_spec.ts b/packages/core/test/di/r3_injector_spec.ts index c1aba322548b..e02830580e72 100644 --- a/packages/core/test/di/r3_injector_spec.ts +++ b/packages/core/test/di/r3_injector_spec.ts @@ -15,10 +15,10 @@ import { ɵɵdefineInjector, ɵɵinject, } from '../../src/core'; -import {ERROR_DETAILS_PAGE_BASE_URL} from '../../src/error_details_base_url'; import {createInjector} from '../../src/di/create_injector'; import {InternalInjectFlags} from '../../src/di/interface/injector'; import {R3Injector} from '../../src/di/r3_injector'; +import {ERROR_DETAILS_PAGE_BASE_URL} from '../../src/error_details_base_url'; describe('InjectorDef-based createInjector()', () => { class CircularA { @@ -461,14 +461,14 @@ describe('InjectorDef-based createInjector()', () => { it('does not allow injection after destroy', () => { (injector as R3Injector).destroy(); expect(() => injector.get(DeepService)).toThrowError( - 'NG0205: Injector has already been destroyed.', + /NG0205: Injector has already been destroyed./, ); }); it('does not allow double destroy', () => { (injector as R3Injector).destroy(); expect(() => (injector as R3Injector).destroy()).toThrowError( - 'NG0205: Injector has already been destroyed.', + /NG0205: Injector has already been destroyed./, ); }); @@ -506,7 +506,7 @@ describe('InjectorDef-based createInjector()', () => { static ɵinj = ɵɵdefineInjector({providers: [MissingArgumentType]}); } expect(() => createInjector(ErrorModule).get(MissingArgumentType)).toThrowError( - "NG0204: Can't resolve all parameters for MissingArgumentType: (?).", + /NG0204: Can't resolve all parameters for MissingArgumentType: \(\?\)./, ); }); }); diff --git a/packages/core/test/linker/ng_module_integration_spec.ts b/packages/core/test/linker/ng_module_integration_spec.ts index 5680166765bc..3557f027ac98 100644 --- a/packages/core/test/linker/ng_module_integration_spec.ts +++ b/packages/core/test/linker/ng_module_integration_spec.ts @@ -33,13 +33,13 @@ import {NgModuleType} from '../../src/render3'; import {getNgModuleDef} from '../../src/render3/def_getters'; import {ComponentFixture, inject, TestBed} from '../../testing'; +import {ERROR_DETAILS_PAGE_BASE_URL} from '../../src/error_details_base_url'; import {InternalNgModuleRef, NgModuleFactory} from '../../src/linker/ng_module_factory'; import { clearModulesForTest, setAllowDuplicateNgModuleIdsForTest, } from '../../src/linker/ng_module_registration'; import {stringify} from '../../src/util/stringify'; -import {ERROR_DETAILS_PAGE_BASE_URL} from '../../src/error_details_base_url'; class Engine {} @@ -542,7 +542,7 @@ describe('NgModule', () => { it('should throw when no type and not @Inject (class case)', () => { expect(() => createInjector([NoAnnotations])).toThrowError( - "NG0204: Can't resolve all parameters for NoAnnotations: (?).", + /NG0204: Can't resolve all parameters for NoAnnotations: \(\?\)./, ); }); From b9b5c279b444ab2684fe911982930dc7c31ed43c Mon Sep 17 00:00:00 2001 From: Enzo Cornand Date: Tue, 25 Nov 2025 20:30:38 +0100 Subject: [PATCH 02/21] refactor(core): enhance AnimationCallbackEvent.animationComplete signature BREAKING CHANGE: change AnimationCallbackEvent.animationComplete signature Fixes #65613 --- goldens/public-api/core/index.api.md | 2 +- packages/core/src/animation/interfaces.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 62bba7be1761..ff078f386e6b 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -84,7 +84,7 @@ export const ANIMATION_MODULE_TYPE: InjectionToken<"NoopAnimations" | "BrowserAn // @public export type AnimationCallbackEvent = { target: Element; - animationComplete: Function; + animationComplete: VoidFunction; }; // @public diff --git a/packages/core/src/animation/interfaces.ts b/packages/core/src/animation/interfaces.ts index baab333bf72c..389a1330075f 100644 --- a/packages/core/src/animation/interfaces.ts +++ b/packages/core/src/animation/interfaces.ts @@ -25,7 +25,7 @@ export const ANIMATIONS_DISABLED = new InjectionToken( * * @publicApi 20.2 */ -export type AnimationCallbackEvent = {target: Element; animationComplete: Function}; +export type AnimationCallbackEvent = {target: Element; animationComplete: VoidFunction}; /** * A [DI token](api/core/InjectionToken) that configures the maximum animation timeout From fe25c57a5ccd35309ba2a117f7465e58417d19e0 Mon Sep 17 00:00:00 2001 From: cexbrayat Date: Fri, 20 Feb 2026 11:54:40 +0100 Subject: [PATCH 03/21] fix(forms): preserve parse errors when parse returns value Fixes #67170 by keeping the errors even a value is returned from the parse function. --- packages/forms/signals/src/util/parser.ts | 4 +++- .../signals/test/node/parse_errors.spec.ts | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/forms/signals/src/util/parser.ts b/packages/forms/signals/src/util/parser.ts index b32fc5945028..01c3c05569f1 100644 --- a/packages/forms/signals/src/util/parser.ts +++ b/packages/forms/signals/src/util/parser.ts @@ -44,10 +44,12 @@ export function createParser( const setRawValue = (rawValue: TRaw) => { const result = parse(rawValue); - errors.set(result.errors ?? []); if (result.value !== undefined) { setValue(result.value); } + // `errors` is a linked signal sourced from the model value; write parse errors after + // model updates so `{value, errors}` results do not get reset by the recomputation. + errors.set(result.errors ?? []); }; return {errors: errors.asReadonly(), setRawValue}; diff --git a/packages/forms/signals/test/node/parse_errors.spec.ts b/packages/forms/signals/test/node/parse_errors.spec.ts index 6b650fe98f4b..010e40204e46 100644 --- a/packages/forms/signals/test/node/parse_errors.spec.ts +++ b/packages/forms/signals/test/node/parse_errors.spec.ts @@ -11,6 +11,7 @@ import {TestBed} from '@angular/core/testing'; import { form, FormField, + maxError, transformedValue, validate, type FormValueControl, @@ -304,6 +305,25 @@ describe('parse errors', () => { expect(errors1).toEqual([]); expect(input1.value).toBe('42'); }); + + it('should preserve parse errors when transformedValue parse returns both value and errors', async () => { + @Component({ + imports: [TestNumberInput, FormField], + template: ``, + }) + class TestCmp { + state = signal(5); + f = form(this.state); + } + + const fix = await act(() => TestBed.createComponent(TestCmp)); + const comp = fix.componentInstance; + const input: HTMLInputElement = fix.nativeElement.querySelector('input')!; + + input.value = '11'; + await act(() => input.dispatchEvent(new Event('input'))); + expect(comp.f().errors()).toEqual([jasmine.objectContaining({kind: 'max'})]); + }); }); @Component({ @@ -318,6 +338,7 @@ describe('parse errors', () => { class TestNumberInput implements FormValueControl { readonly value = model.required(); readonly errors = input([]); + readonly parseMax = input(undefined); protected readonly rawValue = transformedValue(this.value, { parse: (rawValue) => { @@ -326,6 +347,9 @@ class TestNumberInput implements FormValueControl { if (Number.isNaN(value)) { return {errors: [{kind: 'parse', message: `${rawValue} is not numeric`}]}; } + if (this.parseMax() != null && value > this.parseMax()!) { + return {value, errors: [maxError(this.parseMax()!)]}; + } return {value}; }, format: (value) => { From d7ddebca90e9a8c582c1eba295017c9322514e3a Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Tue, 28 Oct 2025 08:57:53 -0700 Subject: [PATCH 04/21] ci: update ng-dev config and pullapprove This updates the pullapprove to remove the auto labeling of requires: TGP and instead moves it to the ng-dev config for pr updates. --- .github/workflows/dev-infra.yml | 1 + .pullapprove.yml | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml index 48e6cba7bef2..71aca25eb2d3 100644 --- a/.github/workflows/dev-infra.yml +++ b/.github/workflows/dev-infra.yml @@ -16,6 +16,7 @@ jobs: - uses: angular/dev-infra/github-actions/pull-request-labeling@e006a332028a4c3cb24e9d92437fac7ae99e2ed5 with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + labels: '{"requires: TGP": ["packages/core/primitives/**/{*,.*}"]}' post_approval_changes: runs-on: ubuntu-latest steps: diff --git a/.pullapprove.yml b/.pullapprove.yml index 9cd37692e09a..45a7e962ba78 100644 --- a/.pullapprove.yml +++ b/.pullapprove.yml @@ -481,10 +481,6 @@ groups: - thePunderWoman # Jessica Janiuk - AndrewKushnir # Andrew Kushnir - atscott # Andrew Scott - labels: - pending: 'requires: TGP' - approved: 'requires: TGP' - rejected: 'requires: TGP' # External team required reviews primitives-shared: @@ -502,10 +498,6 @@ groups: - tbondwilkinson # Tom Wilkinson - rahatarmanahmed # Rahat Ahmed - ENAML # Ethan Cline - labels: - pending: 'requires: TGP' - approved: 'requires: TGP' - rejected: 'requires: TGP' #################################################################################### # Override managed result groups From 38749698d055142c85d41a0bdc481956541ac2ee Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:54:29 -0500 Subject: [PATCH 05/21] fix(common): fix LCP image detection with duplicate URLs Addresses an issue where the LCP image observer incorrectly identified LCP elements when the same image URL was used multiple times on a page Fixes #53278 --- .../ng_optimized_image/lcp_image_observer.ts | 73 ++++++++++++++++--- .../test/bundling/image-directive/BUILD.bazel | 1 + .../lcp-check-duplicate.e2e-spec.ts | 35 +++++++++ .../lcp-check-duplicate.ts | 41 +++++++++++ .../oversized-image.e2e-spec.ts | 2 +- .../test/bundling/image-directive/index.ts | 2 + 6 files changed, 141 insertions(+), 13 deletions(-) create mode 100644 packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.e2e-spec.ts create mode 100644 packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.ts diff --git a/packages/common/src/directives/ng_optimized_image/lcp_image_observer.ts b/packages/common/src/directives/ng_optimized_image/lcp_image_observer.ts index 0de0e1a75734..0164e1322184 100644 --- a/packages/common/src/directives/ng_optimized_image/lcp_image_observer.ts +++ b/packages/common/src/directives/ng_optimized_image/lcp_image_observer.ts @@ -25,6 +25,7 @@ interface ObservedImageState { modified: boolean; alreadyWarnedPriority: boolean; alreadyWarnedModified: boolean; + count: number; } /** @@ -94,29 +95,77 @@ export class LCPImageObserver implements OnDestroy { registerImage(rewrittenSrc: string, isPriority: boolean) { if (!this.observer) return; - const newObservedImageState: ObservedImageState = { - priority: isPriority, - modified: false, - alreadyWarnedModified: false, - alreadyWarnedPriority: false, - }; - this.images.set(getUrl(rewrittenSrc, this.window!).href, newObservedImageState); + const url = getUrl(rewrittenSrc, this.window!).href; + const existingState = this.images.get(url); + + if (existingState) { + // If any instance has priority, the URL is considered to have priority + existingState.priority = existingState.priority || isPriority; + existingState.count++; + } else { + const newObservedImageState: ObservedImageState = { + priority: isPriority, + modified: false, + alreadyWarnedModified: false, + alreadyWarnedPriority: false, + count: 1, + }; + this.images.set(url, newObservedImageState); + } } unregisterImage(rewrittenSrc: string) { if (!this.observer) return; - this.images.delete(getUrl(rewrittenSrc, this.window!).href); + const url = getUrl(rewrittenSrc, this.window!).href; + const existingState = this.images.get(url); + + if (existingState) { + existingState.count--; + if (existingState.count <= 0) { + this.images.delete(url); + } + } } updateImage(originalSrc: string, newSrc: string) { if (!this.observer) return; const originalUrl = getUrl(originalSrc, this.window!).href; - const img = this.images.get(originalUrl); - if (img) { - img.modified = true; - this.images.set(getUrl(newSrc, this.window!).href, img); + const newUrl = getUrl(newSrc, this.window!).href; + + // URL hasn't changed + if (originalUrl === newUrl) return; + + const originalState = this.images.get(originalUrl); + if (!originalState) return; + + // Decrement count for original URL + originalState.count--; + if (originalState.count <= 0) { this.images.delete(originalUrl); } + + // Add or update entry for new URL + const newState = this.images.get(newUrl); + if (newState) { + // Merge if original had priority, new should too + newState.priority = newState.priority || originalState.priority; + newState.modified = true; + // Preserve warning flags from the original state to avoid duplicate warnings + newState.alreadyWarnedPriority = + newState.alreadyWarnedPriority || originalState.alreadyWarnedPriority; + newState.alreadyWarnedModified = + newState.alreadyWarnedModified || originalState.alreadyWarnedModified; + newState.count++; + } else { + // Create new entry, preserving state from the image that moved + this.images.set(newUrl, { + priority: originalState.priority, + modified: true, + alreadyWarnedModified: originalState.alreadyWarnedModified, + alreadyWarnedPriority: originalState.alreadyWarnedPriority, + count: 1, + }); + } } ngOnDestroy() { diff --git a/packages/core/test/bundling/image-directive/BUILD.bazel b/packages/core/test/bundling/image-directive/BUILD.bazel index 2a1f9db12174..e4ead4036c4d 100644 --- a/packages/core/test/bundling/image-directive/BUILD.bazel +++ b/packages/core/test/bundling/image-directive/BUILD.bazel @@ -12,6 +12,7 @@ ng_project( "e2e/image-perf-warnings-lazy/image-perf-warnings-lazy.ts", "e2e/image-perf-warnings-oversized/image-perf-warnings-oversized.ts", "e2e/image-perf-warnings-oversized/svg-no-perf-oversized-warnings.ts", + "e2e/lcp-check-duplicate/lcp-check-duplicate.ts", "e2e/lcp-check/lcp-check.ts", "e2e/oversized-image/oversized-image.ts", "e2e/preconnect-check/preconnect-check.ts", diff --git a/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.e2e-spec.ts b/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.e2e-spec.ts new file mode 100644 index 000000000000..e01ea9879aa2 --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.e2e-spec.ts @@ -0,0 +1,35 @@ +/** + * @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 + */ + +/* tslint:disable:no-console */ +import {browser, by, element} from 'protractor'; +import {logging} from 'selenium-webdriver'; + +import {collectBrowserLogs} from '../browser-logs-util'; + +describe('NgOptimizedImage directive', () => { + it('should log a warning when a `priority` is missing on an LCP image', async () => { + await browser.get('/e2e/lcp-check-duplicate'); + // Verify that both images were rendered. + const imgs = element.all(by.css('img')); + let srcB = await imgs.get(0).getAttribute('src'); + expect(srcB.endsWith('b.png')).toBe(true); + let srcA = await imgs.get(1).getAttribute('src'); + expect(srcA.endsWith('a.png')).toBe(true); + // The `b.png` and `a.png` images are used twice in a template. + srcB = await imgs.get(2).getAttribute('src'); + expect(srcB.endsWith('b.png')).toBe(true); + srcA = await imgs.get(3).getAttribute('src'); + expect(srcA.endsWith('a.png')).toBe(true); + + // Make sure that no warnings are in the console for image `a.png`, + // since the first instance has the `priority` attribute, and is the LCP element. + const logs = await collectBrowserLogs(logging.Level.SEVERE); + expect(logs.length).toEqual(0); + }); +}); diff --git a/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.ts b/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.ts new file mode 100644 index 000000000000..d4ef8a385906 --- /dev/null +++ b/packages/core/test/bundling/image-directive/e2e/lcp-check-duplicate/lcp-check-duplicate.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgOptimizedImage} from '@angular/common'; +import {Component} from '@angular/core'; + +@Component({ + selector: 'lcp-check', + imports: [NgOptimizedImage], + template: ` + + + +
+ + + + +
+ + + + + + + +
+ `, +}) +export class LcpCheckDuplicate {} diff --git a/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts b/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts index 4f396b04aeba..bf4fce9c2dee 100644 --- a/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts +++ b/packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {browser, by, element, ExpectedConditions} from 'protractor'; +import {browser} from 'protractor'; import {logging} from 'selenium-webdriver'; import {collectBrowserLogs} from '../browser-logs-util'; diff --git a/packages/core/test/bundling/image-directive/index.ts b/packages/core/test/bundling/image-directive/index.ts index 639db0294d2e..98b79b8c8cdb 100644 --- a/packages/core/test/bundling/image-directive/index.ts +++ b/packages/core/test/bundling/image-directive/index.ts @@ -27,6 +27,7 @@ import { } from './e2e/oversized-image/oversized-image'; import {PreconnectCheckComponent} from './e2e/preconnect-check/preconnect-check'; import {PlaygroundComponent} from './playground'; +import {LcpCheckDuplicate} from './e2e/lcp-check-duplicate/lcp-check-duplicate'; @Component({ selector: 'app-root', @@ -42,6 +43,7 @@ const ROUTES = [ // Paths below are used for e2e testing: {path: 'e2e/basic', component: BasicComponent}, {path: 'e2e/lcp-check', component: LcpCheckComponent}, + {path: 'e2e/lcp-check-duplicate', component: LcpCheckDuplicate}, {path: 'e2e/image-perf-warnings-lazy', component: ImagePerfWarningsLazyComponent}, {path: 'e2e/image-perf-warnings-oversized', component: ImagePerfWarningsOversizedComponent}, {path: 'e2e/svg-no-perf-oversized-warnings', component: SvgNoOversizedPerfWarningsComponent}, From c89d94bd58c1a23cd049c07886086bf97ed12ec4 Mon Sep 17 00:00:00 2001 From: Jaime Burgos <73321943+SkyZeroZx@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:24:28 -0500 Subject: [PATCH 06/21] refactor(core): guards stringify calls with ngDevMode The `stringify` function is only needed for debugging purposes and should not be called in production mode. --- packages/core/src/di/create_injector.ts | 7 +++++-- packages/core/src/di/forward_ref.ts | 9 ++++++--- packages/core/src/di/r3_injector.ts | 16 ++++++++++------ .../bundle.golden_symbols.json | 1 - .../create_component/bundle.golden_symbols.json | 1 - .../bundling/defer/bundle.golden_symbols.json | 1 - .../hydration/bundle.golden_symbols.json | 1 - .../bundle.golden_symbols.json | 1 - 8 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/core/src/di/create_injector.ts b/packages/core/src/di/create_injector.ts index 61a7723b0190..a11b84756365 100644 --- a/packages/core/src/di/create_injector.ts +++ b/packages/core/src/di/create_injector.ts @@ -47,7 +47,10 @@ export function createInjectorWithoutInjectorInstances( scopes = new Set(), ): R3Injector { const providers = [additionalProviders || EMPTY_ARRAY, importProvidersFrom(defType)]; - name = name || (typeof defType === 'object' ? undefined : stringify(defType)); + let source: string | undefined = undefined; + if (ngDevMode) { + source = name || (typeof defType === 'object' ? undefined : stringify(defType)); + } - return new R3Injector(providers, parent || getNullInjector(), name || null, scopes); + return new R3Injector(providers, parent || getNullInjector(), source || null, scopes); } diff --git a/packages/core/src/di/forward_ref.ts b/packages/core/src/di/forward_ref.ts index d91856e4ad31..5a5c5ee25d2e 100644 --- a/packages/core/src/di/forward_ref.ts +++ b/packages/core/src/di/forward_ref.ts @@ -68,9 +68,12 @@ const __forward_ref__ = getClosureSafeProperty({__forward_ref__: getClosureSafeP */ export function forwardRef(forwardRefFn: ForwardRefFn): Type { (forwardRefFn).__forward_ref__ = forwardRef; - (forwardRefFn).toString = function () { - return stringify(this()); - }; + if (ngDevMode) { + (forwardRefFn).toString = function () { + return stringify(this()); + }; + } + return >(forwardRefFn); } diff --git a/packages/core/src/di/r3_injector.ts b/packages/core/src/di/r3_injector.ts index b06cc0404c23..bd1ea0c5a5f6 100644 --- a/packages/core/src/di/r3_injector.ts +++ b/packages/core/src/di/r3_injector.ts @@ -455,12 +455,16 @@ export class R3Injector extends EnvironmentInjector implements PrimitivesInjecto } override toString() { - const tokens: string[] = []; - const records = this.records; - for (const token of records.keys()) { - tokens.push(stringify(token)); + if (ngDevMode) { + const tokens: string[] = []; + const records = this.records; + for (const token of records.keys()) { + tokens.push(stringify(token)); + } + return `R3Injector[${tokens.join(', ')}]`; } - return `R3Injector[${tokens.join(', ')}]`; + + return 'R3Injector[...]'; } /** @@ -521,7 +525,7 @@ export class R3Injector extends EnvironmentInjector implements PrimitivesInjecto const prevConsumer = setActiveConsumer(null); try { if (record.value === CIRCULAR) { - throw cyclicDependencyError(stringify(token)); + throw cyclicDependencyError(ngDevMode ? stringify(token) : ''); } else if (record.value === NOT_YET) { record.value = CIRCULAR; diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 7381ee720cd6..c962544fc4a8 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -830,7 +830,6 @@ "shouldBeIgnoredByZone", "shouldSearchParent", "storeLViewOnDestroy", - "stringify", "stringifyCSSSelector", "stringifyCSSSelectorList", "style", diff --git a/packages/core/test/bundling/create_component/bundle.golden_symbols.json b/packages/core/test/bundling/create_component/bundle.golden_symbols.json index c3e76b26fa04..7054c26136e5 100644 --- a/packages/core/test/bundling/create_component/bundle.golden_symbols.json +++ b/packages/core/test/bundling/create_component/bundle.golden_symbols.json @@ -686,7 +686,6 @@ "stashEventListenerImpl", "storeLViewOnDestroy", "storeListenerCleanup", - "stringify", "stringifyCSSSelector", "stringifyCSSSelectorList", "syncViewWithBlueprint", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 1cd4ce15f57e..0b7af553ee81 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -725,7 +725,6 @@ "shouldTriggerDeferBlock", "storeLViewOnDestroy", "storeTriggerCleanupFn", - "stringify", "stringifyCSSSelector", "stringifyCSSSelectorList", "syncViewWithBlueprint", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index cee174e569bf..eaa7a2cbbb8a 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -775,7 +775,6 @@ "skipTextNodes", "sortAndConcatParams", "storeLViewOnDestroy", - "stringify", "stringifyCSSSelector", "stringifyCSSSelectorList", "subscribeOn", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index db9a26025e96..ec848cf8a5c3 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -612,7 +612,6 @@ "shouldBeIgnoredByZone", "shouldSearchParent", "storeLViewOnDestroy", - "stringify", "stringifyCSSSelector", "stringifyCSSSelectorList", "syncViewWithBlueprint", From 944aefe85468a17b0609a0398d06350c7ebe736b Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:52:12 +0000 Subject: [PATCH 07/21] docs: add documentation for SSR security and host validation, including details on `allowedHosts` configuration This document adds more information about `allowedHost` option --- adev/shared-docs/pipeline/shared/linking.mts | 2 + adev/src/content/guide/ssr.md | 73 +++++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/adev/shared-docs/pipeline/shared/linking.mts b/adev/shared-docs/pipeline/shared/linking.mts index 3c01b92983c8..86906e1573d1 100644 --- a/adev/shared-docs/pipeline/shared/linking.mts +++ b/adev/shared-docs/pipeline/shared/linking.mts @@ -30,6 +30,8 @@ const LINK_EXEMPT = new Set([ 'Event', 'form', 'type', + 'Host', + 'filter', ]); export function shouldLinkSymbol(symbol: string): boolean { diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index 24ca1c780dcb..ee49074531c2 100644 --- a/adev/src/content/guide/ssr.md +++ b/adev/src/content/guide/ssr.md @@ -387,13 +387,15 @@ export class MyComponent { } ``` + IMPORTANT: The above tokens will be `null` in the following scenarios: - - During the build processes. - When the application is rendered in the browser (CSR). - When performing static site generation (SSG). - During route extraction in development (at the time of the request). + + ## Generate a fully static application By default, Angular prerenders your entire application and generates a server file for handling requests. This allows your app to serve pre-rendered content to users. However, if you prefer a fully static site without a server, you can opt out of this behavior by setting the `outputMode` to `static` in your `angular.json` configuration file. @@ -521,7 +523,7 @@ bootstrapApplication(App, { }); ``` -#### `filter` +#### Filtering You can also selectively disable caching for certain requests using the [`filter`](api/common/http/HttpTransferCacheOptions) option in `withHttpTransferCacheOptions`. For example, you can disable caching for a specific API endpoint: @@ -545,7 +547,7 @@ bootstrapApplication(App, { Use this option to exclude endpoints with user‑specific or dynamic data (for example `/api/profile`). -#### Individually +#### Per-request To disable caching for an individual request, you can specify the [`transferCache`](api/common/http/HttpRequest#transferCache) option in an `HttpRequest`. @@ -611,3 +613,68 @@ export const reqHandler = createRequestHandler(async (req: Request) => { // ... }); ``` + +## Security and host validation + +Angular includes strict validation for `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port` headers in the request handling pipeline to prevent header-based [Server-Side Request Forgery (SSRF)](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/SSRF). + +The validation rules are: + +- `Host` and `X-Forwarded-Host` headers are validated against a strict allowlist. +- `Host` and `X-Forwarded-Host` headers cannot contain path separators. +- `X-Forwarded-Port` header must be numeric. +- `X-Forwarded-Proto` header must be `http` or `https`. + +Requests with invalid or disallowed headers will now log an error and fallback to Client-Side Rendering (CSR). In a future major version, these requests will be rejected with a `400 Bad Request`. + +NOTE: Most cloud providers and CDNs already validate these headers before the request reaches the application, but this change adds an essential layer of defense-in-depth. + +### Configuring allowed hosts + +To allow a specific hostname, you must configure the `allowedHosts` list in your `angular.json` to include all hostnames where your application is deployed. This is critical for ensuring your application works correctly and securely when deployed. The patterns support wildcards for flexible hostname matching. + +```json +{ + // ... + "projects": { + "your-project-name": { + // ... + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "security": { + "allowedHosts": [ + "example.com", + "*.example.com" // allows all subdomains of example.com + ] + } + // ... other options + } + } + } + } + } +} +``` + +You can also configure `allowedHosts` when initializing the application engine: + +```typescript +const appEngine = new AngularAppEngine({ + allowedHosts: ['example.com', '*.trusted-example.com'], +}); + +const nodeAppEngine = new AngularNodeAppEngine({ + allowedHosts: ['example.com', '*.trusted-example.com'], +}); +``` + +For the Node.js variant `AngularNodeAppEngine`, you can also provide `NG_ALLOWED_HOSTS` (comma-separated list) and `HOSTNAME` environment variables for authorizing hosts. + +Example: + +```bash +export NG_ALLOWED_HOSTS="example.com,*.trusted-example.com" +export HOSTNAME="example.com" +``` From 75fd6c0ff25c15000f21f2f426ecc1d974d5fc67 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:13:39 +0000 Subject: [PATCH 08/21] docs: convert SSR guide's important note list to HTML `
    ` for proper rendering. --- adev/src/content/guide/ssr.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index ee49074531c2..997f4d6c6424 100644 --- a/adev/src/content/guide/ssr.md +++ b/adev/src/content/guide/ssr.md @@ -387,12 +387,15 @@ export class MyComponent { } ``` + -IMPORTANT: The above tokens will be `null` in the following scenarios: -- During the build processes. -- When the application is rendered in the browser (CSR). -- When performing static site generation (SSG). -- During route extraction in development (at the time of the request). + +IMPORTANT: The above tokens will be `null` in the following scenarios:
      +
    • During the build processes.
    • +
    • When the application is rendered in the browser (CSR).
    • +
    • When performing static site generation (SSG).
    • +
    • During route extraction in development (at the time of the request).
    • +
    From efcf76ea61010b6ca11884d66879b96039eaf908 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:51:44 +0000 Subject: [PATCH 09/21] feat(docs-infra): add `hideDollar` option to hide the dollar sign prefix in shell code blocks. The dollar sign is not always required. --- .../extensions/docs-code/docs-code-block.mts | 2 + .../marked/extensions/docs-code/docs-code.mts | 7 +++- .../extensions/docs-code/format/index.mts | 6 +++ .../test/docs-code-block/docs-code-block.md | 4 ++ .../docs-code-block/docs-code-block.spec.mts | 9 ++++- .../shared/marked/test/docs-code/docs-code.md | 2 + .../marked/test/docs-code/docs-code.spec.mts | 5 +++ adev/shared-docs/styles/docs/_code.scss | 23 +++++++---- adev/src/content/guide/ssr.md | 6 +-- adev/src/content/kitchen-sink.md | 40 ++++++++++--------- 10 files changed, 70 insertions(+), 34 deletions(-) diff --git a/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/docs-code-block.mts b/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/docs-code-block.mts index 75678a67deeb..cccf135afde9 100644 --- a/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/docs-code-block.mts +++ b/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/docs-code-block.mts @@ -41,6 +41,7 @@ export const docsCodeBlockExtension = { const headerRule = /header\s*:\s*(['"`])([^'"`]+)\1/; // The 2nd capture matters here const highlightRule = /highlight\s*:\s*(.*)([^,])/; const hideCopyRule = /hideCopy/; + const hideDollarRule = /hideDollar/; const preferRule = /\b(prefer|avoid)\b/; const linenumsRule = /linenums/; @@ -52,6 +53,7 @@ export const docsCodeBlockExtension = { header: headerRule.exec(metadataStr)?.[2], highlight: highlightRule.exec(metadataStr)?.[1], hideCopy: hideCopyRule.test(metadataStr), + hideDollar: hideDollarRule.test(metadataStr), style: preferRule.exec(metadataStr)?.[1] as 'prefer' | 'avoid' | undefined, linenums: linenumsRule.test(metadataStr), }; diff --git a/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/docs-code.mts b/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/docs-code.mts index 2bb5797f07d0..d0981a9cdee6 100644 --- a/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/docs-code.mts +++ b/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/docs-code.mts @@ -32,6 +32,7 @@ const visibleLinesRule = /visibleLines="([^"]*)"/; const regionRule = /region="([^"]*)"/; const previewRule = /preview/; const hideCodeRule = /hideCode/; +const hideDollarRule = /hideDollar/; const preferRule = /\b(prefer|avoid)\b/; export const docsCodeExtension = { @@ -52,8 +53,9 @@ export const docsCodeExtension = { const language = languageRule.exec(attr); const visibleLines = visibleLinesRule.exec(attr); const region = regionRule.exec(attr); - const preview = previewRule.exec(attr) ? true : false; - const hideCode = hideCodeRule.exec(attr) ? true : false; + const preview = previewRule.test(attr); + const hideCode = hideCodeRule.test(attr); + const hideDollar = hideDollarRule.test(attr); const classes = classRule.exec(attr); const style = preferRule.exec(attr); @@ -78,6 +80,7 @@ export const docsCodeExtension = { region: region?.[1], preview: preview, hideCode, + hideDollar, style: style?.[1] as 'prefer' | 'avoid' | undefined, classes: classes?.[1]?.split(' '), }; diff --git a/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/format/index.mts b/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/format/index.mts index d077de171fd2..21957368737c 100644 --- a/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/format/index.mts +++ b/adev/shared-docs/pipeline/shared/marked/extensions/docs-code/format/index.mts @@ -39,6 +39,8 @@ export interface CodeToken extends Tokens.Generic { highlight?: string; /** Whether to hide the copy button */ hideCopy?: boolean; + /** Whether to hide the dollar sign in the shell code */ + hideDollar?: boolean; // additional classes for the element classes?: string[]; @@ -132,6 +134,10 @@ function applyContainerAttributesAndClasses(el: Element, token: CodeToken) { if (token.hideCopy) { el.setAttribute('hideCopy', 'true'); } + if (token.hideDollar) { + el.setAttribute('hideDollar', 'true'); + } + const language = token.language; if (language === 'mermaid') { diff --git a/adev/shared-docs/pipeline/shared/marked/test/docs-code-block/docs-code-block.md b/adev/shared-docs/pipeline/shared/marked/test/docs-code-block/docs-code-block.md index 507ed4546ea4..4091ceb4b797 100644 --- a/adev/shared-docs/pipeline/shared/marked/test/docs-code-block/docs-code-block.md +++ b/adev/shared-docs/pipeline/shared/marked/test/docs-code-block/docs-code-block.md @@ -11,3 +11,7 @@ this is a code block ``` code block without language ``` + +```shell {hideDollar} +echo "hello" +``` diff --git a/adev/shared-docs/pipeline/shared/marked/test/docs-code-block/docs-code-block.spec.mts b/adev/shared-docs/pipeline/shared/marked/test/docs-code-block/docs-code-block.spec.mts index c544411c8963..6bc9e27ddf5f 100644 --- a/adev/shared-docs/pipeline/shared/marked/test/docs-code-block/docs-code-block.spec.mts +++ b/adev/shared-docs/pipeline/shared/marked/test/docs-code-block/docs-code-block.spec.mts @@ -27,10 +27,10 @@ describe('markdown to html', () => { expect(codeBlock?.textContent?.trim()).toBe('this is a code block'); }); - it('should parse all 3 code blocks', () => { + it('should parse all 4 code blocks', () => { const codeBlocks = markdownDocument.querySelectorAll('.docs-code'); - expect(codeBlocks.length).toBe(3); + expect(codeBlocks.length).toBe(4); }); it('should deindent code blocks correctly', () => { @@ -42,4 +42,9 @@ describe('markdown to html', () => { const codeBlock = markdownDocument.querySelectorAll('.docs-code')[2]; expect(codeBlock).toBeDefined(); }); + + it('should parse the hideDollar attribute', () => { + const codeBlock = markdownDocument.querySelectorAll('.docs-code')[3]; + expect(codeBlock.getAttribute('hideDollar')).toBe('true'); + }); }); diff --git a/adev/shared-docs/pipeline/shared/marked/test/docs-code/docs-code.md b/adev/shared-docs/pipeline/shared/marked/test/docs-code/docs-code.md index fa52fce3c58c..0be343a07f63 100644 --- a/adev/shared-docs/pipeline/shared/marked/test/docs-code/docs-code.md +++ b/adev/shared-docs/pipeline/shared/marked/test/docs-code/docs-code.md @@ -12,3 +12,5 @@ const form = { state: [''] }; + + diff --git a/adev/shared-docs/pipeline/shared/marked/test/docs-code/docs-code.spec.mts b/adev/shared-docs/pipeline/shared/marked/test/docs-code/docs-code.spec.mts index f008639a8d7d..38dd735f6c4f 100644 --- a/adev/shared-docs/pipeline/shared/marked/test/docs-code/docs-code.spec.mts +++ b/adev/shared-docs/pipeline/shared/marked/test/docs-code/docs-code.spec.mts @@ -52,4 +52,9 @@ describe('markdown to html', () => { const codeBlock = markdownDocument.querySelectorAll('code')[4]; expect(codeBlock?.innerHTML).not.toContain('state'); }); + + it('should parse the hideDollar attribute', () => { + const codeBlock = markdownDocument.querySelectorAll('.docs-code')[5]; + expect(codeBlock.getAttribute('hideDollar')).toBe('true'); + }); }); diff --git a/adev/shared-docs/styles/docs/_code.scss b/adev/shared-docs/styles/docs/_code.scss index 8639ecd89c7a..8714ea283cef 100644 --- a/adev/shared-docs/styles/docs/_code.scss +++ b/adev/shared-docs/styles/docs/_code.scss @@ -204,15 +204,24 @@ $code-font-size: 0.875rem; white-space: nowrap; } - .shiki .line { - &::before { - content: '$'; - padding-inline-end: 0.5rem; + &:not([hideDollar]) { + .shiki { + .line { + &::before { + content: '$'; + padding-inline-end: 0.5rem; + } + } } - display: block; + } - &.hidden { - display: none; + .shiki { + .line { + display: block; + + &.hidden { + display: none; + } } } } diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index 997f4d6c6424..f64b6d7b73c5 100644 --- a/adev/src/content/guide/ssr.md +++ b/adev/src/content/guide/ssr.md @@ -636,7 +636,7 @@ NOTE: Most cloud providers and CDNs already validate these headers before the re To allow a specific hostname, you must configure the `allowedHosts` list in your `angular.json` to include all hostnames where your application is deployed. This is critical for ensuring your application works correctly and securely when deployed. The patterns support wildcards for flexible hostname matching. -```json +```json {hideCopy} { // ... "projects": { @@ -675,9 +675,7 @@ const nodeAppEngine = new AngularNodeAppEngine({ For the Node.js variant `AngularNodeAppEngine`, you can also provide `NG_ALLOWED_HOSTS` (comma-separated list) and `HOSTNAME` environment variables for authorizing hosts. -Example: - -```bash +```bash {hideDollar} export NG_ALLOWED_HOSTS="example.com,*.trusted-example.com" export HOSTNAME="example.com" ``` diff --git a/adev/src/content/kitchen-sink.md b/adev/src/content/kitchen-sink.md index 3db3580790c4..eb8b72106113 100644 --- a/adev/src/content/kitchen-sink.md +++ b/adev/src/content/kitchen-sink.md @@ -170,19 +170,20 @@ console.log('Awesome Angular Docs!'); #### `` Attributes -| Attributes | Type | Details | -| :------------- | :------------------- | :--------------------------------------------------- | -| code | `string` | Anything between tags is treated as code | -| `path` | `string` | Path to code example (root: `content/examples/`) | -| `header` | `string` | Title of the example (default: `file-name`) | -| `language` | `string` | code language | -| `linenums` | `boolean` | (False) displays line numbers | -| `highlight` | `string of number[]` | lines highlighted | -| `diff` | `string` | path to changed code | -| `visibleLines` | `string of number[]` | range of lines for collapse mode | -| `region` | `string` | only show the provided region. | -| `preview` | `boolean` | (False) display preview | -| `hideCode` | `boolean` | (False) Whether to collapse code example by default. | +| Attributes | Type | Details | +| :------------- | :------------------- | :-------------------------------------------------------------- | +| code | `string` | Anything between tags is treated as code | +| `path` | `string` | Path to code example (root: `content/examples/`) | +| `header` | `string` | Title of the example (default: `file-name`) | +| `language` | `string` | code language | +| `linenums` | `boolean` | (False) displays line numbers | +| `highlight` | `string of number[]` | lines highlighted | +| `diff` | `string` | path to changed code | +| `visibleLines` | `string of number[]` | range of lines for collapse mode | +| `region` | `string` | only show the provided region. | +| `preview` | `boolean` | (False) display preview | +| `hideCode` | `boolean` | (False) Whether to collapse code example by default. | +| `hideDollar` | `boolean` | (False) Whether to hide the dollar sign in shell code examples. | ### Multifile examples @@ -201,12 +202,13 @@ You can create multifile examples by wrapping the examples inside a `` Attributes -| Attributes | Type | Details | -| :------------ | :-------- | :--------------------------------------------------- | -| body contents | `string` | nested tabs of `docs-code` examples | -| `path` | `string` | Path to code example for preview and external link | -| `preview` | `boolean` | (False) display preview | -| `hideCode` | `boolean` | (False) Whether to collapse code example by default. | +| Attributes | Type | Details | +| :------------ | :-------- | :-------------------------------------------------------------- | +| body contents | `string` | nested tabs of `docs-code` examples | +| `path` | `string` | Path to code example for preview and external link | +| `preview` | `boolean` | (False) display preview | +| `hideCode` | `boolean` | (False) Whether to collapse code example by default. | +| `hideDollar` | `boolean` | (False) Whether to hide the dollar sign in shell code examples. | ### Adding `preview` to your code example From 099b4a8bbb26d48174a0118d93c4d7135b07e8c1 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:05:35 +0000 Subject: [PATCH 10/21] docs: move server-side security and host validation documentation from `ssr.md` to `security.md`. --- adev/src/content/guide/security.md | 65 ++++++++++++++++++++++++++++++ adev/src/content/guide/ssr.md | 63 +---------------------------- 2 files changed, 67 insertions(+), 61 deletions(-) diff --git a/adev/src/content/guide/security.md b/adev/src/content/guide/security.md index 699631a40f20..e6db9a1577f8 100644 --- a/adev/src/content/guide/security.md +++ b/adev/src/content/guide/security.md @@ -363,6 +363,71 @@ Angular's `HttpClient` library recognizes this convention and automatically stri For more information, see the XSSI section of this [Google web security blog post](https://security.googleblog.com/2011/05/website-security-for-webmasters.html). +## Preventing Server-Side Request Forgery (SSRF) + +Angular includes strict validation for `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port` headers in the request handling pipeline to prevent header-based [Server-Side Request Forgery (SSRF)](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/SSRF). + +The validation rules are: + +- `Host` and `X-Forwarded-Host` headers are validated against a strict allowlist. +- `Host` and `X-Forwarded-Host` headers cannot contain path separators. +- `X-Forwarded-Port` header must be numeric. +- `X-Forwarded-Proto` header must be `http` or `https`. +- `X-Forwarded-Prefix` header must not start with multiple `/` or `\` or contain `.`, `..` path segments. + +Invalid or disallowed headers now trigger an error log. Requests with unrecognized hostnames will result in a Client-Side Rendered (CSR) page if `allowedHosts` is defined; if not, a `400 Bad Request` is issued. Note that in a future major release, all unrecognized hostnames will default to a `400 Bad Request` regardless of `allowedHosts` settings. + +NOTE: Most cloud providers and CDN providers perform automatic validation of these headers before a request ever reaches the application origin. This inherent filtering significantly reduces the practical attack surface. + +### Configuring allowed hosts + +To allow specific hostnames, you need to add them to the allowlist. This is critical for ensuring your application works correctly and securely when deployed. The patterns support wildcards for flexible hostname matching. + +You can configure the `allowedHosts` option in your `angular.json`: + +```json {hideCopy} +{ + // ... + "projects": { + "your-project-name": { + // ... + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "security": { + "allowedHosts": [ + "example.com", + "*.example.com" // allows all subdomains of example.com + ] + } + // ... other options + } + } + } + } + } +} +``` + +You can also configure `allowedHosts` when initializing the application engine: + +```typescript +const appEngine = new AngularAppEngine({ + allowedHosts: ['example.com', '*.trusted-example.com'], +}); + +const nodeAppEngine = new AngularNodeAppEngine({ + allowedHosts: ['example.com', '*.trusted-example.com'], +}); +``` + +For the Node.js variant `AngularNodeAppEngine`, you can also provide `NG_ALLOWED_HOSTS` (comma-separated list) environment variable for authorizing hosts. + +```bash {hideDollar} +export NG_ALLOWED_HOSTS="example.com,*.trusted-example.com" +``` + ## Auditing Angular applications Angular applications must follow the same security principles as regular web applications, and must be audited as such. diff --git a/adev/src/content/guide/ssr.md b/adev/src/content/guide/ssr.md index f64b6d7b73c5..eca4beb225b1 100644 --- a/adev/src/content/guide/ssr.md +++ b/adev/src/content/guide/ssr.md @@ -617,65 +617,6 @@ export const reqHandler = createRequestHandler(async (req: Request) => { }); ``` -## Security and host validation +## Security -Angular includes strict validation for `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and `X-Forwarded-Port` headers in the request handling pipeline to prevent header-based [Server-Side Request Forgery (SSRF)](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/SSRF). - -The validation rules are: - -- `Host` and `X-Forwarded-Host` headers are validated against a strict allowlist. -- `Host` and `X-Forwarded-Host` headers cannot contain path separators. -- `X-Forwarded-Port` header must be numeric. -- `X-Forwarded-Proto` header must be `http` or `https`. - -Requests with invalid or disallowed headers will now log an error and fallback to Client-Side Rendering (CSR). In a future major version, these requests will be rejected with a `400 Bad Request`. - -NOTE: Most cloud providers and CDNs already validate these headers before the request reaches the application, but this change adds an essential layer of defense-in-depth. - -### Configuring allowed hosts - -To allow a specific hostname, you must configure the `allowedHosts` list in your `angular.json` to include all hostnames where your application is deployed. This is critical for ensuring your application works correctly and securely when deployed. The patterns support wildcards for flexible hostname matching. - -```json {hideCopy} -{ - // ... - "projects": { - "your-project-name": { - // ... - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "security": { - "allowedHosts": [ - "example.com", - "*.example.com" // allows all subdomains of example.com - ] - } - // ... other options - } - } - } - } - } -} -``` - -You can also configure `allowedHosts` when initializing the application engine: - -```typescript -const appEngine = new AngularAppEngine({ - allowedHosts: ['example.com', '*.trusted-example.com'], -}); - -const nodeAppEngine = new AngularNodeAppEngine({ - allowedHosts: ['example.com', '*.trusted-example.com'], -}); -``` - -For the Node.js variant `AngularNodeAppEngine`, you can also provide `NG_ALLOWED_HOSTS` (comma-separated list) and `HOSTNAME` environment variables for authorizing hosts. - -```bash {hideDollar} -export NG_ALLOWED_HOSTS="example.com,*.trusted-example.com" -export HOSTNAME="example.com" -``` +For detailed information on preventing Server-Side Request Forgery (SSRF) and configuring allowed hosts, see the [Server-side security](best-practices/security#preventing-server-side-request-forgery-ssrf) guide. From 1a19d61e1990fbb19c5abbb5836fd93020978349 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Tue, 17 Feb 2026 10:48:01 -0800 Subject: [PATCH 11/21] refactor(forms): clean up * Remove unused `TValue` type parameter from `FormUiControl` * Remove unused imports * Remove unnecessary cast --- .../signal_form_control/signal_form_control.ts | 2 +- .../forms/signals/test/node/field_proxy.spec.ts | 15 ++------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts b/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts index 620e88e54af9..db21abf8bc4d 100644 --- a/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts +++ b/packages/forms/signals/compat/src/signal_form_control/signal_form_control.ts @@ -108,7 +108,7 @@ export class SignalFormControl extends AbstractControl { this.fieldTree = wrapFieldTreeForSyncUpdates(rawTree, () => this.parent?.updateValueAndValidity({sourceControl: this} as any), ); - this.fieldState = this.fieldTree() as FieldState; + this.fieldState = this.fieldTree(); this.defineCompatProperties(); diff --git a/packages/forms/signals/test/node/field_proxy.spec.ts b/packages/forms/signals/test/node/field_proxy.spec.ts index 9f13d12799f6..356efe7bd916 100644 --- a/packages/forms/signals/test/node/field_proxy.spec.ts +++ b/packages/forms/signals/test/node/field_proxy.spec.ts @@ -6,20 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - Component, - Input, - Injector, - input, - signal, - ApplicationRef, - effect, - untracked, - computed, -} from '@angular/core'; +import {Injector, signal, ApplicationRef, effect, untracked} from '@angular/core'; import {TestBed} from '@angular/core/testing'; -import {form, FieldTree} from '../../public_api'; -import {ChangeDetectionStrategy} from '@angular/compiler'; +import {form} from '../../public_api'; describe('FieldTree proxy', () => { it('should not forward methods through the proxy', () => { From 163dd8ee383f77337e32d604abfcb0012dee4c8b Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:13:56 -0500 Subject: [PATCH 12/21] docs: add Route Loading Strategies section and update navigation links --- .../app/routing/navigation-entries/index.ts | 11 +++ .../best-practices/performance/overview.md | 12 +-- .../content/guide/routing/define-routes.md | 98 +------------------ .../guide/routing/loading-strategies.md | 96 ++++++++++++++++++ .../routing/route-transition-animations.md | 2 +- adev/src/llms.txt | 1 + 6 files changed, 119 insertions(+), 101 deletions(-) create mode 100644 adev/src/content/guide/routing/loading-strategies.md diff --git a/adev/src/app/routing/navigation-entries/index.ts b/adev/src/app/routing/navigation-entries/index.ts index 35be26c71ac5..5aa059c410af 100644 --- a/adev/src/app/routing/navigation-entries/index.ts +++ b/adev/src/app/routing/navigation-entries/index.ts @@ -363,6 +363,11 @@ export const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [ path: 'guide/routing/define-routes', contentPath: 'guide/routing/define-routes', }, + { + label: 'Route Loading Strategies', + path: 'guide/routing/loading-strategies', + contentPath: 'guide/routing/loading-strategies', + }, { label: 'Show routes with Outlets', path: 'guide/routing/show-routes-with-outlets', @@ -1086,6 +1091,12 @@ export const DOCS_SUB_NAVIGATION_DATA: NavigationItem[] = [ }, // Loading Performance + { + label: 'Lazy-loaded routes', + path: 'best-practices/performance/lazy-loaded-routes', + contentPath: 'guide/routing/loading-strategies', + category: 'Loading Performance', + }, { label: 'Deferred loading with @defer', path: 'best-practices/performance/defer', diff --git a/adev/src/content/best-practices/performance/overview.md b/adev/src/content/best-practices/performance/overview.md index 3ff3ac8044d9..0280057f3ff2 100644 --- a/adev/src/content/best-practices/performance/overview.md +++ b/adev/src/content/best-practices/performance/overview.md @@ -6,12 +6,12 @@ Angular includes many optimizations out of the box, but as applications grow, yo Loading performance determines how quickly your application becomes visible and interactive. Slow loading directly impacts [Core Web Vitals](https://web.dev/vitals/) like Largest Contentful Paint (LCP) and Time to First Byte (TTFB). -| Technique | What it does | When to use it | -| :------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------- | -| [Lazy-loaded routes](guide/routing/define-routes#lazily-loaded-components-and-routes) | Defers loading route components until navigation, reducing the initial bundle size | Applications with multiple routes where not all are needed on initial load | -| [Deferred loading with `@defer`](best-practices/performance/defer) | Splits components into separate bundles that load on demand | Components not visible on initial render, heavy third-party libraries, below-the-fold content | -| [Image optimization](best-practices/performance/image-optimization) | Prioritizes LCP images, lazy loads others, generates responsive `srcset` attributes | Any application that displays images | -| [Server-side rendering](best-practices/performance/ssr) | Renders pages on the server for faster first paint and better SEO, with [hydration](guide/hydration) to restore interactivity and [incremental hydration](guide/incremental-hydration) to defer hydrating sections until needed | Content-heavy applications, pages that need search engine indexing | +| Technique | What it does | When to use it | +| :------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------- | +| [Lazy-loaded routes](best-practices/performance/lazy-loaded-routes#lazily-loaded-components-and-routes) | Defers loading route components until navigation, reducing the initial bundle size | Applications with multiple routes where not all are needed on initial load | +| [Deferred loading with `@defer`](best-practices/performance/defer) | Splits components into separate bundles that load on demand | Components not visible on initial render, heavy third-party libraries, below-the-fold content | +| [Image optimization](best-practices/performance/image-optimization) | Prioritizes LCP images, lazy loads others, generates responsive `srcset` attributes | Any application that displays images | +| [Server-side rendering](best-practices/performance/ssr) | Renders pages on the server for faster first paint and better SEO, with [hydration](guide/hydration) to restore interactivity and [incremental hydration](guide/incremental-hydration) to defer hydrating sections until needed | Content-heavy applications, pages that need search engine indexing | ## Runtime performance diff --git a/adev/src/content/guide/routing/define-routes.md b/adev/src/content/guide/routing/define-routes.md index 1e55a6459e8d..3854b43e15e4 100644 --- a/adev/src/content/guide/routing/define-routes.md +++ b/adev/src/content/guide/routing/define-routes.md @@ -166,99 +166,6 @@ If a user visits `/users/new`, Angular router would go through the following ste 1. Never reaches `users` 1. Never reaches `**` -## Route Loading Strategies - -Understanding how and when routes and components load in Angular routing is crucial for building responsive web applications. Angular offers two primary strategies to control loading behavior: - -1. **Eagerly loaded**: Routes and components that are loaded immediately -2. **Lazily loaded**: Routes and components loaded only when needed - -Each approach offers distinct advantages for different scenarios. - -### Eagerly loaded components - -When you define a route with the `component` property, the referenced component is eagerly loaded as part of the same JavaScript bundle as the route configuration. - -```ts -import {Routes} from '@angular/router'; -import {HomePage} from './components/home/home-page'; -import {LoginPage} from './components/auth/login-page'; - -export const routes: Routes = [ - // HomePage and LoginPage are both directly referenced in this config, - // so their code is eagerly included in the same JavaScript bundle as this file. - { - path: '', - component: HomePage, - }, - { - path: 'login', - component: LoginPage, - }, -]; -``` - -Eagerly loading route components like this means that the browser has to download and parse all of the JavaScript for these components as part of your initial page load, but the components are available to Angular immediately. - -While including more JavaScript in your initial page load leads to slower initial load times, this can lead to more seamless transitions as the user navigates through an application. - -### Lazily loaded components and routes - -You can use the `loadComponent` property to lazily load the JavaScript for a component at the point at which that route would become active. The `loadChildren` property lazily loads child routes during route matching. - -```ts -import {Routes} from '@angular/router'; - -export const routes: Routes = [ - { - path: 'login', - loadComponent: () => import('./components/auth/login-page'), - }, - { - path: 'admin', - loadComponent: () => import('./admin/admin.component'), - loadChildren: () => import('./admin/admin.routes'), - }, -]; -``` - -The `loadComponent` and `loadChildren` properties accept a loader function that returns a Promise that resolves to an Angular component or a set of routes respectively. In most cases, this function uses the standard [JavaScript dynamic import API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import). You can, however, use any arbitrary async loader function. - -If the lazily loaded file uses a `default` export, you can return the `import()` promise directly without an additional `.then` call to select the exported class. - -Lazily loading routes can significantly improve the load speed of your Angular application by removing large portions of JavaScript from the initial bundle. These portions of your code compile into separate JavaScript "chunks" that the router requests only when the user visits the corresponding route. - -### Injection context lazy loading - -The Router executes `loadComponent` and `loadChildren` within the **injection context of the current route**, allowing you to call [`inject`](/api/core/inject)inside these loader functions to access providers declared on that route, inherited from parent routes through hierarchical dependency injection, or available globally. This enables context-aware lazy loading. - -```ts -import {Routes} from '@angular/router'; -import {inject} from '@angular/core'; -import {FeatureFlags} from './feature-flags'; - -export const routes: Routes = [ - { - path: 'dashboard', - // Runs inside the route's injection context - loadComponent: () => { - const flags = inject(FeatureFlags); - return flags.isPremium - ? import('./dashboard/premium-dashboard') - : import('./dashboard/basic-dashboard'); - }, - }, -]; -``` - -### Should I use an eager or a lazy route? - -There are many factors to consider when deciding on whether a route should be eager or lazy. - -In general, eager loading is recommended for primary landing page(s) while other pages would be lazy-loaded. - -NOTE: While lazy routes have the upfront performance benefit of reducing the amount of initial data requested by the user, it adds future data requests that could be undesirable. This is particularly true when dealing with nested lazy loading at multiple levels, which can significantly impact performance. - ## Redirects You can define a route that redirects to another route instead of rendering a component: @@ -462,4 +369,7 @@ After adding child routes to the configuration and adding a `` to ## Next steps -Learn how to [display the contents of your routes with Outlets](/guide/routing/show-routes-with-outlets). + + + + diff --git a/adev/src/content/guide/routing/loading-strategies.md b/adev/src/content/guide/routing/loading-strategies.md new file mode 100644 index 000000000000..dfdd13c73481 --- /dev/null +++ b/adev/src/content/guide/routing/loading-strategies.md @@ -0,0 +1,96 @@ +# Route Loading Strategies + +Understanding how and when routes and components load in Angular routing is crucial for building responsive web applications. Angular offers two primary strategies to control loading behavior: + +1. **Eagerly loaded**: Routes and components that are loaded immediately +2. **Lazily loaded**: Routes and components loaded only when needed + +Each approach offers distinct advantages for different scenarios. + +## Eagerly loaded components + +When you define a route with the [`component`](api/router/Route#component) property, the referenced component is eagerly loaded as part of the same JavaScript bundle as the route configuration. + +```ts +import {Routes} from '@angular/router'; +import {HomePage} from './components/home/home-page'; +import {LoginPage} from './components/auth/login-page'; + +export const routes: Routes = [ + // HomePage and LoginPage are both directly referenced in this config, + // so their code is eagerly included in the same JavaScript bundle as this file. + { + path: '', + component: HomePage, + }, + { + path: 'login', + component: LoginPage, + }, +]; +``` + +Eagerly loading route components like this means that the browser has to download and parse all of the JavaScript for these components as part of your initial page load, but the components are available to Angular immediately. + +While including more JavaScript in your initial page load leads to slower initial load times, this can lead to more seamless transitions as the user navigates through an application. + +## Lazily loaded components and routes + +You can use the [`loadComponent`](api/router/Route#loadComponent) property to lazily load the JavaScript for a component at the point at which that route would become active. The [`loadChildren`](api/router/Route#loadChildren) property lazily loads child routes during route matching. + +```ts +import {Routes} from '@angular/router'; + +export const routes: Routes = [ + { + path: 'login', + loadComponent: () => import('./components/auth/login-page'), + }, + { + path: 'admin', + loadComponent: () => import('./admin/admin.component'), + loadChildren: () => import('./admin/admin.routes'), + }, +]; +``` + +The [`loadComponent`](/api/router/Route#loadComponent) and [`loadChildren`](/api/router/Route#loadChildren) properties accept a loader function that returns a Promise that resolves to an Angular component or a set of routes respectively. In most cases, this function uses the standard [JavaScript dynamic import API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import). You can, however, use any arbitrary async loader function. + +If the lazily loaded file uses a `default` export, you can return the `import()` promise directly without an additional `.then` call to select the exported class. + +Lazily loading routes can significantly improve the load speed of your Angular application by removing large portions of JavaScript from the initial bundle. These portions of your code compile into separate JavaScript "chunks" that the router requests only when the user visits the corresponding route. + +## Injection context lazy loading + +The Router executes [`loadComponent`](/api/router/Route#loadComponent) and [`loadChildren`](/api/router/Route#loadChildren) within the **injection context of the current route**, allowing you to call [`inject`](/api/core/inject)inside these loader functions to access providers declared on that route, inherited from parent routes through hierarchical dependency injection, or available globally. This enables context-aware lazy loading. + +```ts +import {Routes} from '@angular/router'; +import {inject} from '@angular/core'; +import {FeatureFlags} from './feature-flags'; + +export const routes: Routes = [ + { + path: 'dashboard', + // Runs inside the route's injection context + loadComponent: () => { + const flags = inject(FeatureFlags); + return flags.isPremium + ? import('./dashboard/premium-dashboard') + : import('./dashboard/basic-dashboard'); + }, + }, +]; +``` + +## Should I use an eager or a lazy route? + +There are many factors to consider when deciding on whether a route should be eager or lazy. + +In general, eager loading is recommended for primary landing page(s) while other pages would be lazy-loaded. + +NOTE: While lazy routes have the upfront performance benefit of reducing the amount of initial data requested by the user, it adds future data requests that could be undesirable. This is particularly true when dealing with nested lazy loading at multiple levels, which can significantly impact performance. + +## Next steps + +Learn how to [display the contents of your routes with Outlets](/guide/routing/show-routes-with-outlets). diff --git a/adev/src/content/guide/routing/route-transition-animations.md b/adev/src/content/guide/routing/route-transition-animations.md index 4f7cafacc2f0..7b776f0acf99 100644 --- a/adev/src/content/guide/routing/route-transition-animations.md +++ b/adev/src/content/guide/routing/route-transition-animations.md @@ -27,7 +27,7 @@ For more details about the browser API, see the [Chrome Explainer](https://devel Angular Router integrates view transitions into the navigation lifecycle to create seamless route changes. During navigation, the Router: -1. **Completes navigation preparation** - Route matching, [lazy loading](/guide/routing/define-routes#lazily-loaded-components-and-routes), [guards](/guide/routing/route-guards), and [resolvers](/guide/routing/data-resolvers) execute +1. **Completes navigation preparation** - Route matching, [lazy loading](guide/routing/loading-strategies#lazily-loaded-components-and-routes), [guards](/guide/routing/route-guards), and [resolvers](/guide/routing/data-resolvers) execute 2. **Initiates the view transition** - Router calls `startViewTransition` when routes are ready for activation 3. **Updates the DOM** - Router activates new routes and deactivates old ones within the transition callback 4. **Finalizes the transition** - The transition Promise resolves when Angular completes rendering diff --git a/adev/src/llms.txt b/adev/src/llms.txt index 7c302e1e32a3..811312baef32 100644 --- a/adev/src/llms.txt +++ b/adev/src/llms.txt @@ -76,6 +76,7 @@ Angular — Deliver web apps with confidence 🚀 ## Routing - [Routing overview](https://angular.dev/guide/routing) - [Define routes](https://angular.dev/guide/routing/define-routes) +- [Route loading strategies](https://angular.dev/guide/routing/loading-strategies) - [Show routes with outlets](https://angular.dev/guide/routing/show-routes-with-outlets) - [Navigate to routes](https://angular.dev/guide/routing/navigate-to-routes) - [Read route state](https://angular.dev/guide/routing/read-route-state) From 23fd8fa58660b1a5051f26f40d8f3a673ed78dc9 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Sun, 22 Feb 2026 09:51:04 -0800 Subject: [PATCH 13/21] fix(forms): use consistent error format returned from parse Aligns the errors returned from the `parse` function in `transformedValue` to use the same convention as the rest of signal forms (a property called `error` that can contain a single error or list of errors) --- goldens/public-api/forms/signals/index.api.md | 2 +- .../forms/signals/src/api/rules/validation/util.ts | 14 ++++++++++++++ .../forms/signals/src/api/transformed_value.ts | 7 ++++--- packages/forms/signals/src/directive/native.ts | 2 +- packages/forms/signals/src/util/parser.ts | 4 +++- .../forms/signals/test/node/parse_errors.spec.ts | 4 ++-- 6 files changed, 25 insertions(+), 8 deletions(-) diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index ed6748649623..9aa2d50fbeba 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -436,7 +436,7 @@ export type OneOrMany = T | readonly T[]; // @public export interface ParseResult { - readonly errors?: readonly ValidationError.WithoutFieldTree[]; + readonly error?: OneOrMany; readonly value?: TValue; } diff --git a/packages/forms/signals/src/api/rules/validation/util.ts b/packages/forms/signals/src/api/rules/validation/util.ts index b8ac570225cd..0befeed29f80 100644 --- a/packages/forms/signals/src/api/rules/validation/util.ts +++ b/packages/forms/signals/src/api/rules/validation/util.ts @@ -58,3 +58,17 @@ export function isEmpty(value: unknown): boolean { } return value === '' || value === false || value == null; } + +/** + * Normalizes validation errors (which can be a single error, an array of errors, or undefined) + * into a list of errors. + */ +export function normalizeErrors(error: OneOrMany | undefined): readonly T[] { + if (error === undefined) { + return []; + } + if (Array.isArray(error)) { + return error as readonly T[]; + } + return [error as T]; +} diff --git a/packages/forms/signals/src/api/transformed_value.ts b/packages/forms/signals/src/api/transformed_value.ts index 247f8c062697..ab4d67192c95 100644 --- a/packages/forms/signals/src/api/transformed_value.ts +++ b/packages/forms/signals/src/api/transformed_value.ts @@ -16,6 +16,7 @@ import { import {FORM_FIELD_PARSE_ERRORS} from '../directive/parse_errors'; import {createParser} from '../util/parser'; import type {ValidationError} from './rules'; +import type {OneOrMany} from './types'; /** * Result of parsing a raw value into a model value. @@ -28,7 +29,7 @@ export interface ParseResult { /** * Errors encountered during parsing, if any. */ - readonly errors?: readonly ValidationError.WithoutFieldTree[]; + readonly error?: OneOrMany; } /** @@ -42,7 +43,7 @@ export interface TransformedValueOptions { * * Should return an object containing the parsed result, which may contain: * - `value`: The parsed model value. If `undefined`, the model will not be updated. - * - `errors`: Any parse errors encountered. If `undefined`, no errors are reported. + * - `error`: Any parse errors encountered. If `undefined`, no errors are reported. */ parse: (rawValue: TRaw) => ParseResult; @@ -93,7 +94,7 @@ export interface TransformedValueSignal extends WritableSignal { * if (val === '') return {value: null}; * const num = Number(val); * if (Number.isNaN(num)) { - * return {errors: [{kind: 'parse', message: `${val} is not numeric`}]}; + * return {error: {kind: 'parse', message: `${val} is not numeric`}}; * } * return {value: num}; * }, diff --git a/packages/forms/signals/src/directive/native.ts b/packages/forms/signals/src/directive/native.ts index a82841a60532..1359c83dfb4e 100644 --- a/packages/forms/signals/src/directive/native.ts +++ b/packages/forms/signals/src/directive/native.ts @@ -70,7 +70,7 @@ export function getNativeControlValue( if (element.validity.badInput) { return { - errors: [new NativeInputParseError() as WithoutFieldTree], + error: new NativeInputParseError() as WithoutFieldTree, }; } diff --git a/packages/forms/signals/src/util/parser.ts b/packages/forms/signals/src/util/parser.ts index 01c3c05569f1..7eb21364cfb2 100644 --- a/packages/forms/signals/src/util/parser.ts +++ b/packages/forms/signals/src/util/parser.ts @@ -8,6 +8,7 @@ import {type Signal, linkedSignal} from '@angular/core'; import type {ValidationError} from '../api/rules'; +import {normalizeErrors} from '../api/rules/validation/util'; import type {ParseResult} from '../api/transformed_value'; /** @@ -44,12 +45,13 @@ export function createParser( const setRawValue = (rawValue: TRaw) => { const result = parse(rawValue); + errors.set(normalizeErrors(result.error)); if (result.value !== undefined) { setValue(result.value); } // `errors` is a linked signal sourced from the model value; write parse errors after // model updates so `{value, errors}` results do not get reset by the recomputation. - errors.set(result.errors ?? []); + errors.set(normalizeErrors(result.error)); }; return {errors: errors.asReadonly(), setRawValue}; diff --git a/packages/forms/signals/test/node/parse_errors.spec.ts b/packages/forms/signals/test/node/parse_errors.spec.ts index 010e40204e46..aa7c36941e4c 100644 --- a/packages/forms/signals/test/node/parse_errors.spec.ts +++ b/packages/forms/signals/test/node/parse_errors.spec.ts @@ -345,10 +345,10 @@ class TestNumberInput implements FormValueControl { if (rawValue === '') return {value: null}; const value = Number(rawValue); if (Number.isNaN(value)) { - return {errors: [{kind: 'parse', message: `${rawValue} is not numeric`}]}; + return {error: {kind: 'parse', message: `${rawValue} is not numeric`}}; } if (this.parseMax() != null && value > this.parseMax()!) { - return {value, errors: [maxError(this.parseMax()!)]}; + return {value, error: [maxError(this.parseMax()!)]}; } return {value}; }, From 9a3f5cbacc9a6fc7687ff5480f3ca7995f9f76a1 Mon Sep 17 00:00:00 2001 From: Angular Robot Date: Mon, 23 Feb 2026 06:29:34 +0000 Subject: [PATCH 14/21] build: update pnpm to v10.30.1 See associated pull request for more information. --- MODULE.bazel | 4 ++-- MODULE.bazel.lock | 8 ++++---- integration/cli-hello-world-ivy-i18n/package.json | 2 +- integration/cli-hello-world-lazy/package.json | 2 +- integration/cli-hello-world/package.json | 2 +- integration/cli-signal-inputs/package.json | 2 +- integration/defer/package.json | 2 +- integration/legacy-animations-async/package.json | 2 +- integration/legacy-animations/package.json | 2 +- integration/ng-add-localize/package.json | 2 +- integration/ng_elements/package.json | 2 +- integration/ng_update/package.json | 2 +- integration/no_ts_linker/package.json | 2 +- integration/nodenext_resolution/package.json | 2 +- integration/platform-server-hydration/package.json | 2 +- integration/platform-server-zoneless/package.json | 2 +- integration/platform-server/package.json | 2 +- integration/service-worker-schema/package.json | 2 +- integration/side-effects/package.json | 2 +- integration/standalone-bootstrap/package.json | 2 +- integration/terser/package.json | 2 +- integration/trusted-types/package.json | 2 +- integration/typings_test_rxjs7/package.json | 2 +- integration/typings_test_ts59/package.json | 2 +- integration/typings_test_ts60/package.json | 2 +- package.json | 4 ++-- 26 files changed, 31 insertions(+), 31 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index 327dec92c52a..8976953f05dc 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -71,8 +71,8 @@ use_repo(node, "nodejs_windows_amd64") pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm") pnpm.pnpm( name = "pnpm", - pnpm_version = "10.30.0", - pnpm_version_integrity = "sha512-K1dT3gFdSA7riPW1th4AUfBbQwGAioLsi4QMnSrfd0jrNSyD9cFZPKcD/xAXKVvD/dMRmruWhu/Ja5/LGCAJNw==", + pnpm_version = "10.30.1", + pnpm_version_integrity = "sha512-NZDlUNU4TKo5vVx8c59yJwI0svYFnhMBj5dcMTseuf78wJcUBIdl1Nnv6WE4LDEuYkVywEIHYr3F1ZQM35vnOg==", ) use_repo(pnpm, "pnpm") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index bd2f451fbcd2..1244e327fb23 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -417,7 +417,7 @@ "@@aspect_rules_js+//npm:extensions.bzl%pnpm": { "general": { "bzlTransitiveDigest": "HC+l+mTivq1p/KbcVQ+iV5QwYR+oKESJh827FY68SH8=", - "usagesDigest": "i/ja+7Le/9VdqJB4MTzD11aQl7qURG7BbkP/sBkILdc=", + "usagesDigest": "PpJ4FqoV3QW67OW92+3MJMwspuSoWC6aDXT9WFaBpPs=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, "envVariables": {}, @@ -426,11 +426,11 @@ "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_rule", "attributes": { "package": "pnpm", - "version": "10.30.0", + "version": "10.30.1", "root_package": "", "link_workspace": "", "link_packages": {}, - "integrity": "sha512-K1dT3gFdSA7riPW1th4AUfBbQwGAioLsi4QMnSrfd0jrNSyD9cFZPKcD/xAXKVvD/dMRmruWhu/Ja5/LGCAJNw==", + "integrity": "sha512-NZDlUNU4TKo5vVx8c59yJwI0svYFnhMBj5dcMTseuf78wJcUBIdl1Nnv6WE4LDEuYkVywEIHYr3F1ZQM35vnOg==", "url": "", "commit": "", "patch_args": [ @@ -453,7 +453,7 @@ "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_links", "attributes": { "package": "pnpm", - "version": "10.30.0", + "version": "10.30.1", "dev": false, "root_package": "", "link_packages": {}, diff --git a/integration/cli-hello-world-ivy-i18n/package.json b/integration/cli-hello-world-ivy-i18n/package.json index 60aab9555763..826c89a89348 100644 --- a/integration/cli-hello-world-ivy-i18n/package.json +++ b/integration/cli-hello-world-ivy-i18n/package.json @@ -45,5 +45,5 @@ "ts-node": "^10.9.1", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/cli-hello-world-lazy/package.json b/integration/cli-hello-world-lazy/package.json index 0e85fda5d9ff..9564135900c9 100644 --- a/integration/cli-hello-world-lazy/package.json +++ b/integration/cli-hello-world-lazy/package.json @@ -27,5 +27,5 @@ "ts-node": "^10.9.1", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/cli-hello-world/package.json b/integration/cli-hello-world/package.json index 64a08c410f49..a774d6297942 100644 --- a/integration/cli-hello-world/package.json +++ b/integration/cli-hello-world/package.json @@ -32,5 +32,5 @@ "ts-node": "^10.9.1", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/cli-signal-inputs/package.json b/integration/cli-signal-inputs/package.json index b7cd4b0dfb93..3a3422a21d3d 100644 --- a/integration/cli-signal-inputs/package.json +++ b/integration/cli-signal-inputs/package.json @@ -10,7 +10,7 @@ "lint": "ng lint", "e2e": "ng build --configuration production && concurrently \"serve dist/browser -l 4210 --no-clipboard --single\" \"protractor e2e/protractor.conf.js --baseUrl=http://localhost:4210\" --kill-others --success first" }, - "packageManager": "pnpm@10.30.0", + "packageManager": "pnpm@10.30.1", "private": true, "dependencies": { "@angular/animations": "link:./in-existing-linked-by-bazel", diff --git a/integration/defer/package.json b/integration/defer/package.json index 81f2ab6895bb..784a8d92d10e 100644 --- a/integration/defer/package.json +++ b/integration/defer/package.json @@ -30,5 +30,5 @@ "ts-node": "10.9.2", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/legacy-animations-async/package.json b/integration/legacy-animations-async/package.json index 7a938031884a..042d2cf50add 100644 --- a/integration/legacy-animations-async/package.json +++ b/integration/legacy-animations-async/package.json @@ -30,5 +30,5 @@ "ts-node": "^10.9.1", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/legacy-animations/package.json b/integration/legacy-animations/package.json index 51059a858822..a28f41246d95 100644 --- a/integration/legacy-animations/package.json +++ b/integration/legacy-animations/package.json @@ -39,5 +39,5 @@ "ts-node": "^10.9.1", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/ng-add-localize/package.json b/integration/ng-add-localize/package.json index 6cebd2b15bf7..07e381a0276a 100644 --- a/integration/ng-add-localize/package.json +++ b/integration/ng-add-localize/package.json @@ -27,5 +27,5 @@ "@types/node": "^20.14.8", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/ng_elements/package.json b/integration/ng_elements/package.json index aaaa24f7ca0a..6a1071368864 100644 --- a/integration/ng_elements/package.json +++ b/integration/ng_elements/package.json @@ -33,5 +33,5 @@ "protractor": "protractor e2e/protractor.config.js" }, "private": true, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/ng_update/package.json b/integration/ng_update/package.json index 29e34120e671..49c476a26e21 100644 --- a/integration/ng_update/package.json +++ b/integration/ng_update/package.json @@ -21,5 +21,5 @@ "typescript": "5.9.3", "zone.js": "0.16.0" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/no_ts_linker/package.json b/integration/no_ts_linker/package.json index f6011ca49312..acb51de44871 100644 --- a/integration/no_ts_linker/package.json +++ b/integration/no_ts_linker/package.json @@ -13,5 +13,5 @@ "scripts": { "test": "node ./test.mjs" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/nodenext_resolution/package.json b/integration/nodenext_resolution/package.json index e430f4c28e51..4356e606139a 100644 --- a/integration/nodenext_resolution/package.json +++ b/integration/nodenext_resolution/package.json @@ -27,5 +27,5 @@ "scripts": { "test": "tsc" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/platform-server-hydration/package.json b/integration/platform-server-hydration/package.json index 7a71e0349625..2db71ff48333 100644 --- a/integration/platform-server-hydration/package.json +++ b/integration/platform-server-hydration/package.json @@ -41,5 +41,5 @@ "ts-node": "^10.9.1", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/platform-server-zoneless/package.json b/integration/platform-server-zoneless/package.json index a4ecc08ee049..bd91bf7e5ffd 100644 --- a/integration/platform-server-zoneless/package.json +++ b/integration/platform-server-zoneless/package.json @@ -43,5 +43,5 @@ "ts-node": "^10.9.1", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/platform-server/package.json b/integration/platform-server/package.json index fd8e83784533..104525c4cba2 100644 --- a/integration/platform-server/package.json +++ b/integration/platform-server/package.json @@ -47,5 +47,5 @@ "ts-node": "^10.9.1", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/service-worker-schema/package.json b/integration/service-worker-schema/package.json index 61032fc39c50..dcd4a1350dcf 100644 --- a/integration/service-worker-schema/package.json +++ b/integration/service-worker-schema/package.json @@ -13,5 +13,5 @@ "rxjs": "^7.0.0", "zone.js": "0.16.0" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/side-effects/package.json b/integration/side-effects/package.json index 7070de96b29a..0f41db735358 100644 --- a/integration/side-effects/package.json +++ b/integration/side-effects/package.json @@ -16,5 +16,5 @@ "@angular/router": "link:./in-existing-linked-by-bazel", "check-side-effects": "0.0.23" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/standalone-bootstrap/package.json b/integration/standalone-bootstrap/package.json index 9ed598f35370..a050c53e50b7 100644 --- a/integration/standalone-bootstrap/package.json +++ b/integration/standalone-bootstrap/package.json @@ -31,5 +31,5 @@ "ts-node": "^10.9.1", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/terser/package.json b/integration/terser/package.json index b809ab8c8093..356da3eab1b0 100644 --- a/integration/terser/package.json +++ b/integration/terser/package.json @@ -14,5 +14,5 @@ "typescript": "5.9.3", "zone.js": "0.16.0" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/trusted-types/package.json b/integration/trusted-types/package.json index 226849532dfe..9e4aaccc3311 100644 --- a/integration/trusted-types/package.json +++ b/integration/trusted-types/package.json @@ -45,5 +45,5 @@ "ts-node": "^10.9.1", "typescript": "5.9.3" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/typings_test_rxjs7/package.json b/integration/typings_test_rxjs7/package.json index 9878cd115358..35af29607c94 100644 --- a/integration/typings_test_rxjs7/package.json +++ b/integration/typings_test_rxjs7/package.json @@ -26,5 +26,5 @@ "scripts": { "test": "tsc" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/typings_test_ts59/package.json b/integration/typings_test_ts59/package.json index 3a7a31d3bdc9..3bf4d335108b 100644 --- a/integration/typings_test_ts59/package.json +++ b/integration/typings_test_ts59/package.json @@ -26,5 +26,5 @@ "scripts": { "test": "tsc" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/integration/typings_test_ts60/package.json b/integration/typings_test_ts60/package.json index fa79dd1c01d5..96dcbf9cc320 100644 --- a/integration/typings_test_ts60/package.json +++ b/integration/typings_test_ts60/package.json @@ -26,5 +26,5 @@ "scripts": { "test": "tsc" }, - "packageManager": "pnpm@10.30.0" + "packageManager": "pnpm@10.30.1" } diff --git a/package.json b/package.json index 45ad69eb820b..449cc562b0b5 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "homepage": "https://github.com/angular/angular", "bugs": "https://github.com/angular/angular/issues", "license": "MIT", - "packageManager": "pnpm@10.30.0", + "packageManager": "pnpm@10.30.1", "engines": { "npm": "Please use pnpm instead of NPM to install dependencies", "yarn": "Please use pnpm instead of Yarn to install dependencies", - "pnpm": "10.30.0" + "pnpm": "10.30.1" }, "repository": { "type": "git", From 0af9e3e9eed1279b033fb66f261162df56ac376f Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Mon, 23 Feb 2026 09:27:05 -0800 Subject: [PATCH 15/21] ci: remove mmalerba from reviewers remove mmalerba from reviewers --- .pullapprove.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.pullapprove.yml b/.pullapprove.yml index 45a7e962ba78..1f84917e316b 100644 --- a/.pullapprove.yml +++ b/.pullapprove.yml @@ -106,7 +106,6 @@ groups: - thePunderWoman - kirjs - JoostK - - mmalerba - ~amishne - ~leonsenft - ~mattrbeck @@ -153,7 +152,6 @@ groups: - kirjs - thePunderWoman - ~pkozlowski-opensource - - mmalerba - JeanMeche - ~amishne - ~leonsenft @@ -209,7 +207,6 @@ groups: - kirjs - thePunderWoman - ~pkozlowski-opensource - - mmalerba - ~amishne - ~leonsenft - ~mattrbeck @@ -263,7 +260,6 @@ groups: - ~pkozlowski-opensource - ~mgechev - MarkTechson - - mmalerba - ~hawkgs - ~amishne - ~leonsenft @@ -376,7 +372,6 @@ groups: - thePunderWoman - ~pkozlowski-opensource - kirjs - - mmalerba - crisbeto - devversion - JeanMeche @@ -412,7 +407,6 @@ groups: - kirjs - thePunderWoman - ~pkozlowski-opensource - - mmalerba - ~amishne - ~leonsenft - ~mattrbeck From a9ac7f87d7fc4b3c53e27edbbabab5d77eaba030 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Mon, 23 Feb 2026 17:41:40 +0100 Subject: [PATCH 16/21] docs: mention browser mode debugging --- adev/src/content/guide/testing/debugging.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/adev/src/content/guide/testing/debugging.md b/adev/src/content/guide/testing/debugging.md index 49012648e752..c40d0d463e80 100644 --- a/adev/src/content/guide/testing/debugging.md +++ b/adev/src/content/guide/testing/debugging.md @@ -15,6 +15,6 @@ Debugging in the default Node.js environment is often the quickest way to diagno ## Debugging in a browser -Debugging with Vitest and [browser mode](/guide/testing/migrating-to-vitest#5-configure-browser-mode-optional) is not supported today. +The same way you start a debugging session with in Node, you can use `ng test` with the `--debug` flag with Vitest and [browser mode](/guide/testing/migrating-to-vitest#5-configure-browser-mode-optional). -