diff --git a/app/src/app.ts b/app/src/app.ts
index fcb36f8..052034f 100644
--- a/app/src/app.ts
+++ b/app/src/app.ts
@@ -7,20 +7,11 @@ import { SignalInspector } from './pages/signal-inspector';
import { DiInspector } from './pages/di-inspector';
import { StoreInspector } from './pages/store-inspector';
import { FormsInspector } from './pages/forms-inspector';
+import { PipesInspector } from './pages/pipes-inspector';
+import type { Tab, Tabs } from './types/tab.types';
import { AnalogInspector } from './pages/analog-inspector';
import { NetworkInspector } from './pages/network-inspector';
-type Tab =
- | 'dashboard'
- | 'components'
- | 'routes'
- | 'signals'
- | 'injectors'
- | 'store'
- | 'forms'
- | 'network'
- | 'analog';
-
@Component({
selector: 'app-root',
imports: [
@@ -31,6 +22,7 @@ type Tab =
DiInspector,
StoreInspector,
FormsInspector,
+ PipesInspector,
AnalogInspector,
NetworkInspector,
],
@@ -101,6 +93,9 @@ type Tab =
(focusHandled)="formFocus.set(null)"
/>
}
+ @case ('pipes') {
+
{{ pipeCount() }}
+template transformers
++ Recording live call counts, instance counts and last input/output. This patches pipe + prototypes in the inspected page, so turn it off when you're done. +
+ } + + @if (loading()) { +Scanning pipes…
+ } @else if (filtered().length === 0) { +No pipes found.
+ } @else { ++ experimental + Fed an argument that changed contents without changing reference — this pure + pipe may be showing a stale value. +
+ } +Not seen on the page yet.
+ } +Couldn't run the lint check. Try Refresh.
+ } @else if (lint() === null) { +Checking…
+ } @else if (!lint()!.length) { +No problems found.
+ } @else { +{{ f.rule }}
+
+
+ {{ a() | currency }}
{{ b() | currency }}
`, +})(Receipt); + +// Pure by default (no `pure: false`), takes the array itself — not a +// derived primitive — so an in-place mutation is invisible by reference. +class JoinPipe implements PipeTransform { + transform(value: string[]): string { + return value.join(','); + } +} +Pipe({ name: 'join' })(JoinPipe); + +function harness(pageId = 'pg') { + const calls: { name: string; args: unknown[] }[] = []; + const handlers = new Map{{ shared$ | async }}
{{ shared$ | async }}
{{ solo$ | async }}
`, + })(Feed); + + await mount(Feed); + const h = harness(); + h.collector.push(); + await Promise.resolve(); + + const report = h.reports().at(-1)!; + const asyncUsages = report.async ?? []; + expect(asyncUsages).toHaveLength(3); + expect(asyncUsages.filter((a) => a.duplicate)).toHaveLength(2); + expect(asyncUsages.filter((a) => !a.duplicate)).toHaveLength(1); + for (const usage of asyncUsages) { + expect(usage.hasSource).toBe(true); + expect(usage.component).toBe('Feed'); + } + expect(asyncUsages.some((a) => a.latestValue === 'one')).toBe(true); + expect(asyncUsages.some((a) => a.latestValue === 'solo')).toBe(true); + }); + + it('flags a pure pipe fed a mutated-in-place argument (experimental stale check)', async () => { + class ListView { + items = signal{{ items() | join }}
`, + })(ListView); + + const fixture = await mount(ListView); + const h = harness(); + h.handlers.get('instrument-pipes')!(true); + h.collector.push(); + await Promise.resolve(); + expect( + h + .reports() + .at(-1)! + .pipes.find((p) => p.name === 'join')?.stale, + ).toBeUndefined(); + + fixture.componentInstance.items().push('c'); // same array reference, mutated in place + fixture.componentInstance.cdr.markForCheck(); // force a recheck without changing any binding + fixture.detectChanges(); + await fixture.whenStable(); + h.collector.push(); + await Promise.resolve(); + + const stale = h + .reports() + .at(-1)! + .pipes.find((p) => p.name === 'join')?.stale; + expect(stale).toBeDefined(); + expect(stale?.detectedAt).toBeGreaterThan(0); + }); + + it('does not flag a normal, immutable update as stale', async () => { + class ListView { + items = signal{{ items() | join }}
`, + })(ListView); + + const fixture = await mount(ListView); + const h = harness(); + h.handlers.get('instrument-pipes')!(true); + h.collector.push(); + await Promise.resolve(); + + fixture.componentInstance.items.set([...fixture.componentInstance.items(), 'c']); + fixture.detectChanges(); + await fixture.whenStable(); + h.collector.push(); + await Promise.resolve(); + + expect( + h + .reports() + .at(-1)! + .pipes.find((p) => p.name === 'join')?.stale, + ).toBeUndefined(); + }); + + it('does not run the stale check while not instrumented', async () => { + class ListView { + items = signal{{ items() | join }}
`, + })(ListView); + + const fixture = await mount(ListView); + const h = harness(); + h.collector.push(); + await Promise.resolve(); + + fixture.componentInstance.items().push('c'); + fixture.componentInstance.cdr.markForCheck(); + fixture.detectChanges(); + await fixture.whenStable(); + h.collector.push(); + await Promise.resolve(); + + expect( + h + .reports() + .at(-1)! + .pipes.find((p) => p.name === 'join')?.stale, + ).toBeUndefined(); + }); + + it('does nothing harmful when Angular has no debug API on the page', async () => { + const my = { + rpc: { call: async () => {}, register: () => {} }, + }; + const collector = attachPipes(my, 'pg', () => undefined); + stops.push(collector.stop); + expect(() => collector.push()).not.toThrow(); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/pipes-pages.test.ts b/packages/ng-devtools/src/__tests__/pipes-pages.test.ts new file mode 100644 index 0000000..d1fd928 --- /dev/null +++ b/packages/ng-devtools/src/__tests__/pipes-pages.test.ts @@ -0,0 +1,83 @@ +import { createHostContext } from 'devframe/node'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import ngDevtools from '../devframe.ts'; +import type { PipePageReport } from '../rpc/pipes-tools.ts'; + +async function boot() { + const host = { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: () => '', + }; + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: host as never }); + await ngDevtools.setup(ctx as never); + const invoke = (name: string, ...args: unknown[]) => + ctx.rpc.invokeLocal(`ng-devtools:${name}` as never, ...(args as never)); + const explain = async () => + ( + (await ctx.agent.invoke('ng-devtools:explain-pipe', { name: 'async' })) as { + markdown: string; + } + ).markdown; + return { ctx, invoke, explain }; +} + +function page(pageId: string): PipePageReport { + return { + pageId, + pipes: [ + { + name: 'async', + className: 'AsyncPipe', + isPure: false, + instanceCount: 3, + components: [{ name: 'ExamplePage', count: 3 }], + }, + ], + async: [1, 2, 3].map(() => ({ component: 'ExamplePage', hasSource: true, duplicate: false })), + instrumented: false, + }; +} + +describe('pipe pages across tabs', () => { + afterEach(() => vi.useRealTimers()); + + it('drops a closed tab so reopening does not stack its instances', async () => { + vi.useFakeTimers(); + const { invoke, explain } = await boot(); + + await invoke('push-pipes', page('tab-1')); + expect(await explain()).toContain('3 instance(s)'); + + // tab-1 closes without a `forget`; a new tab opens 20s later. + await vi.advanceTimersByTimeAsync(20_000); + await invoke('push-pipes', page('tab-2')); + expect(await explain()).toContain('3 instance(s)'); + }); +}); + +describe('pipe pages and their connections', () => { + it('drops a page the moment its connection closes', async () => { + const { ctx, invoke, explain } = await boot(); + const host = ctx.rpc as unknown as { + getCurrentRpcSession: () => unknown; + _emitSessionDisconnected: (meta: { id: number }) => void; + }; + + host.getCurrentRpcSession = () => ({ meta: { id: 1 } }); + await invoke('push-pipes', page('tab-1')); + host.getCurrentRpcSession = () => ({ meta: { id: 2 } }); + await invoke('push-pipes', page('tab-2')); + expect(await explain()).toContain('6 instance(s)'); + + host._emitSessionDisconnected({ id: 1 }); + expect(await explain()).toContain('3 instance(s)'); + + // A reconnect re-binds the page to its new connection, so the old + // connection closing afterwards must not drop it. + host.getCurrentRpcSession = () => ({ meta: { id: 3 } }); + await invoke('push-pipes', page('tab-2')); + host._emitSessionDisconnected({ id: 2 }); + expect(await explain()).toContain('3 instance(s)'); + }); +}); diff --git a/packages/ng-devtools/src/__tests__/pipes-runtime.test.ts b/packages/ng-devtools/src/__tests__/pipes-runtime.test.ts new file mode 100644 index 0000000..b244acd --- /dev/null +++ b/packages/ng-devtools/src/__tests__/pipes-runtime.test.ts @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import '@angular/compiler'; +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CurrencyPipe, UpperCasePipe } from '@angular/common'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + findPipeUsages, + instrumentPipes, + pipeSlotsIn, + readBoundArg, + staleCheckFor, +} from '../pipes-runtime.ts'; + +try { + TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +} catch { + // already initialized in this worker +} + +afterEach(() => { + TestBed.resetTestingModule(); + document.body.innerHTML = ''; +}); + +async function mount{{ amount | currency }}
{{ name | uppercase }}
`, +})(Price); + +describe('pipe runtime discovery (real Angular render)', () => { + it('finds the live CurrencyPipe and UpperCasePipe instances used in a rendered template', async () => { + const fixture = await mount(Price); + const usages = findPipeUsages(ng(), document.querySelectorAll('*')); + const names = usages.map((u) => u.name).sort(); + expect(names).toEqual(['currency', 'uppercase']); + + const currency = usages.find((u) => u.name === 'currency')!; + expect(currency.className).toBe('CurrencyPipe'); + expect(currency.isPure).toBe(true); + expect(typeof currency.instance.transform).toBe('function'); + expect(currency.component).toBe(fixture.componentInstance); + }); + + it('reads pipe defs and live instances straight off tView.data/lView at the same index', async () => { + await mount(Price); + const usages = findPipeUsages(ng(), document.querySelectorAll('*')); + const { lView } = usages[0]; + const slots = pipeSlotsIn(lView); + expect(slots.map((s) => s.name).sort()).toEqual(['currency', 'uppercase']); + for (const slot of slots) { + expect(slot.lView[slot.index]).toBe(slot.instance); + } + }); + + it('does not find pipes in an element tree with no Angular views', () => { + document.body.innerHTML = '{{ items().join(',') | uppercase }}
`, +})(Cart); + +describe('stale pure-pipe check (experimental, real Angular render)', () => { + it('recovers a real binding slot and reads the last argument Angular compared', async () => { + const fixture = await mount(Cart); + const usages = findPipeUsages(ng(), document.querySelectorAll('*')); + const upper = usages.find((u) => u.name === 'uppercase')!; + const check = staleCheckFor(upper); + expect(check).not.toBeNull(); + expect(readBoundArg(upper, check!)).toBe('a,b'); + + fixture.componentInstance.items.set(['c', 'd']); + fixture.detectChanges(); + await fixture.whenStable(); + expect(readBoundArg(upper, check!)).toBe('c,d'); + }); + + it('returns null gracefully when the template source does not match the expected shape', () => { + const fakeSlot = { + name: 'uppercase', + isPure: true, + instance: {}, + lView: [null, { template: () => {}, bindingStartIndex: 10 }] as any, + }; + expect(staleCheckFor(fakeSlot)).toBeNull(); + }); + + it('returns null gracefully when the view has no usable tView', () => { + const fakeSlot = { name: 'uppercase', isPure: true, instance: {}, lView: [] as any }; + expect(staleCheckFor(fakeSlot)).toBeNull(); + }); +}); diff --git a/packages/ng-devtools/src/devframe.ts b/packages/ng-devtools/src/devframe.ts index c11a714..4265d92 100644 --- a/packages/ng-devtools/src/devframe.ts +++ b/packages/ng-devtools/src/devframe.ts @@ -2,6 +2,10 @@ import type { RemoteAssets } from 'devframe'; import { defineDevframe } from 'devframe'; import { getRoutes } from './rpc/get-routes.ts'; import { getComponents } from './rpc/get-components.ts'; +import { getPipes } from './rpc/get-pipes.ts'; +import { lintPipes, lintPipesText } from './rpc/pipe-lint.ts'; +import { explainPipeText } from './rpc/pipe-explain.ts'; +import { trackPageSessions } from './rpc/page-sessions.ts'; import { getBuildMeta } from './rpc/build-meta.ts'; import { getSignals } from './rpc/get-signals.ts'; import { getProviders } from './rpc/get-providers.ts'; @@ -36,6 +40,14 @@ import { type WaitUntil, } from './rpc/forms-explain.ts'; import { findFormSource, sourceText } from './rpc/forms-source.ts'; +import { + currentPipes, + expirePipePages, + isPipePageReport, + mergePipePageReport, + type PipePageReport, + type PipesState, +} from './rpc/pipes-tools.ts'; import { currentRouter, expireRouterPages, @@ -98,6 +110,7 @@ const ngDevtools = defineDevframe({ my.rpc.register(getRoutes); my.rpc.register(getComponents); + my.rpc.register(getPipes); my.rpc.register(getSignals); my.rpc.register(getProviders); my.rpc.register(getNgrxStore); @@ -166,6 +179,70 @@ const ngDevtools = defineDevframe({ }, }); + const pipePages = new Map