Skip to content

Commit 5810cfd

Browse files
authored
fix: skip ElementEffect callback on reattach when no signals changed (#23771)
Add passivate/activate lifecycle to Effect so that ElementEffect can preserve tracked Usage instances across detach/attach cycles. On reattach, activate checks whether any dependency has changed since passivation: if so, the callback re-runs with isInitialRun=true; if not, only the dependency listeners are re-registered without invoking the callback, avoiding redundant work and confusing double invocations. Fixes #23730
1 parent b48701d commit 5810cfd

File tree

4 files changed

+301
-11
lines changed

4 files changed

+301
-11
lines changed

flow-server/src/main/java/com/vaadin/flow/dom/ElementEffect.java

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@
5656
*/
5757
public final class ElementEffect implements Serializable {
5858
private final ContextualEffectAction effectFunction;
59-
private boolean closed = false;
6059
private Effect effect = null;
60+
private Registration attachRegistration;
6161
private Registration detachRegistration;
6262

6363
public ElementEffect(Element owner, EffectAction effectFunction) {
@@ -69,7 +69,7 @@ public ElementEffect(Element owner, ContextualEffectAction effectFunction) {
6969
Objects.requireNonNull(effectFunction,
7070
"Effect function cannot be null");
7171
this.effectFunction = effectFunction;
72-
owner.addAttachListener(attach -> {
72+
attachRegistration = owner.addAttachListener(attach -> {
7373
enableEffect(attach.getSource());
7474

7575
detachRegistration = owner.addDetachListener(detach -> {
@@ -216,10 +216,14 @@ public static Registration effect(Element owner,
216216
}
217217

218218
private void enableEffect(Element owner) {
219-
if (closed) {
220-
return;
219+
if (effect != null) {
220+
effect.activate();
221+
} else {
222+
createEffect(owner);
221223
}
224+
}
222225

226+
private void createEffect(Element owner) {
223227
Component parentComponent = ComponentUtil.findParentComponent(owner)
224228
.get();
225229
UI ui = parentComponent.getUI().get();
@@ -233,7 +237,6 @@ private void enableEffect(Element owner) {
233237
}
234238
};
235239

236-
assert effect == null;
237240
effect = new Effect(errorHandlingEffectFunction, command -> {
238241
if (UI.getCurrent() == ui) {
239242
// Run immediately if on the same UI
@@ -255,14 +258,23 @@ private void enableEffect(Element owner) {
255258

256259
private void disableEffect() {
257260
if (effect != null) {
258-
effect.dispose();
259-
effect = null;
261+
effect.passivate();
260262
}
261263
}
262264

263265
public void close() {
264-
disableEffect();
265-
closed = true;
266+
if (effect != null) {
267+
effect.dispose();
268+
effect = null;
269+
}
270+
if (attachRegistration != null) {
271+
attachRegistration.remove();
272+
attachRegistration = null;
273+
}
274+
if (detachRegistration != null) {
275+
detachRegistration.remove();
276+
detachRegistration = null;
277+
}
266278
}
267279

268280
/**

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

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ public class Effect implements Serializable {
5252

5353
private SerializableExecutor dispatcher;
5454
private final List<Registration> registrations = new ArrayList<>();
55+
private final List<UsageTracker.Usage> usages = new ArrayList<>();
5556

5657
// Non-final to allow clearing when the effect is closed
5758
private @Nullable SerializableRunnable action;
@@ -60,6 +61,7 @@ public class Effect implements Serializable {
6061

6162
private boolean firstRun = true;
6263
private volatile boolean invalidatedFromBackground = false;
64+
private boolean passivated = false;
6365

6466
/**
6567
* Creates a signal effect with the given action and the default dispatcher.
@@ -160,16 +162,19 @@ public Effect(ContextualEffectAction action,
160162
private void revalidate() {
161163
assert registrations.isEmpty();
162164

163-
if (action == null) {
164-
// closed
165+
if (action == null || passivated) {
166+
// closed or passivated
165167
return;
166168
}
167169

170+
usages.clear();
171+
168172
activeEffects.get().add(this);
169173
try {
170174
boolean[] hasSignalUsage = { false };
171175
UsageTracker.track(action, usage -> {
172176
hasSignalUsage[0] = true;
177+
usages.add(usage);
173178
// avoid lambda to allow proper deserialization
174179
TransientListener usageListener = new TransientListener() {
175180
@Override
@@ -236,12 +241,62 @@ private void clearRegistrations() {
236241
registrations.clear();
237242
}
238243

244+
/**
245+
* Passivates this effect by removing all dependency listeners while
246+
* preserving the tracked usages. The effect can later be re-activated with
247+
* {@link #activate()}, which will check if any tracked values have changed
248+
* and only re-run the callback if needed.
249+
*/
250+
public synchronized void passivate() {
251+
clearRegistrations();
252+
passivated = true;
253+
}
254+
255+
/**
256+
* Re-activates a previously passivated effect. If any tracked signal has
257+
* changed since passivation, the effect callback is re-run with
258+
* {@link EffectContext#isInitialRun()} returning {@code true}. If nothing
259+
* has changed, the effect simply re-registers its dependency listeners
260+
* without running the callback.
261+
*/
262+
public synchronized void activate() {
263+
if (action == null || !passivated) {
264+
return;
265+
}
266+
passivated = false;
267+
firstRun = true;
268+
269+
if (usages.isEmpty()
270+
|| usages.stream().anyMatch(UsageTracker.Usage::hasChanges)) {
271+
// Something changed while passivated, do a full revalidation
272+
usages.clear();
273+
revalidate();
274+
} else {
275+
// Nothing changed, just re-register listeners. A change
276+
// listener may still fire immediately if a change sneaks in
277+
// between the hasChanges check and the onNextChange call,
278+
// in which case firstRun is already set to true above.
279+
for (UsageTracker.Usage usage : usages) {
280+
registrations.add(usage.onNextChange(this::onDependencyChange));
281+
}
282+
if (!invalidateScheduled.get()) {
283+
// No invalidation was scheduled, so no change was
284+
// detected during re-registration. Reset firstRun for
285+
// normal tracking. If an invalidation was scheduled,
286+
// firstRun stays true and will be reset by the
287+
// eventual revalidation.
288+
firstRun = false;
289+
}
290+
}
291+
}
292+
239293
/**
240294
* Disposes this effect by unregistering all current dependencies and
241295
* preventing the action from running again.
242296
*/
243297
public synchronized void dispose() {
244298
clearRegistrations();
299+
usages.clear();
245300
action = null;
246301
}
247302

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,81 @@ public void effect_componentAttachedAndDetached_effectEnabledAndDisabled() {
470470
assertEquals(3, count.get(), "Effect should not be run after remove");
471471
}
472472

473+
@Test
474+
public void effect_reattachWithoutChanges_effectNotReRun() {
475+
CurrentInstance.clearAll();
476+
TestComponent component = new TestComponent();
477+
ValueSignal<String> signal = new ValueSignal<>("initial");
478+
AtomicInteger count = new AtomicInteger();
479+
Signal.effect(component, () -> {
480+
signal.get();
481+
count.incrementAndGet();
482+
});
483+
484+
MockUI ui = new MockUI();
485+
ui.add(component);
486+
assertEquals(1, count.get(), "Effect should run on attach");
487+
488+
ui.remove(component);
489+
ui.add(component);
490+
assertEquals(1, count.get(),
491+
"Effect should not re-run on reattach when nothing changed");
492+
493+
signal.set("changed");
494+
assertEquals(2, count.get(),
495+
"Effect should still respond to changes after reattach");
496+
}
497+
498+
@Test
499+
public void effect_reattachWithChanges_effectReRunWithInitialRun() {
500+
CurrentInstance.clearAll();
501+
TestComponent component = new TestComponent();
502+
ValueSignal<String> signal = new ValueSignal<>("initial");
503+
List<Boolean> initialRuns = new ArrayList<>();
504+
Signal.effect(component, ctx -> {
505+
signal.get();
506+
initialRuns.add(ctx.isInitialRun());
507+
});
508+
509+
MockUI ui = new MockUI();
510+
ui.add(component);
511+
assertEquals(List.of(true), initialRuns);
512+
513+
signal.set("update");
514+
assertEquals(List.of(true, false), initialRuns);
515+
516+
ui.remove(component);
517+
signal.set("changed while detached");
518+
ui.add(component);
519+
assertEquals(List.of(true, false, true), initialRuns,
520+
"Reattach with changes should run with isInitialRun=true");
521+
}
522+
523+
@Test
524+
public void effect_reattachWithoutChanges_nextChangeNotInitialRun() {
525+
CurrentInstance.clearAll();
526+
TestComponent component = new TestComponent();
527+
ValueSignal<String> signal = new ValueSignal<>("initial");
528+
List<Boolean> initialRuns = new ArrayList<>();
529+
Signal.effect(component, ctx -> {
530+
signal.get();
531+
initialRuns.add(ctx.isInitialRun());
532+
});
533+
534+
MockUI ui = new MockUI();
535+
ui.add(component);
536+
assertEquals(List.of(true), initialRuns);
537+
538+
ui.remove(component);
539+
ui.add(component);
540+
assertEquals(List.of(true), initialRuns,
541+
"No re-run on reattach without changes");
542+
543+
signal.set("changed after reattach");
544+
assertEquals(List.of(true, false), initialRuns,
545+
"Normal change after reattach should not be initial run");
546+
}
547+
473548
@Test
474549
public void elementEffect_signalValueChanges_componentUpdated() {
475550
CurrentInstance.clearAll();

0 commit comments

Comments
 (0)