Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/src/pages/component-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ interface SourceComponent {
inputs: string[];
outputs: string[];
isStandalone: boolean;
changeDetection?: 'OnPush' | 'Eager' | 'unknown';
}

interface LiveNode {
id: string;
name: string;
Expand Down Expand Up @@ -474,6 +474,10 @@ function bare(name: string): string {
<dd class="mono">{{ comp.file }}:{{ comp.line }}</dd>
<dt>Standalone</dt>
<dd>{{ comp.isStandalone ? 'Yes' : 'No' }}</dd>
@if (comp.kind === 'component') {
<dt>Change detection</dt>
<dd>{{ comp.changeDetection ?? 'Unknown' }}</dd>
}
<dt>Inputs</dt>
<dd class="mono">{{ comp.inputs.join(', ') || 'None' }}</dd>
<dt>Outputs</dt>
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion extension/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<meta name="theme-color" content="#0b0b0e" />
<style>html{background:#0b0b0e}</style>
<title>Angular DevTools</title>
<script type="module" crossorigin src="./assets/index-BPVPMcLi.js"></script>
<script type="module" crossorigin src="./assets/index-B5-EMQmo.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BO7DtGyn.css">
</head>
<body>
Expand Down
15 changes: 15 additions & 0 deletions packages/ng-devtools/src/__tests__/component-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,21 @@ describe('collectComponentTree', () => {
],
});
});

it('labels change detection value 1 as Eager', () => {
document.body.innerHTML = `<app-root ng-version="22.0.0"><app-card></app-card></app-root>`;
const [root] = document.getElementsByTagName('app-root');
const card = document.querySelector('app-card')!;
const { ng } = fakeNg(
new Map<Element, object>([
[root, new _App()],
[card, new Card()],
]),
{ getDirectiveMetadata: () => ({ changeDetection: 1 }) },
);
const tree = collectComponentTree(ng, { selectedId: elementId(card) });
expect(tree.detail?.changeDetection).toBe('Eager');
});
});

describe('componentDetail', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/ng-devtools/src/component-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const MAX_DEPTH = 256;
const MAX_PROPS = 60;
const VALUE_LIMITS = { depth: 3, keys: 30, items: 30, text: 300 };

const CHANGE_DETECTION: Record<number, string> = { 0: 'OnPush', 1: 'Default' };
const CHANGE_DETECTION: Record<number, string> = { 0: 'OnPush', 1: 'Eager' };
const ENCAPSULATION: Record<number, string> = {
0: 'Emulated',
2: 'None',
Expand Down
79 changes: 79 additions & 0 deletions packages/ng-devtools/src/rpc/__tests__/angular-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { fixtureDir } from './fixture-dir.ts';
import { angularMajor } from '../angular-version.ts';

describe('angularMajor', () => {
it('reads the major from the installed package', () => {
const dir = fixtureDir('ng-devtools-version-');
const coreDir = join(dir, 'node_modules', '@angular', 'core');
mkdirSync(coreDir, { recursive: true });
writeFileSync(join(coreDir, 'package.json'), JSON.stringify({ version: '22.1.7' }));
expect(angularMajor(dir)).toBe(22);
});

it('prefers the installed version over the declared range', () => {
const dir = fixtureDir('ng-devtools-version-');
const coreDir = join(dir, 'node_modules', '@angular', 'core');
mkdirSync(coreDir, { recursive: true });
writeFileSync(join(coreDir, 'package.json'), JSON.stringify({ version: '22.1.7' }));
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ dependencies: { '@angular/core': '^20.0.0' } }),
);
expect(angularMajor(dir)).toBe(22);
});

it('falls back to a pinned or ^/~ range in package.json', () => {
const dir = fixtureDir('ng-devtools-version-');
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ dependencies: { '@angular/core': '^21.0.0' } }),
);
expect(angularMajor(dir)).toBe(21);

const dir2 = fixtureDir('ng-devtools-version-');
writeFileSync(
join(dir2, 'package.json'),
JSON.stringify({ devDependencies: { '@angular/core': '~21.1.0' } }),
);
expect(angularMajor(dir2)).toBe(21);
});

it('gives up on a range that names no single version', () => {
const dir = fixtureDir('ng-devtools-version-');
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ dependencies: { '@angular/core': '>=20.0.0' } }),
);
expect(angularMajor(dir)).toBeUndefined();

const dir2 = fixtureDir('ng-devtools-version-');
writeFileSync(
join(dir2, 'package.json'),
JSON.stringify({ dependencies: { '@angular/core': 'latest' } }),
);
expect(angularMajor(dir2)).toBeUndefined();
});

it('returns undefined when nothing names a version at all', () => {
const dir = fixtureDir('ng-devtools-version-');
expect(angularMajor(dir)).toBeUndefined();
});
});

