Skip to content

Commit bf08f80

Browse files
fix: resolve route scope UI from the detach event (CP: 24.10) (#25069) (CP: 24.9) (#25072)
This PR cherry-picks changes from the original PR #25069 to branch 24.9. --- #### Original PR description > This PR cherry-picks changes from the original PR #25064 to branch 24.10. > --- > #### Original PR description > > 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 Co-authored-by: Marco Collovati <marco@vaadin.com>
1 parent 20ef3dd commit bf08f80

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)