From 0e462e1641c4d1707c9e06d62bbc1ec9521bda25 Mon Sep 17 00:00:00 2001 From: Subhanu Majumder Date: Wed, 22 Apr 2026 12:11:19 +0530 Subject: [PATCH 1/7] fix(field): recompute isDirty when value matches initial --- lib/src/form_builder_field.dart | 2 +- test/src/form_builder_field_test.dart | 52 +++++++++++++++++++++++++++ test/src/form_builder_test.dart | 21 +++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/lib/src/form_builder_field.dart b/lib/src/form_builder_field.dart index 11824b110..2c47dd8d5 100644 --- a/lib/src/form_builder_field.dart +++ b/lib/src/form_builder_field.dart @@ -185,7 +185,7 @@ class FormBuilderFieldState, T> void _informFormForFieldChange() { if (_formBuilderState != null) { - _dirty = true; + _dirty = value != initialValue; if (enabled || readOnly) { _formBuilderState!.setInternalFieldValue(widget.name, value); return; diff --git a/test/src/form_builder_field_test.dart b/test/src/form_builder_field_test.dart index ef9a31b0a..4829a98d0 100644 --- a/test/src/form_builder_field_test.dart +++ b/test/src/form_builder_field_test.dart @@ -419,6 +419,27 @@ void main() { expect(textFieldKey.currentState?.isDirty, true); }, ); + testWidgets('Should not dirty when value returns to initial by user', ( + tester, + ) async { + const textFieldName = 'text'; + final textFieldKey = GlobalKey(); + final testWidget = FormBuilderTextField( + name: textFieldName, + key: textFieldKey, + initialValue: 'hi', + ); + await tester.pumpWidget(buildTestableFieldWidget(testWidget)); + + final widgetFinder = find.byWidget(testWidget); + await tester.enterText(widgetFinder, 'test'); + await tester.pumpAndSettle(); + expect(textFieldKey.currentState?.isDirty, true); + + await tester.enterText(widgetFinder, 'hi'); + await tester.pumpAndSettle(); + expect(textFieldKey.currentState?.isDirty, false); + }); testWidgets('Should not dirty when reset field value', (tester) async { const textFieldName = 'text'; final textFieldKey = GlobalKey(); @@ -481,6 +502,37 @@ void main() { }); }); group('reset -', () { + testWidgets( + 'Should avoid reset recursion when value returns to initial in onChanged', + (tester) async { + const textFieldName = 'text'; + final textFieldKey = GlobalKey(); + var onChangedCalls = 0; + final testWidget = FormBuilderTextField( + name: textFieldName, + key: textFieldKey, + initialValue: 'hi', + onChanged: (value) { + onChangedCalls++; + final state = textFieldKey.currentState; + if (value == state?.initialValue && state?.isDirty == true) { + state?.reset(); + } + }, + ); + await tester.pumpWidget(buildTestableFieldWidget(testWidget)); + + final widgetFinder = find.byWidget(testWidget); + await tester.enterText(widgetFinder, 'test'); + await tester.pumpAndSettle(); + await tester.enterText(widgetFinder, 'hi'); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(textFieldKey.currentState?.isDirty, false); + expect(onChangedCalls, equals(2)); + }, + ); testWidgets('Should reset to null when call reset', (tester) async { const textFieldName = 'text'; final textFieldKey = GlobalKey(); diff --git a/test/src/form_builder_test.dart b/test/src/form_builder_test.dart index 4eb79e0e5..2883452c6 100644 --- a/test/src/form_builder_test.dart +++ b/test/src/form_builder_test.dart @@ -444,6 +444,27 @@ void main() { expect(formKey.currentState?.isDirty, true); }); + testWidgets('Should not dirty when value returns to initial by user', ( + tester, + ) async { + const textFieldName = 'text'; + final testWidget = FormBuilderTextField(name: textFieldName); + await tester.pumpWidget( + buildTestableFieldWidget( + testWidget, + initialValue: {textFieldName: 'hi'}, + ), + ); + + final widgetFinder = find.byWidget(testWidget); + await tester.enterText(widgetFinder, 'test'); + await tester.pumpAndSettle(); + expect(formKey.currentState?.isDirty, true); + + await tester.enterText(widgetFinder, 'hi'); + await tester.pumpAndSettle(); + expect(formKey.currentState?.isDirty, false); + }); testWidgets('Should dirty when update field with initial value by method', ( tester, ) async { From fc232d626e700aa3d4e2e1a3a974a64db6b7675e Mon Sep 17 00:00:00 2001 From: Subhanu Majumder Date: Wed, 22 Apr 2026 12:12:41 +0530 Subject: [PATCH 2/7] fix: set dirty to false before didChange --- lib/src/form_builder_field.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/form_builder_field.dart b/lib/src/form_builder_field.dart index 2c47dd8d5..679e0622f 100644 --- a/lib/src/form_builder_field.dart +++ b/lib/src/form_builder_field.dart @@ -224,8 +224,8 @@ class FormBuilderFieldState, T> /// Also reset custom error text if exists, and set [isDirty] to `false`. void reset() { super.reset(); - didChange(initialValue); _dirty = false; + didChange(initialValue); if (_customErrorText != null) { setState(() => _customErrorText = null); } From d0f07cb396b4bf614d0564252873d8e53ab0705c Mon Sep 17 00:00:00 2001 From: Subhanu Majumder Date: Wed, 22 Apr 2026 15:43:08 +0530 Subject: [PATCH 3/7] fix(tests): verify reset() recursion avoidance --- test/src/form_builder_field_test.dart | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/src/form_builder_field_test.dart b/test/src/form_builder_field_test.dart index 4829a98d0..211e44481 100644 --- a/test/src/form_builder_field_test.dart +++ b/test/src/form_builder_field_test.dart @@ -503,11 +503,13 @@ void main() { }); group('reset -', () { testWidgets( - 'Should avoid reset recursion when value returns to initial in onChanged', + 'Should avoid reset recursion when reset is called after dirty state', (tester) async { const textFieldName = 'text'; final textFieldKey = GlobalKey(); var onChangedCalls = 0; + var onChangedCallsBeforeReset = 0; + bool? isDirtyInResetOnChanged; final testWidget = FormBuilderTextField( name: textFieldName, key: textFieldKey, @@ -515,8 +517,8 @@ void main() { onChanged: (value) { onChangedCalls++; final state = textFieldKey.currentState; - if (value == state?.initialValue && state?.isDirty == true) { - state?.reset(); + if (value == state?.initialValue) { + isDirtyInResetOnChanged = state?.isDirty; } }, ); @@ -525,12 +527,18 @@ void main() { final widgetFinder = find.byWidget(testWidget); await tester.enterText(widgetFinder, 'test'); await tester.pumpAndSettle(); - await tester.enterText(widgetFinder, 'hi'); + + expect(textFieldKey.currentState?.isDirty, true); + onChangedCallsBeforeReset = onChangedCalls; + + textFieldKey.currentState?.reset(); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); + expect(textFieldKey.currentState?.value, equals('hi')); expect(textFieldKey.currentState?.isDirty, false); - expect(onChangedCalls, equals(2)); + expect(onChangedCalls, equals(onChangedCallsBeforeReset + 1)); + expect(isDirtyInResetOnChanged, isFalse); }, ); testWidgets('Should reset to null when call reset', (tester) async { From 409df571cde77f80dbbb8ed8c3cda4006c9eb7e6 Mon Sep 17 00:00:00 2001 From: Subhanu Majumder Date: Wed, 22 Apr 2026 15:48:07 +0530 Subject: [PATCH 4/7] fix: equality check for iteratable data types --- lib/src/form_builder_field.dart | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/src/form_builder_field.dart b/lib/src/form_builder_field.dart index 679e0622f..0e896f1c4 100644 --- a/lib/src/form_builder_field.dart +++ b/lib/src/form_builder_field.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; @@ -185,7 +186,7 @@ class FormBuilderFieldState, T> void _informFormForFieldChange() { if (_formBuilderState != null) { - _dirty = value != initialValue; + _dirty = !_hasSameValue(value, initialValue); if (enabled || readOnly) { _formBuilderState!.setInternalFieldValue(widget.name, value); return; @@ -194,6 +195,19 @@ class FormBuilderFieldState, T> } } + bool _hasSameValue(T? currentValue, T? initialValue) { + if (currentValue is List && initialValue is List) { + return listEquals(currentValue, initialValue); + } + if (currentValue is Set && initialValue is Set) { + return setEquals(currentValue, initialValue); + } + if (currentValue is Map && initialValue is Map) { + return mapEquals(currentValue, initialValue); + } + return currentValue == initialValue; + } + void _touchedHandler() { if (effectiveFocusNode.hasFocus && _touched == false) { setState(() => _touched = true); From 819803b8e867d63c4aea10f7a9ff2ec896ce1bb8 Mon Sep 17 00:00:00 2001 From: Subhanu Majumder Date: Wed, 22 Apr 2026 15:49:15 +0530 Subject: [PATCH 5/7] test(FormBuilderCheckboxGroup): equality check --- .../form_builder_checkbox_group_test.dart | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/src/fields/form_builder_checkbox_group_test.dart b/test/src/fields/form_builder_checkbox_group_test.dart index 2a2d455db..bded24a6f 100644 --- a/test/src/fields/form_builder_checkbox_group_test.dart +++ b/test/src/fields/form_builder_checkbox_group_test.dart @@ -166,6 +166,34 @@ void main() { expect(checkbox2.value, false); expect(checkbox3.value, true); }); + testWidgets( + 'Should not dirty when didChange receives a list matching initial contents', + (WidgetTester tester) async { + const fieldName = 'cbg1'; + final testWidget = FormBuilderCheckboxGroup( + name: fieldName, + options: const [ + FormBuilderFieldOption(key: ValueKey('1'), value: 1), + FormBuilderFieldOption(key: ValueKey('2'), value: 2), + FormBuilderFieldOption(key: ValueKey('3'), value: 3), + ], + ); + await tester.pumpWidget( + buildTestableFieldWidget( + testWidget, + initialValue: const {fieldName: [1, 3]}, + ), + ); + + final state = formKey.currentState?.fields[fieldName]; + expect(state?.isDirty, false); + + formFieldDidChange(fieldName, [1, 3]); + await tester.pumpAndSettle(); + + expect(state?.isDirty, false); + }, + ); testWidgets('When press tab, field will be focused', ( WidgetTester tester, ) async { From 3afd6f2e597450cbd0be55caa8daf92186d34533 Mon Sep 17 00:00:00 2001 From: Subhanu Majumder Date: Wed, 22 Apr 2026 15:54:02 +0530 Subject: [PATCH 6/7] chore: dart format and fix --- test/src/fields/form_builder_checkbox_group_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/src/fields/form_builder_checkbox_group_test.dart b/test/src/fields/form_builder_checkbox_group_test.dart index bded24a6f..64b370385 100644 --- a/test/src/fields/form_builder_checkbox_group_test.dart +++ b/test/src/fields/form_builder_checkbox_group_test.dart @@ -181,7 +181,9 @@ void main() { await tester.pumpWidget( buildTestableFieldWidget( testWidget, - initialValue: const {fieldName: [1, 3]}, + initialValue: const { + fieldName: [1, 3], + }, ), ); From 06bc03bb6f922b93148f60b5b7ea032b09accf60 Mon Sep 17 00:00:00 2001 From: Subhanu Majumder Date: Sun, 3 May 2026 12:58:29 +0530 Subject: [PATCH 7/7] test: initial value testing --- test/src/form_builder_field_test.dart | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/test/src/form_builder_field_test.dart b/test/src/form_builder_field_test.dart index 211e44481..4829a98d0 100644 --- a/test/src/form_builder_field_test.dart +++ b/test/src/form_builder_field_test.dart @@ -503,13 +503,11 @@ void main() { }); group('reset -', () { testWidgets( - 'Should avoid reset recursion when reset is called after dirty state', + 'Should avoid reset recursion when value returns to initial in onChanged', (tester) async { const textFieldName = 'text'; final textFieldKey = GlobalKey(); var onChangedCalls = 0; - var onChangedCallsBeforeReset = 0; - bool? isDirtyInResetOnChanged; final testWidget = FormBuilderTextField( name: textFieldName, key: textFieldKey, @@ -517,8 +515,8 @@ void main() { onChanged: (value) { onChangedCalls++; final state = textFieldKey.currentState; - if (value == state?.initialValue) { - isDirtyInResetOnChanged = state?.isDirty; + if (value == state?.initialValue && state?.isDirty == true) { + state?.reset(); } }, ); @@ -527,18 +525,12 @@ void main() { final widgetFinder = find.byWidget(testWidget); await tester.enterText(widgetFinder, 'test'); await tester.pumpAndSettle(); - - expect(textFieldKey.currentState?.isDirty, true); - onChangedCallsBeforeReset = onChangedCalls; - - textFieldKey.currentState?.reset(); + await tester.enterText(widgetFinder, 'hi'); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); - expect(textFieldKey.currentState?.value, equals('hi')); expect(textFieldKey.currentState?.isDirty, false); - expect(onChangedCalls, equals(onChangedCallsBeforeReset + 1)); - expect(isDirtyInResetOnChanged, isFalse); + expect(onChangedCalls, equals(2)); }, ); testWidgets('Should reset to null when call reset', (tester) async {