diff --git a/app/src/__tests__/router-panels.test.ts b/app/src/__tests__/router-panels.test.ts index a482bb69..92b93f64 100644 --- a/app/src/__tests__/router-panels.test.ts +++ b/app/src/__tests__/router-panels.test.ts @@ -718,6 +718,41 @@ describe('RouteCurrent page switch', () => { }); describe('RouteCurrent', () => { + it('tells screen readers how each active route is nested', async () => { + const fixture = mount( + RouteCurrent, + page({ + snapshot: { + ...page().snapshot!, + root: { + path: '', + url: '', + outlet: 'primary', + params: {}, + data: {}, + children: [ + { + path: 'users', + url: 'users', + outlet: 'primary', + params: {}, + data: {}, + children: [ + { path: ':id', url: '7', outlet: 'primary', params: {}, data: {}, children: [] }, + ], + }, + ], + }, + }, + }), + offline, + ); + await settle(fixture); + const cells = Array.from(el(fixture).querySelectorAll('td.path')); + const hidden = cells.map((cell) => cell.querySelector('.visually-hidden')?.textContent ?? ''); + expect(hidden).toEqual(['', ', level 2, child of (root)', ', level 3, child of /users']); + }); + it('reports an unreachable page on Abort instead of #undefined', async () => { const fixture = mount( RouteCurrent, diff --git a/app/src/__tests__/store-types.test.ts b/app/src/__tests__/store-types.test.ts index 1eacbeca..115fd835 100644 --- a/app/src/__tests__/store-types.test.ts +++ b/app/src/__tests__/store-types.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { pretty, short } from '../pages/store-types'; +import { changeCountText, moreChangesText, pretty, short } from '../pages/store-types'; describe('pretty', () => { it('shows every field of an app object that has its own @type key', () => { @@ -20,3 +20,20 @@ describe('pretty', () => { expect(pretty({ '@type': 'number', value: 'NaN' })).toBe('NaN'); }); }); + +describe('change counts', () => { + const row = { path: 'a', op: 'change' as const, before: 0, after: 1 }; + + it('counts the rows a long diff left out', () => { + expect(changeCountText({ diff: [] })).toBe('no change'); + expect(changeCountText({ diff: [row] })).toBe('1 change'); + expect(changeCountText({ diff: [row, row], diffTruncated: 10 })).toBe('12 changes'); + expect(changeCountText({ diff: [row], diffTruncated: 1000 })).toBe('1001+ changes'); + }); + + it('says how many changes are not shown', () => { + expect(moreChangesText(1)).toBe('1 more change not shown'); + expect(moreChangesText(10)).toBe('10 more changes not shown'); + expect(moreChangesText(1000)).toBe('1000+ more changes not shown'); + }); +}); diff --git a/app/src/pages/analog-inspector.ts b/app/src/pages/analog-inspector.ts index a3f57987..84c74cb2 100644 --- a/app/src/pages/analog-inspector.ts +++ b/app/src/pages/analog-inspector.ts @@ -114,6 +114,7 @@ interface Finding { severity: 'error' | 'warning' | 'info'; file?: string; path?: string; + relatedFile?: string; message: string; fix: string; } @@ -354,9 +355,9 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth Server calls {{ allCalls().length }} -
+
Issues - {{ findings().length }} + {{ lintCount().errors + lintCount().warnings }}
@if (page(); as p) {
@@ -1289,6 +1290,9 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth .stat[data-tone='good'] .value { color: var(--ok); } + .stat[data-tone='bad'] .value { + color: var(--danger-text); + } .tabs { display: inline-flex; @@ -1348,6 +1352,11 @@ function walk(routes: AnalogRoute[], depth = 0, out: { route: AnalogRoute; depth background: color-mix(in srgb, var(--warn) 12%, transparent); color: var(--warn-text); } + .count[data-tone='bad'], + [role='tab'][aria-selected='true'] .count[data-tone='bad'] { + background: color-mix(in srgb, var(--danger) 12%, transparent); + color: var(--danger-text); + } .panel { display: grid; @@ -2355,8 +2364,20 @@ export class AnalogInspector { } return Array.from(cards.values()).sort((a, b) => order[a.severity] - order[b.severity]); }); + /** Errors and warnings; info findings are advice, so they are not counted. */ + readonly lintCount = computed(() => { + const findings = this.findings(); + return { + errors: findings.filter((f) => f.severity === 'error').length, + warnings: findings.filter((f) => f.severity === 'warning').length, + }; + }); + readonly lintTone = computed(() => { + const { errors, warnings } = this.lintCount(); + return errors ? 'bad' : warnings ? 'warn' : ''; + }); readonly views = computed(() => { - const errors = this.findings().length; + const { errors, warnings } = this.lintCount(); return [ { id: 'routes' as View, label: 'Routes', count: this.pageCount(), tone: '' }, { id: 'server' as View, label: 'Server', count: this.allCalls().length, tone: '' }, @@ -2367,7 +2388,7 @@ export class AnalogInspector { count: this.project()?.content.length ?? 0, tone: '', }, - { id: 'lint' as View, label: 'Lint', count: errors, tone: errors ? 'warn' : '' }, + { id: 'lint' as View, label: 'Lint', count: errors + warnings, tone: this.lintTone() }, ]; }); @@ -2423,7 +2444,7 @@ export class AnalogInspector { if (!client) return; const run = ++this.refreshRun; const [findings, render] = await Promise.all([ - call(client, 'analog-lint'), + call(client, 'analog-lint', this.hostPageId ?? undefined), call<{ rows: RenderRow[]; plan: PrerenderPlan }>(client, 'analog-render'), ]); if (run !== this.refreshRun) return; @@ -2519,7 +2540,7 @@ export class AnalogInspector { const finding = this.findings().find( (f) => f.rule === 'content-shadows-page' && f.file === file, ); - return finding?.message.match(/(\/\S+\.page\.ts)/)?.[1]; + return finding?.relatedFile; } contentUrl(file: string): string | undefined { diff --git a/app/src/pages/route-current.ts b/app/src/pages/route-current.ts index 2d74ea4f..805c4d39 100644 --- a/app/src/pages/route-current.ts +++ b/app/src/pages/route-current.ts @@ -14,6 +14,9 @@ import type { ComponentFocus } from '../types/component-focus.types'; interface RouteRow { route: ActiveRoute; depth: number; + label: string; + /** What a screen reader hears for the nesting the indent shows, on child rows. */ + nesting?: string; } interface OutletRow { @@ -95,7 +98,10 @@ interface OutletRow { @for (row of rows(); track $index) { - {{ row.depth === 0 && !row.route.path ? '(root)' : '/' + row.route.path }} + {{ row.label }} + @if (row.nesting) { + , {{ row.nesting }} + } @if (row.route.outlet !== 'primary') { {{ row.route.outlet }} } @@ -350,9 +356,11 @@ export class RouteCurrent { readonly rows = computed(() => { const rows: RouteRow[] = []; - const visit = (route: ActiveRoute, depth: number) => { - rows.push({ route, depth }); - for (const child of route.children) visit(child, depth + 1); + const visit = (route: ActiveRoute, depth: number, parent?: string) => { + const label = depth === 0 && !route.path ? '(root)' : '/' + route.path; + const nesting = parent === undefined ? undefined : `level ${depth + 1}, child of ${parent}`; + rows.push({ route, depth, label, nesting }); + for (const child of route.children) visit(child, depth + 1, label); }; const root = this.page().snapshot?.root; if (root) visit(root, 0); diff --git a/app/src/pages/store-inspector.ts b/app/src/pages/store-inspector.ts index 25e7d81b..882a00b2 100644 --- a/app/src/pages/store-inspector.ts +++ b/app/src/pages/store-inspector.ts @@ -20,6 +20,8 @@ import { actionAllowed, actionBlockedMessage, panelConfig } from '../devtools-co import { LimitNote } from '../ui/limit-note'; import { Select, type SelectOption } from '../ui/select'; import { + changeCountText, + moreChangesText, pretty, sameNgrxLog, short, @@ -387,12 +389,7 @@ const CLASSIC_KINDS = new Set([ @if (entry.origin) { {{ entry.origin }} } - {{ - entry.diff.length === 0 - ? 'no change' - : entry.diff.length + - (entry.diff.length === 1 ? ' change' : ' changes') - }} + {{ changesLabel(entry) }} · {{ formatTime(entry.timestamp) }} @if (entry.durationMs !== undefined) { @@ -634,6 +631,9 @@ const CLASSIC_KINDS = new Set([ } + @if (selected.diffTruncated) { +

{{ moreChanges(selected.diffTruncated) }}

+ } } @else {

The state did not change.

} @@ -2110,6 +2110,10 @@ export class StoreInspector { return ORIGIN_TEXT[origin] ?? origin; } + readonly changesLabel = changeCountText; + + readonly moreChanges = moreChangesText; + opLabel(op: string) { return op === 'add' ? 'added' : op === 'remove' ? 'removed' : 'changed'; } diff --git a/app/src/pages/store-types.ts b/app/src/pages/store-types.ts index e8a5413e..120d77f3 100644 --- a/app/src/pages/store-types.ts +++ b/app/src/pages/store-types.ts @@ -103,6 +103,29 @@ export interface NgrxLogEntry { /** `source: 'signal-store'` only: the event that this state change was correlated with. */ causedByEvent?: { type: string; payload?: unknown }; unrestorable?: 'dropped' | 'not-recorded'; + /** + * How many more changes the state went through than `diff` holds (it keeps at most 50). + * The page counts up to {@link DIFF_MORE_CAP}, so at the cap it is a lower bound. + */ + diffTruncated?: number; +} + +/** The most changes the page counts past a full diff (`NGRX_DIFF_MORE_CAP` in the package). */ +export const DIFF_MORE_CAP = 1000; + +/** "12 more changes not shown", or "1000+ more changes not shown" at the cap. */ +export function moreChangesText(count: number): string { + const n = count >= DIFF_MORE_CAP ? `${DIFF_MORE_CAP}+` : String(count); + return `${n} more ${count === 1 ? 'change' : 'changes'} not shown`; +} + +/** The change count of a log entry, counting the rows left out of a long diff. */ +export function changeCountText(entry: Pick): string { + const more = entry.diffTruncated ?? 0; + const total = entry.diff.length + more; + if (total === 0) return 'no change'; + const n = more >= DIFF_MORE_CAP ? `${total}+` : String(total); + return `${n} ${total === 1 ? 'change' : 'changes'}`; } export interface NgrxPage { diff --git a/apps/docs/src/content/agents/resources.md b/apps/docs/src/content/agents/resources.md index c6b8d3c8..71e4fdda 100644 --- a/apps/docs/src/content/agents/resources.md +++ b/apps/docs/src/content/agents/resources.md @@ -58,7 +58,7 @@ The injector hierarchy the page last reported, with the providers at each level. ### ngrx-store -Each `@ngrx/signals` store on the page: state, computed values, methods, and the component fields that reference it. It also holds the `@ngrx/store` state and the change log, with a state diff per entry. The log records method calls, `patchState` writes, dispatched actions and restores. An `@ngrx/store` action has an `origin`: `dispatch`, `effect` or `reactive`. `classic.paused` is `true` while a restore holds `@ngrx/store` on a past state. An `@ngrx/store` entry with `unrestorable` cannot be restored: `dropped` means Store DevTools no longer holds the action (it was dropped past `maxAge`, or the history was committed, reset or imported), and `not-recorded` means Store DevTools never recorded it. `dropped` counts the older log entries removed at [`limits.changeLog`](../getting-started/configuration.md#limits). +Each `@ngrx/signals` store on the page: state, computed values, methods, and the component fields that reference it. It also holds the `@ngrx/store` state and the change log, with a state diff per entry. A diff holds up to 50 changes, and `diffTruncated` counts the ones left out, up to 1000. The log records method calls, `patchState` writes, dispatched actions and restores. An `@ngrx/store` action has an `origin`: `dispatch`, `effect` or `reactive`. `classic.paused` is `true` while a restore holds `@ngrx/store` on a past state. An `@ngrx/store` entry with `unrestorable` cannot be restored: `dropped` means Store DevTools no longer holds the action (it was dropped past `maxAge`, or the history was committed, reset or imported), and `not-recorded` means Store DevTools never recorded it. `dropped` counts the older log entries removed at [`limits.changeLog`](../getting-started/configuration.md#limits). ### forms diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index e24f1f32..345fc244 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -194,7 +194,7 @@ With `storeId`, the full state of one store: state, computed values, `withEntiti ### signal-store-history -The change log for a page's stores, oldest first: `@ngrx/signals` state diffs (method calls and `patchState` writes, each with a per-key diff), classic `@ngrx/store` actions, and `@ngrx/signals/events` dispatched events. A method-call entry carries a duration in milliseconds. With `watchState` registered, it is the time from the start of the method to that patch; without it, the time the whole call took. A signal-store entry carries the event that caused it when the change happened while that event was being dispatched. +The change log for a page's stores, oldest first: `@ngrx/signals` state diffs (method calls and `patchState` writes, each with a per-key diff), classic `@ngrx/store` actions, and `@ngrx/signals/events` dispatched events. A method-call entry carries a duration in milliseconds. With `watchState` registered, it is the time from the start of the method to that patch; without it, the time the whole call took. A signal-store entry carries the event that caused it when the change happened while that event was being dispatched. Each entry lists up to 50 changes; when there were more, it ends with how many were left out, such as `12 more changes not shown`. | Argument | Required | Value | | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -208,11 +208,11 @@ All router tools read the page, except `explain-render-mode`, which also reads y ### Read the current route -| Tool | What it answers | Arguments | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| `inspect-route` | The current route: URL, query params, fragment, title, the navigation in flight, the active route tree (component, params, data, guards, resolvers) and the outlet tree with each outlet's `routerOutletData`. With `selector`, the route a component was rendered for, or whether a link is active. | `selector`: component class, element tag or link text | -| `explain-navigation` | Recent navigations, newest first: who started each one, redirects and loops with the cause of each hop, per-phase timing, guard and resolver verdicts, lazy loads, and the cancel or error reason in plain language. | `url`, `id`, `limit` (1 to 50, default 5), `perf` | -| `export-navigation` | A markdown repro of one navigation, with router options, any loop it is part of and the relevant slice of the route config. Defaults to the latest one that did not succeed. | `id` | +| Tool | What it answers | Arguments | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `inspect-route` | The current route: URL, query params, fragment, title, the navigation in flight, the active route tree (component, params, data, guards, resolvers) and the outlet tree with each outlet's `routerOutletData`. With `selector`, the route a component was rendered for, or whether a link is active. | `selector`: component class, element tag or link text | +| `explain-navigation` | Recent navigations, newest first: who started each one, redirects and loops with the cause of each hop, per-phase timing, guard and resolver verdicts, lazy loads, and the cancel or error reason in plain language. | `url`, `id`, `limit` (1 to `limits.navigations`, default 5), `perf` | +| `export-navigation` | A markdown repro of one navigation, with router options, any loop it is part of and the relevant slice of the route config. Defaults to the latest one that did not succeed. | `id` | Use `explain-navigation` for "why was I redirected". Pass `perf: true` for "why is navigation slow": it lists the slowest navigations and preloads. @@ -335,6 +335,8 @@ When a write goes through a native ` +
+ + + +
+ `, +})(Booking); + +describe('user-mode writes to date and time inputs', () => { + it('writes dates in the input format and refuses values that are not dates', async () => { + const fixture = await render(Booking); + const ctx = contextFor(fixture.nativeElement); + const ids = new Map([...ctx.forms].map(([id, f]) => [f.kind, id])); + const set = (kind: string, path: string, value: unknown) => + runFormAction(ctx, { + action: 'set-value', + formId: ids.get(kind), + path, + value, + mode: 'user', + }); + const app = fixture.componentInstance; + + expect(await set('signal', 'day', new Date(Date.UTC(2026, 2, 14)))).toMatchObject({ + ok: true, + }); + expect(app.model().day?.toISOString()).toBe('2026-03-14T00:00:00.000Z'); + + expect(await set('reactive', 'on', '2026-03-14T09:30:00Z')).toMatchObject({ ok: true }); + expect(app.reactive.controls.on.value).toBe('2026-03-14'); + expect(await set('reactive', 'on', '2026-04-01')).toMatchObject({ ok: true }); + expect(app.reactive.controls.on.value).toBe('2026-04-01'); + + const bad = await set('reactive', 'on', 'next tuesday'); + expect(bad.ok).toBe(false); + expect(bad.error).toContain('not a valid date value'); + expect(app.reactive.controls.on.value).toBe('2026-04-01'); + expect((fixture.nativeElement.querySelector('#on') as HTMLInputElement).value).toBe( + '2026-04-01', + ); + + expect(await set('reactive', 'at', '09:45')).toMatchObject({ ok: true }); + expect(app.reactive.controls.at.value).toBe('09:45'); + + expect(await set('reactive', 'month', Date.UTC(2026, 2, 14))).toMatchObject({ ok: true }); + expect(app.reactive.controls.month.value).toBe('2026-03'); + }); +}); + +describe('form action guards', () => { + it('needs a formId for actions on a form', async () => { + const fixture = await render(Signup); + const ctx = contextFor(fixture.nativeElement); + const result = await runFormAction(ctx, { action: 'touch-all' }); + expect(result).toMatchObject({ ok: false, error: 'touch-all needs a formId.' }); + expect(fixture.componentInstance.form.touched).toBe(false); + }); + + it('does not overwrite a $form the page defined itself, unless forced', async () => { + const fixture = await render(Signup); + const ctx = contextFor(fixture.nativeElement); + const w = window as any; + const own = { mine: true }; + w.$form = own; + try { + const refused = await runFormAction(ctx, { action: 'store-as-global', formId: 'form-1' }); + expect(refused.ok).toBe(false); + expect(refused.error).toContain('already defines $form'); + expect(w.$form).toBe(own); + const forced = await runFormAction(ctx, { + action: 'store-as-global', + formId: 'form-1', + force: true, + }); + expect(forced.ok).toBe(true); + expect(w.$form).toBe(fixture.componentInstance.form); + const again = await runFormAction(ctx, { + action: 'store-as-global', + formId: 'form-1', + path: 'name', + }); + expect(again.ok).toBe(true); + } finally { + delete w.$form; + delete w.$control; + } + }); +}); diff --git a/packages/devtools/src/__tests__/forms-read.test.ts b/packages/devtools/src/__tests__/forms-read.test.ts index 897d8127..4d05d41d 100644 --- a/packages/devtools/src/__tests__/forms-read.test.ts +++ b/packages/devtools/src/__tests__/forms-read.test.ts @@ -129,6 +129,17 @@ describe('Signal Forms internals', () => { node = byKey(serializeField(tree()))['name']; expect(node.changed).toBe(true); }); + + it('reports an edit past the display limits of the value as changed', () => { + const long = 'x'.repeat(300); + const tree = signalForm({ bio: long }); + tree.bio(); + serializeField(tree()); + tree.bio().markAsDirty(); + expect(byKey(serializeField(tree()))['bio'].changed).toBe(false); + tree.bio().value.set(`${long.slice(0, 250)}y${long.slice(251)}`); + expect(byKey(serializeField(tree()))['bio'].changed).toBe(true); + }); }); describe('reactive internals', () => { diff --git a/packages/devtools/src/__tests__/forms-scan.test.ts b/packages/devtools/src/__tests__/forms-scan.test.ts new file mode 100644 index 00000000..c4588906 --- /dev/null +++ b/packages/devtools/src/__tests__/forms-scan.test.ts @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +import '@angular/compiler'; +import { Component, ViewEncapsulation, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { FormField, FormRoot, form } from '@angular/forms/signals'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { attachForms } from '../forms-collector.ts'; +import { createFormIds, pageElements, type FoundForm } from '../forms.ts'; + +try { + TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +} catch { + // already initialized in this worker +} + +const stops: (() => void)[] = []; +afterEach(() => { + stops.splice(0).forEach((stop) => stop()); + TestBed.resetTestingModule(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +class Login { + form = new FormGroup({ email: new FormControl('') }); +} +Component({ + selector: 'login-form', + imports: [ReactiveFormsModule], + template: `
`, +})(Login); + +class Shadowed { + form = form(signal({ city: '' })); +} +Component({ + selector: 'shadowed-form', + imports: [FormField, FormRoot], + encapsulation: ViewEncapsulation.ShadowDom, + template: `
`, +})(Shadowed); + +function harness() { + const calls: { name: string; args: any[] }[] = []; + const handlers = new Map unknown>(); + const my = { + rpc: { + call: async (name: string, ...args: unknown[]) => { + calls.push({ name, args }); + }, + register: (def: { name: string; handler: (...args: any[]) => unknown }) => { + handlers.set(def.name, def.handler); + }, + }, + }; + const highlight = { show: vi.fn(), clear: vi.fn() }; + const collector = attachForms(my, 'pg', () => (globalThis as any).ng, highlight); + stops.push(collector.stop); + const reports = () => calls.filter((c) => c.name === 'push-forms').map((c) => c.args[0]); + return { calls, handlers, collector, reports, highlight }; +} + +const tick = (ms = 120) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function mount(type: new () => T) { + const fixture = TestBed.createComponent(type); + document.body.appendChild(fixture.nativeElement); + fixture.detectChanges(); + await fixture.whenStable(); + return fixture; +} + +const found = (root: object): FoundForm => ({ kind: 'reactive', root }) as FoundForm; + +describe('form ids', () => { + it('gives a recreated form its old id back and keeps ~n for forms shown together', () => { + const assign = createFormIds('pg'); + const label = (roots: object[]) => new Map(roots.map((root) => [root, 'Checkout form'])); + const first = {}; + expect(assign([found(first)], label([first])).get(first)).toBe('Checkout_form@pg'); + assign([], new Map()); + const again = {}; + expect(assign([found(again)], label([again])).get(again)).toBe('Checkout_form@pg'); + const twin = {}; + const ids = assign([found(again), found(twin)], label([again, twin])); + expect(ids.get(again)).toBe('Checkout_form@pg'); + expect(ids.get(twin)).toBe('Checkout_form~2@pg'); + }); +}); + +describe('page elements', () => { + it('walks into open shadow roots', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = '
'; + const roots: ShadowRoot[] = []; + const ids = pageElements(document, (root) => roots.push(root)).map((el) => el.id); + expect(ids).toContain('inside'); + expect(roots).toEqual([shadow]); + }); +}); + +describe('forms collector scanning', () => { + it('finds a form inside a ShadowDom component', async () => { + await mount(Shadowed); + const h = harness(); + h.collector.push(); + await tick(); + const report = h.reports().at(-1); + expect(report.forms.map((f: { owner: string }) => f.owner)).toEqual(['Shadowed']); + }); + + it('does not rescan the whole page for text changes and finds forms added later', async () => { + const h = harness(); + h.collector.push(); + await tick(); + const spy = vi.spyOn(document, 'querySelectorAll'); + const clock = document.createElement('span'); + document.body.appendChild(clock); + await tick(0); + spy.mockClear(); + clock.textContent = '12:00:01'; + await tick(0); + h.collector.push(); + await tick(); + expect(spy).not.toHaveBeenCalledWith('*'); + await mount(Login); + h.collector.push(); + await tick(); + expect(spy).not.toHaveBeenCalledWith('*'); + expect( + h + .reports() + .at(-1) + .forms.map((f: { owner: string }) => f.owner), + ).toEqual(['Login']); + }); + + it('keeps the id of a form that is destroyed and created again', async () => { + const h = harness(); + const fixture = await mount(Login); + h.collector.push(); + await tick(); + const id = h.reports().at(-1).forms[0].id; + fixture.destroy(); + fixture.nativeElement.remove(); + h.collector.push(); + await tick(); + expect(h.reports().at(-1).forms).toEqual([]); + await mount(Login); + h.collector.push(); + await tick(); + expect(h.reports().at(-1).forms[0].id).toBe(id); + }); + + it('answers a form action without formId with an error instead of acting on a form', async () => { + await mount(Login); + const h = harness(); + h.collector.push(); + await tick(); + h.handlers.get('form-action')!({ + requestId: 'r1', + request: { action: 'set-value', path: 'email', value: 'x' }, + }); + await tick(0); + const answer = h.calls.find((c) => c.name === 'form-action-result')?.args[0]; + expect(answer.result).toMatchObject({ ok: false, error: 'set-value needs a formId.' }); + expect((document.getElementById('email') as HTMLInputElement).value).toBe(''); + }); +}); diff --git a/packages/devtools/src/__tests__/forms-webmcp.test.ts b/packages/devtools/src/__tests__/forms-webmcp.test.ts index 43975663..f52b61d7 100644 --- a/packages/devtools/src/__tests__/forms-webmcp.test.ts +++ b/packages/devtools/src/__tests__/forms-webmcp.test.ts @@ -289,3 +289,46 @@ describe('watchWebMcp', () => { }); }); }); + +describe('WebMCP audit fixes', () => { + it('names the field nested past the depth limit', () => { + let deep: Record = { leaf: 'x' }; + for (let i = 0; i < 11; i++) deep = { a: deep }; + const shape = inferShape(deep); + expect(shape.schema).toBeUndefined(); + expect(shape.blocking).toEqual([ + { path: 'a.a.a.a.a.a.a.a.a.a', reason: 'nested deeper than 10 levels' }, + ]); + }); + + it('patches a modelContext added after the overlay loaded', async () => { + const watcher = watch(); + expect(watcher.describe([]).page).toBeUndefined(); + const { tools } = fakeContext(); + expect(watcher.describe([]).page).toMatchObject({ modelContext: true }); + await (navigator as any).modelContext.registerTool({ + name: 'late', + description: 'Registered after load', + inputSchema: { type: 'object', properties: {} }, + execute: async () => ({ content: [] }), + }); + expect(tools.has('late')).toBe(true); + expect(watcher.describe([]).page?.tools.map((t) => t.name)).toEqual(['late']); + }); + + it('counts only the synchronous part of a call as agent activity', async () => { + const { tools } = fakeContext(); + const watcher = watch(); + let release!: () => void; + await (navigator as any).modelContext.registerTool({ + name: 'slow', + description: '', + inputSchema: { type: 'object', properties: {} }, + execute: () => new Promise((resolve) => (release = resolve)), + }); + const pending = tools.get('slow')!.execute({}); + expect(watcher.active()).toBe(false); + release(); + await pending; + }); +}); diff --git a/packages/devtools/src/__tests__/ngrx-collector.test.ts b/packages/devtools/src/__tests__/ngrx-collector.test.ts index baaaaae8..d68c9db2 100644 --- a/packages/devtools/src/__tests__/ngrx-collector.test.ts +++ b/packages/devtools/src/__tests__/ngrx-collector.test.ts @@ -4,7 +4,13 @@ import { describe, expect, it, vi } from 'vitest'; import { domTree } from '../host-tree.ts'; import { createNgrxCollector } from '../ngrx-collector.ts'; import { attachNgrx } from '../ngrx-overlay.ts'; -import { diff, serialize, type NgrxPageReport } from '../ngrx-shared.ts'; +import { + diff, + NGRX_DIFF_MORE_CAP, + referenceDiff, + serialize, + type NgrxPageReport, +} from '../ngrx-shared.ts'; import { mergeNgrxReport, nameStore, ngrxStateOf, type NgrxPages } from '../rpc/ngrx-tools.ts'; const SIGNAL = Symbol('SIGNAL'); @@ -998,6 +1004,33 @@ describe('ngrx collector with a classic store', () => { ]); }); + it('marks a diff cut at 50 rows with the number of changes left out', () => { + const initial = Object.fromEntries(Array.from({ length: 60 }, (_, i) => [`k${i}`, 0])); + const { store, collector } = fakeClassic(initial, (state, action) => + action.type === 'bump' + ? Object.fromEntries(Object.keys(state).map((key) => [key, 1])) + : { ...state, k0: 2 }, + ); + store.dispatch({ type: 'bump' }); + store.dispatch({ type: 'one' }); + const [bump, one] = collector.logSince(0); + expect(bump.diff).toHaveLength(50); + expect(bump.diffTruncated).toBe(10); + expect(one.diff).toHaveLength(1); + expect(one).not.toHaveProperty('diffTruncated'); + }); + + it('reuses the serialized classic state while the store holds the same object', () => { + const { store, collector } = fakeClassic({ n: 0 }, (s, action) => + action.type === 'inc' ? { n: s.n + 1 } : s, + ); + store.dispatch({ type: 'noop' }); + const first = collector.collect().classic!.state; + expect(collector.collect().classic!.state).toBe(first); + store.dispatch({ type: 'inc' }); + expect(collector.collect().classic!.state).toEqual({ n: 1 }); + }); + it('keeps a redacted top-level key out of the logged diff', () => { const { store, collector } = fakeClassic({ apiKey: 'hunter2' }, (state, action) => action.type === 'rotate' ? { apiKey: 'swordfish' } : state, @@ -1284,6 +1317,33 @@ describe('serialize', () => { { path: 'c', op: 'add', after: true }, ]); }); + + it('counts the changes past the limit without keeping them', () => { + const before = Object.fromEntries(Array.from({ length: 60 }, (_, i) => [`k${i}`, 0])); + const after = Object.fromEntries(Array.from({ length: 60 }, (_, i) => [`k${i}`, 1])); + const overflow = { more: 0 }; + expect(diff(before, after, 50, 6, overflow)).toHaveLength(50); + expect(overflow.more).toBe(10); + const raw = { more: 0 }; + expect(referenceDiff(before, after, '', {}, 50, 12, raw)).toHaveLength(50); + expect(raw.more).toBe(10); + }); + + it('stops counting at the cap', () => { + const size = NGRX_DIFF_MORE_CAP + 200; + const before = Array.from({ length: size }, () => 0); + const after = Array.from({ length: size }, () => 1); + const overflow = { more: 0 }; + expect(diff(before, after, 50, 6, overflow)).toHaveLength(50); + expect(overflow.more).toBe(NGRX_DIFF_MORE_CAP); + }); + + it('treats equal objects as unchanged whatever their key order', () => { + expect(diff({ a: [{ x: 1, y: 2 }] }, { a: [{ y: 2, x: 1 }] }, 50, 1)).toEqual([]); + expect(diff({ a: { x: 1 } }, { a: { x: 1, y: 2 } }, 50, 1)).toEqual([ + { path: 'a', op: 'change', before: { x: 1 }, after: { x: 1, y: 2 } }, + ]); + }); }); describe('ngrx tools', () => { diff --git a/packages/devtools/src/__tests__/router-attach.test.ts b/packages/devtools/src/__tests__/router-attach.test.ts index 5befe8ca..6c42a1eb 100644 --- a/packages/devtools/src/__tests__/router-attach.test.ts +++ b/packages/devtools/src/__tests__/router-attach.test.ts @@ -4,6 +4,7 @@ import { PANGULAR_INSPECTORS } from '../config.ts'; import { createScanBackoff } from '../router-attach.ts'; const calls: string[] = []; +const routerReports: { navigations: { id: number; url: string; outcome: string }[] }[] = []; vi.mock('devframe/client', () => ({ connectDevframe: async () => ({ @@ -18,8 +19,9 @@ vi.mock('devframe/client', () => ({ }, scope: () => ({ rpc: { - call: async (name: string) => { + call: async (name: string, arg: unknown) => { calls.push(name); + if (name === 'push-router') routerReports.push(JSON.parse(JSON.stringify(arg))); return undefined; }, register: () => {}, @@ -79,3 +81,114 @@ describe('overlay router lookup without an Angular root', () => { } }); }); + +describe('overlay router lookup after the router goes away', () => { + function fakeRouter() { + const listeners = new Set(); + return { + listeners, + config: [{ path: 'a' }], + navigated: true, + url: '/', + routerState: { snapshot: null }, + disposed: false, + navigateByUrl: () => Promise.resolve(true), + events: { + subscribe(listener: unknown) { + listeners.add(listener); + return { unsubscribe: () => listeners.delete(listener) }; + }, + }, + }; + } + + let current = fakeRouter(); + + beforeEach(() => { + calls.length = 0; + document.body.innerHTML = ''; + (window as { ng?: unknown }).ng = { + getComponent: () => ({}), + getInjector: () => ({}), + ɵgetRouterInstance: () => current, + }; + vi.stubGlobal('BroadcastChannel', undefined); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { status: 404 })), + ); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete (window as { ng?: unknown }).ng; + document.body.innerHTML = ''; + }); + + it('lets go of a disposed router and watches the new one', async () => { + const first = fakeRouter(); + current = first; + const { initOverlay } = await import('../overlay.ts'); + const dispose = await initOverlay(); + try { + await vi.advanceTimersByTimeAsync(2_000); + expect(first.listeners.size).toBeGreaterThan(0); + const second = fakeRouter(); + first.disposed = true; + current = second; + await vi.advanceTimersByTimeAsync(2_000); + expect(first.listeners.size).toBe(0); + expect(second.listeners.size).toBeGreaterThan(0); + } finally { + dispose(); + } + }); + + it('does not fold the new router navigation ids into the old router records', async () => { + const emit = (router: ReturnType, event: Record) => { + for (const listener of router.listeners) (listener as (event: unknown) => void)(event); + }; + const first = fakeRouter(); + current = first; + routerReports.length = 0; + const { initOverlay } = await import('../overlay.ts'); + const dispose = await initOverlay(); + try { + await vi.advanceTimersByTimeAsync(2_000); + emit(first, { type: 0, id: 1, url: '/a' }); + emit(first, { type: 1, id: 1, url: '/a', urlAfterRedirects: '/a' }); + await vi.advanceTimersByTimeAsync(2_000); + const second = fakeRouter(); + first.disposed = true; + current = second; + await vi.advanceTimersByTimeAsync(2_000); + emit(second, { type: 0, id: 1, url: '/b' }); + emit(second, { type: 1, id: 1, url: '/b', urlAfterRedirects: '/b' }); + await vi.advanceTimersByTimeAsync(2_000); + const last = routerReports.at(-1)!; + expect(last.navigations.map(({ id, url, outcome }) => ({ id, url, outcome }))).toEqual([ + { id: 1, url: '/b', outcome: 'succeeded' }, + ]); + } finally { + dispose(); + } + }); + + it('keeps looking, with a back-off, when the root has no router yet', async () => { + current = null as never; + const { initOverlay } = await import('../overlay.ts'); + const dispose = await initOverlay(); + try { + await vi.advanceTimersByTimeAsync(10_000); + const late = fakeRouter(); + current = late; + await vi.advanceTimersByTimeAsync(60_000); + expect(late.listeners.size).toBeGreaterThan(0); + } finally { + dispose(); + } + }); +}); diff --git a/packages/devtools/src/__tests__/router-audit.test.ts b/packages/devtools/src/__tests__/router-audit.test.ts index 5a20036f..3f45b3ad 100644 --- a/packages/devtools/src/__tests__/router-audit.test.ts +++ b/packages/devtools/src/__tests__/router-audit.test.ts @@ -56,6 +56,9 @@ Component({ >Partial Invite + @if (nullOptions) { Never } @@ -128,6 +131,14 @@ describe('router audit fixes on a real Router', () => { expect(matchOptionsOf(null)).toBeNull(); }); + it('finds RouterLinkActive on a parent of the RouterLink', async () => { + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/a/child'); + harness.detectChanges(); + const parent = linksOf(ng(), router as never).find((l) => l.element === 'li'); + expect(parent).toMatchObject({ linkActive: true, activeClasses: ['on'] }); + }); + it.skipIf(!angularAtLeast('22.0.0'))( 'agrees with RouterLinkActive for partial match options and null options', async () => { diff --git a/packages/devtools/src/__tests__/router-mcp.test.ts b/packages/devtools/src/__tests__/router-mcp.test.ts index 24e9a860..666243e0 100644 --- a/packages/devtools/src/__tests__/router-mcp.test.ts +++ b/packages/devtools/src/__tests__/router-mcp.test.ts @@ -227,7 +227,8 @@ describe('router MCP tools', () => { ); expect(text).toContain('redirect from #3'); expect(text).toContain('error `NG04002`: No route matches the URL'); - expect(text).toContain('Instrumentation is on.'); + expect(text).toContain('Instrumentation is on,'); + expect(text).not.toContain('turn it on'); expect(await call('explain-navigation', { id: 4 })).not.toContain('#3 `/admin`'); const perf = await call('explain-navigation', { perf: true }); expect(perf).toContain('#3 `/admin` 12ms, mostly guards (3ms)'); diff --git a/packages/devtools/src/__tests__/router-regressions.test.ts b/packages/devtools/src/__tests__/router-regressions.test.ts new file mode 100644 index 00000000..aa78bebd --- /dev/null +++ b/packages/devtools/src/__tests__/router-regressions.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import { EventType, applyRouterEvent, type NavigationRecord } from '../router.ts'; +import { configSignature, type RouteNode } from '../router-config.ts'; +import { instrument, runAction } from '../router-actions.ts'; +import { lintRoutes } from '../rpc/router-config-tools.ts'; +import { explainNavigationText, type RouterPage, type RouterState } from '../rpc/router-tools.ts'; + +function node(fullPath: string, extra: Partial = {}): RouteNode { + const path = extra.path ?? fullPath.split('/').pop() ?? ''; + return { id: fullPath, path, fullPath, kind: 'component', component: 'C', ...extra }; +} + +function page(extra: Partial = {}): RouterPage { + return { pageId: 'p', snapshot: null, navigations: [], reportedAt: 1, changedAt: 1, ...extra }; +} + +function events() { + const listeners: ((event: Record) => void)[] = []; + return { + subscribe(listener: (event: Record) => void) { + listeners.push(listener); + return { unsubscribe: () => listeners.splice(listeners.indexOf(listener), 1) }; + }, + }; +} + +describe('router config signature', () => { + it('changes when a route is edited in place with the same route count', () => { + const routes: Record[] = [{ path: 'a' }, { path: 'b', canActivate: [] }]; + const router = { config: routes }; + const before = configSignature(router); + routes[0]['path'] = 'renamed'; + const renamed = configSignature(router); + expect(renamed).not.toBe(before); + (routes[1]['canActivate'] as unknown[]).push(() => true); + expect(configSignature(router)).not.toBe(renamed); + expect(configSignature(router)).toBe(configSignature(router)); + }); + + it('changes when a guard is swapped for another at the same index, but not when it is instrumented', () => { + const first = () => true; + const routes: Record[] = [{ path: 'a', canActivate: [first] }]; + const router = { config: routes, events: events() }; + const before = configSignature(router); + const stop = instrument(router, []); + expect((routes[0]['canActivate'] as unknown[])[0]).not.toBe(first); + expect(configSignature(router)).toBe(before); + stop(); + (routes[0]['canActivate'] as unknown[])[0] = () => false; + expect(configSignature(router)).not.toBe(before); + }); +}); + +describe('router lint', () => { + it('flags a param route that shadows a literal, with or without children', () => { + const rules = (config: RouteNode[]) => + lintRoutes(page({ config })).filter((f) => f.rule === 'param-shadows-literal'); + // Angular matches '/new' as ':id' with no child activated; it does not backtrack. + const withChildren = node('/:id', { + kind: 'children', + component: undefined, + children: [node('/:id/edit', { path: 'edit' })], + }); + expect(rules([withChildren, node('/new')])).toHaveLength(1); + const withEmptyChild = { + ...withChildren, + children: [node('/:id', { path: '' }), node('/:id/edit', { path: 'edit' })], + }; + expect(rules([withEmptyChild, node('/new')])).toHaveLength(1); + expect(rules([node('/:id'), node('/new')])).toHaveLength(1); + }); + + it('flags unloaded lazy param routes, loadComponent and loadChildren alike', () => { + const rules = (config: RouteNode[]) => + lintRoutes(page({ config })).filter((f) => f.rule === 'param-shadows-literal'); + const lazyComponent = node('/:id', { lazy: 'unloaded', component: undefined }); + expect(rules([lazyComponent, node('/new')])).toHaveLength(1); + const lazyChildren = node('/:id', { kind: 'lazy', lazy: 'unloaded', component: undefined }); + expect(rules([lazyChildren, node('/new')])).toHaveLength(1); + }); + + it('says Angular stays silent about an empty-path prefix redirect', () => { + const config = [ + node('/', { path: '', kind: 'redirect', redirectTo: 'home', pathMatch: 'prefix' }), + node('/home'), + ]; + const finding = lintRoutes(page({ config })).find((f) => f.rule === 'empty-redirect-prefix'); + expect(finding).toMatchObject({ angular: 'silent' }); + expect(finding?.message).toContain("pathMatch 'prefix'"); + }); +}); + +describe('router events', () => { + it('takes a skipped navigation trigger from the current navigation', () => { + const navigations: NavigationRecord[] = []; + const router = { + navigationTransitions: { currentNavigation: () => ({ id: 4, trigger: 'popstate' }) }, + }; + applyRouterEvent( + navigations, + { type: EventType.NavigationSkipped, id: 4, url: '/x' }, + 1, + router, + ); + applyRouterEvent( + navigations, + { type: EventType.NavigationSkipped, id: 5, url: '/y' }, + 2, + router, + ); + expect(navigations.map((nav) => nav.trigger)).toEqual(['popstate', 'unknown']); + }); + + it('reports the title set after NavigationEnd', async () => { + const navigations: NavigationRecord[] = []; + const router = { url: '/', routerState: { snapshot: null } }; + let late = 0; + applyRouterEvent(navigations, { type: EventType.NavigationStart, id: 1, url: '/a' }, 0, router); + document.title = 'Before'; + applyRouterEvent( + navigations, + { type: EventType.NavigationEnd, id: 1, urlAfterRedirects: '/a' }, + 1, + router, + () => late++, + ); + document.title = 'Page A'; + await Promise.resolve(); + expect(navigations[0].title).toBe('Page A'); + expect(late).toBe(1); + }); +}); + +describe('router probe', () => { + it('answers at once when navigateByUrl throws', async () => { + const router = { + navigationTransitions: { events: events() }, + currentNavigation: () => null, + navigationId: 1, + navigateByUrl: () => { + throw new Error('bad url'); + }, + }; + const started = Date.now(); + const result = await runAction(router, [], { action: 'probe', url: '/x' }, () => {}); + expect(Date.now() - started).toBeLessThan(1000); + expect(result).toEqual({ error: expect.stringContaining('bad url') }); + }); +}); + +describe('explain-navigation', () => { + const navs: NavigationRecord[] = Array.from({ length: 80 }, (_, i) => ({ + id: i + 1, + url: `/n${i + 1}`, + trigger: 'imperative', + startedAt: i + 1, + outcome: 'succeeded', + })); + + it('allows up to the configured navigation limit', () => { + const state: RouterState = { pages: [page({ navigations: navs })] } as RouterState; + const text = explainNavigationText(state, { limit: 80 }, 1, 200); + expect(text).toContain('Most recent 80 of 80'); + expect(explainNavigationText(state, { limit: 80 }, 1)).toContain('Most recent 50 of 80'); + }); + + it('only asks to turn instrumentation on when it is off', () => { + const off = explainNavigationText({ pages: [page({ navigations: navs })] } as RouterState, {}); + expect(off).toContain('Instrumentation is off'); + const on = explainNavigationText( + { pages: [page({ navigations: navs, instrumented: true })] } as RouterState, + {}, + ); + expect(on).not.toContain('turn it on'); + }); +}); diff --git a/packages/devtools/src/analog-redact.ts b/packages/devtools/src/analog-redact.ts index 0507239c..ec0ba45e 100644 --- a/packages/devtools/src/analog-redact.ts +++ b/packages/devtools/src/analog-redact.ts @@ -3,21 +3,50 @@ import { redactPreview } from './http-redact.ts'; import { redactText, redactUrl } from './router.ts'; import { clip } from './text.ts'; +/** Longest URL, `load()` preview and file path kept from a page report. */ +const MAX_URL = 2000; +const MAX_PREVIEW = 1001; +const MAX_PATH = 500; +const MAX_ERROR = 300; + +/** A JWT cut before its signature, which the JWT pattern no longer matches. */ +const PARTIAL_JWT = /eyJ[\w-]*(?:\.[\w-]*)?$/; + +/** + * Clips `value` to twice `max` before redacting it, so an oversized field + * costs little to redact, and to `max` after. A JWT that the first cut splits + * is dropped, so its header and claims cannot slip past the redaction. + */ +function capped(value: string, max: number, redact: (text: string) => string = (t) => t) { + const head = + value.length > max * 2 ? `${value.slice(0, max * 2).replace(PARTIAL_JWT, '')}…` : value; + return clip(redact(head), max); +} + /** * Hides secret query values, JWTs, bearer tokens and `redaction.secretNames` - * in the text fields of a page report before it is stored or served. + * in the text fields of a page report before it is stored or served, and + * clips each text field. */ export function redactAnalogReport(report: AnalogRuntimeReport): AnalogRuntimeReport { + const path = (value: string) => capped(value, MAX_PATH); return { ...report, - url: redactUrl(report.url), + url: capped(report.url, MAX_URL, redactUrl), + chain: report.chain.map((item) => ({ + ...item, + path: path(item.path), + ...(item.file !== undefined && { file: path(item.file) }), + ...(item.serverFile !== undefined && { serverFile: path(item.serverFile) }), + })), ...(report.load && { load: { ...report.load, - preview: redactPreview(report.load.preview), - keys: report.load.keys.map((key) => redactUrl(key)), + preview: capped(report.load.preview, MAX_PREVIEW, redactPreview), + keys: report.load.keys.map((key) => capped(key, MAX_PATH, redactUrl)), }, }), - hydrationErrors: report.hydrationErrors.map((error) => clip(redactText(error), 300)), + hydrationErrors: report.hydrationErrors.map((error) => capped(error, MAX_ERROR, redactText)), + configPaths: report.configPaths.map(path), }; } diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts index 49f25aa1..b2c7ac15 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -387,6 +387,14 @@ export function pickPangularConfig( return { config: { inspectors, agent, actions, redaction, limits }, rest }; } +/** Form actions that work without a `formId`: they act on the page, not on one form. */ +export const FORMLESS_FORM_ACTIONS: readonly string[] = [ + 'locate', + 'pick', + 'cancel-pick', + 'instrument', +]; + /** Form actions that change the form or its validity, as opposed to reading or focusing it. */ export const FORM_WRITE_ACTIONS: readonly string[] = [ 'set-value', @@ -492,6 +500,15 @@ export const RPC_INSPECTOR: Record = { 'clear-http-calls': 'http', 'push-capacitor': 'capacitor', 'forget-capacitor-page': 'capacitor', + 'push-analog': 'analog', + 'forget-analog-page': 'analog', + 'analog-clear-calls': 'analog', + 'analog-project': 'analog', + 'analog-explain-url': 'analog', + 'analog-lint': 'analog', + 'analog-render': 'analog', + 'analog-text': 'analog', + 'analog-call-api': 'analog', }; /** diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 9a2300f4..7a793caa 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -175,7 +175,7 @@ import { } from './rpc/cd-tools.ts'; import type { HttpPage, HttpPayloadState, HttpReport, HttpState } from './types.ts'; -import { nameServerFns, registerAnalog } from './rpc/analog-register.ts'; +import { analogPages, nameServerFns, registerAnalog } from './rpc/analog-register.ts'; import { createPageVisibility } from './rpc/page-ttl.ts'; import { PAGE_ARGUMENT, @@ -191,6 +191,7 @@ import { clip } from './text.ts'; import { setRedaction } from './forms-privacy.ts'; import { FORM_WRITE_ACTIONS, + FORMLESS_FORM_ACTIONS, PANGULAR_CONFIG_KEY, ROUTER_WRITE_ACTIONS, actionBlockedMessage, @@ -1217,13 +1218,23 @@ const pangular = defineDevframe({ const pendingFormActions = new Map void>(); let formActionSeq = 0; - const requestFormAction = (request: Record, timeoutMs = 15_000) => - !config.actions.forms && FORM_WRITE_ACTIONS.includes(request['action'] as string) - ? Promise.resolve>({ - ok: false, - error: actionBlockedMessage('forms'), - }) - : sendFormAction(request, timeoutMs); + const requestFormAction = (request: Record, timeoutMs = 15_000) => { + const action = request['action'] as string; + if (!config.actions.forms && FORM_WRITE_ACTIONS.includes(action)) { + return Promise.resolve>({ + ok: false, + error: actionBlockedMessage('forms'), + }); + } + const formId = request['formId']; + if (!FORMLESS_FORM_ACTIONS.includes(action) && (typeof formId !== 'string' || !formId)) { + return Promise.resolve>({ + ok: false, + error: `${String(action)} needs a formId.`, + }); + } + return sendFormAction(request, timeoutMs); + }; const sendFormAction = (request: Record, timeoutMs: number) => new Promise>((resolve) => { @@ -2269,8 +2280,8 @@ const pangular = defineDevframe({ limit: { type: 'integer', minimum: 1, - maximum: 50, - description: 'How many navigations to return (default 5, at most 50).', + maximum: limits.navigations, + description: `How many navigations to return (default 5, at most ${limits.navigations}).`, }, perf: { type: 'boolean', @@ -2287,7 +2298,7 @@ const pangular = defineDevframe({ }) => { const state = routerState.value() as RouterState; if (!state.pages.length) return { markdown: noRouter }; - return { markdown: explainNavigationText(state, args) }; + return { markdown: explainNavigationText(state, args, Date.now(), limits.navigations) }; }, }); @@ -2915,7 +2926,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:form-action', description: - 'Act on a live form (dev mode). Actions: set-value (mode code or user; user goes through the input like typing), mark-touched, mark-untouched, mark-dirty, mark-pristine, touch-all, revalidate (Signal Forms: reloads async/HTTP validation), reset, enable, disable (reactive only), submit, focus, focus-first-invalid, store-as-global ($form in the page console), snapshot, restore, instrument (value true or false: record the calling code of form changes, validator changes and template updates per keystroke, shown by form-history). reset, submit and restore need confirm: true. Secret, hidden and readonly fields are never written; disabled reactive fields need force.', + 'Act on a live form (dev mode). Actions: set-value (mode code or user; user goes through the input like typing), mark-touched, mark-untouched, mark-dirty, mark-pristine, touch-all, revalidate (Signal Forms: reloads async/HTTP validation), reset, enable, disable (reactive only), submit, focus, focus-first-invalid, store-as-global ($form in the page console; needs force to replace a $form or $control the page set itself), snapshot, restore, instrument (value true or false: record the calling code of form changes, validator changes and template updates per keystroke, shown by form-history). reset, submit and restore need confirm: true. Secret, hidden and readonly fields are never written; disabled reactive fields need force.', safety: 'action', inputSchema: { type: 'object', @@ -3043,6 +3054,7 @@ const pangular = defineDevframe({ pipes: withUrl(pipePages.values()), http: withUrl(httpPages.values()), capacitor: withUrl(capacitorPages.values()), + analog: withUrl(on.analog ? analogPages() : []), }, visibility.list(), ); diff --git a/packages/devtools/src/forms-actions.ts b/packages/devtools/src/forms-actions.ts index c5e7a338..2383206a 100644 --- a/packages/devtools/src/forms-actions.ts +++ b/packages/devtools/src/forms-actions.ts @@ -18,6 +18,7 @@ import { import { childPath, splitPath } from './forms-path.ts'; import { fieldPath, submitSetup } from './forms-read.ts'; import { clip } from './text.ts'; +import { FORMLESS_FORM_ACTIONS } from './config.ts'; type AnyRecord = Record; @@ -102,6 +103,9 @@ const snapshots = new Map< const MAX_GUARDED_NODES = 5000; +/** What `store-as-global` last wrote, so it only overwrites globals it set itself. */ +const storedGlobals = new Map(); + export function secretInside( value: unknown, prefix = '', @@ -404,7 +408,45 @@ function dispatchPick(element: Element): void { element.dispatchEvent(new Event('change', { bubbles: true })); } -function nativeWrite(element: Element, value: unknown): boolean { +const DATE_INPUTS = new Set(['date', 'datetime-local', 'time', 'month', 'week']); + +/** + * Writes a date or time input in its wire format. A string already in that format is + * written as is. A `Date`, a timestamp or another date string goes through `valueAsDate` + * (or `valueAsNumber` for `datetime-local`), in UTC like Signal Forms does. Returns a + * problem, and leaves the input as it was, when the value is not a valid date. + */ +function writeDateInput(element: HTMLInputElement, value: unknown): string | null { + const previous = element.value; + if (value == null || value === '') { + element.value = ''; + return null; + } + let date: Date | null = null; + if (typeof value === 'string') { + element.value = value; + if (element.value) return null; + const time = Date.parse(value); + date = Number.isNaN(time) ? null : new Date(time); + } else if (value instanceof Date) date = value; + else if (typeof value === 'number') date = new Date(value); + if (date !== null && !Number.isNaN(Number(date))) { + read(() => { + // valueAsNumber counts months for a month input, so only datetime-local takes it. + if (element.type === 'datetime-local') element.valueAsNumber = Number(date); + else element.valueAsDate = date; + }, undefined); + if (element.value) return null; + } + element.value = previous; + return `is a ${element.type} input and ${label(value)} is not a valid ${element.type} value`; +} + +/** + * Writes a native input or textarea and fires its events. Returns false when the element + * can't be written this way, or a problem when the value doesn't fit the input. + */ +function nativeWrite(element: Element, value: unknown): boolean | string { if (element instanceof HTMLInputElement) { if (element.type === 'file') return false; if (element.type === 'checkbox') { @@ -424,7 +466,10 @@ function nativeWrite(element: Element, value: unknown): boolean { dispatchPick(target); return true; } - element.value = value == null ? '' : String(value); + if (DATE_INPUTS.has(element.type)) { + const problem = writeDateInput(element, value); + if (problem) return problem; + } else element.value = value == null ? '' : String(value); element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true })); return true; @@ -545,7 +590,9 @@ function writeValue( ? null : `holds ${label(stored)} after the write`; } - if (viaDom && nativeWrite(element, value)) { + const written = viaDom ? nativeWrite(element, value) : false; + if (typeof written === 'string') return written; + if (viaDom && written) { if (mode === 'user') element.dispatchEvent(new Event('blur')); const picked = element instanceof HTMLInputElement && @@ -688,7 +735,10 @@ export async function runFormAction( if (!request.selector) return fail('Pass a CSS selector.'); return locate(ctx, request.selector); } - const found = request.formId ? ctx.forms.get(request.formId) : ctx.forms.values().next().value; + if (!request.formId && !FORMLESS_FORM_ACTIONS.includes(request.action)) { + return fail(`${request.action} needs a formId.`); + } + const found = request.formId ? ctx.forms.get(request.formId) : undefined; if (!found) return fail(`No form ${request.formId ?? ''} on this page.`); if (CONFIRM_ACTIONS.includes(request.action) || (request.action === 'fill' && request.submit)) { if (request.confirm !== true) { @@ -896,8 +946,18 @@ async function perform( case 'store-as-global': { const target = signal ? read(() => found.root['fieldTree'], found.root) : found.root; const w = window as unknown as AnyRecord; + const taken = (['$form', '$control'] as const).find( + (name) => w[name] !== undefined && w[name] !== storedGlobals.get(name), + ); + if (taken && !request.force) { + return fail( + `The page already defines ${taken}, so it was not overwritten. Call again with force: true to replace it.`, + ); + } w['$form'] = target; w['$control'] = signal ? read(() => nodeAt(found, path, true)?.['fieldTree'], null) : node(); + storedGlobals.set('$form', w['$form']); + storedGlobals.set('$control', w['$control']); const expression = expressionFor(found, path); return { ok: true, diff --git a/packages/devtools/src/forms-collector.ts b/packages/devtools/src/forms-collector.ts index af5faef9..fd53b074 100644 --- a/packages/devtools/src/forms-collector.ts +++ b/packages/devtools/src/forms-collector.ts @@ -5,6 +5,7 @@ import { diffForms, findFieldElement, findForms, + pageElements, createFormIds, formLabels, propertyHolding, @@ -37,6 +38,7 @@ import { import { redactMessage } from './forms-privacy.ts'; import { fieldPath, probeEveryTime } from './forms-read.ts'; import { watchWebMcp } from './forms-webmcp.ts'; +import { FORMLESS_FORM_ACTIONS } from './config.ts'; type AnyRecord = Record; @@ -123,16 +125,40 @@ export function attachForms( const assignIds = createFormIds(pageId); let ids = new WeakMap(); const idOf = (root: object) => ids.get(root) ?? `form@${pageId}`; + // Directive hosts from the last scan. Elements added since then are scanned on the next + // push; a full rescan runs at most once per heartbeat, as a safety net. let hosts: Element[] | null = null; - let hostsStale = true; + const added = new Set(); + let lastFullScan = 0; + const observed = new WeakSet(); const observer = typeof MutationObserver === 'function' - ? new MutationObserver(() => { - hostsStale = true; + ? new MutationObserver((records) => { + for (const record of records) { + for (const node of Array.from(record.addedNodes)) { + if (node.nodeType === 1) added.add(node as Element); + } + } }) : null; - observer?.observe(document.documentElement, { childList: true, subtree: true }); + const observe = (target: Node) => { + if (!observer || observed.has(target)) return; + observed.add(target); + observer.observe(target, { childList: true, subtree: true }); + }; + observe(document.documentElement); + const scanAll = () => pageElements(document, observe); + /** The elements worth scanning: the cached hosts plus what was added since the last scan. */ + const knownElements = (): Element[] => { + if (!hosts || !observer) return scanAll(); + const out = new Set(hosts.filter((el) => el.isConnected)); + for (const el of added) { + if (el.isConnected) for (const inner of pageElements(el, observe)) out.add(inner); + } + return [...out]; + }; let lastForms: CollectedForm[] = []; + let lastRoots = new Map(); let lastPayload = ''; let lastPushAt = 0; const formEvents: FormEvent[] = []; @@ -237,9 +263,20 @@ export function attachForms( } function watchRoots(found: FoundForm[]) { - const live = new Set(); + const live = new Set(found.map((form) => form.root)); + // Forget the forms that are gone first: a recreated form can take over their id. + for (const [root, { formId, stop }] of watched) { + if (live.has(root)) continue; + stop(); + watched.delete(root); + forgetKeys(formId); + } + for (const [root, { formId }] of signalRoots) { + if (live.has(root)) continue; + unwrapSignalRoot(root); + forgetKeys(formId); + } for (const form of found) { - live.add(form.root); if (form.kind === 'signal') { wrapSignalSubmit(form); continue; @@ -270,17 +307,6 @@ export function attachForms( watched.set(form.root, { formId, stop }); seedStatuses(formId, serializeControl(form.root, fieldElements)); } - for (const [root, { formId, stop }] of watched) { - if (live.has(root)) continue; - stop(); - watched.delete(root); - forgetKeys(formId); - } - for (const [root, { formId }] of signalRoots) { - if (live.has(root)) continue; - unwrapSignalRoot(root); - forgetKeys(formId); - } } function wrapSignalSubmit(form: FoundForm) { @@ -366,26 +392,27 @@ export function attachForms( try { const ng = getNg(); if (!ng?.getDirectives) return; - const rescan = hostsStale || !hosts || !observer; - const found = findForms( - ng, - rescan ? document.querySelectorAll('*') : hosts!.filter((el) => el.isConnected), - ); - if (rescan) { - hosts = found.hosts ?? null; - hostsStale = false; - } + const now = Date.now(); + const full = !hosts || !observer || (added.size > 0 && now - lastFullScan >= HEARTBEAT_MS); + const found = findForms(ng, full ? scanAll() : knownElements()); + if (full) lastFullScan = now; + added.clear(); + hosts = found.hosts ?? null; const labels = formLabels(found.forms); ids = assignIds(found.forms, labels); foundById = new Map(found.forms.map((form) => [idOf(form.root), form])); fieldElements = found.elements; watchRoots(found.forms); const forms = collectForms(found, (root) => idOf(root)); - const now = Date.now(); for (const form of forms) stampPending(form.id, form.root, now); const streamed = new Set(Array.from(watched.values(), ({ formId }) => formId)); const agentSincePush = webMcp.takeActivity(); - for (const event of diffForms(lastForms, forms)) { + // A form recreated under the same id starts over instead of diffing against the old one. + const comparable = lastForms.filter( + (form) => lastRoots.get(form.id) === foundById.get(form.id)?.root, + ); + lastRoots = new Map(found.forms.map((form) => [idOf(form.root), form.root])); + for (const event of diffForms(comparable, forms)) { if (!streamed.has(event.formId) || DIFFED_FOR_ALL.includes(event.type)) { const caller = signalCallers.get(event.formId); const origin: EventOrigin | undefined = devtoolsSincePush @@ -540,8 +567,11 @@ export function attachForms( cancelPick = null; resolve(result); }; + // The first node of the composed path, so fields inside open shadow roots can be picked. + const targetOf = (event: Event) => event.composedPath?.()[0] ?? event.target; const onHover = (event: Event) => { - if (event.target instanceof HTMLElement) highlight.show(event.target); + const target = targetOf(event); + if (target instanceof HTMLElement) highlight.show(target); }; const onLeave = (event: MouseEvent) => { if (!event.relatedTarget) highlight.clear(); @@ -549,7 +579,8 @@ export function attachForms( const onClick = (event: Event) => { event.preventDefault(); event.stopPropagation(); - const target = event.target instanceof Element ? event.target : null; + const picked = targetOf(event); + const target = picked instanceof Element ? picked : null; const field = target?.closest('input, select, textarea, [formcontrolname], [ngmodel]') ?? target; finish( @@ -647,13 +678,7 @@ export function attachForms( const found = foundById.get(target.formId); if (!found) return; try { - const el = findFieldElement( - ng, - document.querySelectorAll('*'), - found, - target.path, - fieldElements, - ); + const el = findFieldElement(ng, knownElements(), found, target.path, fieldElements); if (el instanceof HTMLElement) highlight.show(el); } catch { return; @@ -682,7 +707,9 @@ export function attachForms( return respond({ ok: false, error: 'Unknown form action.' }); const request = message.request; if (request.formId && !foundById.has(request.formId) && request.action !== 'locate') return; - if (!request.formId && !foundById.size && request.action !== 'locate') return; + if (!request.formId && !FORMLESS_FORM_ACTIONS.includes(request.action)) { + return respond({ ok: false, error: `${request.action} needs a formId.` }); + } if (request.action === 'locate') { let el: Element | null = null; try { @@ -696,7 +723,7 @@ export function attachForms( ng, forms: foundById, elements: fieldElements, - all: () => document.querySelectorAll('*'), + all: knownElements, }; if (request.action === 'instrument') return respond(setInstrumented(request.value !== false)); if (request.action === 'pick') { diff --git a/packages/devtools/src/forms-webmcp.ts b/packages/devtools/src/forms-webmcp.ts index 78d985b5..421fe3f5 100644 --- a/packages/devtools/src/forms-webmcp.ts +++ b/packages/devtools/src/forms-webmcp.ts @@ -93,9 +93,12 @@ export function inferShape(value: unknown, path = '', depth = 0): Shape { const at = path || '(form)'; if (value === null) return { blocking: [{ path: at, reason: 'null' }] }; if (value === undefined) return { blocking: [{ path: at, reason: 'undefined' }] }; - if (depth >= MAX_DEPTH) return { blocking: [] }; + if (depth >= MAX_DEPTH) { + return { blocking: [{ path: at, reason: `nested deeper than ${MAX_DEPTH} levels` }] }; + } if (Array.isArray(value)) { if (!value.length) return { blocking: [{ path: at, reason: 'empty array' }] }; + // Angular infers an array from its first item only, so the others are not checked. const item = inferShape(value[0], `${path}[]`, depth + 1); return { schema: item.schema && { type: 'array', items: item.schema }, @@ -236,7 +239,7 @@ export function watchWebMcp(onChange: () => void): WebMcpWatcher { let activity = false; let provided: boolean | undefined; let providerCheckedAt = 0; - const context = modelContextOf(); + let context: AnyRecord | null = null; let restore: (() => void) | null = null; const add = (record: ToolRecord) => { @@ -295,7 +298,8 @@ export function watchWebMcp(onChange: () => void): WebMcpWatcher { }; } - if (context) { + // Patches `registerTool` and lists the tools already there, once a `modelContext` exists. + function attach(context: AnyRecord) { const original = context['registerTool'] as (...args: unknown[]) => unknown; const own = Object.prototype.hasOwnProperty.call(context, 'registerTool'); const patched = function (this: unknown, tool: AnyRecord, options?: AnyRecord) { @@ -366,6 +370,15 @@ export function watchWebMcp(onChange: () => void): WebMcpWatcher { } } + // A polyfill or extension can add `modelContext` after the overlay loads, so `describe` + // looks again until one shows up. + const lookUp = () => { + if (context) return; + context = modelContextOf(); + if (context) attach(context); + }; + lookUp(); + const onRejection = (event: PromiseRejectionEvent) => { const match = read( () => String(event.reason?.message ?? event.reason).match(SCHEMA_ERROR), @@ -435,7 +448,9 @@ export function watchWebMcp(onChange: () => void): WebMcpWatcher { } return { - active: () => running > 0, + // Only the synchronous part of `execute`: user typing or app code that runs while a + // long async submit is pending is not the agent's. + active: () => current !== null, takeActivity() { const was = activity; activity = running > 0; @@ -446,6 +461,7 @@ export function watchWebMcp(onChange: () => void): WebMcpWatcher { }, describe(forms, ng) { const out = new Map(); + lookUp(); if (!context) checkProvided(forms, ng); if (!records.length) { return { diff --git a/packages/devtools/src/forms.ts b/packages/devtools/src/forms.ts index ac1557df..2240acb5 100644 --- a/packages/devtools/src/forms.ts +++ b/packages/devtools/src/forms.ts @@ -166,6 +166,7 @@ const MAX_REDACT_WINDOW = 65536; const MAX_VALUE_ITEMS = 20; const MAX_VALUE_DEPTH = 3; const MAX_DETAIL = 120; +const MAX_SHADOW_DEPTH = 32; type AnyRecord = Record; @@ -211,8 +212,53 @@ function uidOf(target: object): string { return uid; } +const MAX_HASH_NODES = 10_000; + +/** + * A hash of the whole value, unclipped, so an edit past the display limits of + * `serializeFormValue` still counts as a change. Stops after `MAX_HASH_NODES` nodes. + */ +export function valueHash(value: unknown): string { + let hash = 0x811c9dc5; + let nodes = 0; + const mix = (text: string) => { + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + }; + const seen = new WeakSet(); + const visit = (item: unknown) => { + if (++nodes > MAX_HASH_NODES) return; + if (item === null || typeof item !== 'object') { + mix(`${typeof item}:${typeof item === 'function' ? 'ƒ' : String(item)};`); + return; + } + if (item instanceof Date) return mix(`date:${item.getTime()};`); + if (typeof Node !== 'undefined' && item instanceof Node) return mix(`node:${item.nodeName};`); + if (seen.has(item)) return mix('cycle;'); + seen.add(item); + const entries: [unknown, unknown][] = + item instanceof Map + ? Array.from(item.entries()) + : item instanceof Set + ? Array.from(item, (entry, index) => [index, entry]) + : Object.keys(item) + .filter((key) => !key.startsWith('__ng')) + .map((key) => [key, read(() => (item as AnyRecord)[key], undefined)]); + mix(Array.isArray(item) ? `[${entries.length}` : `{${entries.length}`); + for (const [key, child] of entries) { + mix(`${String(key)}=`); + visit(child); + } + mix(Array.isArray(item) ? ']' : '}'); + }; + visit(value); + return (hash >>> 0).toString(36); +} + function changedOf(target: object, value: unknown, dirty: boolean): boolean | undefined { - const text = JSON.stringify(serializeFormValue(value)) ?? 'undefined'; + const text = valueHash(value); if (!dirty || !baselines.has(target)) { baselines.set(target, text); return undefined; @@ -772,17 +818,28 @@ export function formIdFor(label: string): string { return id || 'form'; } +/** + * Gives each form root a stable id for as long as it is on the page. Ids of roots that are + * gone are released, so a form recreated by a route round trip gets its old id back, and + * `~n` only tells apart forms that are on the page at the same time. + */ export function createFormIds(pageId: string) { const assigned = new WeakMap(); - const used = new Set(); + const live = new Map(); return (forms: FoundForm[], labels: Map): WeakMap => { + const present = new Set(forms.map((form) => form.root)); + for (const [id, root] of live) { + if (present.has(root)) continue; + live.delete(id); + assigned.delete(root); + } for (const form of forms) { if (assigned.has(form.root)) continue; const base = formIdFor(labels.get(form.root) ?? 'form'); const stem = base.replace(/~\d+$/, ''); let id = `${base}@${pageId}`; - for (let n = 2; used.has(id); n++) id = `${stem}~${n}@${pageId}`; - used.add(id); + for (let n = 2; live.has(id); n++) id = `${stem}~${n}@${pageId}`; + live.set(id, form.root); assigned.set(form.root, id); } return assigned; @@ -826,6 +883,32 @@ function compatControls(root: AnyRecord): AnyRecord[] { return out; } +/** + * Every element under `root`, including the ones inside open shadow roots, so forms in + * `ViewEncapsulation.ShadowDom` components are found. With an element, it comes first. + * `onShadowRoot` sees each shadow root on the way. + */ +export function pageElements( + root: Document | Element = document, + onShadowRoot?: (shadow: ShadowRoot) => void, +): Element[] { + const out: Element[] = []; + const visit = (scope: Document | Element | ShadowRoot, depth: number) => { + const add = (el: Element) => { + out.push(el); + const shadow = el.shadowRoot; + if (shadow && depth < MAX_SHADOW_DEPTH) { + onShadowRoot?.(shadow); + visit(shadow, depth + 1); + } + }; + if (scope instanceof Element) add(scope); + for (const el of Array.from(scope.querySelectorAll('*'))) add(el); + }; + visit(root, 0); + return out; +} + export function findForms(ng: FormsDebugApi, elements: Iterable): FoundForms { const byRoot = new Map(); const controlElements = new WeakMap(); diff --git a/packages/devtools/src/ngrx-collector.ts b/packages/devtools/src/ngrx-collector.ts index ee2dd307..b9993941 100644 --- a/packages/devtools/src/ngrx-collector.ts +++ b/packages/devtools/src/ngrx-collector.ts @@ -5,6 +5,7 @@ import { className, tokenName } from './injector-tree.ts'; import { diff, dispatchProblem, + NGRX_DIFF_MORE_CAP, referenceDiff, serialize, serializeSlice, @@ -170,23 +171,50 @@ function envScope(ng: NgrxDebugNg, injector: AnyRecord): st return typeof source === 'string' && source ? source : 'environment'; } -function snapshotDiff(before: Snapshot, after: Snapshot, keys: PropertyKey[]): NgrxDiffEntry[] { +/** The kept diff rows, and how many more changes were counted past them. */ +interface Changes { + diff: NgrxDiffEntry[]; + more: number; +} + +/** `diffTruncated` for an entry, set only when changes were left out. */ +function truncated(more: number): { diffTruncated?: number } { + return more > 0 ? { diffTruncated: Math.min(more, NGRX_DIFF_MORE_CAP) } : {}; +} + +function snapshotDiff(before: Snapshot, after: Snapshot, keys: PropertyKey[]): Changes { const out: NgrxDiffEntry[] = []; + const overflow = { more: 0 }; for (const key of keys) { - if (out.length >= MAX_DIFF) break; + if (out.length >= MAX_DIFF && overflow.more >= NGRX_DIFF_MORE_CAP) break; if (before[key] === after[key]) continue; const root = String(key); if (typeof key === 'string' && isRedactedKey(key)) { - out.push({ path: root, op: 'change', before: REDACTED, after: REDACTED }); + if (out.length < MAX_DIFF) { + out.push({ path: root, op: 'change', before: REDACTED, after: REDACTED }); + } else overflow.more++; continue; } + const counted = overflow.more; const found = diff( serializeSlice(key, before[key]), serializeSlice(key, after[key]), MAX_DIFF - out.length, + undefined, + overflow, ); - if (!found.length) { - out.push(...referenceDiff(before[key], after[key], root, SMALL, MAX_DIFF - out.length)); + if (!found.length && overflow.more === counted) { + out.push( + ...referenceDiff( + before[key], + after[key], + root, + SMALL, + MAX_DIFF - out.length, + undefined, + overflow, + ), + ); continue; } for (const entry of found) { @@ -204,7 +232,7 @@ function snapshotDiff(before: Snapshot, after: Snapshot, keys: PropertyKey[]): N }); } } - return out; + return { diff: out, more: overflow.more }; } function syncValue(observable: AnyRecord | null | undefined): unknown { @@ -315,7 +343,7 @@ export function createNgrxCollector( // noop entry). Otherwise the next real `watchState` firing would diff // against stale state. if (t.watched) t.lastWatched = after; - if (!changes.length) { + if (!changes.diff.length) { if (!logNoop) return; const noopAt = Date.now(); if (noopAt - (t.lastNoop.get(type) ?? 0) < NOOP_GAP_MS) return; @@ -328,7 +356,8 @@ export function createNgrxCollector( type, ...(args ? { args: args.map((a) => serialize(a, SMALL)) } : {}), timestamp: Date.now(), - diff: changes, + diff: changes.diff, + ...truncated(changes.more), restorable: t.writable, ...(causedByEvent ? { causedByEvent } : {}), ...(durationMs !== undefined ? { durationMs } : {}), @@ -383,7 +412,7 @@ export function createNgrxCollector( // microtask-fallback path, where `finish()` runs after dispatch returns). const causedByEvent = currentDispatchedEvent ?? pendingEventByInstance.get(t.instance); if (!currentDispatchedEvent && causedByEvent) pendingEventByInstance.delete(t.instance); - if (!changes.length) return; + if (!changes.diff.length) return; const type = call?.name ?? 'patchState'; const isRestore = /^Restore #\d+/.test(type); const durationMs = call && !isRestore ? Math.round(now() - call.startedAt) : undefined; @@ -396,7 +425,8 @@ export function createNgrxCollector( ? { args: call.args.map((a) => serialize(a, SMALL)) } : {}), timestamp: Date.now(), - diff: changes, + diff: changes.diff, + ...truncated(changes.more), restorable: t.writable, ...(causedByEvent ? { causedByEvent } : {}), ...(durationMs !== undefined ? { durationMs } : {}), @@ -735,6 +765,15 @@ export function createNgrxCollector( return fromSource !== undefined ? fromSource : syncValue(store); }; + /** + * The serialized classic state. `last` already holds `serialize(lastRaw)`, so the + * state is serialized again only when the store emitted a new object since then. + */ + const classicSnapshot = (c: Classic): unknown => { + const raw = classicState(c.store); + return Object.is(raw, c.lastRaw) ? c.last : serialize(raw); + }; + const logClassic = ( c: Classic, type: string, @@ -743,14 +782,15 @@ export function createNgrxCollector( ): NgrxLogEntry => { const raw = classicState(c.store); const next = serialize(raw); - let changes = diff(c.last, next, MAX_DIFF).map((entry) => ({ + const overflow = { more: 0 }; + let changes = diff(c.last, next, MAX_DIFF, undefined, overflow).map((entry) => ({ ...entry, ...('before' in entry ? { before: serialize(entry.before, SMALL) } : {}), ...('after' in entry ? { after: serialize(entry.after, SMALL) } : {}), })); - if (!changes.length && !Object.is(c.lastRaw, raw)) { - changes = referenceDiff(c.lastRaw, raw, '', SMALL, MAX_DIFF).map((entry) => - entry.path ? entry : { ...entry, path: '(root)' }, + if (!changes.length && !overflow.more && !Object.is(c.lastRaw, raw)) { + changes = referenceDiff(c.lastRaw, raw, '', SMALL, MAX_DIFF, undefined, overflow).map( + (entry) => (entry.path ? entry : { ...entry, path: '(root)' }), ); } const origin = @@ -771,6 +811,7 @@ export function createNgrxCollector( ...(origin ? { origin } : {}), timestamp: Date.now(), diff: changes, + ...truncated(overflow.more), restorable: held, ...(c.devtools && !held ? { unrestorable: 'not-recorded' as const } : {}), }, @@ -1161,7 +1202,7 @@ export function createNgrxCollector( stores, classic: classic ? { - state: serialize(classicState(classic.store)), + state: classicSnapshot(classic), devtools: !!classic.devtools, scope: classic.scope, ...(isPaused(classic.devtools) ? { paused: true } : {}), diff --git a/packages/devtools/src/ngrx-shared.ts b/packages/devtools/src/ngrx-shared.ts index 0eb60ec4..ec0168ae 100644 --- a/packages/devtools/src/ngrx-shared.ts +++ b/packages/devtools/src/ngrx-shared.ts @@ -103,6 +103,29 @@ export interface NgrxLogEntry { */ causedByEvent?: { type: string; payload?: unknown }; unrestorable?: NgrxUnrestorable; + /** + * How many more changes the state went through than `diff` holds (it keeps at most + * 50). Counted up to {@link NGRX_DIFF_MORE_CAP}, so at the cap it is a lower bound. + * Absent when `diff` is complete. + */ + diffTruncated?: number; +} + +/** The most changes counted past a full `diff`; `diffTruncated` stops there. */ +export const NGRX_DIFF_MORE_CAP = 1000; + +/** "12 more changes not shown", or "1000+ more…" at the cap. */ +export function moreChangesText(count: number): string { + const n = count >= NGRX_DIFF_MORE_CAP ? `${NGRX_DIFF_MORE_CAP}+` : String(count); + return `${n} more ${count === 1 ? 'change' : 'changes'} not shown`; +} + +/** + * Counts the changes `diff` and `referenceDiff` find past their `limit`, up to + * {@link NGRX_DIFF_MORE_CAP}, without building or serializing them. + */ +export interface DiffOverflow { + more: number; } export interface NgrxUnrestorableUpdate { @@ -297,10 +320,20 @@ function isPlain(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } +/** Deep equality of serialized (JSON-like) values, without building strings. */ function same(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false; - return JSON.stringify(a) === JSON.stringify(b); + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a)) { + const y = b as unknown[]; + return a.length === y.length && a.every((item, i) => same(item, y[i])); + } + const x = a as Record; + const y = b as Record; + const keys = Object.keys(x); + if (keys.length !== Object.keys(y).length) return false; + return keys.every((key) => Object.hasOwn(y, key) && same(x[key], y[key])); } function join(path: string, key: string | number): string { @@ -310,34 +343,45 @@ function join(path: string, key: string | number): string { return safe.startsWith('"') ? `${path}[${safe}]` : `${path}.${safe}`; } -export function diff(before: unknown, after: unknown, limit = 50, maxDepth = 6): NgrxDiffEntry[] { +export function diff( + before: unknown, + after: unknown, + limit = 50, + maxDepth = 6, + overflow?: DiffOverflow, +): NgrxDiffEntry[] { const out: NgrxDiffEntry[] = []; + const full = () => out.length >= limit && (!overflow || overflow.more >= NGRX_DIFF_MORE_CAP); + const emit = (entry: NgrxDiffEntry) => { + if (out.length < limit) out.push(entry); + else if (overflow) overflow.more++; + }; const walk = (a: unknown, b: unknown, path: string, depth: number) => { - if (out.length >= limit || same(a, b)) return; + if (full() || same(a, b)) return; const bothArrays = Array.isArray(a) && Array.isArray(b); const bothObjects = isPlain(a) && isPlain(b) && !(TYPE_KEY in a) && !(TYPE_KEY in b); if (depth >= maxDepth || (!bothArrays && !bothObjects)) { - out.push({ path: path || '(root)', op: 'change', before: a, after: b }); + emit({ path: path || '(root)', op: 'change', before: a, after: b }); return; } if (bothArrays) { const x = a as unknown[]; const y = b as unknown[]; for (let i = 0; i < Math.max(x.length, y.length); i++) { - if (i >= x.length) out.push({ path: join(path, i), op: 'add', after: y[i] }); - else if (i >= y.length) out.push({ path: join(path, i), op: 'remove', before: x[i] }); + if (i >= x.length) emit({ path: join(path, i), op: 'add', after: y[i] }); + else if (i >= y.length) emit({ path: join(path, i), op: 'remove', before: x[i] }); else walk(x[i], y[i], join(path, i), depth + 1); - if (out.length >= limit) return; + if (full()) return; } return; } const x = a as Record; const y = b as Record; for (const key of new Set([...Object.keys(x), ...Object.keys(y)])) { - if (!(key in y)) out.push({ path: join(path, key), op: 'remove', before: x[key] }); - else if (!(key in x)) out.push({ path: join(path, key), op: 'add', after: y[key] }); + if (!(key in y)) emit({ path: join(path, key), op: 'remove', before: x[key] }); + else if (!(key in x)) emit({ path: join(path, key), op: 'add', after: y[key] }); else walk(x[key], y[key], join(path, key), depth + 1); - if (out.length >= limit) return; + if (full()) return; } }; walk(before, after, '', 0); @@ -365,53 +409,65 @@ export function referenceDiff( options: SerializeOptions, limit = 50, maxDepth = 12, + overflow?: DiffOverflow, ): NgrxDiffEntry[] { const out: NgrxDiffEntry[] = []; + let found = 0; const show = (value: unknown) => serialize(value, options); + const full = () => out.length >= limit && (!overflow || overflow.more >= NGRX_DIFF_MORE_CAP); + // Builds (and serializes) an entry only when it is kept; past `limit` it is only counted. + const emit = (entry: () => NgrxDiffEntry) => { + found++; + if (out.length < limit) out.push(entry()); + else if (overflow) overflow.more++; + }; const walk = (a: unknown, b: unknown, at: string, depth: number) => { - if (out.length >= limit || Object.is(a, b)) return; + if (full() || Object.is(a, b)) return; const arrays = Array.isArray(a) && Array.isArray(b); const records = isRecord(a) && isRecord(b); if (depth >= maxDepth || (!arrays && !records)) { - out.push({ path: at, op: 'change', before: show(a), after: show(b) }); + emit(() => ({ path: at, op: 'change', before: show(a), after: show(b) })); return; } - const start = out.length; + const start = found; if (arrays) { const x = a as unknown[]; const y = b as unknown[]; - for (let i = 0; i < Math.max(x.length, y.length) && out.length < limit; i++) { - if (i >= x.length) out.push({ path: join(at, i), op: 'add', after: show(field(y, i)) }); - else if (i >= y.length) { - out.push({ path: join(at, i), op: 'remove', before: show(field(x, i)) }); + for (let i = 0; i < Math.max(x.length, y.length) && !full(); i++) { + if (i >= x.length) { + emit(() => ({ path: join(at, i), op: 'add', after: show(field(y, i)) })); + } else if (i >= y.length) { + emit(() => ({ path: join(at, i), op: 'remove', before: show(field(x, i)) })); } else walk(field(x, i), field(y, i), join(at, i), depth + 1); } } else { const x = a as Record; const y = b as Record; for (const key of new Set([...Object.keys(x), ...Object.keys(y)])) { - if (out.length >= limit) break; + if (full()) break; const secret = isRedactedKey(key); if (!(key in y)) { - out.push({ + emit(() => ({ path: join(at, key), op: 'remove', before: secret ? REDACTED : show(field(x, key)), - }); + })); } else if (!(key in x)) { - out.push({ + emit(() => ({ path: join(at, key), op: 'add', after: secret ? REDACTED : show(field(y, key)), - }); + })); } else if (secret) { if (!Object.is(field(x, key), field(y, key))) { - out.push({ path: join(at, key), op: 'change', before: REDACTED, after: REDACTED }); + emit(() => ({ path: join(at, key), op: 'change', before: REDACTED, after: REDACTED })); } } else walk(field(x, key), field(y, key), join(at, key), depth + 1); } } - if (out.length === start) out.push({ path: at, op: 'change', before: show(a), after: show(b) }); + if (found === start) { + emit(() => ({ path: at, op: 'change', before: show(a), after: show(b) })); + } }; walk(before, after, path, 0); return out; diff --git a/packages/devtools/src/overlay.ts b/packages/devtools/src/overlay.ts index 3973cc3a..f871fe59 100644 --- a/packages/devtools/src/overlay.ts +++ b/packages/devtools/src/overlay.ts @@ -16,6 +16,7 @@ import { findRouters, redactMessage, redactUrl, + resetNavigations, setGeneration, setNavigationLimit, snapshotRouter, @@ -416,7 +417,6 @@ async function startOverlay(options: OverlayOptions, own: (cleanup: () => void) let configTruncated = 0; let setup: RouterSetup | undefined; let sentGeneration = -1; - let routerMisses = 0; const routerScan = createScanBackoff(); let lastRouterPayload = ''; let lastRouterPushAt = 0; @@ -462,15 +462,17 @@ async function startOverlay(options: OverlayOptions, own: (cleanup: () => void) } function attachRouter(ng: RouterDebugApi) { + if (!routerScan.due()) return; const roots = Array.from(document.querySelectorAll('[ng-version]')); - if (!roots.length && !routerScan.due()) return; const candidates = roots.length ? roots : findAngularElements().slice(0, 1); const routers = findRouters(ng, candidates); if (!routers.length) { - if (candidates.some((root) => read(() => !!ng?.getComponent?.(root), false))) routerMisses++; - else if (!roots.length) routerScan.miss(); + // A root that is not bootstrapped yet is cheap to check again on the next push. + const bootstrapped = candidates.some((root) => read(() => !!ng?.getComponent?.(root), false)); + if (bootstrapped || !roots.length) routerScan.miss(); return; } + routerScan.reset(); router = routers[0]; routerCount = routers.length; routerRoot = candidates[0] ?? null; @@ -482,10 +484,39 @@ async function startOverlay(options: OverlayOptions, own: (cleanup: () => void) routerCleanup.push(capturePreloads(preloaderOf(ng, routerRoot), preloads, scheduleRouterPush)); } + /** True when the watched router was destroyed or its root left the page (HMR, re-bootstrap). */ + function routerGone(): boolean { + if (!router) return false; + if (read(() => router!['disposed'] === true, false)) return true; + return !!routerRoot && !routerRoot.isConnected; + } + + function detachRouter() { + for (const cleanup of routerCleanup) cleanup(); + routerCleanup = []; + stopInstrument?.(); + stopInstrument = null; + router = null; + routerCount = 0; + routerRoot = null; + // A new router counts navigation ids from 1 again, so old records would take its events. + resetNavigations(navigations); + preloads.length = 0; + config = undefined; + configTruncated = 0; + setup = undefined; + sentGeneration = -1; + lastRouterPayload = ''; + routerDomDirty = true; + outlets = []; + links = []; + } + async function pushRouter() { try { const ng = getNg() as RouterDebugApi | undefined; - if (!router && routerMisses < 3 && ng) attachRouter(ng); + if (routerGone()) detachRouter(); + if (!router && ng) attachRouter(ng); const snapshot = router ? snapshotRouter(router) : null; const report: Record = { pageId, snapshot, navigations }; const dropped = droppedNavigations(navigations); diff --git a/packages/devtools/src/router-actions.ts b/packages/devtools/src/router-actions.ts index 2049a1ab..be4effeb 100644 --- a/packages/devtools/src/router-actions.ts +++ b/packages/devtools/src/router-actions.ts @@ -1,5 +1,6 @@ import { isRouterLink } from './router-links.ts'; import { + CANCEL_CODES, EventType, MAX_DEPTH, clip, @@ -21,6 +22,7 @@ import { type AnyRecord, type NavigationRecord, type RouterDebugApi, + WRAPPED, } from './router.ts'; export interface PreloadRecord { @@ -88,6 +90,7 @@ export function routerLinkOf(target: Element | null, ng?: RouterDebugApi): Eleme const INSTRUMENT_KEY = 'pangular-router-instrument'; +/** Whether instrumentation is on for this tab: on unless it was turned off. */ export function storedInstrumented(): boolean { try { return sessionStorage.getItem(INSTRUMENT_KEY) !== '0'; @@ -371,8 +374,6 @@ function track(result: unknown, record: Recorder): unknown { return result; } -const WRAPPED = '__pangularOriginal'; - interface Wrapping { restore: (() => void)[]; } @@ -539,7 +540,8 @@ function instrumentRoutes( } /** - * Opt-in: wraps every guard and resolver in the live config so each run is + * On by default (see `storedInstrumented`); turned off per tab with the + * "instrument" action. Wraps every guard and resolver in the live config so each run is * recorded with its verdict and time. Call again after the config changes to * cover newly loaded routes. The returned function unwraps everything. */ @@ -663,6 +665,15 @@ function findConfig(router: AnyRecord, id: string): AnyRecord | null { return found; } +const REDIRECT_CANCEL_CODE = CANCEL_CODES.indexOf('Redirect'); +/** The events that end a probed navigation. */ +const PROBE_END_EVENTS: number[] = [ + EventType.NavigationEnd, + EventType.NavigationCancel, + EventType.NavigationError, + EventType.NavigationSkipped, +]; + function probe(router: AnyRecord, navigations: NavigationRecord[], url: string): Promise { const transitions = read(() => router['navigationTransitions'] as AnyRecord, null); const events = read(() => transitions?.['events'] as AnyRecord, null); @@ -700,15 +711,20 @@ function probe(router: AnyRecord, navigations: NavigationRecord[], url: string): abortCurrent(); } if (id !== targetId) return; - if (event['type'] === 4 && redirectedTo === undefined) { + if (event['type'] === EventType.RoutesRecognized && redirectedTo === undefined) { matched = read(() => serializeRoute(event['state']['root']), null); abortCurrent(); } - if (event['type'] === 2 && event['code'] === 0 && redirectedTo === undefined && !matched) { + if ( + event['type'] === EventType.NavigationCancel && + event['code'] === REDIRECT_CANCEL_CODE && + redirectedTo === undefined && + !matched + ) { redirecting = true; return; } - if ([1, 2, 3, 16].includes(event['type'])) { + if (PROBE_END_EVENTS.includes(event['type'])) { const record = markProbe(targetId); finish( redirectedTo !== undefined @@ -743,14 +759,21 @@ function probe(router: AnyRecord, navigations: NavigationRecord[], url: string): targetId = current < 0 ? current : current + 1; setCaller('probe from DevTools', true); // 'reload' so probing the current URL is matched instead of skipped as the same URL. - const promise = read( - () => - router['navigateByUrl'](url, { - skipLocationChange: true, - onSameUrlNavigation: 'reload', - }) as Promise, - null, - ); + let promise: Promise | null = null; + try { + promise = router['navigateByUrl'](url, { + skipLocationChange: true, + onSameUrlNavigation: 'reload', + }) as Promise; + } catch (error) { + markProbe(targetId); + finish({ + error: `navigateByUrl threw before the probe started: ${redactMessage( + read(() => String((error as Error)?.message ?? error), 'unknown error'), + )}`, + }); + return; + } promise?.catch?.(() => {}); }); } diff --git a/packages/devtools/src/router-attach.ts b/packages/devtools/src/router-attach.ts index b870215f..f9402f5c 100644 --- a/packages/devtools/src/router-attach.ts +++ b/packages/devtools/src/router-attach.ts @@ -14,6 +14,11 @@ export function createScanBackoff(maxSkip = 32) { wait--; return false; }, + /** The scan found a router: scan right away the next time one is needed. */ + reset(): void { + skip = 0; + wait = 0; + }, /** The scan found nothing: wait longer before the next one. */ miss(): void { skip = Math.min(maxSkip, skip ? skip * 2 : 1); diff --git a/packages/devtools/src/router-config.ts b/packages/devtools/src/router-config.ts index da085233..dd5369ff 100644 --- a/packages/devtools/src/router-config.ts +++ b/packages/devtools/src/router-config.ts @@ -7,6 +7,7 @@ import { nameOf, read, resolversOf, + WRAPPED, type AnyRecord, type RouteGuards, } from './router.ts'; @@ -208,19 +209,70 @@ export function activeIds(router: AnyRecord): string[] { return out; } +const SIGNATURE_GUARDS = [ + 'canActivate', + 'canActivateChild', + 'canDeactivate', + 'canMatch', + 'canLoad', +]; + +const guardIds = new WeakMap(); +let nextGuardId = 0; + +/** + * A stable id per guard function, class or token, so swapping one guard for + * another at the same index changes the signature. Instrumentation wraps + * guards in place, so the id is taken from the app's own function. + */ +function guardId(entry: unknown): string { + if ((typeof entry !== 'function' && typeof entry !== 'object') || entry === null) { + return typeof entry; + } + const own = ((entry as AnyRecord)[WRAPPED] as object | undefined) ?? entry; + let id = guardIds.get(own); + if (id === undefined) guardIds.set(own, (id = ++nextGuardId)); + return String(id); +} + +/** What an in-place edit of a route changes: its path, redirect and guards. */ +function routeShape(route: AnyRecord): string { + return read(() => { + const redirect = route['redirectTo']; + const guards = SIGNATURE_GUARDS.map((kind) => { + const list = route[kind]; + return Array.isArray(list) ? list.map(guardId).join('+') : '-'; + }); + return `${String(route['path'] ?? '')}|${typeof redirect === 'string' ? redirect : typeof redirect}|${guards.join(',')}`; + }, ''); +} + +/** A short FNV-1a hash, so the signature stays small however big the config is. */ +function hashInto(hash: number, text: string): number { + let next = hash; + for (let i = 0; i < text.length; i++) { + next ^= text.charCodeAt(i); + next = Math.imul(next, 0x01000193); + } + return next >>> 0; +} + /** - * Counts config changes: a new config array (resetConfig, HMR) or a lazy - * route whose children or component finished loading. + * Counts config changes: a new config array (resetConfig, HMR), a lazy route + * whose children or component finished loading, or a route whose path, + * redirect or guards were edited in place. */ export function configSignature(router: AnyRecord): string { const config = read(() => router['config'] as AnyRecord[], []); let loaded = 0; let total = 0; + let shape = 0x811c9dc5; const visit = (routes: AnyRecord[], depth: number) => { if (!Array.isArray(routes) || depth > MAX_DEPTH || total > MAX_NODES) return; for (const route of routes) { total++; if (read(() => !!route['_loadedRoutes'] || !!route['_loadedComponent'], false)) loaded++; + shape = hashInto(shape, `${depth}:${routeShape(route)};`); visit( read(() => route['children'] as AnyRecord[], []), depth + 1, @@ -232,7 +284,7 @@ export function configSignature(router: AnyRecord): string { } }; visit(config, 0); - return `${total}:${loaded}`; + return `${total}:${loaded}:${shape.toString(36)}`; } export class ConfigTracker { diff --git a/packages/devtools/src/router-links.ts b/packages/devtools/src/router-links.ts index 71461cb8..3b18cfc2 100644 --- a/packages/devtools/src/router-links.ts +++ b/packages/devtools/src/router-links.ts @@ -161,7 +161,11 @@ function isLinkActive(value: unknown): value is AnyRecord { */ export function linksOf(ng: RouterDebugApi, router: AnyRecord): LinkInfo[] { if (typeof document === 'undefined' || !ng.getDirectives) return []; - const elements = Array.from(document.querySelectorAll('a, button, [routerlink], [routerLink]')); + const elements = Array.from( + document.querySelectorAll( + 'a, button, [routerlink], [routerLink], [routerlinkactive], [routerLinkActive]', + ), + ); const out: LinkInfo[] = []; for (const element of elements) { if (out.length >= MAX_LINKS) break; diff --git a/packages/devtools/src/router.ts b/packages/devtools/src/router.ts index 9b0416cd..1ef33511 100644 --- a/packages/devtools/src/router.ts +++ b/packages/devtools/src/router.ts @@ -97,6 +97,9 @@ export interface RouterDebugApi { export type AnyRecord = Record; export const MAX_DEPTH = 12; + +/** Key on a wrapped guard or resolver that holds the app's own function. */ +export const WRAPPED = '__pangularOriginal'; const MAX_REASON = 300; const MAX_URL = 2000; const URL_SECRET_KEY = @@ -108,7 +111,8 @@ const GUARD_KINDS = [ 'canMatch', 'canLoad', ] as const; -const CANCEL_CODES = [ +/** Angular's `NavigationCancellationCode` names, by code. */ +export const CANCEL_CODES = [ 'Redirect', 'SupersededByNewNavigation', 'NoDataFromResolver', @@ -677,6 +681,21 @@ function extrasOf(extras: AnyRecord | null | undefined, restored: unknown): stri return out.length ? out : undefined; } +/** + * A skipped navigation has no NavigationStart to carry its trigger. Read it + * from the router's current navigation when that is the same one, else say + * it is unknown rather than guess it was started by code. + */ +function skippedTriggerOf(router: AnyRecord | undefined, id: number): string { + return read(() => { + const transitions = router?.['navigationTransitions']; + const current = transitions?.['currentNavigation']; + const navigation = (typeof current === 'function' ? current() : current) as AnyRecord | null; + if (navigation?.['id'] !== id || typeof navigation['trigger'] !== 'string') return 'unknown'; + return navigation['trigger'] as string; + }, 'unknown'); +} + function findPending(navigations: NavigationRecord[]): NavigationRecord | undefined { for (let i = navigations.length - 1; i >= 0; i--) { if (navigations[i].outcome === 'pending') return navigations[i]; @@ -841,6 +860,16 @@ export function clearNavigations(navigations: NavigationRecord[]) { droppedNavigationCounts.delete(navigations); } +/** + * Empties the list, pending navigations included, and forgets the dropped count. + * Used when the overlay lets go of a router: a new router counts its navigation + * ids from 1 again, so old records would take its events. + */ +export function resetNavigations(navigations: NavigationRecord[]) { + navigations.length = 0; + droppedNavigationCounts.delete(navigations); +} + /** How many navigations `trimNavigations` removed from this list. */ export function droppedNavigations(navigations: NavigationRecord[]): number { return droppedNavigationCounts.get(navigations) ?? 0; @@ -848,13 +877,15 @@ export function droppedNavigations(navigations: NavigationRecord[]): number { /** * Folds one router event into the navigation it belongs to. Returns true when - * something changed. + * something changed. `onLateChange` runs when a field is filled in after the + * event, such as the page title the app sets once the navigation ended. */ export function applyRouterEvent( navigations: NavigationRecord[], event: AnyRecord, at: number, router?: AnyRecord, + onLateChange?: () => void, ): boolean { const type = read(() => event['type'] as number | undefined, undefined); const id = read(() => event['id'] as number, -1); @@ -966,12 +997,13 @@ export function applyRouterEvent( const nav: NavigationRecord = { id, url: redactUrl(String(event['url'])), - trigger: 'imperative', + trigger: skippedTriggerOf(router, id), startedAt: at, outcome: 'pending', generation: configGeneration, }; - if (pendingCaller && at - pendingCaller.at < 2000) nav.caller = pendingCaller.text; + if (pendingCaller && at - pendingCaller.at < 2000 && nav.trigger === 'imperative') + nav.caller = pendingCaller.text; pendingCaller = null; navigations.push(nav); } @@ -1040,8 +1072,11 @@ export function applyRouterEvent( const reused = reusedOf(router); if (reused.length) nav.reused = reused; Promise.resolve().then(() => { - if (typeof document !== 'undefined') - nav.title = redactMessage(document.title, state.secrets); + if (typeof document === 'undefined') return; + const title = redactMessage(document.title, state.secrets); + if (title === nav.title) return; + nav.title = title; + onLateChange?.(); }); } finish(nav, at, router); @@ -1188,7 +1223,7 @@ export function watchRouter( const subscription = read( () => events['subscribe']((event: AnyRecord) => { - if (!applyRouterEvent(navigations, event, Date.now(), router)) return; + if (!applyRouterEvent(navigations, event, Date.now(), router, onChange)) return; trimNavigations(navigations, max); onChange(); }) as { unsubscribe(): void }, diff --git a/packages/devtools/src/rpc/__tests__/ngrx-live-tools.test.ts b/packages/devtools/src/rpc/__tests__/ngrx-live-tools.test.ts index 4e77ca9b..a2df6b8a 100644 --- a/packages/devtools/src/rpc/__tests__/ngrx-live-tools.test.ts +++ b/packages/devtools/src/rpc/__tests__/ngrx-live-tools.test.ts @@ -290,6 +290,34 @@ describe('signal-store-history', () => { const text = signalStoreHistoryText(pages, undefined, undefined, 0); expect(text).toMatch(/pass `page`/i); }); + + it('says how many changes a long diff left out', () => { + const pages = pagesWith( + [store()], + [logEntry({ seq: 1, diffTruncated: 12 }), logEntry({ seq: 2, diffTruncated: 1000 })], + ); + const text = signalStoreHistoryText(pages); + expect(text).toContain('count change 0 → 1; 12 more changes not shown'); + expect(text).toContain('1000+ more changes not shown'); + }); + + it('drops malformed log entries and stores from a report instead of failing', () => { + const pages: NgrxPages = new Map(); + const report = { + pageId: 'p1', + session: 's1', + url: '/app', + title: 'My App', + stores: [store(), null, { id: 'ngrx-2' }], + classic: { state: 'not a classic store' }, + log: [null, 'entry', { seq: 2 }, logEntry({ seq: 3 })], + } as unknown as NgrxPageReport; + expect(mergeNgrxReport(pages, report, [])).toBe(3); + const page = pages.get('p1')!; + expect(page.stores.map((s) => s.id)).toEqual(['ngrx-1']); + expect(page.log.map((e) => e.seq)).toEqual([3]); + expect(page.classic).toBeNull(); + }); }); describe('withUntrustedPreamble', () => { diff --git a/packages/devtools/src/rpc/analog-register.ts b/packages/devtools/src/rpc/analog-register.ts index 1e752999..71e6f2fe 100644 --- a/packages/devtools/src/rpc/analog-register.ts +++ b/packages/devtools/src/rpc/analog-register.ts @@ -49,6 +49,12 @@ const MAX_BODY = 2000; let disposeAnalog: (() => void) | undefined; let analogOwner: unknown; let findServerFn: ((id: string) => AnalogServerFn | undefined) | undefined; +let reportingPages: (() => { pageId: string; reportedAt: number }[]) | undefined; + +/** The pages that sent an Analog report, for `list-pages`. */ +export function analogPages(): { pageId: string; reportedAt: number }[] { + return reportingPages?.() ?? []; +} /** Adds the name and file of each Analog server function seeded into a TransferState payload. */ export function nameServerFns(payload: PayloadSummary): PayloadSummary { @@ -192,7 +198,10 @@ export async function registerAnalog( clearInterval(expiry); stopCalls(); findServerFn = undefined; + reportingPages = undefined; }; + reportingPages = () => + current().pages.map((p) => ({ pageId: p.pageId, reportedAt: seenAt.get(p.pageId) ?? 0 })); let cache: { at: number; project: AnalogProject } | null = null; const project = () => { @@ -246,7 +255,8 @@ export async function registerAnalog( name: 'analog-lint', type: 'query', jsonSerializable: true, - handler: () => analogLint(project(), current()), + handler: (page: unknown) => + analogLint(project(), current(), typeof page === 'string' ? page : undefined), }); my.rpc.register({ name: 'analog-render', @@ -395,9 +405,10 @@ export async function registerAnalog( ctx.agent.registerTool({ id: 'pangular:analog-lint', description: - 'Analog checks: two files for one URL, sibling [param] files, missing default export, layout without router-outlet, .server.ts without load, action or server functions, or without a page, redirect mistakes, bad API method suffix, duplicate API routes, routes outside the API prefix, prerender entries that match nothing, frontmatter errors, duplicate slugs, plus live problems (load fetched twice, seeded server function read called again, hydration errors, restart needed, API 404/405).', + 'Analog checks: two files for one URL, sibling [param] files, missing default export, layout without router-outlet, .server.ts without load, action or server functions, or without a page, redirect mistakes, bad API method suffix, duplicate API routes, routes outside the API prefix, prerender entries that match nothing, frontmatter errors, duplicate slugs, plus live problems (load fetched twice, seeded server function read called again, hydration errors, restart needed, API 404/405). Hydration errors and restart hints come from one page: `page`, or the most recent one.', safety: 'read', - inputSchema: { type: 'object', properties: {} }, - handler: async () => text(analogLintText(project(), current())), + inputSchema: { type: 'object', properties: { page } }, + handler: async (args: { page?: string }) => + text(analogLintText(project(), current(), args?.page)), }); } diff --git a/packages/devtools/src/rpc/analog-scan.ts b/packages/devtools/src/rpc/analog-scan.ts index 7ee98b2d..fac2c5ad 100644 --- a/packages/devtools/src/rpc/analog-scan.ts +++ b/packages/devtools/src/rpc/analog-scan.ts @@ -86,6 +86,8 @@ export interface AnalogLintFinding { severity: 'error' | 'warning' | 'info'; file?: string; path?: string; + /** Another file the finding is about, such as the page a content file takes over. */ + relatedFile?: string; message: string; fix: string; } @@ -409,7 +411,7 @@ export function serverFnsOf(file: string, source: string): AnalogServerFn[] { if (!locals.size) locals.add('serverFn'); const out: AnalogServerFn[] = []; for (const match of masked.matchAll( - /\bexport\s+(?:const|let|var)\s+(\w+)\s*(?::[^=]+)?=\s*(\w+)\s*\(/g, + /\bexport\s+(?:const|let|var)\s+(\w+)\s*(?::[^=]+)?=\s*(\w+)\s*(?:<[^;=()]*?>\s*)?\(/g, )) { if (!locals.has(match[2])) continue; out.push({ @@ -479,6 +481,16 @@ function blockEnd(source: string, open: number): number { return source.length; } +/** Index of the parenthesis that closes the one at `open`, in a string-masked source. */ +function callEnd(masked: string, open: number): number { + let depth = 0; + for (let i = open; i < masked.length; i++) { + if (masked[i] === '(') depth++; + else if (masked[i] === ')' && --depth === 0) return i; + } + return masked.length; +} + function ruleTiming(body: string, key: string): string | undefined { const match = body.match(new RegExp(`\\b${key}\\s*:\\s*(true|false|\\d+|\\{)`)); if (!match || match[1] === 'false') return undefined; @@ -549,6 +561,25 @@ function stripBlocks(source: string, keys: string[]): string { return out; } +/** + * The `key: {` of the analog() options object itself, skipping the same key + * nested deeper, such as `nitro: { prerender: { ... } }`. `masked` is the + * analog(...) call with its strings blanked, so braces in strings don't count. + */ +function topLevelBlock(masked: string, key: string): RegExpExecArray | null { + const pattern = new RegExp(`\\b${key}\\s*:\\s*\\{`, 'g'); + let depth = 0; + let at = 0; + for (let match = pattern.exec(masked); match; match = pattern.exec(masked)) { + for (; at < match.index; at++) { + if (masked[at] === '{') depth++; + else if (masked[at] === '}') depth--; + } + if (depth === 1) return match; + } + return null; +} + export function analogConfig(root: string): AnalogConfig { const file = configFile(root); const config: AnalogConfig = { apiPrefix: 'api', noSsrRoutes: [], routeRules: [] }; @@ -557,7 +588,7 @@ export function analogConfig(root: string): AnalogConfig { const source = stripComments(read(join(root, file))); const start = source.search(/\banalog\s*\(/); if (start < 0) return config; - const options = source.slice(start, start + 4000); + const options = source.slice(start, callEnd(maskStrings(source), source.indexOf('(', start)) + 1); const topLevel = stripBlocks(options.slice(options.indexOf('(') + 1), [ 'nitro', 'routeRules', @@ -575,9 +606,16 @@ export function analogConfig(root: string): AnalogConfig { if (isStatic !== undefined) config.static = isStatic; const prefix = options.match(/\bapiPrefix\s*:\s*['"`]([^'"`]*)['"`]/); if (prefix) config.apiPrefix = prefix[1]; - const prerender = options.match( - /\bprerender\s*:\s*\{[\s\S]*?\broutes\s*:\s*(\[[\s\S]*?\]|async|\(|function)/, - ); + const masked = maskStrings(options); + const prerenderBlock = topLevelBlock(masked, 'prerender'); + const prerender = prerenderBlock + ? options + .slice( + prerenderBlock.index, + blockEnd(masked, prerenderBlock.index + prerenderBlock[0].length - 1) + 1, + ) + .match(/\broutes\s*:\s*(\[[\s\S]*?\]|async|\(|function)/) + : null; if (prerender) { if (prerender[1].startsWith('[')) { config.prerender = Array.from(prerender[1].matchAll(/['"`](\/[^'"`]*)['"`]/g), (m) => m[1]); @@ -1031,6 +1069,7 @@ export function lintAnalog(project: AnalogProject): AnalogLintFinding[] { severity: 'warning', file: route.file, path: route.fullPath, + relatedFile: page.file, message: `Files under src/content are routes too, so ${route.file} serves ${route.fullPath} and ${page.file} never renders for it.`, fix: 'Move content outside the routed content folder (for example src/content-data with contentDir), or drop the [param] page.', }); diff --git a/packages/devtools/src/rpc/analog-tools.ts b/packages/devtools/src/rpc/analog-tools.ts index dfcd7c75..a38540bb 100644 --- a/packages/devtools/src/rpc/analog-tools.ts +++ b/packages/devtools/src/rpc/analog-tools.ts @@ -145,10 +145,21 @@ export function loadEndpointUrl( ): string { let path = `/${apiPrefix}/_analog${analogEndpoint(file)}`.replace(/\/{2,}/g, '/'); for (const [name, value] of Object.entries(params)) { - if (name !== '**') path = path.replace(`[${name}]`, value); + if (name !== '**') path = path.replace(`[${name}]`, encodeURIComponent(value)); + } + if (!path.includes('**')) return path; + const catchAll = file.match(/\[\[?\.{3}([^\]]+)\]\]?/)?.[1]; + const rest = params['**'] ?? (catchAll ? params[catchAll] : undefined) ?? ''; + return path.replace('**', rest.split('/').map(encodeSegment).join('/')); +} + +/** Catch-all values hold the URL's own segments, so decode before encoding. */ +function encodeSegment(part: string): string { + try { + return encodeURIComponent(decodeURIComponent(part)); + } catch { + return encodeURIComponent(part); } - const rest = params['**'] ?? Object.values(params).at(-1) ?? ''; - return path.replace('**', rest); } function kindLabel(route: AnalogRoute): string { @@ -638,7 +649,15 @@ function hasHandler(project: AnalogProject, call: AnalogCall): boolean { }); } -export function analogLint(project: AnalogProject, state: AnalogState): AnalogLintFinding[] { +/** + * Static findings, live call findings and the findings of one page: `page`, + * or the most recent page when it is left out. + */ +export function analogLint( + project: AnalogProject, + state: AnalogState, + page?: string, +): AnalogLintFinding[] { if (!project.analog) return []; const findings = lintAnalog(project); const dupes = new Map(duplicateLoads(state.calls).map((d) => [d.route, d])); @@ -662,7 +681,7 @@ export function analogLint(project: AnalogProject, state: AnalogState): AnalogLi fix: 'The browser read did not use the TransferState seed. Read it with injectServerFn and the same input on both sides, and check provideClientHydration().', }); } - const report = state.pages[0]; + const report = pickPage(state, page); if (report) { for (const path of restartNeeded(project, report)) { findings.push({ @@ -701,16 +720,31 @@ export function analogLint(project: AnalogProject, state: AnalogState): AnalogLi return findings; } -export function analogLintText(project: AnalogProject, state: AnalogState): string { +export function analogLintText(project: AnalogProject, state: AnalogState, page?: string): string { if (!project.analog) return NOT_ANALOG; - const findings = analogLint(project, state); - if (!findings.length) return 'No Analog problems found.'; + const findings = analogLint(project, state, page); + const missing = page && !state.pages.some((p) => p.pageId === page) ? page : undefined; + if (!findings.length && !missing) return 'No Analog problems found.'; const order = { error: 0, warning: 1, info: 2 }; - return `${UNTRUSTED}\n\n${findings + const lines = findings .sort((a, b) => order[a.severity] - order[b.severity]) .map( (f) => `- **${f.severity}** ${f.rule}${f.file ? ` in ${code(f.file)}` : ''}${f.path ? ` at ${code(f.path)}` : ''}: ${f.message} Fix: ${f.fix}`, - ) - .join('\n')}`; + ); + if (missing) { + lines.unshift(unknownLintPage(missing, state.pages), ''); + if (!findings.length) lines.push('No problems found in the files or API calls.'); + } + return `${UNTRUSTED}\n\n${lines.join('\n')}`; +} + +/** Says that `page` sends no Analog report, so its hydration errors and restart checks are left out. */ +function unknownLintPage(page: string, pages: readonly AnalogRuntimeReport[]): string { + const known = pages.map((p) => `${code(p.pageId)} (${code(p.url)})`).join(', '); + return `No page ${code(page)} is reporting Analog data, so its hydration errors and restart checks are left out. ${ + known + ? `Pages that report Analog data: ${known}. Pass one of these ids as \`page\`, or leave \`page\` out for the most recent.` + : 'No page reports Analog data right now.' + }`; } diff --git a/packages/devtools/src/rpc/ngrx-live-tools.ts b/packages/devtools/src/rpc/ngrx-live-tools.ts index 32aca033..bd2315db 100644 --- a/packages/devtools/src/rpc/ngrx-live-tools.ts +++ b/packages/devtools/src/rpc/ngrx-live-tools.ts @@ -1,5 +1,5 @@ import type { NgrxPageRecord, NgrxPages } from './ngrx-tools.ts'; -import type { NgrxLogEntry, NgrxSignalStoreInfo } from '../ngrx-shared.ts'; +import { moreChangesText, type NgrxLogEntry, type NgrxSignalStoreInfo } from '../ngrx-shared.ts'; import { unknownPageText } from './pages.ts'; /** @@ -170,7 +170,7 @@ function entryLine(entry: NgrxLogEntry, showPage?: string): string { (d) => `${d.path} ${d.op}${'before' in d ? ` ${JSON.stringify(d.before)}` : ''}${'before' in d && 'after' in d ? ' →' : ''}${'after' in d ? ` ${JSON.stringify(d.after)}` : ''}`, ) - .join('; ') + .join('; ') + (entry.diffTruncated ? `; ${moreChangesText(entry.diffTruncated)}` : '') : 'no state change'; const kind = entry.source === 'store' ? 'action' : 'call'; return `- #${entry.seq} ${when}${page}: ${kind} ${code(entry.type)}${duration} on ${code(entry.storeId)}${caused}${restorable}: ${diffText}`; diff --git a/packages/devtools/src/rpc/ngrx-tools.ts b/packages/devtools/src/rpc/ngrx-tools.ts index 836d149d..62110c9b 100644 --- a/packages/devtools/src/rpc/ngrx-tools.ts +++ b/packages/devtools/src/rpc/ngrx-tools.ts @@ -1,4 +1,6 @@ +import * as v from 'valibot'; import type { + NgrxClassicStoreInfo, NgrxLogEntry, NgrxPage, NgrxPageReport, @@ -40,6 +42,43 @@ export function isNgrxReport(value: unknown): value is NgrxPageReport { ); } +const StoreSchema = v.looseObject({ + id: v.string(), + kind: v.picklist(['signal-store', 'signal-state']), + className: v.string(), + scope: v.string(), + stateKeys: v.array(v.string()), + state: v.record(v.string(), v.unknown()), + computed: v.record(v.string(), v.unknown()), + methods: v.array(v.looseObject({ name: v.string(), calls: v.number() })), + references: v.array(v.string()), + writable: v.boolean(), +}); + +const LogEntrySchema = v.looseObject({ + seq: v.number(), + source: v.picklist(['signal-store', 'store', 'event']), + storeId: v.string(), + type: v.string(), + timestamp: v.number(), + diff: v.array(v.looseObject({ path: v.string(), op: v.picklist(['add', 'remove', 'change']) })), + restorable: v.boolean(), +}); + +const ClassicSchema = v.looseObject({ + state: v.unknown(), + devtools: v.boolean(), + scope: v.string(), +}); + +/** + * The page writes the report, so its items are checked one by one: a malformed store + * or log entry is dropped instead of failing the merge or reaching the panel. + */ +function validItems(items: unknown[], schema: v.GenericSchema): T[] { + return items.filter((item): item is T => v.is(schema, item)); +} + /** * Matches a live `signalStore`/`signalState` to its source declaration (by * state-key overlap) and returns the fields we can derive from the match. @@ -98,7 +137,9 @@ export function mergeNgrxReport( // (forget-ngrx-page, expiry, restart), drop the partial log and answer 0 so // the page resends everything it still holds. const resync = !known && typeof report.since === 'number' && report.since > 0; - const fresh = resync ? [] : report.log.filter((entry: NgrxLogEntry) => entry.seq > lastSeq); + const fresh = resync + ? [] + : validItems(report.log, LogEntrySchema).filter((entry) => entry.seq > lastSeq); const lost = new Map(); for (const update of Array.isArray(report.unrestorable) ? report.unrestorable : []) { if (typeof update?.seq === 'number' && UNRESTORABLE.has(update.reason)) { @@ -114,8 +155,11 @@ export function mergeNgrxReport( session: report.session, url: redactUrl(String(report.url ?? '').slice(0, 2000)), title: redactMessage(String(report.title ?? '').slice(0, 2000)).slice(0, 200), - stores: report.stores.map((store) => ({ ...store, ...nameStore(store, declarations) })), - classic: report.classic ?? null, + stores: validItems(report.stores, StoreSchema).map((store) => ({ + ...store, + ...nameStore(store, declarations), + })), + classic: v.is(ClassicSchema, report.classic) ? (report.classic as NgrxClassicStoreInfo) : null, log, dropped: Math.max(0, (log[0]?.seq ?? 1) - 1), reportedAt: now, diff --git a/packages/devtools/src/rpc/router-config-tools.ts b/packages/devtools/src/rpc/router-config-tools.ts index 352c410d..c56024d3 100644 --- a/packages/devtools/src/rpc/router-config-tools.ts +++ b/packages/devtools/src/rpc/router-config-tools.ts @@ -367,6 +367,8 @@ export function lintRoutes(page: RouterPage): LintFinding[] { primary.forEach((node, index) => { const own = parts(node.path); if (own.length !== 1 || !own[0].startsWith(':') || node.guards?.canMatch) return; + // Children don't stop it: when the URL ends at this route, Angular matches it with no + // child activated (recognize's NoLeftoversInUrl) and never backtracks to the literal. for (const later of primary.slice(index + 1)) { const laterParts = parts(later.path); if (laterParts.length === 1 && !laterParts[0].startsWith(':') && laterParts[0] !== '**') { @@ -387,9 +389,10 @@ export function lintRoutes(page: RouterPage): LintFinding[] { rule: 'empty-redirect-prefix', severity: 'error', route: node.fullPath, - message: "An empty-path redirect without pathMatch 'full' matches every URL.", - fix: "Add pathMatch: 'full'.", - angular: 'throws', + message: + "An empty-path redirect with pathMatch 'prefix' matches every URL, so every navigation is redirected.", + fix: "Use pathMatch: 'full'.", + angular: 'silent', }); } } diff --git a/packages/devtools/src/rpc/router-tools.ts b/packages/devtools/src/rpc/router-tools.ts index 4aee7e25..0299f473 100644 --- a/packages/devtools/src/rpc/router-tools.ts +++ b/packages/devtools/src/rpc/router-tools.ts @@ -785,6 +785,7 @@ export function explainNavigationText( state: RouterState, args: { page?: string; url?: string; limit?: number; perf?: boolean; id?: number }, now = Date.now(), + maxNavigations = MAX_NAVIGATIONS, ): string { const page = pickPage(state, args.page); if (!page) return noPage(args.page, state); @@ -794,7 +795,7 @@ export function explainNavigationText( ); } const requested = Number.isFinite(args.limit) ? Math.floor(args.limit as number) : 5; - const limit = Math.max(1, Math.min(requested, MAX_NAVIGATIONS)); + const limit = Math.max(1, Math.min(requested, maxNavigations)); const needle = args.url?.toLowerCase(); const matching = page.navigations.filter( (nav) => @@ -810,7 +811,7 @@ export function explainNavigationText( } const recent = matching.slice(-limit).reverse(); const loops = loopsOf(page).filter((loop) => recent.some((nav) => loop.ids.includes(nav.id))); - const header = `Most recent ${recent.length} of ${matching.length} navigation(s), newest first. Guards lists the candidates: canDeactivate guards of the page being left (marked "leaving") and canActivate/canActivateChild guards of the target; without per-guard instrumentation the router reports one result for all of them. Turn instrumentation on (navigate tool, action "instrument") to see each guard's verdict.${page.instrumented ? ' Instrumentation is on.' : ''}`; + const header = `Most recent ${recent.length} of ${matching.length} navigation(s), newest first. Guards lists the candidates: canDeactivate guards of the page being left (marked "leaving") and canActivate/canActivateChild guards of the target; without per-guard instrumentation the router reports one result for all of them. ${page.instrumented ? 'Instrumentation is on, so each guard and resolver run is listed with its verdict.' : 'Instrumentation is off: turn it on (navigate tool, action "instrument") to see each guard\'s verdict.'}`; return capped( `${UNTRUSTED}\n\n${header}\n\n${loopsText(loops)}${recent.map((nav) => describeNavigation(nav, page)).join('\n')}${droppedNote(page.dropped, 'navigations', 'navigations')}${otherPages(state, page)}${freshness(page, now)}`, ); diff --git a/src/app/examples/forms-example.ts b/src/app/examples/forms-example.ts index a14e60bb..be6c182e 100644 --- a/src/app/examples/forms-example.ts +++ b/src/app/examples/forms-example.ts @@ -1,17 +1,25 @@ import { Component } from '@angular/core'; import { ExamplePage } from './example-page'; import { ReactiveFormExample } from './reactive-form-example'; +import { ShadowFormExample } from './shadow-form-example'; import { SignalFormExample } from './signal-form-example'; import { TemplateFormExample } from './template-form-example'; @Component({ selector: 'app-forms-example', - imports: [ExamplePage, SignalFormExample, ReactiveFormExample, TemplateFormExample], + imports: [ + ExamplePage, + SignalFormExample, + ReactiveFormExample, + TemplateFormExample, + ShadowFormExample, + ], template: ` All three kinds of Angular form on one page: Signal Forms, reactive forms and - template-driven forms, each with nested fields, a list and failing validators. + template-driven forms, each with nested fields, a list and failing validators, plus a Signal + Form inside a shadow root. Type into the fields and watch validity, touched and dirty change in the Forms tab. @@ -21,6 +29,7 @@ import { TemplateFormExample } from './template-form-example'; + `, diff --git a/src/app/examples/shadow-form-example.ts b/src/app/examples/shadow-form-example.ts new file mode 100644 index 00000000..b1201e4b --- /dev/null +++ b/src/app/examples/shadow-form-example.ts @@ -0,0 +1,33 @@ +import { Component, ViewEncapsulation, signal } from '@angular/core'; +import { form, FormField, FormRoot, required } from '@angular/forms/signals'; + +/** A Signal Form inside a shadow root, to check that the Forms tab still finds it. */ +@Component({ + selector: 'app-shadow-form-example', + imports: [FormField, FormRoot], + encapsulation: ViewEncapsulation.ShadowDom, + template: ` +
+

Shadow DOM

+ +
+ @for (error of address.city().errors(); track error.kind) { +

{{ error.message }}

+ } +
+
+ `, + styleUrl: './forms-example.css', +}) +export class ShadowFormExample { + readonly address = form(signal({ city: '' }), (p) => { + required(p.city, { message: 'City is required' }); + }); +}