Visitar URL original
fix(forms): write a pending debounced value when its control is destr… · angular/angular@6cd3a8a · GitHub
Skip to content

Commit 6cd3a8a

Browse files
arturovtNothingEverHappens
authored andcommitted
fix(forms): write a pending debounced value when its control is destroyed
With `debounce(path, 'blur')` the value is only written when the control is blurred. If the focused control is destroyed first, browsers that do not fire `blur` on removal (e.g. Safari) never write it, and the typed value is lost. Flush the pending value when the control is destroyed or bound to another field, unless the field itself was removed or another control is still bound to it. Fixes #71004 (cherry picked from commit c58464d)
1 parent f864e3d commit 6cd3a8a

3 files changed

Lines changed: 130 additions & 6 deletions

File tree

‎packages/forms/signals/src/directive/form_field.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -335,9 +335,13 @@ export class FormField<T> {
335335
this as FormField<unknown>,
336336
]);
337337
onCleanup(() => {
338-
fieldNode.nodeState.formFieldBindings.update((controls) =>
339-
controls.filter((c) => c !== this),
340-
);
338+
const remaining = fieldNode.nodeState.formFieldBindings().filter((c) => c !== this);
339+
fieldNode.nodeState.formFieldBindings.set(remaining);
340+
// Once the last control is gone, nothing can blur anymore, so write any value still
341+
// waiting for a blur debounce (e.g. `debounce(path, 'blur')`).
342+
if (remaining.length === 0 && !fieldNode.structure.isOrphaned()) {
343+
fieldNode.flushSync();
344+
}
341345
});
342346
},
343347
{injector: this.injector},

‎packages/forms/signals/src/field/node.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,7 @@ export class FieldNode implements FieldState<unknown> {
117117
* first focusable binding in the DOM for any descendant node of this one.
118118
*/
119119
private getBindingForFocus():
120-
| (FormField<unknown> & {focus: (options?: FocusOptions) => void})
121-
| undefined {
120+
(FormField<unknown> & {focus: (options?: FocusOptions) => void}) | undefined {
122121
// First try to focus one of our own bindings.
123122
const own = this.formFieldBindings()
124123
.filter(
@@ -411,7 +410,7 @@ export class FieldNode implements FieldState<unknown> {
411410
/**
412411
* If there is a pending sync, abort it and sync immediately.
413412
*/
414-
private flushSync() {
413+
flushSync() {
415414
const pending = this.pendingSync();
416415
if (pending && !pending.signal.aborted) {
417416
pending.abort();

‎packages/forms/signals/test/web/form_field.spec.ts‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ function isFirefox() {
4242

4343
import {NG_STATUS_CLASSES} from '../../compat/public_api';
4444
import {
45+
applyEach,
4546
debounce,
4647
disabled,
4748
form,
@@ -6146,6 +6147,126 @@ describe('field directive', () => {
61466147
expect(input.value).toBe('initial');
61476148
expect(cmp.f().value()).toEqual({child: 'initial'});
61486149
});
6150+
6151+
it('should write a value pending a blur debounce when the control is destroyed', () => {
6152+
@Component({
6153+
imports: [FormField],
6154+
template: `
6155+
@if (show()) {
6156+
<input [formField]="f" />
6157+
}
6158+
`,
6159+
})
6160+
class TestCmp {
6161+
readonly show = signal(true);
6162+
readonly f = form(signal('initial'), (p) => {
6163+
debounce(p, 'blur');
6164+
});
6165+
}
6166+
6167+
const fixture = act(() => TestBed.createComponent(TestCmp));
6168+
const input = fixture.nativeElement.querySelector('input');
6169+
const cmp = fixture.componentInstance;
6170+
6171+
act(() => {
6172+
input.value = 'typing';
6173+
input.dispatchEvent(new Event('input'));
6174+
});
6175+
expect(cmp.f().value()).toBe('initial');
6176+
6177+
act(() => cmp.show.set(false));
6178+
expect(cmp.f().value()).toBe('typing');
6179+
expect(cmp.f().touched()).toBe(false);
6180+
});
6181+
6182+
it('should write a value pending a blur debounce when the control is bound to another field', () => {
6183+
@Component({
6184+
imports: [FormField],
6185+
template: `<input [formField]="useFirst() ? f.first : f.second" />`,
6186+
})
6187+
class TestCmp {
6188+
readonly useFirst = signal(true);
6189+
readonly f = form(signal({first: '', second: ''}), (p) => {
6190+
debounce(p.first, 'blur');
6191+
});
6192+
}
6193+
6194+
const fixture = act(() => TestBed.createComponent(TestCmp));
6195+
const input = fixture.nativeElement.querySelector('input');
6196+
const cmp = fixture.componentInstance;
6197+
6198+
act(() => {
6199+
input.value = 'typing';
6200+
input.dispatchEvent(new Event('input'));
6201+
});
6202+
expect(cmp.f().value()).toEqual({first: '', second: ''});
6203+
6204+
act(() => cmp.useFirst.set(false));
6205+
expect(cmp.f().value()).toEqual({first: 'typing', second: ''});
6206+
});
6207+
6208+
it('should not write a value pending a blur debounce when its field is removed', () => {
6209+
@Component({
6210+
imports: [FormField],
6211+
template: `
6212+
@for (item of f; track $index) {
6213+
<input [formField]="item" />
6214+
}
6215+
`,
6216+
})
6217+
class TestCmp {
6218+
readonly model = signal(['a', 'b']);
6219+
readonly f = form(this.model, (p) => {
6220+
applyEach(p, (item) => debounce(item, 'blur'));
6221+
});
6222+
}
6223+
6224+
const fixture = act(() => TestBed.createComponent(TestCmp));
6225+
const input = fixture.nativeElement.querySelectorAll('input')[1];
6226+
const cmp = fixture.componentInstance;
6227+
6228+
act(() => {
6229+
input.value = 'typing';
6230+
input.dispatchEvent(new Event('input'));
6231+
});
6232+
6233+
act(() => cmp.model.set(['a']));
6234+
expect(cmp.model()).toEqual(['a']);
6235+
});
6236+
6237+
it('should not write a value pending a blur debounce while another control is still bound', () => {
6238+
@Component({
6239+
imports: [FormField],
6240+
template: `
6241+
@if (show()) {
6242+
<input id="first" [formField]="f" />
6243+
}
6244+
<input id="second" [formField]="f" />
6245+
`,
6246+
})
6247+
class TestCmp {
6248+
readonly show = signal(true);
6249+
readonly f = form(signal('initial'), (p) => {
6250+
debounce(p, 'blur');
6251+
});
6252+
}
6253+
6254+
const fixture = act(() => TestBed.createComponent(TestCmp));
6255+
const second = fixture.nativeElement.querySelector('input#second');
6256+
const cmp = fixture.componentInstance;
6257+
6258+
act(() => {
6259+
second.value = 'typing';
6260+
second.dispatchEvent(new Event('input'));
6261+
});
6262+
expect(cmp.f().value()).toBe('initial');
6263+
6264+
act(() => cmp.show.set(false));
6265+
expect(cmp.f().value()).toBe('initial');
6266+
6267+
act(() => second.dispatchEvent(new Event('blur')));
6268+
expect(cmp.f().value()).toBe('typing');
6269+
});
61496270
});
61506271

61516272
describe('config', () => {

0 commit comments

Comments
 (0)