From a4a8573a75d2031ca531b57ff59b0327cdcd953b Mon Sep 17 00:00:00 2001 From: Edu Date: Wed, 2 Sep 2026 00:19:17 +0000 Subject: [PATCH] fix(devtools): safely handle foreign root elements in feature detection Prevent Angular DevTools from crashing when elements matching [ng-version] in the DOM do not belong to the host application (such as third-party custom elements or browser extensions). ng.getComponent returns null for these elements, which previously caused ng.getDirectiveMetadata to throw. (cherry picked from commit ef09d108f23adc1aa29ec260fcf04b7271ea53c1) --- .../component-tree/BUILD.bazel | 1 + .../component-tree/component-tree.spec.ts | 21 ++++++++++++ .../component-tree/component-tree.ts | 10 +++--- .../shared/ng-debug-api/ng-debug-api.spec.ts | 34 +++++++++++++++++++ .../lib/shared/ng-debug-api/ng-debug-api.ts | 4 +-- 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/BUILD.bazel b/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/BUILD.bazel index f55d384bce9..1505cfddd05 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/BUILD.bazel +++ b/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/BUILD.bazel @@ -35,6 +35,7 @@ ts_test_library( ":get-roots", "//:node_modules/@angular/core", "//:node_modules/jasmine", + "//devtools/projects/ng-devtools-backend/src/lib/directive-forest:core-enums", ], ) diff --git a/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/component-tree.spec.ts b/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/component-tree.spec.ts index 57ad54703f3..5c061deba23 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/component-tree.spec.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/component-tree.spec.ts @@ -13,10 +13,12 @@ import { InjectionToken, } from '@angular/core'; import { + getDirectiveCdStrategy, getInjectorFromElementNode, getRootElements, serializeProviderRecord, } from './component-tree'; +import {ChangeDetectionStrategy, Framework} from '../core-enums'; type Ng = ɵExternalCoreGlobalUtils; const NG_VERSION = 'ng-version'; @@ -183,4 +185,23 @@ describe('component-tree', () => { expect(result.token).toBe('InjectionToken (FOO)'); }); }); + + describe('getDirectiveCdStrategy', () => { + it('returns change detection strategy when passed valid component', () => { + const ng: Partial = { + getDirectiveMetadata: jasmine.createSpy('getDirectiveMetadata').and.returnValue({ + framework: Framework.Angular, + changeDetection: ChangeDetectionStrategy.OnPush, + } as any), + }; + (globalThis as any).ng = ng; + + const result = getDirectiveCdStrategy({ + instance: {}, + name: 'Foo', + isElement: false, + }); + expect(result).toBe('ng-on-push'); + }); + }); }); diff --git a/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/component-tree.ts b/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/component-tree.ts index 689ce5b5b55..effe57afb03 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/component-tree.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/directive-forest/component-tree/component-tree.ts @@ -228,9 +228,9 @@ const enum DirectiveMetadataKey { // Gets directive metadata. For newer versions of Angular (v12+) it uses // the global `getDirectiveMetadata`. For prior versions of the framework // the method directly interacts with the directive/component definition. -const getDirectiveMetadata = (dir: any): DirectiveMetadata => { - const getMetadata = ngDebugClient().getDirectiveMetadata!; - const metadata = getMetadata?.(dir); +function getDirectiveMetadata(dir: any): DirectiveMetadata { + const getMetadata = ngDebugClient().getDirectiveMetadata; + const metadata = dir ? getMetadata?.(dir) : null; if (metadata) { const {framework} = metadata; switch (framework) { @@ -287,9 +287,9 @@ const getDirectiveMetadata = (dir: any): DirectiveMetadata => { encapsulation: safelyGrabMetadata(DirectiveMetadataKey.ENCAPSULATION), changeDetection: safelyGrabMetadata(DirectiveMetadataKey.CHANGE_DETECTION), }; -}; +} -export function getDirectiveCdStrategy(dir: any): ChangeDetection | undefined { +export function getDirectiveCdStrategy(dir: ComponentInstanceType): ChangeDetection | undefined { const metadata = getDirectiveMetadata(dir.instance); switch (metadata.framework) { diff --git a/devtools/projects/ng-devtools-backend/src/lib/shared/ng-debug-api/ng-debug-api.spec.ts b/devtools/projects/ng-devtools-backend/src/lib/shared/ng-debug-api/ng-debug-api.spec.ts index e5f723c3ad8..ceb7d736bb5 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/shared/ng-debug-api/ng-debug-api.spec.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/shared/ng-debug-api/ng-debug-api.spec.ts @@ -114,6 +114,23 @@ describe('ng-debug-api', () => { expect(ngDebugRoutesApiIsSupported()).toBeFalse(); }); + + it('should ignore elements where getComponent returns null (e.g. extension elements)', () => { + const foreignRoot = document.createElement('foreign-extension-app'); + foreignRoot.setAttribute('ng-version', '0.0.0-PLACEHOLDER'); + document.body.appendChild(foreignRoot); + + try { + const fakeNg = fakeNgGlobal(Framework.Angular); + const originalGetComponent = fakeNg.getComponent!; + fakeNg.getComponent = (el) => (el === foreignRoot ? null : originalGetComponent(el)); + (globalThis as any).ng = fakeNg; + + expect(ngDebugProfilerApiIsSupported()).toBeTrue(); + } finally { + foreignRoot.remove(); + } + }); }); describe('ngDebugRoutesApiIsSupported', () => { @@ -191,5 +208,22 @@ describe('ng-debug-api', () => { (globalThis as any).ng = fakeNgGlobal(Framework.ACX); expect(ngDebugSignalPropertiesInspectionApiIsSupported()).toBeFalse(); }); + + it('should ignore elements where getComponent returns null (e.g. extension elements)', () => { + const foreignRoot = document.createElement('foreign-extension-app'); + foreignRoot.setAttribute('ng-version', '0.0.0-PLACEHOLDER'); + document.body.appendChild(foreignRoot); + + try { + const fakeNg = fakeNgGlobal(Framework.Angular); + const originalGetComponent = fakeNg.getComponent!; + fakeNg.getComponent = (el) => (el === foreignRoot ? null : originalGetComponent(el)); + (globalThis as any).ng = fakeNg; + + expect(ngDebugSignalPropertiesInspectionApiIsSupported()).toBeTrue(); + } finally { + foreignRoot.remove(); + } + }); }); }); diff --git a/devtools/projects/ng-devtools-backend/src/lib/shared/ng-debug-api/ng-debug-api.ts b/devtools/projects/ng-devtools-backend/src/lib/shared/ng-debug-api/ng-debug-api.ts index fe2101039ae..eace2aa1e0a 100644 --- a/devtools/projects/ng-devtools-backend/src/lib/shared/ng-debug-api/ng-debug-api.ts +++ b/devtools/projects/ng-devtools-backend/src/lib/shared/ng-debug-api/ng-debug-api.ts @@ -58,7 +58,7 @@ export function ngDebugProfilerApiIsSupported(): boolean { // Temporary solution. Convert to an eligible API when available. // https://github.com/angular/angular/pull/60585#discussion_r2017047132 // If there is a Wiz application, make Profiler API unavailable. - const roots = getAppRoots(); + const roots = getAppRoots().filter((el) => ng.getComponent?.(el)); return ( !!roots.length && !roots.some((el) => { @@ -98,7 +98,7 @@ export function ngDebugSignalPropertiesInspectionApiIsSupported(): boolean { const ng = ngDebugClient(); // If all apps are Angular, make the API available. - const roots = getAppRoots(); + const roots = getAppRoots().filter((el) => ng.getComponent?.(el)); return ( !!roots.length && roots.every((el) => {