Skip to content

Commit ee7465a

Browse files
fix: prevent NPE in ElementEffect detach listener on re-attach (#25005) (CP: 25.2) (#25013)
This PR cherry-picks changes from the original PR #25005 to branch 25.2. --- #### Original PR description > ElementEffect registered a new detach listener on every attach, stored it in the single detachRegistration field, and relied on each detach listener removing itself. When an element is re-attached without a detach event firing in between - StateNode.removeFromTree(false), as used by UIInternals.moveToNewUI for `@PreserveOnRefresh` - a second detach listener was registered while the first was still present. The next real detach ran both: the first nulled detachRegistration, the second dereferenced null and threw a NullPointerException. > > Register the detach listener through a helper that removes any previous registration before adding a new one, and null-guard the removal inside the listener so at most one detach listener is ever active. > > Fixes #24973 Co-authored-by: Marco Collovati <marco@vaadin.com>
1 parent f3535cc commit ee7465a

2 files changed

Lines changed: 70 additions & 8 deletions

File tree

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,7 @@ public ElementEffect(Element owner, ContextualEffectAction effectFunction) {
8787
// execution context.
8888
enableEffect(owner);
8989

90-
detachRegistration = owner.addDetachListener(detach -> {
91-
disableEffect();
92-
detachRegistration.remove();
93-
detachRegistration = null;
94-
});
90+
registerDetachListener();
9591
} else {
9692
// Element is not yet attached: run a probe immediately so that
9793
// structural errors (e.g. MissingSignalUsageException) are reported
@@ -106,11 +102,33 @@ public ElementEffect(Element owner, ContextualEffectAction effectFunction) {
106102
attachRegistration = owner.addAttachListener(attach -> {
107103
enableEffect(attach.getSource());
108104

109-
detachRegistration = owner.addDetachListener(detach -> {
110-
disableEffect();
105+
registerDetachListener();
106+
});
107+
}
108+
109+
/**
110+
* Registers the detach listener that disables the effect when the owner is
111+
* detached.
112+
* <p>
113+
* A detach listener from a previous attach may still be registered if the
114+
* element was re-attached without a detach event firing in between - e.g.
115+
* {@code StateNode.removeFromTree(false)} as used by
116+
* {@code UIInternals.moveToNewUI} for {@code @PreserveOnRefresh}. Such a
117+
* stale listener is removed first so that the effect never accumulates
118+
* multiple detach listeners sharing the single {@link #detachRegistration}
119+
* field, which would otherwise cause a {@link NullPointerException} when
120+
* the second listener dereferences the already-nulled registration.
121+
*/
122+
private void registerDetachListener() {
123+
if (detachRegistration != null) {
124+
detachRegistration.remove();
125+
}
126+
detachRegistration = owner.addDetachListener(detach -> {
127+
disableEffect();
128+
if (detachRegistration != null) {
111129
detachRegistration.remove();
112130
detachRegistration = null;
113-
});
131+
}
114132
});
115133
}
116134

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import com.vaadin.flow.signals.shared.SharedValueSignal;
5151
import com.vaadin.tests.util.MockUI;
5252

53+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
5354
import static org.junit.jupiter.api.Assertions.assertEquals;
5455
import static org.junit.jupiter.api.Assertions.assertFalse;
5556
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -630,6 +631,49 @@ void effect_componentAttachedAndDetached_effectEnabledAndDisabled() {
630631
assertEquals(4, count.get(), "Effect should not be run after remove");
631632
}
632633

634+
@Test
635+
void effect_reattachedViaMoveToNewUI_detachDoesNotThrow() {
636+
// Reproduces #24973: UIInternals.moveToNewUI (used for
637+
// @PreserveOnRefresh) re-attaches an element via
638+
// StateNode.removeFromTree(false) followed by appendChild. This fires
639+
// the attach listener again without a detach event in between.
640+
// ElementEffect must not accumulate multiple detach listeners sharing
641+
// the single detachRegistration field, otherwise the next real detach
642+
// throws a NullPointerException.
643+
CurrentInstance.clearAll();
644+
TestComponent component = new TestComponent();
645+
ValueSignal<String> signal = new ValueSignal<>("initial");
646+
AtomicInteger count = new AtomicInteger();
647+
Signal.effect(component, () -> {
648+
signal.get();
649+
count.incrementAndGet();
650+
});
651+
652+
MockUI ui = new MockUI();
653+
ui.add(component);
654+
655+
// Simulate UIInternals.moveToNewUI: reset the node without firing
656+
// detach listeners, then re-attach the element to a new UI.
657+
MockUI newUi = new MockUI();
658+
component.getElement().getNode().removeFromTree(false);
659+
newUi.getElement().appendChild(component.getElement());
660+
661+
// An ordinary detach must not throw. Before the fix this raised a
662+
// NullPointerException from a second, stale ElementEffect detach
663+
// listener dereferencing the already-nulled registration.
664+
assertDoesNotThrow(() -> component.getElement().removeFromParent());
665+
666+
// The effect keeps working after the move: it is disabled while
667+
// detached and re-enabled (and re-run because the signal changed) on a
668+
// fresh attach.
669+
signal.set("while detached");
670+
int countAfterDetach = count.get();
671+
newUi.getElement().appendChild(component.getElement());
672+
signal.set("after reattach");
673+
assertTrue(count.get() > countAfterDetach,
674+
"Effect should still run after re-attach following a move");
675+
}
676+
633677
@Test
634678
void effect_reattachWithoutChanges_effectNotReRun() {
635679
CurrentInstance.clearAll();

0 commit comments

Comments
 (0)