Skip to content

Commit d0af703

Browse files
mshabarovclaude
andauthored
fix: Prevent multiple effect invocations when reading the same signal multiple times (#23978)
When an effect reads the same signal multiple times (e.g. signal.get() called 3 times), each call creates a separate Usage instance and registers a separate listener. When the signal changes, all listeners fire, causing the effect to run multiple times instead of once. Added an AtomicBoolean flag in Effect.revalidate() that ensures onDependencyChange is called only once per change event, even if multiple usages refer to the same signal. The flag is reset on each revalidate() call so subsequent signal changes can trigger the effect again. Fixes #23974 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 18f0648 commit d0af703

File tree

4 files changed

+147
-12
lines changed

4 files changed

+147
-12
lines changed

flow-data/src/test/java/com/vaadin/flow/data/binder/BinderSignalTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -928,9 +928,9 @@ void bindingValue_multipleValueSignalCalls_revalidateTriggeredForEach() {
928928
assertEquals(3, validatorCalls.get());
929929

930930
lastNameField.setValue("");
931-
// value change runs revalidation for both Signal.get() calls in the
932-
// validator
933-
assertEquals(5, validatorCalls.get());
931+
// value change runs revalidation only once even for
932+
// two signal reads in the validator
933+
assertEquals(4, validatorCalls.get());
934934
}
935935

936936
private void testInitialStatusChangeRunEffects(

flow-server/src/main/java/com/vaadin/flow/signals/impl/Effect.java

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -181,17 +181,14 @@ private void revalidate() {
181181
activeEffects.get().add(this);
182182
try {
183183
boolean[] hasSignalUsage = { false };
184+
// Ensure effect runs only once per change event, even if the same
185+
// signal is read multiple times (each read registers a listener)
186+
boolean[] changeHandled = { false };
184187
UsageTracker.track(action, usage -> {
185188
hasSignalUsage[0] = true;
186189
usages.add(usage);
187-
// avoid lambda to allow proper deserialization
188-
TransientListener usageListener = new TransientListener() {
189-
@Override
190-
public boolean invoke(boolean immediate) {
191-
return onDependencyChange(immediate);
192-
}
193-
};
194-
registrations.add(usage.onNextChange(usageListener));
190+
registrations.add(usage
191+
.onNextChange(createChangeListener(changeHandled)));
195192
});
196193
if (!hasSignalUsage[0]) {
197194
throw new MissingSignalUsageException(
@@ -203,6 +200,20 @@ public boolean invoke(boolean immediate) {
203200
}
204201
}
205202

203+
private TransientListener createChangeListener(boolean[] changeHandled) {
204+
// avoid lambda to allow proper deserialization
205+
return new TransientListener() {
206+
@Override
207+
public boolean invoke(boolean immediate) {
208+
if (!changeHandled[0]) {
209+
changeHandled[0] = true;
210+
return onDependencyChange(immediate);
211+
}
212+
return false;
213+
}
214+
};
215+
}
216+
206217
private boolean onDependencyChange(boolean immediate) {
207218
/*
208219
* Detect loops by checking if the same effect is already active on this
@@ -317,8 +328,12 @@ public synchronized void activate() {
317328
// listener may still fire immediately if a change sneaks in
318329
// between the hasChanges check and the onNextChange call,
319330
// in which case firstRun is already set to true above.
331+
// Ensure effect runs only once even if the same signal is
332+
// registered multiple times
333+
boolean[] changeHandled = { false };
320334
for (UsageTracker.Usage usage : usages) {
321-
registrations.add(usage.onNextChange(this::onDependencyChange));
335+
registrations.add(usage
336+
.onNextChange(createChangeListener(changeHandled)));
322337
}
323338
if (!invalidateScheduled.get()) {
324339
// No invalidation was scheduled, so no change was

flow-server/src/test/java/com/vaadin/flow/dom/ElementEffectTest.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,43 @@ void bindChildren_removeItemsWithSlottedChildPresent_slottedUnaffected() {
15181518
assertEquals("footer", parent.getChild(1).getAttribute("slot"));
15191519
}
15201520

1521+
@Test
1522+
void effect_reattachWithoutChanges_readSameSignalMultipleTimes_effectRunOnlyOnce() {
1523+
CurrentInstance.clearAll();
1524+
TestComponent component = new TestComponent();
1525+
ValueSignal<String> signal = new ValueSignal<>("initial");
1526+
AtomicInteger count = new AtomicInteger();
1527+
ArrayList<String> invocations = new ArrayList<>();
1528+
Signal.effect(component, () -> {
1529+
count.incrementAndGet();
1530+
invocations.add(signal.get());
1531+
signal.get();
1532+
signal.get();
1533+
});
1534+
1535+
MockUI ui = new MockUI();
1536+
ui.add(component);
1537+
assertEquals(1, count.get());
1538+
assertEquals(List.of("initial"), invocations);
1539+
1540+
// Detach and reattach without changes (passivate/activate path)
1541+
ui.remove(component);
1542+
ui.add(component);
1543+
assertEquals(1, count.get(), "No re-run on reattach without changes");
1544+
1545+
// Change signal - should trigger the effect exactly once,
1546+
// even though the effect reads the same signal three times
1547+
signal.set("update");
1548+
assertEquals(2, count.get(),
1549+
"Effect should run exactly once after reattach despite multiple reads of the same signal");
1550+
assertEquals(List.of("initial", "update"), invocations);
1551+
1552+
signal.set("again");
1553+
assertEquals(3, count.get(),
1554+
"Effect should run exactly once on subsequent change");
1555+
assertEquals(List.of("initial", "update", "again"), invocations);
1556+
}
1557+
15211558
private TestLayout prepareTestLayout(ListSignal<String> listSignal) {
15221559
TestLayout parentComponent = new TestLayout();
15231560
new MockUI().add(parentComponent);

flow-server/src/test/java/com/vaadin/flow/signals/impl/EffectTest.java

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,4 +901,87 @@ void noOwnerUI_fallsBackToVaadinRequestCheck() {
901901
assertTrue(backgroundChanges.get(2),
902902
"Change without VaadinRequest should be background");
903903
}
904+
905+
@Test
906+
void changeTracking_readSameSignalMultipleTimes_effectRunOnlyOnce() {
907+
SharedValueSignal<String> signal = new SharedValueSignal<>("initial");
908+
ArrayList<String> invocations = new ArrayList<>();
909+
910+
Signal.unboundEffect(() -> {
911+
invocations.add(signal.get());
912+
signal.get();
913+
signal.get();
914+
});
915+
916+
assertEquals(List.of("initial"), invocations);
917+
918+
signal.set("update");
919+
assertEquals(List.of("initial", "update"), invocations);
920+
921+
signal.set("again");
922+
assertEquals(List.of("initial", "update", "again"), invocations);
923+
}
924+
925+
@Test
926+
void changeTracking_readMultipleDifferentSignals_effectRunsOncePerChange() {
927+
SharedValueSignal<String> signal1 = new SharedValueSignal<>("a");
928+
SharedValueSignal<String> signal2 = new SharedValueSignal<>("b");
929+
ArrayList<String> invocations = new ArrayList<>();
930+
931+
Signal.unboundEffect(() -> {
932+
invocations.add(signal1.get() + signal2.get());
933+
});
934+
935+
assertEquals(List.of("ab"), invocations);
936+
937+
signal1.set("A");
938+
assertEquals(List.of("ab", "Ab"), invocations);
939+
940+
signal2.set("B");
941+
assertEquals(List.of("ab", "Ab", "AB"), invocations);
942+
}
943+
944+
@Test
945+
void changeTracking_multipleReadsFromTwoSignals_eachSignalChangeTriggersEffectOnce() {
946+
SharedValueSignal<String> signal1 = new SharedValueSignal<>("a");
947+
SharedValueSignal<Integer> signal2 = new SharedValueSignal<>(1);
948+
AtomicInteger effectRunCount = new AtomicInteger(0);
949+
ArrayList<String> invocations = new ArrayList<>();
950+
951+
Signal.unboundEffect(() -> {
952+
effectRunCount.incrementAndGet();
953+
// Read signal1 three times
954+
String s1 = signal1.get();
955+
signal1.get();
956+
signal1.get();
957+
// Read signal2 two times
958+
Integer s2 = signal2.get();
959+
signal2.get();
960+
invocations.add(s1 + ":" + s2);
961+
});
962+
963+
assertEquals(1, effectRunCount.get(),
964+
"Effect should run exactly once initially");
965+
assertEquals(List.of("a:1"), invocations);
966+
967+
signal1.set("b");
968+
assertEquals(2, effectRunCount.get(),
969+
"Effect should run exactly once when signal1 changes (despite 3 reads)");
970+
assertEquals(List.of("a:1", "b:1"), invocations);
971+
972+
signal2.set(2);
973+
assertEquals(3, effectRunCount.get(),
974+
"Effect should run exactly once when signal2 changes (despite 2 reads)");
975+
assertEquals(List.of("a:1", "b:1", "b:2"), invocations);
976+
977+
signal1.set("c");
978+
assertEquals(4, effectRunCount.get(),
979+
"Effect should run exactly once when signal1 changes again");
980+
assertEquals(List.of("a:1", "b:1", "b:2", "c:2"), invocations);
981+
982+
signal2.set(3);
983+
assertEquals(5, effectRunCount.get(),
984+
"Effect should run exactly once when signal2 changes again");
985+
assertEquals(List.of("a:1", "b:1", "b:2", "c:2", "c:3"), invocations);
986+
}
904987
}

0 commit comments

Comments
 (0)