diff --git a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChange.java b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChange.java
deleted file mode 100644
index b9ff62761ae..00000000000
--- a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChange.java
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright 2000-2026 Vaadin Ltd.
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not
- * use this file except in compliance with the License. You may obtain a copy of
- * the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
- * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
- * License for the specific language governing permissions and limitations under
- * the License.
- */
-package com.vaadin.flow.component.ai.form;
-
-import java.io.Serializable;
-
-import com.vaadin.flow.component.HasValue;
-
-/**
- * Captures the before / after values of a single field across one
- * {@link FormAIController} turn. Reported through
- * {@link FormAIController#addFieldValueChangedListener} for every field whose
- * value changed during the turn — either written by the LLM or by a cascade.
- *
- * @param field
- * the field whose value changed
- * @param oldValue
- * the field's value at the start of the turn, possibly {@code null}
- * @param newValue
- * the field's value at the end of the turn, possibly {@code null}
- *
- * @author Vaadin Ltd
- * @since 25.2
- */
-public record FieldValueChange(HasValue, ?> field, Object oldValue,
- Object newValue) implements Serializable {
-}
diff --git a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeEvent.java b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeEvent.java
new file mode 100644
index 00000000000..e5e1f145ef3
--- /dev/null
+++ b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeEvent.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.component.ai.form;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+import com.vaadin.flow.component.HasValue;
+
+/**
+ * Fired by {@link FormAIController} once per field whose value changed during a
+ * successful AI turn — either written by the LLM or by a cascade. Events fire
+ * in document order, after every field's post-turn value has been applied. A
+ * field whose post-turn value equals its pre-turn value (by
+ * {@link Objects#equals(Object, Object)}) does not produce an event. No events
+ * fire when the turn ended in error.
+ *
+ * @since 25.3
+ */
+public final class FieldValueChangeEvent implements Serializable {
+
+ private final transient FormAIController source;
+ private final HasValue, ?> field;
+ @SuppressWarnings("java:S1948")
+ private final Object oldValue;
+ @SuppressWarnings("java:S1948")
+ private final Object newValue;
+
+ FieldValueChangeEvent(FormAIController source, HasValue, ?> field,
+ Object oldValue, Object newValue) {
+ this.source = Objects.requireNonNull(source, "Source must not be null");
+ this.field = Objects.requireNonNull(field, "Field must not be null");
+ this.oldValue = oldValue;
+ this.newValue = newValue;
+ }
+
+ /**
+ * Returns the controller that produced this event. Listeners can use this
+ * to call back into the controller (e.g.
+ * {@link FormAIController#showFieldHighlight}) without capturing it from
+ * the registration site.
+ *
+ * The source is transient, so it is {@code null} on an event restored from
+ * a serialized session.
+ *
+ * @return the source controller, or {@code null} if this event was
+ * deserialized
+ */
+ public FormAIController getSource() {
+ return source;
+ }
+
+ /**
+ * Returns the field whose value changed during this turn.
+ *
+ * @return the changed field, never {@code null}
+ */
+ @SuppressWarnings("java:S1452")
+ public HasValue, ?> getField() {
+ return field;
+ }
+
+ /**
+ * Returns the field's value at the start of the turn, before the LLM ran.
+ * For a field that was hidden at turn start and revealed during the turn,
+ * this is the field's actual pre-turn value rather than {@code null}. For a
+ * field added to the form during the turn, this is the field's
+ * {@link HasValue#getEmptyValue() empty value} — the field had no pre-turn
+ * value to report.
+ *
+ * @return the pre-turn value, possibly {@code null}
+ */
+ public Object getOldValue() {
+ return oldValue;
+ }
+
+ /**
+ * Returns the field's value at the end of the turn, after the LLM's writes
+ * and any cascading value-change listeners have settled.
+ *
+ * @return the post-turn value, possibly {@code null}
+ */
+ public Object getNewValue() {
+ return newValue;
+ }
+}
diff --git a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeListener.java b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeListener.java
new file mode 100644
index 00000000000..6835a13bdf5
--- /dev/null
+++ b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChangeListener.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2000-2026 Vaadin Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy of
+ * the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+package com.vaadin.flow.component.ai.form;
+
+import java.io.Serializable;
+
+/**
+ * Listener for {@link FieldValueChangeEvent}s fired by a
+ * {@link FormAIController} after a successful AI turn. One event is delivered
+ * per changed field, in document order.
+ *
+ * @since 25.3
+ */
+@FunctionalInterface
+public interface FieldValueChangeListener extends Serializable {
+
+ /**
+ * Invoked once per changed field after the AI turn has settled.
+ *
+ * @param event
+ * the event describing the change, never {@code null}
+ */
+ void onFieldValueChange(FieldValueChangeEvent event);
+}
diff --git a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormAIController.java b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormAIController.java
index 4fdcf0da843..67625014b42 100644
--- a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormAIController.java
+++ b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormAIController.java
@@ -47,7 +47,6 @@
import com.vaadin.flow.component.ai.provider.LLMProvider;
import com.vaadin.flow.data.binder.Binder;
import com.vaadin.flow.data.selection.MultiSelect;
-import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.internal.JacksonUtils;
import com.vaadin.flow.shared.Registration;
@@ -155,10 +154,10 @@
*
*
* Change tracking and highlight: a listener registered through
- * {@link #addFieldValueChangedListener(SerializableConsumer)} fires once per
- * successful turn with the fields whose value changed during the turn — the
- * common driver for {@link #showFieldHighlight(HasValue)} /
- * {@link #hideFieldHighlight} to flash the AI's edits in the UI.
+ * {@link #addFieldValueChangeListener(FieldValueChangeListener)} fires once per
+ * field whose value changed during a successful turn — the common driver for
+ * {@link #showFieldHighlight(HasValue)} / {@link #hideFieldHighlight} to flash
+ * the AI's edits in the UI.
*
*
*
@@ -269,7 +268,7 @@ spans multiple fields (e.g. start date must precede end date). \
* {@link #hideFieldHighlight}.
*/
private final Map, Registration> highlightedFields = new HashMap<>();
- private final List>> fieldValuesChangedListeners = new ArrayList<>();
+ private final List fieldValueChangeListeners = new ArrayList<>();
/**
* Creates a new form AI controller for the given container. Fields are
@@ -613,28 +612,32 @@ public boolean isFieldValuesHidden() {
}
/**
- * Registers a listener that is invoked once per successful turn with the
- * fields whose value differs from what was read at the start of the turn.
+ * Registers a listener that is invoked once per field whose value changed
+ * during a successful AI turn. The listener fires once per changed field,
+ * in document order, after every field's post-turn value has been applied.
* Comparison is by {@link Objects#equals(Object, Object)} so multi-select
* sets, dates, and other value-objects work naturally.
*
- * Multiple listeners are supported and fire in registration order. If one
- * listener throws, the exception is logged and the remaining listeners
- * still fire.
+ * Multiple listeners are supported. For each changed field, every listener
+ * fires in registration order before the next field's event is dispatched.
+ * If one listener throws, the exception is logged and the remaining
+ * listeners still fire — both for that change and for subsequent changes in
+ * the same turn.
*
- * Only non-ignored fields are tracked, and only changed fields appear in
- * the list. A field's pre-turn value is captured regardless of its current
- * visibility, so a value cascaded into a freshly-revealed field is reported
- * with the field's real pre-turn value rather than a spurious {@code null}.
- * The listener is not called when the turn ended in error or when no field
- * changed. The list iterates in document order; modifying it has no effect
- * on the controller.
+ * Only non-ignored fields are tracked, and only fields whose value differs
+ * at end-of-turn produce events. A field's pre-turn value is captured
+ * regardless of its current visibility, so a value cascaded into a
+ * freshly-revealed field is reported with the field's real pre-turn value
+ * rather than a spurious {@code null}. A field added to the form during the
+ * turn is compared against its {@link HasValue#getEmptyValue() empty
+ * value}. No events fire when the turn ended in error.
*
- * The listener runs on the UI thread with the session lock held, so it can
+ * Listeners run on the UI thread with the session lock held, so they can
* update components and call {@link #showFieldHighlight} /
* {@link #hideFieldHighlight} directly without {@code ui.access(...)}. A
* typical use is to flash the AI's edits by calling
- * {@code showFieldHighlight} on every changed field.
+ * {@code showFieldHighlight} on every event's
+ * {@link FieldValueChangeEvent#getField field}.
*
* @param listener
* the listener to register, not {@code null}
@@ -642,11 +645,11 @@ public boolean isFieldValuesHidden() {
* @throws NullPointerException
* if {@code listener} is {@code null}
*/
- public Registration addFieldValueChangedListener(
- SerializableConsumer> listener) {
+ public Registration addFieldValueChangeListener(
+ FieldValueChangeListener listener) {
Objects.requireNonNull(listener, "Listener must not be null");
- fieldValuesChangedListeners.add(listener);
- return () -> fieldValuesChangedListeners.remove(listener);
+ fieldValueChangeListeners.add(listener);
+ return () -> fieldValueChangeListeners.remove(listener);
}
/**
@@ -792,7 +795,7 @@ private void refreshValueOptionsBindings() {
@Override
public void onResponse(Throwable error) {
try {
- fireFieldValuesChanged(error);
+ fireFieldValueChanges(error);
} finally {
// Unlock regardless of success or failure: locks set in onRequest
// must be released so the user can edit again. The failure path
@@ -804,7 +807,7 @@ public void onResponse(Throwable error) {
/**
* Captures the current value of every known field before the LLM runs. The
* snapshot is consulted in {@link #onResponse} to compute the before /
- * after diff for {@link #addFieldValueChangedListener}. Skipped when no
+ * after diff for {@link #addFieldValueChangeListener}. Skipped when no
* listener is registered to avoid copying values that no one will read.
*
* Hidden and disabled fields are included so a value cascaded into a field
@@ -813,7 +816,7 @@ public void onResponse(Throwable error) {
*/
private void snapshotPreTurnValues() {
preTurnValues.clear();
- if (fieldValuesChangedListeners.isEmpty()) {
+ if (fieldValueChangeListeners.isEmpty()) {
return;
}
for (var field : collectKnownFields()) {
@@ -822,43 +825,58 @@ private void snapshotPreTurnValues() {
}
/**
- * Builds the change list from the pre-turn snapshot and the post-turn value
- * of every known field, then invokes every registered listener if anything
- * changed. The post-turn walk picks up fields that were hidden (or absent)
- * at turn start but became visible / were added during the turn, so
- * visibility cascades report their value changes correctly. On error the
- * snapshot is discarded and no listener fires — the application learns
- * about errors through the orchestrator's response listener instead. A
- * throwing listener is logged and otherwise ignored so subsequent listeners
- * still fire and the rest of the response lifecycle (notably
+ * Walks the pre-turn snapshot against the post-turn value of every known
+ * field and fires one {@link FieldValueChangeEvent} per changed field, in
+ * document order. The post-turn walk picks up fields that were hidden (or
+ * absent) at turn start but became visible / were added during the turn, so
+ * visibility cascades report their value changes correctly. A field with no
+ * snapshot entry (added mid-turn) compares against its empty value, so
+ * adding a field that keeps its empty value does not produce an event.
+ *
+ * The diff is materialised before any listener runs, so a listener that
+ * writes to a tracked field cannot retroactively change the
+ * {@code newValue} another field's event carries. The listener set is also
+ * snapshotted once per turn, so adding or removing listeners mid-dispatch
+ * (including a listener removing itself) affects only subsequent turns, not
+ * the rest of the current turn.
+ *
+ * On error the snapshot is discarded and no events fire — the application
+ * learns about errors through the orchestrator's response listener instead.
+ * A throwing listener is logged and otherwise ignored so subsequent
+ * listeners still fire, subsequent change events in the same turn still
+ * fire, and the rest of the response lifecycle (notably
* {@link #unlockFields}) still runs.
*/
- private void fireFieldValuesChanged(Throwable error) {
+ private void fireFieldValueChanges(Throwable error) {
if (preTurnValues.isEmpty() || error != null) {
preTurnValues.clear();
return;
}
- var changes = new ArrayList();
+ var events = new ArrayList();
for (var field : collectKnownFields()) {
- var oldValue = preTurnValues.get(field);
+ var oldValue = preTurnValues.containsKey(field)
+ ? preTurnValues.get(field)
+ : field.getEmptyValue();
var newValue = field.getValue();
if (!Objects.equals(oldValue, newValue)) {
- changes.add(new FieldValueChange(field, oldValue, newValue));
+ events.add(new FieldValueChangeEvent(this, field, oldValue,
+ newValue));
}
}
preTurnValues.clear();
- if (changes.isEmpty()) {
+ if (events.isEmpty()) {
return;
}
- // Snapshot the list before iterating so a listener that adds or
- // removes listeners (its own Registration included) doesn't break
- // the dispatch.
- for (var listener : List.copyOf(fieldValuesChangedListeners)) {
- try {
- listener.accept(changes);
- } catch (Exception ex) {
- LOGGER.warn("Field-values-changed listener threw an exception",
- ex);
+ var snapshot = List.copyOf(fieldValueChangeListeners);
+ for (var event : events) {
+ for (var listener : snapshot) {
+ try {
+ listener.onFieldValueChange(event);
+ } catch (Exception ex) {
+ LOGGER.warn(
+ "Field-value-change listener threw an exception",
+ ex);
+ }
}
}
}
@@ -912,8 +930,8 @@ private void lockFields() {
* tracks — i.e. all discovered fields minus those hidden via
* {@link #ignoreField(HasValue)}. Visibility and enabled state are NOT
* filtered, so this is the right set for the snapshot + diff used by
- * {@link #addFieldValueChangedListener}: a field hidden at turn start may
- * be revealed during the turn, and a value cascaded into it should compare
+ * {@link #addFieldValueChangeListener}: a field hidden at turn start may be
+ * revealed during the turn, and a value cascaded into it should compare
* against its real pre-turn value rather than {@code null}.
*/
private List> collectKnownFields() {
diff --git a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/test/java/com/vaadin/flow/component/ai/form/FormAIControllerTest.java b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/test/java/com/vaadin/flow/component/ai/form/FormAIControllerTest.java
index ba7d6c93ca9..ef00117b915 100644
--- a/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/test/java/com/vaadin/flow/component/ai/form/FormAIControllerTest.java
+++ b/vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/test/java/com/vaadin/flow/component/ai/form/FormAIControllerTest.java
@@ -22,6 +22,7 @@
import static com.vaadin.flow.component.ai.form.FormTestSupport.json;
import java.time.LocalDate;
+import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
@@ -1029,26 +1030,26 @@ void seedingSkipsNonComponentHasValue() {
}
@Nested
- class FieldValuesChangedListener {
+ class FieldValueChange {
@Test
- void listenerFiresWithChangedFieldsAfterSuccessfulTurn() {
+ void listenerFiresWithChangedFieldAfterSuccessfulTurn() {
var field = new TestField();
var controller = new FormAIController(new Div(field));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
field.setValue("John");
controller.onResponse(null);
- var changes = captured.get();
- Assertions.assertNotNull(changes, "Listener must fire when a "
- + "field's value changed during the turn");
- Assertions.assertEquals(1, changes.size());
- var change = changeFor(changes, field);
- Assertions.assertEquals("", change.oldValue());
- Assertions.assertEquals("John", change.newValue());
+ Assertions.assertEquals(1, events.size(), "Listener must fire "
+ + "exactly once for the single changed field");
+ var event = eventFor(events, field);
+ Assertions.assertEquals("", event.getOldValue());
+ Assertions.assertEquals("John", event.getNewValue());
+ Assertions.assertSame(controller, event.getSource(),
+ "Event source must be the controller that produced it");
}
@Test
@@ -1056,8 +1057,8 @@ void listenerNotInvokedWhenNoFieldChanged() {
var field = new TestField();
var controller = new FormAIController(new Div(field));
var invocations = new AtomicInteger();
- controller.addFieldValueChangedListener(
- c -> invocations.incrementAndGet());
+ controller.addFieldValueChangeListener(
+ e -> invocations.incrementAndGet());
controller.onRequest();
// No setValue between request and response.
@@ -1072,8 +1073,8 @@ void listenerNotInvokedOnError() {
var field = new TestField();
var controller = new FormAIController(new Div(field));
var invocations = new AtomicInteger();
- controller.addFieldValueChangedListener(
- c -> invocations.incrementAndGet());
+ controller.addFieldValueChangeListener(
+ e -> invocations.incrementAndGet());
controller.onRequest();
field.setValue("partial");
@@ -1085,48 +1086,45 @@ void listenerNotInvokedOnError() {
}
@Test
- void listContainsOnlyChangedFields() {
+ void onlyChangedFieldsProduceEvents() {
var changed = new TestField();
var untouched = new TestField();
var controller = new FormAIController(new Div(changed, untouched));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
changed.setValue("X");
controller.onResponse(null);
- var changes = captured.get();
- Assertions.assertEquals(1, changes.size(),
- "Only the changed field must appear; got: " + changes);
- Assertions.assertTrue(containsChangeFor(changes, changed));
- Assertions.assertFalse(containsChangeFor(changes, untouched));
+ Assertions.assertEquals(1, events.size(),
+ "Only the changed field must produce an event; got: "
+ + events);
+ Assertions.assertTrue(containsEventFor(events, changed));
+ Assertions.assertFalse(containsEventFor(events, untouched));
}
@Test
- void ignoredFieldsDoNotAppearEvenIfTheirValueChanged() {
+ void ignoredFieldsDoNotProduceEventsEvenIfTheirValueChanged() {
// Application-driven cascades into a field marked ignoreField()
- // must
- // not leak into the change list — ignoreField() is the
- // application's
- // opt-out from AI-driven tracking on either side of the lifecycle.
+ // must not leak into the event stream — ignoreField() is the
+ // application's opt-out from AI-driven tracking on either side
+ // of the lifecycle.
var visible = new TestField();
var ignored = new TestField();
visible.addValueChangeListener(e -> ignored.setValue("cascade"));
var controller = new FormAIController(new Div(visible, ignored));
controller.ignoreField(ignored);
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
visible.setValue("primary");
controller.onResponse(null);
- var changes = captured.get();
- Assertions.assertTrue(containsChangeFor(changes, visible));
- Assertions.assertFalse(containsChangeFor(changes, ignored),
- "Ignored fields must not appear in the change list; got: "
- + changes);
+ Assertions.assertTrue(containsEventFor(events, visible));
+ Assertions.assertFalse(containsEventFor(events, ignored),
+ "Ignored fields must not produce events; got: " + events);
}
@Test
@@ -1147,7 +1145,7 @@ void listenerExceptionStillReleasesFieldLocks() {
// outcome: a stuck-locked field strands the user.
var field = new TestField();
var controller = new FormAIController(new Div(field));
- controller.addFieldValueChangedListener(c -> {
+ controller.addFieldValueChangeListener(e -> {
throw new RuntimeException("listener boom");
});
@@ -1160,73 +1158,73 @@ void listenerExceptionStillReleasesFieldLocks() {
}
@Test
- void cascadingChangesAppearInTheSameTurn() {
- // Cascades through ValueChangeListener are observable in the diff
- // regardless of who triggered them — pin the symmetry so this
- // doesn't quietly regress to "only AI-driven writes are reported".
+ void cascadingChangesProduceSeparateEventsInTheSameTurn() {
+ // Cascades through ValueChangeListener are observable in the
+ // event stream regardless of who triggered them — pin the
+ // symmetry so this doesn't quietly regress to "only AI-driven
+ // writes are reported".
var primary = new TestField();
var cascaded = new TestField();
primary.addValueChangeListener(e -> cascaded.setValue("derived"));
var controller = new FormAIController(new Div(primary, cascaded));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
primary.setValue("driver");
controller.onResponse(null);
- var changes = captured.get();
- Assertions.assertEquals(2, changes.size(),
- "Both the driver and cascaded fields must be reported; "
- + "got: " + changes);
+ Assertions.assertEquals(2, events.size(),
+ "Both the driver and cascaded fields must produce events; "
+ + "got: " + events);
Assertions.assertEquals("derived",
- changeFor(changes, cascaded).newValue());
+ eventFor(events, cascaded).getNewValue());
}
@Test
- void multiSelectSetWithEqualContentIsNotReportedAsChange() {
+ void multiSelectSetWithEqualContentDoesNotProduceEvent() {
var field = new FormTestFields.MultiSelectField();
field.setValue(Set.of("a", "b"));
var controller = new FormAIController(new Div(field));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
// Same content, different Set instance — Objects.equals true.
field.setValue(Set.of("b", "a"));
controller.onResponse(null);
- Assertions.assertNull(captured.get(),
- "A multi-select set equal to its previous value must not "
- + "be reported as a change");
+ Assertions.assertTrue(events.isEmpty(),
+ "A multi-select set equal to its previous value must "
+ + "not produce an event; got: " + events);
}
@Test
- void multiSelectSetWithDifferentContentIsReportedAsChange() {
+ void multiSelectSetWithDifferentContentProducesEvent() {
var field = new FormTestFields.MultiSelectField();
field.setValue(Set.of("a", "b"));
var controller = new FormAIController(new Div(field));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
field.setValue(Set.of("a", "c"));
controller.onResponse(null);
- var change = changeFor(captured.get(), field);
- Assertions.assertEquals(Set.of("a", "b"), change.oldValue());
- Assertions.assertEquals(Set.of("a", "c"), change.newValue());
+ var event = eventFor(events, field);
+ Assertions.assertEquals(Set.of("a", "b"), event.getOldValue());
+ Assertions.assertEquals(Set.of("a", "c"), event.getNewValue());
}
@Test
- void changeListIteratesInDocumentOrder() {
+ void eventsArriveInDocumentOrder() {
var first = new TestField();
var second = new TestField();
var third = new TestField();
var controller = new FormAIController(
new Div(first, second, third));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
third.setValue("c");
@@ -1235,49 +1233,49 @@ void changeListIteratesInDocumentOrder() {
controller.onResponse(null);
Assertions.assertEquals(List.of(first, second, third),
- captured.get().stream().map(FieldValueChange::field)
+ events.stream().map(FieldValueChangeEvent::getField)
.toList(),
- "List iteration must follow document order regardless of "
- + "the order writes happened in");
+ "Events must fire in document order regardless of the "
+ + "order writes happened in");
}
@Test
void nullPreTurnValueIsReportedFaithfully() {
var field = new FormTestFields.DateField();
var controller = new FormAIController(new Div(field));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
field.setValue(LocalDate.of(2026, 1, 1));
controller.onResponse(null);
- var change = changeFor(captured.get(), field);
- Assertions.assertNull(change.oldValue(),
+ var event = eventFor(events, field);
+ Assertions.assertNull(event.getOldValue(),
"Pre-turn null must round-trip as null");
Assertions.assertEquals(LocalDate.of(2026, 1, 1),
- change.newValue());
+ event.getNewValue());
}
@Test
- void clearingAValueToNullIsReportedAsChange() {
+ void clearingAValueToNullProducesEvent() {
// Inverse of nullPreTurnValueIsReportedFaithfully: pre-turn
// non-null → post-turn null must surface as a change so
// applications can react (e.g. clear the highlight).
var field = new FormTestFields.DateField();
field.setValue(LocalDate.of(2026, 1, 1));
var controller = new FormAIController(new Div(field));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
field.setValue(null);
controller.onResponse(null);
- var change = changeFor(captured.get(), field);
+ var event = eventFor(events, field);
Assertions.assertEquals(LocalDate.of(2026, 1, 1),
- change.oldValue());
- Assertions.assertNull(change.newValue(),
+ event.getOldValue());
+ Assertions.assertNull(event.getNewValue(),
"Clearing to null must surface as the new value");
}
@@ -1285,20 +1283,22 @@ void clearingAValueToNullIsReportedAsChange() {
void multipleListenersAllFire() {
var field = new TestField();
var controller = new FormAIController(new Div(field));
- var first = new AtomicReference>();
- var second = new AtomicReference>();
- controller.addFieldValueChangedListener(first::set);
- controller.addFieldValueChangedListener(second::set);
+ var first = new ArrayList();
+ var second = new ArrayList();
+ controller.addFieldValueChangeListener(first::add);
+ controller.addFieldValueChangeListener(second::add);
controller.onRequest();
field.setValue("X");
controller.onResponse(null);
- Assertions.assertNotNull(first.get(), "First listener must fire");
- Assertions.assertNotNull(second.get(),
- "Second listener must also fire");
- Assertions.assertEquals(first.get(), second.get(),
- "Both listeners must receive equal change lists");
+ Assertions.assertEquals(1, first.size(),
+ "First listener must fire once for the changed field");
+ Assertions.assertEquals(1, second.size(),
+ "Second listener must also fire once");
+ Assertions.assertSame(first.get(0), second.get(0),
+ "Both listeners must receive the same event instance, so "
+ + "they see identical field and values");
}
@Test
@@ -1306,7 +1306,7 @@ void nullListenerThrows() {
var controller = new FormAIController(new Div(new TestField()));
Assertions.assertThrows(NullPointerException.class,
- () -> controller.addFieldValueChangedListener(null));
+ () -> controller.addFieldValueChangeListener(null));
}
@Test
@@ -1315,7 +1315,7 @@ void registrationRemoveStopsFutureCalls() {
var controller = new FormAIController(new Div(field));
var calls = new AtomicInteger();
var registration = controller
- .addFieldValueChangedListener(c -> calls.incrementAndGet());
+ .addFieldValueChangeListener(e -> calls.incrementAndGet());
controller.onRequest();
field.setValue("first");
@@ -1340,11 +1340,11 @@ void listenerExceptionDoesNotPreventOtherListeners() {
var field = new TestField();
var controller = new FormAIController(new Div(field));
var followingCalls = new AtomicInteger();
- controller.addFieldValueChangedListener(c -> {
+ controller.addFieldValueChangeListener(e -> {
throw new RuntimeException("first throws");
});
- controller.addFieldValueChangedListener(
- c -> followingCalls.incrementAndGet());
+ controller.addFieldValueChangeListener(
+ e -> followingCalls.incrementAndGet());
controller.onRequest();
field.setValue("X");
@@ -1355,6 +1355,38 @@ void listenerExceptionDoesNotPreventOtherListeners() {
+ "next listener from firing");
}
+ @Test
+ void listenerExceptionDoesNotPreventSubsequentEventsInSameTurn() {
+ // When a listener throws on the first field's event, both that
+ // throwing listener and any other listener must still fire for
+ // the second field's event. Pin both halves so a regression
+ // that swallows further events for either party would surface.
+ var first = new TestField();
+ var second = new TestField();
+ var controller = new FormAIController(new Div(first, second));
+ var throwerCalls = new AtomicInteger();
+ var followerCalls = new AtomicInteger();
+ controller.addFieldValueChangeListener(e -> {
+ throwerCalls.incrementAndGet();
+ throw new RuntimeException("always throws");
+ });
+ controller.addFieldValueChangeListener(
+ e -> followerCalls.incrementAndGet());
+
+ controller.onRequest();
+ first.setValue("a");
+ second.setValue("b");
+ controller.onResponse(null);
+
+ Assertions.assertEquals(2, throwerCalls.get(),
+ "The throwing listener must still be invoked for every "
+ + "changed field, even after its own prior "
+ + "invocation threw");
+ Assertions.assertEquals(2, followerCalls.get(),
+ "A non-throwing follower must also receive every event "
+ + "even when a prior listener throws on each");
+ }
+
@Test
void listenerCanRemoveItselfDuringDispatchWithoutBreakingTheTurn() {
// Self-removal during dispatch must not throw or skip remaining
@@ -1364,12 +1396,12 @@ void listenerCanRemoveItselfDuringDispatchWithoutBreakingTheTurn() {
var registration = new AtomicReference();
var selfRemovingCalls = new AtomicInteger();
var followingCalls = new AtomicInteger();
- registration.set(controller.addFieldValueChangedListener(c -> {
+ registration.set(controller.addFieldValueChangeListener(e -> {
selfRemovingCalls.incrementAndGet();
registration.get().remove();
}));
- controller.addFieldValueChangedListener(
- c -> followingCalls.incrementAndGet());
+ controller.addFieldValueChangeListener(
+ e -> followingCalls.incrementAndGet());
controller.onRequest();
field.setValue("X");
@@ -1393,11 +1425,11 @@ void listenerCanRemoveItselfDuringDispatchWithoutBreakingTheTurn() {
}
@Test
- void fieldRevealedMidTurnIsReportedAsChange() {
+ void fieldRevealedMidTurnProducesEvent() {
// Visibility-cascade headline: a hidden field that gets
- // revealed-and-written during a single turn must surface in
- // the change list. Otherwise the LLM's effect on the form
- // would be silently underreported.
+ // revealed-and-written during a single turn must surface as an
+ // event. Otherwise the LLM's effect on the form would be
+ // silently underreported.
var primary = new TestField();
var conditional = new TestField();
conditional.setVisible(false);
@@ -1407,23 +1439,23 @@ void fieldRevealedMidTurnIsReportedAsChange() {
});
var controller = new FormAIController(
new Div(primary, conditional));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
primary.setValue("driver");
controller.onResponse(null);
- var change = changeFor(captured.get(), conditional);
- Assertions.assertEquals("", change.oldValue(),
+ var event = eventFor(events, conditional);
+ Assertions.assertEquals("", event.getOldValue(),
"Old value must reflect the field's pre-turn value");
- Assertions.assertEquals("derived", change.newValue());
+ Assertions.assertEquals("derived", event.getNewValue());
}
@Test
void hiddenFieldRevealedAndChangedReportsRealOldValue() {
// When the hidden field already had a non-null value
- // (e.g. bound to a bean), the diff must report the real
+ // (e.g. bound to a bean), the event must report the real
// (preset → derived) transition rather than (null → derived).
var primary = new TestField();
var conditional = new TestField();
@@ -1435,25 +1467,24 @@ void hiddenFieldRevealedAndChangedReportsRealOldValue() {
});
var controller = new FormAIController(
new Div(primary, conditional));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
primary.setValue("driver");
controller.onResponse(null);
- var change = changeFor(captured.get(), conditional);
- Assertions.assertEquals("preset", change.oldValue(),
+ var event = eventFor(events, conditional);
+ Assertions.assertEquals("preset", event.getOldValue(),
"Pre-turn value of a hidden field must round-trip into "
- + "the change record, not a spurious null");
- Assertions.assertEquals("derived", change.newValue());
+ + "the event, not a spurious null");
+ Assertions.assertEquals("derived", event.getNewValue());
}
@Test
- void fieldRevealedMidTurnWithUnchangedValueIsNotReported() {
+ void fieldRevealedMidTurnWithUnchangedValueProducesNoEvent() {
// False-positive guard: revealing a hidden field without
- // writing to it is not a change and must not appear in the
- // change list.
+ // writing to it is not a change and must not produce an event.
var primary = new TestField();
var conditional = new TestField();
conditional.setValue("preset");
@@ -1461,21 +1492,20 @@ void fieldRevealedMidTurnWithUnchangedValueIsNotReported() {
primary.addValueChangeListener(e -> conditional.setVisible(true));
var controller = new FormAIController(
new Div(primary, conditional));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
primary.setValue("driver");
controller.onResponse(null);
- Assertions.assertFalse(
- containsChangeFor(captured.get(), conditional),
+ Assertions.assertFalse(containsEventFor(events, conditional),
"Revealing a hidden field without changing its value "
- + "must not be reported as a change");
+ + "must not produce an event");
}
@Test
- void fieldRevealedAndFilledInSameTurnIsReported() {
+ void fieldRevealedAndFilledInSameTurnProducesEvent() {
var controlling = new TestField();
var conditional = new TestField();
conditional.setVisible(false);
@@ -1483,27 +1513,25 @@ void fieldRevealedAndFilledInSameTurnIsReported() {
.addValueChangeListener(e -> conditional.setVisible(true));
var controller = new FormAIController(
new Div(controlling, conditional));
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
controlling.setValue("business"); // reveals the conditional field
conditional.setValue("cost-center-42"); // AI fills the revealed one
controller.onResponse(null);
- var changes = captured.get();
- Assertions.assertTrue(containsChangeFor(changes, conditional),
- "A field revealed and filled within the same turn must be "
- + "reported as changed; got: " + changes);
+ Assertions.assertTrue(containsEventFor(events, conditional),
+ "A field revealed and filled within the same turn must "
+ + "produce an event; got: " + events);
}
@Test
- void fieldAddedAndFilledInSameTurnIsReported() {
- // Stronger variant: the conditional field does not exist in
- // the form when onRequest snapshots. A controlling field's
- // listener ADDS it to the form mid-turn (e.g. a checkbox
- // revealing a new panel), and the same turn fills it. It must
- // still be reported as changed.
+ void fieldAddedAndFilledInSameTurnProducesEvent() {
+ // A field that does not exist when onRequest snapshots but is
+ // added to the form mid-turn (e.g. by a controlling field
+ // revealing a new panel) and filled in the same turn must
+ // still produce an event.
var controlling = new TestField();
var added = new TestField();
var form = new Div(controlling);
@@ -1511,32 +1539,207 @@ void fieldAddedAndFilledInSameTurnIsReported() {
// by the controlling field's value change.
controlling.addValueChangeListener(e -> form.add(added));
var controller = new FormAIController(form);
- var captured = new AtomicReference>();
- controller.addFieldValueChangedListener(captured::set);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
controller.onRequest();
controlling.setValue("business"); // adds the new field to the form
added.setValue("cost-center-42"); // AI fills the newly-added field
controller.onResponse(null);
- var changes = captured.get();
- Assertions.assertTrue(containsChangeFor(changes, added),
+ Assertions.assertTrue(containsEventFor(events, added),
"A field added to the form and filled within the same "
- + "turn must be reported as changed; got: "
- + changes);
+ + "turn must produce an event; got: " + events);
+ Assertions.assertEquals(added.getEmptyValue(),
+ eventFor(events, added).getOldValue(),
+ "A field with no pre-turn snapshot must report its empty "
+ + "value as the old value");
+ }
+
+ @Test
+ void fieldAddedMidTurnWithoutWriteProducesNoEvent() {
+ // False-positive guard: a field added to the form mid-turn that
+ // keeps its empty value was not changed by the turn and must not
+ // produce an event, even though it has no pre-turn snapshot.
+ var controlling = new TestField();
+ var added = new TestField();
+ var form = new Div(controlling);
+ controlling.addValueChangeListener(e -> form.add(added));
+ var controller = new FormAIController(form);
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
+
+ controller.onRequest();
+ controlling.setValue("business"); // adds the new field, no write
+ controller.onResponse(null);
+
+ Assertions.assertFalse(containsEventFor(events, added),
+ "A field added mid-turn but never written must not "
+ + "produce an event; got: " + events);
+ }
+
+ @Test
+ void eventSourceIsTheControllerForEveryEventInATurn() {
+ // getSource() is the only way for a listener to recover the
+ // controller without capturing it. Pin that every event in a
+ // multi-event turn returns the same controller, not, say, null
+ // on the second event or a different instance.
+ var first = new TestField();
+ var second = new TestField();
+ var controller = new FormAIController(new Div(first, second));
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(events::add);
+
+ controller.onRequest();
+ first.setValue("a");
+ second.setValue("b");
+ controller.onResponse(null);
+
+ Assertions.assertEquals(2, events.size(),
+ "Expected two events for two changed fields");
+ for (var event : events) {
+ Assertions.assertSame(controller, event.getSource(),
+ "Every event must carry the same controller as its "
+ + "source; got: " + event.getSource());
+ }
+ }
+
+ @Test
+ void eventDispatchVisitsAllListenersBeforeMovingToNextEvent() {
+ // Every listener fires in registration order for event 1
+ // before any listener fires for event 2. Pinning this lets
+ // listeners depend on each other's side effects within an
+ // event (e.g. listener A annotates the source, listener B
+ // reads the annotation) without leaking across events.
+ var first = new TestField();
+ var second = new TestField();
+ var controller = new FormAIController(new Div(first, second));
+ var order = new ArrayList();
+ controller.addFieldValueChangeListener(
+ e -> order.add("A:" + e.getNewValue()));
+ controller.addFieldValueChangeListener(
+ e -> order.add("B:" + e.getNewValue()));
+
+ controller.onRequest();
+ first.setValue("a");
+ second.setValue("b");
+ controller.onResponse(null);
+
+ Assertions.assertEquals(List.of("A:a", "B:a", "A:b", "B:b"), order,
+ "Listeners must visit each event in registration order, "
+ + "then advance to the next event: got " + order);
+ }
+
+ @Test
+ void writingToFieldInsideListenerDoesNotPoisonOtherEventsValues() {
+ // The diff is materialised before any listener runs, so a
+ // listener writing to a tracked field cannot retroactively
+ // alter another event's values. Otherwise events would carry
+ // values that don't match the LLM's effect — they'd carry
+ // whatever the listener happened to write.
+ var first = new TestField();
+ var second = new TestField();
+ var controller = new FormAIController(new Div(first, second));
+ var events = new ArrayList();
+ controller.addFieldValueChangeListener(e -> {
+ events.add(e);
+ if (e.getField() == first) {
+ second.setValue("listener-overwrote");
+ }
+ });
+
+ controller.onRequest();
+ first.setValue("from-llm");
+ second.setValue("from-llm-too");
+ controller.onResponse(null);
+
+ var secondEvent = eventFor(events, second);
+ Assertions.assertEquals("", secondEvent.getOldValue(),
+ "The second event's oldValue must reflect the field's "
+ + "value at turn start, not anything the listener "
+ + "wrote during the turn");
+ Assertions.assertEquals("from-llm-too", secondEvent.getNewValue(),
+ "The second event's newValue must reflect the LLM's "
+ + "write, not the side effect of the listener "
+ + "that ran for the first event");
+ }
+
+ @Test
+ void listenerAddedDuringDispatchDoesNotReceiveCurrentTurnEvents() {
+ // Snapshot-per-turn semantics: a listener that registers itself
+ // from inside another listener's handler must not start
+ // receiving events until the NEXT turn. Otherwise a one-shot
+ // self-registering listener pattern would observe a partial
+ // view of the current turn's changes.
+ var first = new TestField();
+ var second = new TestField();
+ var controller = new FormAIController(new Div(first, second));
+ var lateCalls = new AtomicInteger();
+ controller.addFieldValueChangeListener(e -> {
+ if (e.getField() == first) {
+ controller.addFieldValueChangeListener(
+ ev -> lateCalls.incrementAndGet());
+ }
+ });
+
+ controller.onRequest();
+ first.setValue("a");
+ second.setValue("b");
+ controller.onResponse(null);
+
+ Assertions.assertEquals(0, lateCalls.get(),
+ "Listener registered mid-dispatch must not receive any "
+ + "of the current turn's remaining events");
+
+ controller.onRequest();
+ first.setValue("c");
+ controller.onResponse(null);
+
+ Assertions.assertEquals(1, lateCalls.get(),
+ "Listener registered in the previous turn must receive "
+ + "events from subsequent turns");
+ }
+
+ @Test
+ void removedListenerStillReceivesRestOfCurrentTurnEvents() {
+ // Snapshot-per-turn semantics, opposite direction: a listener
+ // that removes itself from inside its own handler must still
+ // receive the rest of the current turn's events. The cleanup
+ // takes effect only on the next turn.
+ var first = new TestField();
+ var second = new TestField();
+ var controller = new FormAIController(new Div(first, second));
+ var calls = new ArrayList>();
+ var registration = new AtomicReference();
+ registration.set(controller.addFieldValueChangeListener(e -> {
+ calls.add(e.getField());
+ if (e.getField() == first) {
+ registration.get().remove();
+ }
+ }));
+
+ controller.onRequest();
+ first.setValue("a");
+ second.setValue("b");
+ controller.onResponse(null);
+
+ Assertions.assertEquals(List.of(first, second), calls,
+ "A listener that removes itself during event 1 must "
+ + "still receive event 2 of the same turn");
}
- private static FieldValueChange changeFor(
- List changes, HasValue, ?> field) {
- return changes.stream().filter(c -> c.field() == field).findFirst()
+ private static FieldValueChangeEvent eventFor(
+ List events, HasValue, ?> field) {
+ return events.stream().filter(e -> e.getField() == field)
+ .findFirst()
.orElseThrow(() -> new AssertionError(
- "No FieldValueChange entry for field " + field
- + " in " + changes));
+ "No FieldValueChangeEvent for field " + field
+ + " in " + events));
}
- private static boolean containsChangeFor(List changes,
- HasValue, ?> field) {
- return changes.stream().anyMatch(c -> c.field() == field);
+ private static boolean containsEventFor(
+ List events, HasValue, ?> field) {
+ return events.stream().anyMatch(e -> e.getField() == field);
}
}