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
2 changes: 1 addition & 1 deletion apps/docs/src/content/getting-started/angular-native.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ The hook does nothing when `ngDevMode` is off. Without it, only stores created a
Check the Metro log for a <code>[pangular]</code> line. A warning about <code>--no-auth</code> means the server asked for a code. A warning about reaching the server means the address is wrong for where the app runs. See <a href="../guides/angular-native.md#where-the-overlay-connects">where the overlay connects</a>. A release build has no <code>ng</code> global, so nothing is collected there.
</ngmd-accordion-item>
<ngmd-accordion-item title="The Providers list of an injector is empty">
Angular records providers only when a <code>window</code> global exists as <code>mount()</code> creates the platform. If the list stays empty, check that <code>window</code> is defined before <code>mount()</code> runs.
Angular records providers only when a <code>window</code> global exists as <code>mount()</code> creates the platform. When <code>__DEV__</code> is <code>true</code> and no <code>window</code> exists, importing <code>&#64;pangular-inspector/devtools/overlay-angular-native</code> defines one until Angular publishes its debug utilities, then removes it. Keep the import at the top of <code>src/main.ts</code>, so it runs before <code>mount()</code>. If the list stays empty, check that <code>window</code> is defined before <code>mount()</code> runs.
</ngmd-accordion-item>
</ngmd-accordion>

Expand Down
4 changes: 4 additions & 0 deletions apps/docs/src/content/inspectors/ngrx-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@ Angular creates a `signalStore` the first time something injects it. Open a page

Use `provideStore()` or `StoreModule.forRoot()`. The overlay stops looking after a few tries, so if you provide the Store late, reload the page.

### State changed in place

The tab can miss a change made in place to a nested object, without `patchState` or a reducer. It may show the old values until something else rebuilds the report, such as the next real change or a recorded action. Use `patchState`, or a reducer that returns new objects.

### Redaction

