Skip to content

Commit cdff584

Browse files
fix: resolve route scope UI from the detach event (#25064) (CP: 25.2) (#25067)
This PR cherry-picks changes from the original PR #25064 to branch 25.2. --- #### 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 f35611c commit cdff584

3 files changed

Lines changed: 131 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
@@ -304,7 +304,7 @@ private RouteBeanStore(UI ui, VaadinSession session,
304304
public void onComponentEvent(DetachEvent event) {
305305
assert getVaadinSession().hasLock();
306306
uiDetachRegistration.remove();
307-
if (resetUI()) {
307+
if (resetUI(event.getUI())) {
308308
uiDetachRegistration = currentUI.addDetachListener(this);
309309
} else {
310310
destroy();
@@ -341,8 +341,21 @@ BeanNamesWrapper getBeanNamesWrapper() {
341341
return beanNames;
342342
}
343343

344-
private boolean resetUI() {
345-
UI ui = findPreservingUI(currentUI);
344+
/**
345+
* Re-points this store to the UI that replaces the given detached UI on
346+
* the same browser window, if any.
347+
* <p>
348+
* The detached UI is passed in explicitly because {@code currentUI} may
349+
* already have been re-assigned to a newer UI by
350+
* {@link RouteStoreWrapper#getBeanStore(UI)}, in which case it is not
351+
* the UI this detach event originates from.
352+
*
353+
* @param detachedUI
354+
* the UI being detached
355+
* @return {@code true} if a replacement UI was found
356+
*/
357+
private boolean resetUI(UI detachedUI) {
358+
UI ui = findPreservingUI(detachedUI);
346359
if (ui == null) {
347360
return false;
348361
}
@@ -456,11 +469,26 @@ private static UI getUI() {
456469
return UI.getCurrentOrThrow();
457470
}
458471

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

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.springframework.beans.factory.config.Scope;
3030

3131
import com.vaadin.flow.di.Lookup;
32+
import com.vaadin.flow.internal.CurrentInstance;
3233
import com.vaadin.flow.server.DefaultDeploymentConfiguration;
3334
import com.vaadin.flow.server.RouteRegistry;
3435
import com.vaadin.flow.server.VaadinContext;
@@ -66,6 +67,7 @@ public ReentrantLock getLockInstance() {
6667
@AfterEach
6768
void clearSession() {
6869
session = null;
70+
CurrentInstance.clearAll();
6971
}
7072

7173
@Test

‎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
@@ -17,6 +17,7 @@
1717

1818
import jakarta.servlet.ServletContext;
1919

20+
import java.util.ArrayList;
2021
import java.util.Collections;
2122
import java.util.List;
2223
import java.util.concurrent.atomic.AtomicInteger;
@@ -228,6 +229,102 @@ void detachUI_uiWithDifferentWindowName_beanInScopeIsDestroyedwhenUIIsDetached()
228229
assertEquals(0, count.get());
229230
}
230231

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

0 commit comments

Comments
 (0)