diff --git a/adev/src/content/guide/hydration.md b/adev/src/content/guide/hydration.md index 211ea695d9a..e9e71a0a6c7 100644 --- a/adev/src/content/guide/hydration.md +++ b/adev/src/content/guide/hydration.md @@ -174,6 +174,24 @@ Keep in mind that adding the `ngSkipHydration` attribute to your root applicatio Application stability is an important part of the hydration process. Hydration and any post-hydration processes only occur once the application has reported stability. There are a number of ways that stability can be delayed. Examples include setting timeouts and intervals, unresolved promises, and pending microtasks. In those cases, you may encounter the [Application remains unstable](errors/NG0506) error, which indicates that your app has not yet reached the stable state after 10 seconds. If you're finding that your application is not hydrating right away, take a look at what is impacting application stability and refactor to avoid causing these delays. +### Debugging Application Stability + +The `provideStabilityDebugging` utility helps identify why your application fails to stabilize. This utility is provided by default in dev mode when using `provideClientHydration`. You can also add it manually to the application providers for use in production bundles or when using SSR without hydration, for example. The feature logs information to the console if the application takes longer than expected to stabilize. + +```typescript +import {provideStabilityDebugging} from '@angular/core'; +import {bootstrapApplication} from '@angular/platform-browser'; +import 'zone.js/plugins/task-tracking'; // Use if you have Zone.js with `provideZoneChangeDetection` + +bootstrapApplication(AppComponent, { + providers: [provideStabilityDebugging()], +}); +``` + +When enabled, the utility logs pending tasks (`PendingTasks`) to the console. If your application uses Zone.js, you can also import `zone.js/plugins/task-tracking` to see which macrotasks are keeping the Angular Zone from stabilizing. This plugin provides the stack trace of the macrotask creation, effectively helping you identify the source of the delay. + +IMPORTANT: Angular does not remove the zone.js task tracking plugin or this utility from production bundles. Use them only for temporary debugging of stability issues during development, including for optimized production builds. + ## I18N HELPFUL: By default, Angular will skip hydration for components that use i18n blocks, effectively re-rendering those components from scratch. diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index a5c70b82b6d..ce9a39c92a9 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -1493,6 +1493,9 @@ export type Provider = TypeProvider | ValueProvider | ClassProvider | Constructo // @public export type ProviderToken = Type | AbstractType | InjectionToken; +// @public +export function provideStabilityDebugging(): EnvironmentProviders; + // @public export function provideZoneChangeDetection(options?: NgZoneOptions): EnvironmentProviders; diff --git a/packages/core/src/application/stability_debug.ts b/packages/core/src/application/stability_debug.ts new file mode 100644 index 00000000000..518e7d8c0ac --- /dev/null +++ b/packages/core/src/application/stability_debug.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {InjectionToken} from '../di/injection_token'; + +export const DEBUG_TASK_TRACKER = new InjectionToken( + typeof ngDevMode !== 'undefined' && ngDevMode ? 'DEBUG_TASK_TRACKER' : '', +); +export interface DebugTaskTracker { + add(taskId: number): void; + remove(taskId: number): void; +} diff --git a/packages/core/src/application/stability_debug_impl.ts b/packages/core/src/application/stability_debug_impl.ts new file mode 100644 index 00000000000..e72b2d452b9 --- /dev/null +++ b/packages/core/src/application/stability_debug_impl.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {EnvironmentProviders, inject, makeEnvironmentProviders} from '../di'; +import {NgZone} from '../zone'; +import {provideAppInitializer} from './application_init'; +import {ApplicationRef} from './application_ref'; +import {APPLICATION_IS_STABLE_TIMEOUT} from '../hydration/api'; +import {DEBUG_TASK_TRACKER, DebugTaskTracker} from './stability_debug'; + +const STABILITY_WARNING_THRESHOLD = APPLICATION_IS_STABLE_TIMEOUT - 1_000; + +class DebugTaskTrackerImpl implements DebugTaskTracker { + readonly openTasks = new Map(); + + add(taskId: number): void { + this.openTasks.set(taskId, new Error('Task stack tracking error')); + } + + remove(taskId: number): void { + this.openTasks.delete(taskId); + } +} + +/** + * Provides an application initializer that will log information about what tasks are keeping + * the application from stabilizing if the application does not stabilize within 9 seconds. + * + * The logged information includes the stack of the tasks preventing stability. This stack can be traced + * back to the source in the application code. + * + * If you are using Zone.js, it is recommended that you also temporarily import "zone.js/plugins/task-tracking". + * This Zone.js plugin provides additional information about which macrotasks are scheduled in the Angular Zone + * and keeping the Zone from stabilizing. + * + * @usageNotes + * + * ```ts + * import 'zone.js/plugins/task-tracking'; + * + * bootstrapApplication(AppComponent, {providers: [provideStabilityDebugging()]}); + * ``` + * + * IMPORTANT: Neither the zone.js task tracking plugin nor this utility are removed from production bundles. + * They are intended for temporary use while debugging stability issues during development, including for + * optimized production builds. + * + * @publicApi 21.1 + */ +export function provideStabilityDebugging(): EnvironmentProviders { + const taskTracker = new DebugTaskTrackerImpl(); + const {openTasks} = taskTracker; + return makeEnvironmentProviders([ + { + provide: DEBUG_TASK_TRACKER, + useValue: taskTracker, + }, + provideAppInitializer(() => { + if (typeof ngDevMode === 'undefined' || !ngDevMode) { + console.warn( + 'Stability debugging untility was provided in production mode. ' + + 'This will cause debug code to be included in production bundles. ' + + 'If this is intentional because you are debugging stability issues in a production environment, you can ignore this warning.', + ); + } + const ngZone = inject(NgZone); + const applicationRef = inject(ApplicationRef); + + // From TaskTrackingZone: + // https://github.com/angular/angular/blob/ae0c59028a2f393ea5716bf222db2c38e7a3989f/packages/zone.js/lib/zone-spec/task-tracking.ts#L46 + let _taskTrackingZone: {macroTasks: Array<{creationLocation: Error}>} | null = null; + if (typeof Zone !== 'undefined') { + ngZone.run(() => { + _taskTrackingZone = Zone.current.get('TaskTrackingZone'); + }); + } + ngZone.runOutsideAngular(() => { + const timeoutId = setTimeout(() => { + console.debug( + `---- Application did not stabilize within ${STABILITY_WARNING_THRESHOLD / 1000} seconds ----`, + ); + if (typeof Zone !== 'undefined' && !_taskTrackingZone) { + console.info( + 'Zone.js is present but no TaskTrackingZone found. To enable better debugging of tasks in the Angular Zone, ' + + 'import "zone.js/plugins/task-tracking" in your application.', + ); + } + if (_taskTrackingZone?.macroTasks?.length) { + console.group('Macrotasks keeping Angular Zone unstable:'); + for (const t of _taskTrackingZone?.macroTasks ?? []) { + console.debug(t.creationLocation.stack); + } + console.groupEnd(); + } + console.group('PendingTasks keeping application unstable:'); + for (const error of openTasks.values()) { + console.debug(error.stack); + } + console.groupEnd(); + }, STABILITY_WARNING_THRESHOLD); + + applicationRef.whenStable().then(() => { + clearTimeout(timeoutId); + }); + }); + }), + ]); +} diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 375c0848c7f..68e0896287d 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -54,6 +54,7 @@ export { ANIMATION_MODULE_TYPE, CSP_NONCE, } from './application/application_tokens'; +export {provideStabilityDebugging} from './application/stability_debug_impl'; export { APP_INITIALIZER, ApplicationInitStatus, diff --git a/packages/core/src/hydration/api.ts b/packages/core/src/hydration/api.ts index 97ab681af1f..71a800e4211 100644 --- a/packages/core/src/hydration/api.ts +++ b/packages/core/src/hydration/api.ts @@ -83,7 +83,7 @@ let isIncrementalHydrationRuntimeSupportEnabled = false; * Defines a period of time that Angular waits for the `ApplicationRef.isStable` to emit `true`. * If there was no event with the `true` value during this time, Angular reports a warning. */ -const APPLICATION_IS_STABLE_TIMEOUT = 10_000; +export const APPLICATION_IS_STABLE_TIMEOUT = 10_000; /** * Brings the necessary hydration code in tree-shakable manner. diff --git a/packages/core/src/pending_tasks_internal.ts b/packages/core/src/pending_tasks_internal.ts index 01aa327f21c..f55524b2fdd 100644 --- a/packages/core/src/pending_tasks_internal.ts +++ b/packages/core/src/pending_tasks_internal.ts @@ -10,6 +10,8 @@ import {BehaviorSubject, Observable} from 'rxjs'; import {ɵɵdefineInjectable} from './di/interface/defs'; import {OnDestroy} from './change_detection/lifecycle_hooks'; +import {DEBUG_TASK_TRACKER} from './application/stability_debug'; +import {inject} from './di'; /** * Internal implementation of the pending tasks service. @@ -19,8 +21,8 @@ export class PendingTasksInternal implements OnDestroy { private taskId = 0; private pendingTasks = new Set(); private destroyed = false; - private pendingTask = new BehaviorSubject(false); + private debugTaskTracker = inject(DEBUG_TASK_TRACKER, {optional: true}); get hasPendingTasks(): boolean { // Accessing the value of a closed `BehaviorSubject` throws an error. @@ -50,6 +52,7 @@ export class PendingTasksInternal implements OnDestroy { } const taskId = this.taskId++; this.pendingTasks.add(taskId); + this.debugTaskTracker?.add(taskId); return taskId; } @@ -59,6 +62,7 @@ export class PendingTasksInternal implements OnDestroy { remove(taskId: number): void { this.pendingTasks.delete(taskId); + this.debugTaskTracker?.remove(taskId); if (this.pendingTasks.size === 0 && this.hasPendingTasks) { this.pendingTask.next(false); } 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 c07b6161447..d2a5a9fa5ee 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -7,6 +7,7 @@ "ANIMATION_PREFIX", "ANIMATION_QUEUE", "ANY_STATE", + "APPLICATION_IS_STABLE_TIMEOUT", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_ID_ATTRIBUTE_NAME", @@ -66,6 +67,7 @@ "ComponentRef2", "ConsumerObserver", "DASH_CASE_REGEXP", + "DEBUG_TASK_TRACKER", "DECLARATION_COMPONENT_VIEW", "DECLARATION_LCONTAINER", "DECLARATION_VIEW", @@ -226,6 +228,7 @@ "SHARED_ANIMATION_PROVIDERS", "SIGNAL", "SIMPLE_CHANGES_STORE", + "STABILITY_WARNING_THRESHOLD", "STAR_CLASSNAME", "STAR_SELECTOR", "SUBSTITUTION_EXPR_END", 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 41b83fe3bbe..e60602de7b0 100644 --- a/packages/core/test/bundling/create_component/bundle.golden_symbols.json +++ b/packages/core/test/bundling/create_component/bundle.golden_symbols.json @@ -4,6 +4,7 @@ "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS", "ANIMATION_QUEUE", + "APPLICATION_IS_STABLE_TIMEOUT", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_ID_ATTRIBUTE_NAME", @@ -42,6 +43,7 @@ "ComponentRef", "ComponentRef2", "ConsumerObserver", + "DEBUG_TASK_TRACKER", "DECLARATION_COMPONENT_VIEW", "DECLARATION_LCONTAINER", "DECLARATION_VIEW", @@ -174,6 +176,7 @@ "SIGNAL", "SIGNAL_NODE", "SIMPLE_CHANGES_STORE", + "STABILITY_WARNING_THRESHOLD", "SVG_NAMESPACE", "SafeSubscriber", "Sanitizer", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 6f03d12c791..9faceefd656 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -61,6 +61,7 @@ "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS", "ANIMATION_QUEUE", + "APPLICATION_IS_STABLE_TIMEOUT", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_INITIALIZER", @@ -93,6 +94,7 @@ "ComponentRef", "ComponentRef2", "ConsumerObserver", + "DEBUG_TASK_TRACKER", "DECLARATION_COMPONENT_VIEW", "DECLARATION_LCONTAINER", "DECLARATION_VIEW", @@ -218,6 +220,7 @@ "SIMPLE_CHANGES_STORE", "SSR_BLOCK_STATE", "SSR_UNIQUE_ID", + "STABILITY_WARNING_THRESHOLD", "SVG_NAMESPACE", "SafeSubscriber", "Sanitizer", diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index ae1a72f0cdd..cd8df286076 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -4,6 +4,7 @@ "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS", "ANIMATION_QUEUE", + "APPLICATION_IS_STABLE_TIMEOUT", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_ID_ATTRIBUTE_NAME", @@ -62,6 +63,7 @@ "ConsumerObserver", "ControlContainer", "ControlEvent", + "DEBUG_TASK_TRACKER", "DECLARATION_COMPONENT_VIEW", "DECLARATION_LCONTAINER", "DECLARATION_VIEW", @@ -229,6 +231,7 @@ "SIGNAL", "SIGNAL_NODE", "SIMPLE_CHANGES_STORE", + "STABILITY_WARNING_THRESHOLD", "SVG_NAMESPACE", "SafeSubscriber", "SafeValueImpl", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 25eba8d8b90..4f17ff2f041 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -4,6 +4,7 @@ "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS", "ANIMATION_QUEUE", + "APPLICATION_IS_STABLE_TIMEOUT", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_ID_ATTRIBUTE_NAME", @@ -63,6 +64,7 @@ "ConsumerObserver", "ControlContainer", "ControlEvent", + "DEBUG_TASK_TRACKER", "DECLARATION_COMPONENT_VIEW", "DECLARATION_LCONTAINER", "DECLARATION_VIEW", @@ -224,6 +226,7 @@ "SIGNAL", "SIGNAL_NODE", "SIMPLE_CHANGES_STORE", + "STABILITY_WARNING_THRESHOLD", "SVG_NAMESPACE", "SafeSubscriber", "SafeValueImpl", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index c57cab4dee5..ba6f6261734 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -6,6 +6,7 @@ "ALLOWED_METHODS", "ANIMATIONS", "ANIMATION_QUEUE", + "APPLICATION_IS_STABLE_TIMEOUT", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_ID_ATTRIBUTE_NAME", @@ -50,6 +51,7 @@ "ComponentRef", "ComponentRef2", "ConsumerObserver", + "DEBUG_TASK_TRACKER", "DECLARATION_COMPONENT_VIEW", "DECLARATION_LCONTAINER", "DECLARATION_VIEW", @@ -202,6 +204,7 @@ "SKIP_HYDRATION_ATTR_NAME", "SKIP_HYDRATION_ATTR_NAME_LOWER_CASE", "SSR_CONTENT_INTEGRITY_MARKER", + "STABILITY_WARNING_THRESHOLD", "STATUS", "STATUS_TEXT", "SVG_NAMESPACE", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 6926675bf32..a7a977bf205 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -4,6 +4,7 @@ "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS", "ANIMATION_QUEUE", + "APPLICATION_IS_STABLE_TIMEOUT", "APP_BASE_HREF", "APP_BOOTSTRAP_LISTENER", "APP_ID", @@ -63,6 +64,7 @@ "ComponentRef2", "Console", "ConsumerObserver", + "DEBUG_TASK_TRACKER", "DECLARATION_COMPONENT_VIEW", "DECLARATION_LCONTAINER", "DECLARATION_VIEW", @@ -270,6 +272,7 @@ "SIGNAL", "SIGNAL_NODE", "SIMPLE_CHANGES_STORE", + "STABILITY_WARNING_THRESHOLD", "SVG_NAMESPACE", "SafeSubscriber", "SafeValueImpl", 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 9a3ce443f93..47f4af11c79 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -4,6 +4,7 @@ "AFTER_RENDER_SEQUENCES_TO_ADD", "ANIMATIONS", "ANIMATION_QUEUE", + "APPLICATION_IS_STABLE_TIMEOUT", "APP_BOOTSTRAP_LISTENER", "APP_ID", "APP_ID_ATTRIBUTE_NAME", @@ -42,6 +43,7 @@ "ComponentRef", "ComponentRef2", "ConsumerObserver", + "DEBUG_TASK_TRACKER", "DECLARATION_COMPONENT_VIEW", "DECLARATION_LCONTAINER", "DECLARATION_VIEW", @@ -165,6 +167,7 @@ "SCHEDULE_IN_ROOT_ZONE_DEFAULT", "SIGNAL", "SIMPLE_CHANGES_STORE", + "STABILITY_WARNING_THRESHOLD", "SVG_NAMESPACE", "SafeSubscriber", "Sanitizer", diff --git a/packages/core/test/debug/stability_debug_spec.ts b/packages/core/test/debug/stability_debug_spec.ts new file mode 100644 index 00000000000..5d50d8859a3 --- /dev/null +++ b/packages/core/test/debug/stability_debug_spec.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ApplicationInitStatus, PendingTasks, provideZoneChangeDetection} from '../../src/core'; +import {provideStabilityDebugging} from '../../src/application/stability_debug_impl'; +import {TestBed, fakeAsync, tick} from '@angular/core/testing'; + +describe('provideStabilityDebugging', () => { + let consoleWarnSpy: jasmine.Spy; + let consoleDebugSpy: jasmine.Spy; + let consoleInfoSpy: jasmine.Spy; + let consoleGroupEndSpy: jasmine.Spy; + + beforeEach(() => { + spyOn(global, 'Error').and.returnValue({stack: 'fake stack trace', name: 'Error', message: ''}); + consoleWarnSpy = spyOn(console, 'warn'); + consoleDebugSpy = spyOn(console, 'debug'); + consoleInfoSpy = spyOn(console, 'info'); + consoleGroupEndSpy = spyOn(console, 'groupEnd'); + TestBed.configureTestingModule({ + providers: [provideStabilityDebugging(), provideZoneChangeDetection()], + }); + }); + + function runInitializers() { + (TestBed.inject(ApplicationInitStatus) as any).runInitializers(); + } + + it('should log pending tasks if application does not stabilize', fakeAsync(() => { + const pendingTasks = TestBed.inject(PendingTasks); + + // Prevent stability + const removeTask = pendingTasks.add(); + + runInitializers(); + + tick(10_000); + + expect(consoleDebugSpy.calls.first().args[0]).toMatch(/Application did not stabilize/); + expect(consoleDebugSpy.calls.all()[1].args[0]).toMatch(/fake stack trace/); + + removeTask(); + })); + + it('should not log if application stabilizes within 9 seconds', fakeAsync(() => { + const pendingTasks = TestBed.inject(PendingTasks); + + // Prevent stability + const removeTask = pendingTasks.add(); + + runInitializers(); + + tick(5000); + removeTask(); // Stabilize + tick(4000); // Reach 9000 total + + expect(consoleDebugSpy).not.toHaveBeenCalled(); + })); +}); diff --git a/packages/platform-browser/src/hydration.ts b/packages/platform-browser/src/hydration.ts index 1cfd4fabeb4..9b7c6a03ca1 100644 --- a/packages/platform-browser/src/hydration.ts +++ b/packages/platform-browser/src/hydration.ts @@ -22,6 +22,7 @@ import { ɵZONELESS_ENABLED as ZONELESS_ENABLED, ɵwithIncrementalHydration, ɵIS_ENABLED_BLOCKING_INITIAL_NAVIGATION as IS_ENABLED_BLOCKING_INITIAL_NAVIGATION, + provideStabilityDebugging, } from '@angular/core'; import {RuntimeErrorCode} from './errors'; @@ -255,6 +256,7 @@ export function provideClientHydration( typeof ngDevMode !== 'undefined' && ngDevMode ? provideEnabledBlockingInitialNavigationDetector() : [], + typeof ngDevMode !== 'undefined' && ngDevMode ? provideStabilityDebugging() : [], withDomHydration(), featuresKind.has(HydrationFeatureKind.NoHttpTransferCache) || hasHttpTransferCacheOptions ? []