The devtools replace state keys with secret-looking names with `[redacted]`, at any depth. A signal store method call that writes such a key shows every argument as `[redacted]` in the change log, and so do later calls of that method. An argument whose parameter name looks secret, such as `password` in `login(email, password)`, is masked too. See [what the devtools redact](../security.md#components-signals-ngrx-and-pipes).
Expand Down
3 changes: 3 additions & 0 deletions packages/devtools/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN

- Pages push deltas instead of full snapshots, at most about once a second, and idle pages send only a small heartbeat.
- NgRx store updates no longer slow down as the store grows (#432).
- The NgRx collector serializes a signal store or the classic `Store` only when its state, computed values or method stats changed, and the overlay compares a report version instead of the serialized report, so an unchanged store costs no serialization on each check. A change made in place, without a new state object, can show late (#368).
- Collectors schedule their work outside the Angular zone, so an app that writes a signal or logs during change detection settles again on zone.js.
- Long lists and trees in the panel render only the visible rows.
- The agent tool list is much smaller: shorter descriptions, and library tools only for apps that use them.
Expand Down Expand Up @@ -221,6 +222,8 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN
- The **Forms** tab shows the forms setup errors Angular logs on the page, such as `NG01203`, in a notice above the list (#368).
- The **Forms** timeline tags a change as **agent** only when a WebMCP call wrote it, or when it is in the call's own form while the call runs (#368). Typing in any form while a long WebMCP submit is pending is tagged **user**, not **agent**.
- The **Pipes** tab no longer marks two `| async` usages of a Promise, a Subject, an `asObservable()` view of one or an NgRx store select as duplicates, since their subscribers share one execution (#369). Any other source that two `| async` usages subscribe to shows as **possible duplicate subscription**, and `explain-pipe` says **Possible duplicate subscriptions**, because the page can't see `share()` or `shareReplay()` in a pipe.
- Browser DevTools show the app's own line as the caller of a `console` call again. The browser entries ship source maps that list the console tap in `x_google_ignoreList`, so its wrapper frame is skipped (#369).
- The Angular Native overlay gates the injector profiler like the NativeScript overlay: in a development build with no `window`, importing it defines one until Angular publishes its debug utilities, so the **Providers** lists fill (#371).

### Docs

Expand Down
4 changes: 3 additions & 1 deletion packages/devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
"./dist/capacitor-hook.mjs",
"./dist/store-hook.mjs",
"./dist/startup-hook.mjs",
"./dist/overlay-angular-native.mjs",
"./src/overlay-auto.ts",
"./src/popup.ts",
"./src/capacitor-hook.ts",
"./src/store-hook.ts",
"./src/startup-hook.ts"
"./src/startup-hook.ts",
"./src/overlay-angular-native.ts"
],
"bin": {
"pangular": "./bin.mjs"
Expand Down
35 changes: 34 additions & 1 deletion packages/devtools/src/__tests__/build-entries.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
Expand Down Expand Up @@ -131,6 +131,39 @@ describe('the real build', () => {
expect(chunks.some((chunk) => chunk.fileName.startsWith('devframe'))).toBe(false);
}, 60_000);

it('ignore-lists the console tap in the browser source maps, and nothing of ours besides', async () => {
const { outDir } = await buildInto(browserConfig, 'browser-maps');
const maps = readdirSync(outDir)
.filter((file) => file.endsWith('.mjs.map'))
.map((file) => ({
file,
map: JSON.parse(readFileSync(join(outDir, file), 'utf8')) as {
sources: string[];
x_google_ignoreList?: number[];
},
}));
// Each chunk points to the map written next to it.
for (const { file } of maps) {
const chunk = file.slice(0, -'.map'.length);
expect(readFileSync(join(outDir, chunk), 'utf8'), chunk).toContain(
`//# sourceMappingURL=${file}`,
);
}
const ignored = maps.flatMap(({ map }) =>
(map.x_google_ignoreList ?? []).map((index) => map.sources[index]!),
);
const tap = maps.filter(({ map }) => map.sources.some((s) => s.endsWith('/console-tap.ts')));
expect(tap.length).toBeGreaterThan(0);
for (const { map } of tap) {
const index = map.sources.findIndex((s) => s.endsWith('/console-tap.ts'));
expect(map.x_google_ignoreList).toContain(index);
}
// The overlay's own frames stay visible.
expect(ignored.filter((s) => !s.includes('node_modules'))).toEqual(
tap.map(({ map }) => map.sources.find((s) => s.endsWith('/console-tap.ts'))),
);
}, 60_000);

it('fails the browser build when an entry imports a Node builtin', async () => {
const fixture = join(work, 'uses-node.ts');
writeFileSync(
Expand Down
130 changes: 130 additions & 0 deletions packages/devtools/src/__tests__/injector-profiler-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
gateInjectorProfiler,
gateInjectorProfilerInReactNativeDev,
} from '../injector-profiler-gate.ts';

const g = globalThis as Record<string, unknown>;

/** What `publishDefaultGlobalUtils` and `publishUtil` in `@angular/core` do, in that order. */
function bootAngular(): { profiler: boolean } {
const profiler = typeof g['window'] !== 'undefined';
const ng = (g['ng'] ??= {}) as Record<string, unknown>;
ng['getComponent'] = () => null;
ng['getInjector'] = () => null;
return { profiler };
}

function clean() {
delete g['window'];
delete g['ng'];
delete g['__DEV__'];
}

describe('gateInjectorProfiler', () => {
beforeEach(() => {
// The tests run in Node, which has no `window`, like a native runtime.
expect('window' in g).toBe(false);
clean();
});
afterEach(clean);

it('defines window until Angular publishes getComponent, then removes it', () => {
gateInjectorProfiler();
expect(g['window']).toBe(globalThis);
expect(bootAngular().profiler).toBe(true);
expect('window' in g).toBe(false);
expect(typeof (g['ng'] as Record<string, unknown>)['getComponent']).toBe('function');
expect(typeof (g['ng'] as Record<string, unknown>)['getInjector']).toBe('function');
});

it('keeps window while the router publishes onto ng first', () => {
gateInjectorProfiler();
g['ng'] ??= {};
(g['ng'] as Record<string, unknown>)['ɵgetLoadedRoutes'] = () => [];
expect(g['window']).toBe(globalThis);
expect(bootAngular().profiler).toBe(true);
expect('window' in g).toBe(false);
expect((g['ng'] as Record<string, unknown>)['ɵgetLoadedRoutes']).toBeTypeOf('function');
});

it('gates when the router published onto ng before the gate ran', () => {
const ng: Record<string, unknown> = { ɵgetLoadedRoutes: () => [] };
g['ng'] = ng;
gateInjectorProfiler();
expect(g['window']).toBe(globalThis);
expect(bootAngular().profiler).toBe(true);
expect('window' in g).toBe(false);
expect(g['ng']).toBe(ng);
expect(ng['getComponent']).toBeTypeOf('function');
});

it('removes window when ng arrives with the utilities already on it', () => {
gateInjectorProfiler();
const getComponent = () => null;
g['ng'] = { getComponent };
expect('window' in g).toBe(false);
expect((g['ng'] as Record<string, unknown>)['getComponent']).toBe(getComponent);
});

it('does nothing when window already exists', () => {
const window = { own: true };
g['window'] = window;
gateInjectorProfiler();
expect(Object.getOwnPropertyDescriptor(g, 'ng')).toBeUndefined();
bootAngular();
expect(g['window']).toBe(window);
});

it('does nothing once Angular has published ng', () => {
g['ng'] = { getComponent: () => null };
gateInjectorProfiler();
expect('window' in g).toBe(false);
});
});

describe('gateInjectorProfilerInReactNativeDev', () => {
beforeEach(clean);
afterEach(clean);

it('gates in a React Native development build', () => {
g['__DEV__'] = true;
gateInjectorProfilerInReactNativeDev();
expect(bootAngular().profiler).toBe(true);
expect('window' in g).toBe(false);
});

it('leaves a release build and other runtimes alone', () => {
g['__DEV__'] = false;
gateInjectorProfilerInReactNativeDev();
delete g['__DEV__'];
gateInjectorProfilerInReactNativeDev();
expect('window' in g).toBe(false);
expect(bootAngular().profiler).toBe(false);
});
});

describe('the Angular Native overlay', () => {
beforeEach(() => {
clean();
vi.resetModules();
});
afterEach(clean);

it('gates the injector profiler as its module loads, before mount()', async () => {
g['__DEV__'] = true;
await import('../overlay-angular-native.ts');
expect(g['window']).toBe(globalThis);
expect(bootAngular().profiler).toBe(true);
expect('window' in g).toBe(false);
});

it('is a no-op when window exists', async () => {
g['__DEV__'] = true;
const window = { own: true };
g['window'] = window;
await import('../overlay-angular-native.ts');
expect(Object.getOwnPropertyDescriptor(g, 'ng')).toBeUndefined();
expect(g['window']).toBe(window);
});
});
Loading
Loading