Skip to content

Commit 677f145

Browse files
authored
fix: resolve route scope UI from the detach event (CP: 24.10) (#25069)
RouteStoreWrapper#getBeanStore re-points a bean store to the UI created by a browser refresh, but the store's detach listener stays on the previous UI. resetUI() then asked findPreservingUI() about currentUI, which is no longer the UI the detach event comes from. If the refreshed UI had already been removed from the session, its session reference is null and findPreservingUI() threw a NullPointerException. The exception escaped before the branch that destroys the store, so the route scoped beans were not destroyed and the store stayed in routeStores. During session expiration the beans are still released by the session destroy listener of RouteStoreWrapper, but when only the UIs of an idle browser window are removed nothing cleans them up and they leak until the session ends. Pass the detached UI from the event into resetUI() so the lookup always gets the UI the event originates from, which still has its session at that point. findPreservingUI() now also returns null instead of dereferencing a UI that has no session anymore. Fixes #25027
1 parent b43131f commit 677f145

3 files changed

Lines changed: 134 additions & 4 deletions

File tree

vaadin-spring/src/main/java/com/vaadin/flow/spring/scopes/VaadinRouteScope.java

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ private RouteBeanStore(UI ui, VaadinSession session,
297297
public void onComponentEvent(DetachEvent event) {
298298
assert getVaadinSession().hasLock();
299299
uiDetachRegistration.remove();
300-
if (resetUI()) {
300+
if (resetUI(event.getUI())) {
301301
uiDetachRegistration = currentUI.addDetachListener(this);
302302
} else {
303303
destroy();
@@ -334,8 +334,21 @@ BeanNamesWrapper getBeanNamesWrapper() {
334334
return beanNames;
335335
}
336336

337-
private boolean resetUI() {
338-
UI ui = findPreservingUI(currentUI);
337+
/**
338+
* Re-points this store to the UI that replaces the given detached UI on
339+
* the same browser window, if any.
340+
* <p>
341+
* The detached UI is passed in explicitly because {@code currentUI} may
342+
* already have been re-assigned to a newer UI by
343+
* {@link RouteStoreWrapper#getBeanStore(UI)}, in which case it is not
344+
* the UI this detach event originates from.
345+
*
346+
* @param detachedUI
347+
* the UI being detached
348+
* @return {@code true} if a replacement UI was found
349+
*/
350+
private boolean resetUI(UI detachedUI) {
351+
UI ui = findPreservingUI(detachedUI);
339352
if (ui == null) {
340353
return false;
341354
}
@@ -457,11 +470,26 @@ private static UI getUI() {
457470
return ui;
458471
}
459472

473+
/**
474+
* Finds the UI that took over the browser window of the given UI, e.g. on a
475+
* page refresh.
476+
*
477+
* @param ui
478+
* the UI being detached
479+
* @return the UI on the same browser window, or {@code null} if there is
480+
* none
481+
*/
460482
private static UI findPreservingUI(UI ui) {
461483
VaadinSession session = ui.getSession();
462484
String windowName = getWindowName(ui);
485+
if (session == null || windowName == null) {
486+
// Without a window name there is nothing to match the session UIs
487+
// against, and a UI that has already been detached from the session
488+
// cannot be matched at all.
489+
return null;
490+
}
463491
for (UI sessionUi : session.getUIs()) {
464-
if (sessionUi != ui && windowName != null
492+
if (sessionUi != ui
465493
&& windowName.equals(getWindowName(sessionUi))) {
466494
return sessionUi;
467495
}

vaadin-spring/src/test/java/com/vaadin/flow/spring/scopes/AbstractScopeTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.springframework.beans.factory.config.Scope;
2525

2626
import com.vaadin.flow.di.Lookup;
27+
import com.vaadin.flow.internal.CurrentInstance;
2728
import com.vaadin.flow.server.DefaultDeploymentConfiguration;
2829
import com.vaadin.flow.server.RouteRegistry;
2930
import com.vaadin.flow.server.VaadinContext;
@@ -61,6 +62,10 @@ public ReentrantLock getLockInstance() {
6162
@After
6263
public void clearSession() {
6364
session = null;
65+
// mockSession() sets the current VaadinSession, which is stored in a
66+
// thread local. Surefire reuses the JVM and its threads across test
67+
// classes, so it has to be cleared to not leak into unrelated tests.
68+
CurrentInstance.clearAll();
6469
}
6570

6671
@Test(expected = IllegalStateException.class)

vaadin-spring/src/test/java/com/vaadin/flow/spring/scopes/VaadinRouteScopeTest.java

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import jakarta.servlet.ServletContext;
1212

13+
import java.util.ArrayList;
1314
import java.util.Collections;
1415
import java.util.List;
1516
import java.util.concurrent.atomic.AtomicInteger;
@@ -219,6 +220,102 @@ public void detachUI_uiWithDifferentWindowName_beanInScopeIsDestroyedwhenUIIsDet
219220
Assert.assertEquals(0, count.get());
220221
}
221222

223+
@Test
224+
public void detachUI_refreshedUIDetachedFirst_beanIsDestroyedWithoutReportingError() {
225+
UI ui = mockUI();
226+
227+
UI anotherUI = makeAnotherUI(ui);
228+
229+
ExtendedClientDetails details = Mockito
230+
.mock(ExtendedClientDetails.class);
231+
Mockito.when(details.getWindowName()).thenReturn("bar");
232+
ui.getInternals().setExtendedClientDetails(details);
233+
anotherUI.getInternals().setExtendedClientDetails(details);
234+
235+
VaadinSession session = ui.getSession();
236+
session.addUI(ui);
237+
session.addUI(anotherUI);
238+
239+
mockServletContext(ui);
240+
241+
VaadinRouteScope scope = initScope(ui);
242+
243+
AtomicInteger count = new AtomicInteger();
244+
scope.registerDestructionCallback("foo", count::getAndIncrement);
245+
246+
navigateTo(ui, new NavigationTarget());
247+
248+
putObjectIntoScope(scope);
249+
250+
// Refresh: a request on the new UI re-points the store to it, while the
251+
// store's detach listener stays on the first UI.
252+
UI.setCurrent(anotherUI);
253+
initScope(anotherUI);
254+
255+
List<Throwable> reportedErrors = new ArrayList<>();
256+
Mockito.when(session.getErrorHandler())
257+
.thenReturn(event -> reportedErrors.add(event.getThrowable()));
258+
259+
// Session expiration removes the UIs one by one. The refreshed UI, the
260+
// one the store points at, goes first and loses its session.
261+
session.removeUI(anotherUI);
262+
263+
// Detaching the UI that carries the store's detach listener must not
264+
// fail, and must destroy the bean since the browser window is left
265+
// without a UI.
266+
UI.setCurrent(ui);
267+
session.removeUI(ui);
268+
269+
Assert.assertEquals(List.of(), reportedErrors);
270+
Assert.assertEquals(1, count.get());
271+
}
272+
273+
@Test
274+
public void detachUI_refreshedUIDetachedLast_beanIsDestroyedWhenRefreshedUIIsDetached() {
275+
UI ui = mockUI();
276+
277+
UI anotherUI = makeAnotherUI(ui);
278+
279+
ExtendedClientDetails details = Mockito
280+
.mock(ExtendedClientDetails.class);
281+
Mockito.when(details.getWindowName()).thenReturn("bar");
282+
ui.getInternals().setExtendedClientDetails(details);
283+
anotherUI.getInternals().setExtendedClientDetails(details);
284+
285+
VaadinSession session = ui.getSession();
286+
session.addUI(ui);
287+
session.addUI(anotherUI);
288+
289+
mockServletContext(ui);
290+
291+
VaadinRouteScope scope = initScope(ui);
292+
293+
AtomicInteger count = new AtomicInteger();
294+
scope.registerDestructionCallback("foo", () -> count.getAndIncrement());
295+
296+
navigateTo(ui, new NavigationTarget());
297+
298+
putObjectIntoScope(scope);
299+
300+
// Refresh: a request on the new UI re-points the store to it, while the
301+
// store's detach listener stays on the first UI.
302+
UI.setCurrent(anotherUI);
303+
initScope(anotherUI);
304+
305+
// The stale UI is closed later on, e.g. by the inactive UI cleanup.
306+
UI.setCurrent(ui);
307+
session.removeUI(ui);
308+
309+
// the bean is not removed since the refreshed UI still hosts it
310+
Assert.assertEquals(0, count.get());
311+
312+
// Detaching the refreshed UI must destroy the bean.
313+
UI.setCurrent(anotherUI);
314+
session.removeUI(anotherUI);
315+
316+
Assert.assertEquals(1, count.get());
317+
}
318+
222319
private void navigateTo(UI ui, Component component) {
223320
AfterNavigationEvent event = Mockito.mock(AfterNavigationEvent.class);
224321
Mockito.when(event.getActiveChain())

0 commit comments

Comments
 (0)