From ec6dd5bf0b7e9fec31285a3488668a47fcdc97b0 Mon Sep 17 00:00:00 2001 From: Kam Date: Sat, 10 Oct 2026 00:21:14 +0300 Subject: [PATCH 1/3] fix(forms): escape dots in control keys so paths round trip Control paths were built and parsed by joining and splitting on a dot, so a control keyed a.b was read back as a then b and read, fill, set-value, focus and the WebMCP tools hit the wrong or no control. A literal dot in a key is now written \. and a backslash \\, through one joinPath, splitPath and childPath helper in forms-path.ts. Plain keys produce the same strings as before. --- apps/docs/src/content/inspectors/forms.md | 2 +- .../src/__tests__/forms-dotted-keys.test.ts | 285 ++++++++++++++++++ .../devtools/src/__tests__/forms-path.test.ts | 54 ++++ packages/devtools/src/forms-actions.ts | 36 ++- packages/devtools/src/forms-path.ts | 27 ++ packages/devtools/src/forms-read.ts | 4 +- packages/devtools/src/forms-webmcp.ts | 5 +- packages/devtools/src/forms.ts | 31 +- packages/devtools/src/rpc/forms-source.ts | 4 +- 9 files changed, 409 insertions(+), 39 deletions(-) create mode 100644 packages/devtools/src/__tests__/forms-dotted-keys.test.ts create mode 100644 packages/devtools/src/__tests__/forms-path.test.ts create mode 100644 packages/devtools/src/forms-path.ts diff --git a/apps/docs/src/content/inspectors/forms.md b/apps/docs/src/content/inspectors/forms.md index f1139c6d..b61ddc98 100644 --- a/apps/docs/src/content/inspectors/forms.md +++ b/apps/docs/src/content/inspectors/forms.md @@ -165,7 +165,7 @@ You can also open a form from its component in the [Components tab](./components ## Agent tools -`form` is a form id like `Checkout.form@ab12`, or part of its label. `path` is a dotted field path, like `address.city`. +`form` is a form id like `Checkout.form@ab12`, or part of its label. `path` is a dotted field path, like `address.city`. A literal dot in a control key is written `\.` and a literal backslash `\\`, so the control keyed `a.b` has the path `a\.b`. The panel and the tool output show the same escaped form. ### Read tools diff --git a/packages/devtools/src/__tests__/forms-dotted-keys.test.ts b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts new file mode 100644 index 00000000..b6a3dd3f --- /dev/null +++ b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts @@ -0,0 +1,285 @@ +// @vitest-environment jsdom +import '@angular/compiler'; +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { FormField, form } from '@angular/forms/signals'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import { collectForms, controlPathOf, diffForms, findForms, nodeAt } from '../forms.ts'; +import { runFormAction, type ActionContext } from '../forms-actions.ts'; +import { fieldPath } from '../forms-read.ts'; +import { formSourceIn } from '../rpc/forms-source.ts'; +import { inferShape, requiredNow, schemaInputs, schemaRequired } from '../forms-webmcp.ts'; + +try { + TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting()); +} catch { + // already initialized in this worker +} + +afterEach(() => TestBed.resetTestingModule()); + +const ngApi = () => (globalThis as any).ng; + +function contextFor(host: HTMLElement): ActionContext { + const all = () => host.querySelectorAll('*'); + const found = findForms(ngApi(), all()); + const forms = new Map(found.forms.map((f, i) => [`form-${i + 1}`, f])); + return { ng: ngApi(), forms, elements: found.elements, all }; +} + +function collect(host: HTMLElement) { + return collectForms(findForms(ngApi(), host.querySelectorAll('*'))); +} + +class Dotted { + form = new FormGroup({ + 'a.b': new FormControl(''), + a: new FormGroup({ b: new FormControl('inner') }), + 'my.group': new FormGroup({ 'x.y': new FormControl(''), z: new FormControl('') }), + 'api.token': new FormControl(''), + plain: new FormControl(''), + }); +} +Component({ + selector: 'dotted-form', + imports: [ReactiveFormsModule], + template: ` +
+ +
+
+ + +
+ + +
+ `, +})(Dotted); + +class SignalDotted { + model = signal({ 'a.b': '', a: { b: 'inner' }, plain: '' }); + form = form(this.model); +} +Component({ + selector: 'signal-dotted-form', + imports: [FormField], + template: ``, +})(SignalDotted); + +async function render(type: new () => T) { + const fixture = TestBed.createComponent(type); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + return fixture; +} + +function paths(node: any, out: string[] = []): string[] { + out.push(node.path); + for (const child of node.children ?? []) paths(child, out); + return out; +} + +describe('control keys that contain a dot', () => { + it('collects escaped paths and leaves plain ones unchanged', async () => { + const fixture = await render(Dotted); + const [collected] = collect(fixture.nativeElement); + expect(paths(collected.root)).toEqual([ + '', + 'a\\.b', + 'a', + 'a.b', + 'my\\.group', + 'my\\.group.x\\.y', + 'my\\.group.z', + 'api\\.token', + 'plain', + ]); + }); + + it('maps a control back to its escaped path and the path back to the control', async () => { + const fixture = await render(Dotted); + const found = findForms(ngApi(), fixture.nativeElement.querySelectorAll('*')); + const root = fixture.componentInstance.form; + expect(controlPathOf(root as any, root.controls['my.group'].controls['x.y'] as any)).toBe( + 'my\\.group.x\\.y', + ); + expect(nodeAt(found.forms[0], 'a\\.b')).toBe(root.controls['a.b']); + expect(nodeAt(found.forms[0], 'a.b')).toBe(root.controls.a.controls.b); + }); + + it('sets, fills and focuses the dotted control, not the one it used to be read as', async () => { + const fixture = await render(Dotted); + document.body.appendChild(fixture.nativeElement); + const ctx = contextFor(fixture.nativeElement); + const group = fixture.componentInstance.form; + + const set = await runFormAction(ctx, { + action: 'set-value', + formId: 'form-1', + path: 'a\\.b', + value: 'dotted', + mode: 'user', + }); + expect(set.ok).toBe(true); + expect(group.controls['a.b'].value).toBe('dotted'); + expect(group.controls.a.controls.b.value).toBe('inner'); + + const fill = await runFormAction(ctx, { + action: 'fill', + formId: 'form-1', + values: { 'my\\.group.x\\.y': 'deep', 'a.b': 'nested' }, + }); + expect(fill.ok).toBe(true); + expect(group.controls['my.group'].controls['x.y'].value).toBe('deep'); + expect(group.controls.a.controls.b.value).toBe('nested'); + + const whole = await runFormAction(ctx, { + action: 'set-value', + formId: 'form-1', + path: 'my\\.group', + value: { 'x.y': 'whole', z: 'zed' }, + }); + expect(whole.ok).toBe(true); + expect(group.controls['my.group'].controls['x.y'].value).toBe('whole'); + + const focus = await runFormAction(ctx, { action: 'focus', formId: 'form-1', path: 'a\\.b' }); + expect(focus.ok).toBe(true); + expect(document.activeElement?.id).toBe('ab'); + fixture.nativeElement.remove(); + }); + + it('refuses a secret key by its own name and names it escaped', async () => { + const fixture = await render(Dotted); + const ctx = contextFor(fixture.nativeElement); + const result = await runFormAction(ctx, { + action: 'set-value', + formId: 'form-1', + path: 'api\\.token', + value: 'x', + }); + expect(result.ok).toBe(false); + expect(result.error).toContain('is redacted'); + expect(fixture.componentInstance.form.controls['api.token'].value).toBe(''); + + const nested = await runFormAction(ctx, { + action: 'set-value', + formId: 'form-1', + path: 'my\\.group', + value: { 'x.y': 'q', z: 'r', 'api.token': 'nope' }, + }); + expect(nested.ok).toBe(false); + expect(nested.error).toContain('secret field "api\\.token"'); + }); + + it('reports invalid paths and stored-global expressions in the escaped form', async () => { + const fixture = await render(Dotted); + const ctx = contextFor(fixture.nativeElement); + const inner = fixture.componentInstance.form.controls['my.group'].controls['x.y']; + inner.setErrors({ bad: true }); + const stored = await runFormAction(ctx, { + action: 'store-as-global', + formId: 'form-1', + path: 'my\\.group.x\\.y', + }); + expect(stored.invalid).toContain('my\\.group.x\\.y'); + expect(stored.expression).toBe("$form.get(['my.group','x.y'])"); + expect((window as any).$control).toBe(inner); + const plain = await runFormAction(ctx, { + action: 'store-as-global', + formId: 'form-1', + path: 'plain', + }); + expect(plain.expression).toBe("$form.get('plain')"); + }); + + it('diffs events at the escaped path', async () => { + const fixture = await render(Dotted); + const before = collect(fixture.nativeElement); + fixture.componentInstance.form.controls['my.group'].controls['x.y'].setValue('changed'); + const events = diffForms(before, collect(fixture.nativeElement)); + expect(events.map((e) => e.path)).toContain('my\\.group.x\\.y'); + }); + + it('works on Signal Forms keys', async () => { + const fixture = await render(SignalDotted); + document.body.appendChild(fixture.nativeElement); + const ctx = contextFor(fixture.nativeElement); + const [collected] = collect(fixture.nativeElement); + expect(paths(collected.root)).toEqual(['', 'a\\.b', 'a', 'a.b', 'plain']); + const result = await runFormAction(ctx, { + action: 'set-value', + formId: 'form-1', + path: 'a\\.b', + value: 'sig', + }); + expect(result.ok).toBe(true); + expect(fixture.componentInstance.model()['a.b']).toBe('sig'); + expect(fixture.componentInstance.model().a.b).toBe('inner'); + const focus = await runFormAction(ctx, { action: 'focus', formId: 'form-1', path: 'a\\.b' }); + expect(focus.ok).toBe(true); + const stored = await runFormAction(ctx, { + action: 'store-as-global', + formId: 'form-1', + path: 'a\\.b', + }); + expect(stored.expression).toBe("$form['a.b']"); + fixture.nativeElement.remove(); + }); + + it('builds a Signal Forms field path with an escaped key', async () => { + const fixture = await render(SignalDotted); + const found = findForms(ngApi(), fixture.nativeElement.querySelectorAll('*')); + const field = (found.forms[0].root as any).fieldTree['a.b'](); + expect(fieldPath(field)).toBe('a\\.b'); + }); +}); + +describe('WebMCP and source paths with dotted keys', () => { + it('escapes dotted keys in inferred, required and input paths', () => { + expect(inferShape({ 'a.b': null, ok: { 'c.d': undefined } }).blocking).toEqual([ + { path: 'a\\.b', reason: 'null' }, + { path: 'ok.c\\.d', reason: 'undefined' }, + ]); + const schema = { + type: 'object', + properties: { + 'a.b': { type: 'string' }, + n: { type: 'object', properties: { 'c.d': { type: 'number' } }, required: ['c.d'] }, + }, + required: ['a.b'], + }; + expect(schemaInputs(schema)).toEqual(['a\\.b: string', 'n.c\\.d: number']); + expect(schemaRequired(schema)).toEqual(['a\\.b', 'n.c\\.d']); + }); + + it('keeps requiredNow in step with schemaRequired for dotted keys', () => { + const node = (value: unknown, key: string): any => ({ + value: () => value, + required: () => true, + keyInParent: () => key, + structure: { + materializedChildren: () => + value && typeof value === 'object' + ? Object.entries(value).map(([k, v]) => node(v, k)) + : [], + }, + }); + expect([...requiredNow(node({ 'a.b': 'x' }, '')).keys()]).toEqual(['a\\.b']); + }); + + it('looks up the source rule by the unescaped key, not its last dotted piece', () => { + const file = ` +export class Account { + account = new FormGroup({ + b: new FormControl('', Validators.required), + }); +} +`; + expect(formSourceIn(file, 'a.ts', 'Account', 'account', 'a\\.b')?.rules).toEqual([]); + expect(formSourceIn(file, 'a.ts', 'Account', 'account', 'b')?.rules).toHaveLength(1); + }); +}); diff --git a/packages/devtools/src/__tests__/forms-path.test.ts b/packages/devtools/src/__tests__/forms-path.test.ts new file mode 100644 index 00000000..7307dfc6 --- /dev/null +++ b/packages/devtools/src/__tests__/forms-path.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { childPath, joinPath, splitPath } from '../forms-path.ts'; + +describe('form control paths', () => { + it('keeps plain keys exactly as they were joined before', () => { + expect(joinPath(['address', 'city'])).toBe('address.city'); + expect(joinPath(['items', '0', 'name'])).toBe('items.0.name'); + expect(joinPath(['name'])).toBe('name'); + expect(joinPath([])).toBe(''); + expect(splitPath('address.city')).toEqual(['address', 'city']); + expect(splitPath('items.0.name')).toEqual(['items', '0', 'name']); + expect(splitPath('')).toEqual([]); + }); + + it('writes a literal dot as \\. and a literal backslash as \\\\', () => { + expect(joinPath(['a.b'])).toBe('a\\.b'); + expect(joinPath(['a\\b'])).toBe('a\\\\b'); + expect(joinPath(['a.b', 'c'])).toBe('a\\.b.c'); + expect(joinPath(['a', 'b.c'])).toBe('a.b\\.c'); + expect(joinPath(['\\.'])).toBe('\\\\\\.'); + }); + + it('reads an escaped path back into its segments', () => { + expect(splitPath('a\\.b')).toEqual(['a.b']); + expect(splitPath('a\\.b.c')).toEqual(['a.b', 'c']); + expect(splitPath('a\\\\.b')).toEqual(['a\\', 'b']); + expect(splitPath('a\\\\\\.b')).toEqual(['a\\.b']); + }); + + it('round trips dots, backslashes, empty and numeric segments', () => { + const cases = [ + ['a.b'], + ['a.b', 'c.d'], + ['x', 'a\\b', 'y'], + ['\\'], + ['.'], + ['..'], + ['a', ''], + ['', 'a'], + ['a', '', 'b'], + ['0', '1.5', '2'], + ['\\.', '.\\'], + ['trailing\\'], + ]; + for (const segments of cases) expect(splitPath(joinPath(segments))).toEqual(segments); + }); + + it('appends one key to an existing path', () => { + expect(childPath('', 'name')).toBe('name'); + expect(childPath('', 'a.b')).toBe('a\\.b'); + expect(childPath('g', 'a.b')).toBe('g.a\\.b'); + expect(childPath('g\\.h', 'c')).toBe('g\\.h.c'); + }); +}); diff --git a/packages/devtools/src/forms-actions.ts b/packages/devtools/src/forms-actions.ts index d8d0159a..80163dfa 100644 --- a/packages/devtools/src/forms-actions.ts +++ b/packages/devtools/src/forms-actions.ts @@ -15,6 +15,7 @@ import { unmaskHint, type RedactReason, } from './forms-privacy.ts'; +import { childPath, splitPath } from './forms-path.ts'; import { fieldPath, submitSetup } from './forms-read.ts'; import { clip } from './text.ts'; @@ -109,7 +110,7 @@ export function secretInside( if (!value || typeof value !== 'object' || value instanceof Date || seen.has(value)) return null; seen.add(value); for (const [key, child] of Object.entries(value as AnyRecord)) { - const path = prefix ? `${prefix}.${key}` : key; + const path = childPath(prefix, key); if (!/^\d+$/.test(key) && isRedactedKey(key)) return path; const nested = secretInside(child, path, seen); if (nested) return nested; @@ -134,7 +135,7 @@ export function keepSecrets( } function keysOf(path: string): string[] { - return path ? path.split('.') : []; + return splitPath(path); } export function valueAt(value: unknown, path: string): unknown { @@ -156,11 +157,14 @@ function hasPath(value: unknown, path: string): boolean { } export function setAt(value: unknown, path: string, next: unknown): unknown { - const keys = keysOf(path); + return setIn(value, keysOf(path), next); +} + +function setIn(value: unknown, keys: string[], next: unknown): unknown { if (!keys.length) return next; if (!value || typeof value !== 'object') return value; const copy: AnyRecord = Array.isArray(value) ? [...value] : { ...(value as AnyRecord) }; - copy[keys[0]] = setAt(copy[keys[0]], keys.slice(1).join('.'), next); + copy[keys[0]] = setIn(copy[keys[0]], keys.slice(1), next); return copy; } @@ -302,12 +306,12 @@ function secretOf( path: string, element: Element | null, ): { key: string; reason: RedactReason } | null { - const keys = path.split('.'); + const keys = splitPath(path); for (const [i, key] of keys.entries()) { const reason = redactReason(key); if (reason) return { key, reason: i < keys.length - 1 ? 'parent' : reason }; } - const key = keys.at(-1)!; + const key = keys.at(-1) ?? ''; const reason = element && redactReason(key, element); return reason ? { key, reason } : null; } @@ -386,7 +390,7 @@ export function guardedFields( return; } for (const [key, child] of childrenOf(found, current)) { - visit(child, currentPath ? `${currentPath}.${key}` : key); + visit(child, childPath(currentPath, key)); } }; visit(node, path); @@ -502,7 +506,7 @@ function writeValue( const current = rawValue(found, node); const secret = secretInside(value); if (secret) { - const key = secret.split('.').pop()!; + const key = splitPath(secret).pop()!; const reason = redactReason(key) ?? 'key'; return `contains the secret field "${secret}" (${REDACT_LABELS[reason]}). Pangular Inspector never writes secret fields; to write it, ${unmaskHint(reason, key)}`; } @@ -573,7 +577,7 @@ function invalidPaths(found: FoundForm): string[] { if (ownErrors) out.push(path); for (const child of children) { const key = String(read(() => child['keyInParent'](), '')); - visit(child, path ? `${path}.${key}` : key, depth + 1); + visit(child, childPath(path, key), depth + 1); } return; } @@ -584,7 +588,7 @@ function invalidPaths(found: FoundForm): string[] { const entries: [string, AnyRecord][] = Array.isArray(controls) ? controls.map((c, i) => [String(i), c]) : Object.entries(controls); - for (const [key, child] of entries) visit(child, path ? `${path}.${key}` : key, depth + 1); + for (const [key, child] of entries) visit(child, childPath(path, key), depth + 1); }; visit(found.root, '', 0); return out; @@ -619,13 +623,17 @@ function directiveWith(ctx: ActionContext, element: Element | undefined, method: function expressionFor(found: FoundForm, path: string): string { if (!path) return '$form'; + const keys = splitPath(path); + const quote = (key: string) => `'${key.replace(/'/g, "\\'")}'`; if (found.kind === 'signal') { - return `$form${path - .split('.') - .map((key) => (/^\d+$/.test(key) ? `[${key}]` : `.${key}`)) + return `$form${keys + .map((key) => + /^\d+$/.test(key) ? `[${key}]` : key.includes('.') ? `[${quote(key)}]` : `.${key}`, + ) .join('')}`; } - return `$form.get('${path.replace(/'/g, "\\'")}')`; + if (keys.some((key) => key.includes('.'))) return `$form.get([${keys.map(quote).join(',')}])`; + return `$form.get(${quote(keys.join('.'))})`; } function locate(ctx: ActionContext, selector: string): FormActionResult { diff --git a/packages/devtools/src/forms-path.ts b/packages/devtools/src/forms-path.ts new file mode 100644 index 00000000..5452a12d --- /dev/null +++ b/packages/devtools/src/forms-path.ts @@ -0,0 +1,27 @@ +function escapeKey(key: string): string { + return key.replace(/[\\.]/g, '\\$&'); +} + +export function joinPath(segments: readonly string[]): string { + return segments.map(escapeKey).join('.'); +} + +export function childPath(path: string, key: string): string { + return path ? `${path}.${escapeKey(key)}` : escapeKey(key); +} + +export function splitPath(path: string): string[] { + if (!path) return []; + const segments: string[] = []; + let current = ''; + for (let i = 0; i < path.length; i++) { + const char = path[i]; + if (char === '\\' && i + 1 < path.length) current += path[++i]; + else if (char === '.') { + segments.push(current); + current = ''; + } else current += char; + } + segments.push(current); + return segments; +} diff --git a/packages/devtools/src/forms-read.ts b/packages/devtools/src/forms-read.ts index c02e17bd..dfc65f95 100644 --- a/packages/devtools/src/forms-read.ts +++ b/packages/devtools/src/forms-read.ts @@ -1,3 +1,5 @@ +import { joinPath } from './forms-path.ts'; + type AnyRecord = Record; export type ErrorSource = @@ -107,7 +109,7 @@ export function fieldPath(node: AnyRecord): string { keys.unshift(String(read(() => current!['keyInParent'](), '?'))); current = parent; } - return keys.join('.'); + return joinPath(keys); } function originLevel(node: AnyRecord, error: unknown, raw: string): AnyRecord { diff --git a/packages/devtools/src/forms-webmcp.ts b/packages/devtools/src/forms-webmcp.ts index cd68cd91..78d985b5 100644 --- a/packages/devtools/src/forms-webmcp.ts +++ b/packages/devtools/src/forms-webmcp.ts @@ -1,5 +1,6 @@ import { redactFormText } from './forms.ts'; import { redactMessage } from './forms-privacy.ts'; +import { childPath } from './forms-path.ts'; import { tokenName, type DebugNg } from './injector-tree.ts'; type AnyRecord = Record; @@ -83,9 +84,7 @@ function read(fn: () => T, fallback: T): T { } } -function join(path: string, key: string): string { - return path ? `${path}.${key}` : key; -} +const join = childPath; export function inferShape(value: unknown, path = '', depth = 0): Shape { if (typeof value === 'string') return { schema: { type: 'string' }, blocking: [] }; diff --git a/packages/devtools/src/forms.ts b/packages/devtools/src/forms.ts index 3e47696a..337fdcdb 100644 --- a/packages/devtools/src/forms.ts +++ b/packages/devtools/src/forms.ts @@ -1,5 +1,6 @@ import { domFacts, submitDom, type DomFacts, type SubmitDom } from './forms-dom.ts'; import { clip } from './text.ts'; +import { childPath, joinPath, splitPath } from './forms-path.ts'; import type { WebMcpTool } from './forms-webmcp.ts'; import { REDACTED, @@ -548,14 +549,7 @@ export function serializeControl( node.children = entries .slice(0, MAX_CHILDREN) .map(([childKey, child]) => - serializeControl( - child, - elements, - childKey, - path ? `${path}.${childKey}` : childKey, - depth + 1, - secret, - ), + serializeControl(child, elements, childKey, childPath(path, childKey), depth + 1, secret), ); if (entries.length > MAX_CHILDREN) node.truncated = entries.length - MAX_CHILDREN; return node; @@ -583,7 +577,7 @@ function issuePath(issue: AnyRecord): string | undefined { const keys = parts.map((part) => part && typeof part === 'object' ? String((part as AnyRecord)['key']) : String(part), ); - return keys.length ? keys.join('.') : undefined; + return keys.length ? joinPath(keys) : undefined; } function signalError( @@ -613,7 +607,7 @@ export function errorSummaryOf(root: AnyRecord): FormErrorSummary[] { return errors.slice(0, 50).map((error) => { const target = read(() => error['fieldTree']() as AnyRecord, null); const path = target ? fieldPath(target) : ''; - const secret = path.split('.').some((key) => isRedactedKey(key)); + const secret = splitPath(path).some((key) => isRedactedKey(key)); const { kind, message } = signalError(error, 'control', secret); return { path, kind, message }; }); @@ -759,11 +753,11 @@ export function serializeField( (childKey) => created.has(childKey) || (value as AnyRecord)[childKey] !== undefined, ); node.children = fieldKeys.slice(0, MAX_CHILDREN).map((childKey) => { - const childPath = path ? `${path}.${childKey}` : childKey; + const nextPath = childPath(path, childKey); const child = created.get(childKey); return child - ? serializeField(child, elements, childKey, childPath, depth + 1, secret) - : unmaterializedField((value as AnyRecord)[childKey], childKey, childPath, secret); + ? serializeField(child, elements, childKey, nextPath, depth + 1, secret) + : unmaterializedField((value as AnyRecord)[childKey], childKey, nextPath, secret); }); if (fieldKeys.length > MAX_CHILDREN) node.truncated = fieldKeys.length - MAX_CHILDREN; return node; @@ -1086,7 +1080,8 @@ function byUid(node: FormFieldNode, out = new Map()) { } function parentPath(path: string): string { - return path.includes('.') ? path.slice(0, path.lastIndexOf('.')) : ''; + const keys = splitPath(path); + return keys.length > 1 ? joinPath(keys.slice(0, -1)) : ''; } export function diffForms( @@ -1198,7 +1193,7 @@ export function controlPathOf(root: AnyRecord, target: AnyRecord): string { keys.unshift(key); current = parent; } - return keys.join('.'); + return joinPath(keys); } export interface ControlEventOptions { @@ -1219,7 +1214,7 @@ export function controlEventOf( const base = { formId, path, timestamp: now }; const of = (key: string) => read(() => (source ?? event)[key], event[key]); if ('value' in event) { - const keys = [rootKey, ...(path ? path.split('.') : [])]; + const keys = [rootKey, ...splitPath(path)]; const key = keys[keys.length - 1]; const secret = keys.slice(0, -1).some((k) => isRedactedKey(k)); const value = isAbstractControl(source) @@ -1290,7 +1285,7 @@ export function nodeAt( path: string, create = false, ): AnyRecord | null { - const keys = path ? path.split('.') : []; + const keys = splitPath(path); if (found.kind === 'signal' && create) { let tree: unknown = read(() => found.root['fieldTree'], null); for (const key of keys) { @@ -1316,7 +1311,7 @@ export function findFieldElement( known: WeakMap = new WeakMap(), ): Element | null { let node: AnyRecord | null = found.root; - for (const key of path ? path.split('.') : []) { + for (const key of splitPath(path)) { node = node && childAt(node, found.kind, key); } if (!node) return null; diff --git a/packages/devtools/src/rpc/forms-source.ts b/packages/devtools/src/rpc/forms-source.ts index c485a5ae..a5abeaa9 100644 --- a/packages/devtools/src/rpc/forms-source.ts +++ b/packages/devtools/src/rpc/forms-source.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; +import { splitPath } from '../forms-path.ts'; import { lineCounter, skipString, sourceRoots, stripComments, walkFiles } from './source-scan.ts'; export interface SourceLine { @@ -141,8 +142,7 @@ export function formSourceIn( ? new RegExp(`\\b${escape(property)}\\s*(?::[^=;]+)?=`).exec(scope) : null; result.form = at(body.start + (declared ? declared.index : 0)); - const key = path - .split('.') + const key = splitPath(path) .filter((k) => !/^\d+$/.test(k)) .pop(); if (key) fieldRules(scope, body.start, key, at, result.rules); From 34925bf02ba2092317f53bd2024ee6e68f36b338 Mon Sep 17 00:00:00 2001 From: Kam Date: Sat, 10 Oct 2026 19:30:20 +0300 Subject: [PATCH 2/3] fix(forms): escape backslashes in expressions and match quoted source keys The stored-global expression now escapes a backslash and a quote in each key, and Signal Forms use bracket notation for keys with a dot or a backslash. The source rule lookup also matches single and double quoted keys such as 'a.b': new FormControl(). --- .../src/__tests__/forms-dotted-keys.test.ts | 69 +++++++++++++++++++ packages/devtools/src/forms-actions.ts | 4 +- packages/devtools/src/rpc/forms-source.ts | 4 +- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/packages/devtools/src/__tests__/forms-dotted-keys.test.ts b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts index b6a3dd3f..aa4bcd42 100644 --- a/packages/devtools/src/__tests__/forms-dotted-keys.test.ts +++ b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts @@ -69,6 +69,27 @@ Component({ template: ``, })(SignalDotted); +class Slashed { + form = new FormGroup({ 'a\\b': new FormControl(''), 'x.y': new FormControl('') }); +} +Component({ + selector: 'slashed-form', + imports: [ReactiveFormsModule], + template: `
+ +
`, +})(Slashed); + +class SignalSlashed { + model = signal({ 'a\\b': '' }); + form = form(this.model); +} +Component({ + selector: 'signal-slashed-form', + imports: [FormField], + template: ``, +})(SignalSlashed); + async function render(type: new () => T) { const fixture = TestBed.createComponent(type); fixture.detectChanges(); @@ -282,4 +303,52 @@ export class Account { expect(formSourceIn(file, 'a.ts', 'Account', 'account', 'a\\.b')?.rules).toEqual([]); expect(formSourceIn(file, 'a.ts', 'Account', 'account', 'b')?.rules).toHaveLength(1); }); + + it('matches single and double quoted dotted keys in the source', () => { + const file = ` +export class Account { + account = new FormGroup({ + 'a.b': new FormControl('', Validators.required), + "c.d": new FormControl('', Validators.required), + b: new FormControl(''), + }); +} +`; + expect( + formSourceIn(file, 'a.ts', 'Account', 'account', 'a\\.b')?.rules.map((r) => r.line), + ).toEqual([4]); + expect( + formSourceIn(file, 'a.ts', 'Account', 'account', 'c\\.d')?.rules.map((r) => r.line), + ).toEqual([5]); + }); +}); + +describe('stored-global expressions with backslashes in keys', () => { + it('writes a backslash as an escaped character in the reactive expression', async () => { + const fixture = await render(Slashed); + const ctx = contextFor(fixture.nativeElement); + const slash = await runFormAction(ctx, { + action: 'store-as-global', + formId: 'form-1', + path: 'a\\\\b', + }); + expect(slash.expression).toBe(String.raw`$form.get('a\\b')`); + const mixed = await runFormAction(ctx, { + action: 'store-as-global', + formId: 'form-1', + path: 'x\\.y', + }); + expect(mixed.expression).toBe("$form.get(['x.y'])"); + }); + + it('uses bracket notation with an escaped backslash for Signal Forms', async () => { + const fixture = await render(SignalSlashed); + const ctx = contextFor(fixture.nativeElement); + const result = await runFormAction(ctx, { + action: 'store-as-global', + formId: 'form-1', + path: 'a\\\\b', + }); + expect(result.expression).toBe(String.raw`$form['a\\b']`); + }); }); diff --git a/packages/devtools/src/forms-actions.ts b/packages/devtools/src/forms-actions.ts index 80163dfa..5ccf568a 100644 --- a/packages/devtools/src/forms-actions.ts +++ b/packages/devtools/src/forms-actions.ts @@ -624,11 +624,11 @@ function directiveWith(ctx: ActionContext, element: Element | undefined, method: function expressionFor(found: FoundForm, path: string): string { if (!path) return '$form'; const keys = splitPath(path); - const quote = (key: string) => `'${key.replace(/'/g, "\\'")}'`; + const quote = (key: string) => `'${key.replace(/[\\']/g, '\\$&')}'`; if (found.kind === 'signal') { return `$form${keys .map((key) => - /^\d+$/.test(key) ? `[${key}]` : key.includes('.') ? `[${quote(key)}]` : `.${key}`, + /^\d+$/.test(key) ? `[${key}]` : /[.\\]/.test(key) ? `[${quote(key)}]` : `.${key}`, ) .join('')}`; } diff --git a/packages/devtools/src/rpc/forms-source.ts b/packages/devtools/src/rpc/forms-source.ts index a5abeaa9..eab818e6 100644 --- a/packages/devtools/src/rpc/forms-source.ts +++ b/packages/devtools/src/rpc/forms-source.ts @@ -105,7 +105,9 @@ function fieldRules( at: (index: number) => SourceLine, out: SourceLine[], ) { - const field = new RegExp(`(\\.${escape(key)}\\b|\\b${escape(key)}\\s*:)`); + const field = new RegExp( + `(\\.${escape(key)}\\b|\\b${escape(key)}\\s*:|(['"])${escape(key)}\\2\\s*:)`, + ); let offset = 0; for (const text of scope.split('\n')) { if (out.length >= 10) return; From 8e04db34b9f7e182cb9199a41f8fbc8554ba57c1 Mon Sep 17 00:00:00 2001 From: Kam Date: Sat, 10 Oct 2026 20:04:15 +0300 Subject: [PATCH 3/3] fix(forms): match source keys that contain a backslash A control key with a backslash is written with two backslashes in the source literal, so the quoted-key lookup never found its rules. The lookup now compares against the escaped source spelling of the key. --- .../src/__tests__/forms-dotted-keys.test.ts | 16 ++++++++++++++++ packages/devtools/src/rpc/forms-source.ts | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/devtools/src/__tests__/forms-dotted-keys.test.ts b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts index aa4bcd42..aca2972c 100644 --- a/packages/devtools/src/__tests__/forms-dotted-keys.test.ts +++ b/packages/devtools/src/__tests__/forms-dotted-keys.test.ts @@ -323,6 +323,22 @@ export class Account { }); }); +describe('source lookup of keys with a backslash', () => { + it('matches a key whose source literal escapes the backslash', () => { + const file = [ + 'export class Account {', + ' account = new FormGroup({', + " 'a\\\\b': new FormControl('', Validators.required),", + " b: new FormControl(''),", + ' });', + '}', + ].join('\n'); + expect( + formSourceIn(file, 'a.ts', 'Account', 'account', 'a\\\\b')?.rules.map((r) => r.line), + ).toEqual([3]); + }); +}); + describe('stored-global expressions with backslashes in keys', () => { it('writes a backslash as an escaped character in the reactive expression', async () => { const fixture = await render(Slashed); diff --git a/packages/devtools/src/rpc/forms-source.ts b/packages/devtools/src/rpc/forms-source.ts index eab818e6..e38fafeb 100644 --- a/packages/devtools/src/rpc/forms-source.ts +++ b/packages/devtools/src/rpc/forms-source.ts @@ -105,8 +105,9 @@ function fieldRules( at: (index: number) => SourceLine, out: SourceLine[], ) { + const literal = key.replace(/\\/g, '\\\\'); const field = new RegExp( - `(\\.${escape(key)}\\b|\\b${escape(key)}\\s*:|(['"])${escape(key)}\\2\\s*:)`, + `(\\.${escape(key)}\\b|\\b${escape(key)}\\s*:|(['"])${escape(literal)}\\2\\s*:)`, ); let offset = 0; for (const text of scope.split('\n')) {