feat(core): enhance profiling with documentation URLs

Enhances the Chrome DevTools performance profiling integration by adding links to relevant Angular documentation for lifecycle hooks and profiler events.
This commit is contained in:
Jaime Burgos 2026-04-13 14:37:11 -05:00 committed by GitHub
parent f9b74e90e3
commit 2f5ab541ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 117 additions and 3 deletions

View file

@ -7,8 +7,9 @@
*/
import {isTypeProvider} from '../../di/provider_collection';
import {assertDefined, assertEqual} from '../../util/assert';
import {assertDefined} from '../../util/assert';
import {performanceMarkFeature} from '../../util/performance';
import {VERSION} from '../../version';
import {setProfiler} from '../profiler';
import {Profiler, ProfilerEvent} from '../../../primitives/devtools';
import {stringifyForError} from '../util/stringify_utils';
@ -42,6 +43,10 @@ declare global {
trackName?: string,
trackGroup?: string,
color?: DevToolsColor,
// Experimental: 7th argument for user-supplied detail data rendered in the DevTools performance panel.
// https://developer.mozilla.org/en-US/docs/Web/API/Console/timeStamp_static#browser_compatibility
// Requires: chrome://flags/#enable-devtools-deep-link-via-extensibility-api
detail?: object,
): void;
}
}
@ -53,6 +58,54 @@ let counter = 0;
type stackEntry = [ProfilerEvent | ProfilerDIEvent, number];
const eventsStack: stackEntry[] = [];
function getBaseDocUrl(): string {
const full = VERSION.full;
const isPreRelease =
full.includes('-next') || full.includes('-rc') || full === '0.0.0-PLACEHOLDER';
const prefix = isPreRelease ? 'next' : `v${VERSION.major}`;
return `https://${prefix}.angular.dev`;
}
/**
* Returns documentation URL for lifecycle hooks.
* Extracts lifecycle hook name from the component method string and maps to Angular docs.
*/
function getLifecycleHookDocUrl(hookName: string): string | undefined {
// Extract lifecycle hook name (e.g., "MyComponent:ngOnInit" -> "ngOnInit")
const match = hookName.match(/:(ng\w+)$/);
if (!match) return undefined;
const lifecycleHook = match[1].toLowerCase();
const baseUrl = getBaseDocUrl();
return `${baseUrl}/guide/components/lifecycle#${lifecycleHook}`;
}
/**
* Returns documentation URL for profiler events.
*/
function getProfilerEventDocUrl(event: ProfilerEvent | ProfilerDIEvent, entryName: string) {
const baseUrl = getBaseDocUrl();
switch (event) {
case ProfilerEvent.ChangeDetectionStart:
case ProfilerEvent.ChangeDetectionEnd:
case ProfilerEvent.ChangeDetectionSyncStart:
case ProfilerEvent.ChangeDetectionSyncEnd:
return `${baseUrl}/best-practices/runtime-performance`;
case ProfilerEvent.AfterRenderHooksStart:
case ProfilerEvent.AfterRenderHooksEnd:
return `${baseUrl}/guide/components/lifecycle#aftereveryrender-and-afternextrender`;
case ProfilerEvent.DeferBlockStateStart:
case ProfilerEvent.DeferBlockStateEnd:
return `${baseUrl}/guide/defer`;
case ProfilerEvent.LifecycleHookStart:
return getLifecycleHookDocUrl(entryName);
default:
return undefined;
}
}
/**
* Enum mimicking ProfilerEvent. The idea is to have unique event identifiers for both DI and other profiling events.
*/
@ -72,7 +125,6 @@ function measureEnd(
color: DevToolsColor,
) {
let top: stackEntry | undefined;
// The stack may be asymmetric when an end event for a prior start event is missing (e.g. when an exception
// has occurred), unroll the stack until a matching item has been found in that case.
do {
@ -80,6 +132,8 @@ function measureEnd(
assertDefined(top, 'Profiling error: could not find start event entry ' + startEvent);
} while (top[0] !== startEvent);
const docUrl = getProfilerEventDocUrl(startEvent, entryName);
console.timeStamp(
entryName,
'Event_' + top[0] + '_' + top[1],
@ -87,6 +141,7 @@ function measureEnd(
'\u{1F170}\uFE0F Angular',
undefined,
color,
docUrl ? {description: 'Documentation', url: docUrl} : undefined,
);
}

View file

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {Component, HostAttributeToken, inject, Inject} from '../../src/core';
import {Component, HostAttributeToken, inject, Inject, OnInit} from '../../src/core';
import {enableProfiling} from '../../src/render3/debug/chrome_dev_tools_performance';
import {profiler} from '../../src/render3/profiler';
import {ProfilerEvent} from '../../primitives/devtools';
@ -83,4 +83,63 @@ describe('Chrome DevTools Performance integration', () => {
}
});
});
describe('documentation URLs', () => {
it('should include documentation URL for lifecycle hooks', () => {
@Component({
template: ``,
})
class MyCmp implements OnInit {
ngOnInit() {}
}
const timeStampSpy = spyOn(console, 'timeStamp');
const stopProfiling = enableProfiling();
try {
const fixture = TestBed.createComponent(MyCmp);
fixture.detectChanges();
const lifecycleCall = timeStampSpy.calls
.all()
.find((call) => (call.args[0] as string)?.includes(':ngOnInit'));
expect(lifecycleCall).toBeDefined();
// The 7th argument (index 6) is the detail object
const detail = (lifecycleCall!.args as any[])[6] as {url: string; description: string};
expect(detail).toBeDefined();
expect(detail.url).toMatch(/guide\/components\/lifecycle#ngoninit/);
expect(detail.description).toBe('Documentation');
} finally {
stopProfiling();
}
});
it('should include documentation URL for change detection', () => {
@Component({
template: ``,
})
class MyCmp {}
const timeStampSpy = spyOn(console, 'timeStamp');
const stopProfiling = enableProfiling();
try {
const fixture = TestBed.createComponent(MyCmp);
fixture.detectChanges();
const cdCall = timeStampSpy.calls
.all()
.find((call) => call.args[0]?.startsWith?.('Change detection'));
expect(cdCall).toBeDefined();
const detail = (cdCall!.args as any[])[6] as {url: string; description: string};
expect(detail).toBeDefined();
expect(detail.url).toMatch(/best-practices\/runtime-performance/);
expect(detail.description).toBe('Documentation');
} finally {
stopProfiling();
}
});
});
});