describe('angularMajor with malformed metadata', () => {
it('yields undefined when package.json is not an object or its dependencies are', () => {
const arrayRoot = fixtureDir('ng-devtools-version-');
writeFileSync(join(arrayRoot, 'package.json'), '[]');
expect(angularMajor(arrayRoot)).toBeUndefined();

const badDeps = fixtureDir('ng-devtools-version-');
writeFileSync(
join(badDeps, 'package.json'),
JSON.stringify({ dependencies: 'nope', devDependencies: null }),
);
expect(angularMajor(badDeps)).toBeUndefined();
});
});
49 changes: 48 additions & 1 deletion packages/ng-devtools/src/rpc/__tests__/get-components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ import { scan } from './scan.ts';
import { describe, expect, it } from 'vitest';
import { getComponents } from '../get-components.ts';

async function componentsFor(source: string) {
async function componentsFor(source: string, angularVersion?: string) {
const dir = fixtureDir('ng-devtools-components-');
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'widgets.ts'), source);
if (angularVersion) {
const coreDir = join(dir, 'node_modules', '@angular', 'core');
mkdirSync(coreDir, { recursive: true });
writeFileSync(join(coreDir, 'package.json'), JSON.stringify({ version: angularVersion }));
}
return scan(getComponents, dir);
}

Expand Down Expand Up @@ -124,4 +129,46 @@ describe('get-components', () => {
expect.objectContaining({ selector: '', className: 'Base', kind: 'directive', line: 8 }),
]);
});

describe('change detection', () => {
const strategyOf = async (decoratorBody: string, version = '22.0.0') =>
(
await componentsFor(
`@Component({ selector: 'app-x', ${decoratorBody} })\nexport class X {}`,
version,
)
)[0].changeDetection;

it('reads qualified names and numeric values', async () => {
expect(await strategyOf('changeDetection: ChangeDetectionStrategy.Eager')).toBe('Eager');
expect(await strategyOf('changeDetection: core.ChangeDetectionStrategy.OnPush')).toBe(
'OnPush',
);
expect(await strategyOf('changeDetection: ChangeDetectionStrategy.Default')).toBe('Eager');
expect(await strategyOf('changeDetection: 0')).toBe('OnPush');
expect(await strategyOf('changeDetection: 1')).toBe('Eager');
});

it('ignores a changeDetection key nested in another property', async () => {
const nested = 'providers: [{ provide: X, useValue: { changeDetection: 1 } }]';
expect(await strategyOf(nested)).toBe('OnPush');
expect(await strategyOf(`${nested}, changeDetection: ChangeDetectionStrategy.OnPush`)).toBe(
'OnPush',
);
expect(await strategyOf(`${nested}, changeDetection: ChangeDetectionStrategy.Eager`)).toBe(
'Eager',
);
});

it('reports unknown for expressions it cannot evaluate', async () => {
expect(
await strategyOf(
'changeDetection: cond ? ChangeDetectionStrategy.OnPush : ChangeDetectionStrategy.Eager',
),
).toBe('unknown');
expect(await strategyOf('changeDetection: pick(ChangeDetectionStrategy.OnPush)')).toBe(
'unknown',
);
});
});
});
55 changes: 55 additions & 0 deletions packages/ng-devtools/src/rpc/angular-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';

/**
* The major version of `@angular/core` the project builds against, or
* `undefined` when it cannot be told apart from the workspace root.
*
* Angular 22 made `OnPush` the implicit change detection strategy, so a
* component without a `changeDetection` key means something different
* depending on which major the project is on; this resolves that once per
* scan rather than per file.
*
* Only one version is resolved for the whole workspace root, so a monorepo
* that mixes Angular majors across packages is not distinguished per package.
*/
export function angularMajor(cwd: string): number | undefined {
const installed = readJson(join(cwd, 'node_modules', '@angular', 'core', 'package.json'))[
'version'
];
if (typeof installed === 'string') {
const major = majorOf(installed);
if (major !== undefined) return major;
}

const pkg = readJson(join(cwd, 'package.json'));
const range = {
...asRecord(pkg['dependencies']),
...asRecord(pkg['devDependencies']),
}['@angular/core'];
// Only a pinned or `^`/`~` range names one version; `>=20`, `latest`,
// `workspace:*` and the like could resolve to anything and are left alone
// rather than guessed at.
if (typeof range === 'string') return majorOf(range.replace(/^[\^~]/, ''));
return undefined;
}

function majorOf(version: string): number | undefined {
const match = /^(\d+)(?:\.|$)/.exec(version.trim());
return match ? Number(match[1]) : undefined;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function asRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}

function readJson(path: string): Record<string, unknown> {
try {
if (!existsSync(path)) return {};
return asRecord(JSON.parse(readFileSync(path, 'utf-8')));
} catch {
return {};
}
}
Loading
Loading