You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
vaadin-spring: @RouteScope beans are shared by all UIs with the same window.name (Spring twin of vaadin/cdi#526) #26820
VaadinRouteScope.RouteStoreWrapper.getUIStoreKey keys the route-scope bean store by window.name whenever the extended client details carry one. In Vaadin 24 the window name was fetched only for @PreserveOnRefresh routes, so only those views shared beans across UIs. In Vaadin 25 the window name is always known, so every route-scoped bean is shared by all UIs of a browser tab.
When a tab loads again while its previous UI is still open on the server, the new UI is given the previous UI's route-scoped component instance. Attaching it fails with IllegalStateException: Can't move a node from one state tree to another, and the page renders blank. The previous UI stays open whenever the old page ends without its UNLOAD beacon: a crashed or killed tab, a discarded tab, or a reload while offline. A normal reload works, because the beacon closes the old UI first.
vaadin/cdi#526 reported the same problem for CDI, and vaadin/cdi#527 fixed it there. The Spring implementation still keys by window name in 25.3.6.
Expected behavior
A new UI gets new route-scoped beans unless the route or its layout is @PreserveOnRefresh, the same rule as vaadin/cdi#527.
Make the old UI outlive the page, in either of these ways:
In Chrome, open the Task manager, end the tab's process, then click Reload.
Or in the DevTools console, run navigator.sendBeacon = () => true and then reload.
The page is blank, and the server logs:
ERROR c.v.flow.router.InternalServerError : There was an exception while trying to navigate to 'scoped'
java.lang.IllegalStateException: Can't move a node from one state tree to another. [...] Offending component: node id=6, element with tag 'vaadin-horizontal-layout', component 'repro.ScopedPanel', used in 'repro.ScopedView', created at ScopedPanel.java:13, attached at ScopedView.java:10
at com.vaadin.flow.internal.StateNode.doSetTree(StateNode.java:837) ~[flow-server-25.3.6.jar:25.3.6]
Versions
Vaadin 25.3.4, with Flow and vaadin-spring 25.3.6. First seen on 25.1.0.
Thanks for the detailed report and the reproduction. This is a real bug in vaadin-spring, and the code on main still has it.
Root cause
VaadinRouteScope.RouteStoreWrapper.getUIStoreKey(UI) keys the route bean store by "win-" + windowName whenever ExtendedClientDetails.getWindowName() is non-null, and by "uid-" + uiId otherwise. Up to Vaadin 24 a window name was only known for @PreserveOnRefresh chains, so this check was effectively a "preserve on refresh" detector. Since the client details are collected during UI initialization (Flow 25), every UI has a window name, so every route bean store is shared by all UIs of the tab.
getBeanStore(UI) then hands the existing store to the new UI and re-points currentUI to it (beanStore.currentUI = ui). The route-scoped component it returns still belongs to the old UI's state tree. If the old UI is still attached (no UNLOAD beacon: killed or discarded tab, offline reload, duplicated or restored tab), attaching that component to the new UI throws Can't move a node from one state tree to another. After a normal reload the beacon has already closed the old UI, and RouteBeanStore.onComponentEvent(DetachEvent) / findPreservingUI hide the problem.
Key the store by UI id (uid-<uiId>) by default, and destroy it when that UI detaches.
Use the window key (win-<windowName>) only when the current navigation target or one of its layouts has @PreserveOnRefresh. NavigationListener already tracks currentNavigationTarget and currentLayouts from beforeEnter, so it can make that check before the beans are created. This is the same rule Flow applies in AbstractNavigationStateRenderer.isPreserveOnRefreshTarget.
Only window-keyed stores should be handed over to a preserving UI on detach (resetUI / findPreservingUI). A UI-keyed store is destroyed with its UI.
Re-scope the store when the same UI navigates between a preserved and a non-preserved chain while an owner stays in the chain (for example a shared layout). Move the store between the two keys instead of recreating the beans, and only move it back to the UI key if no other live UI of the window holds the preserved chain. The CDI fix needed this too. In Spring it should be simpler, because there is one RouteBeanStore per UI/window rather than one per owner.
The retrieveExtendedClientDetails(... relocateStore ...) path only matters when the window name is still unknown (e.g. embedded web components). With UI-id keys by default it can likely be reduced to the preserved case.
Tests: unit tests in VaadinRouteScopeTest for (a) two live UIs with the same window name on a plain route getting different bean instances, (b) a @PreserveOnRefresh route (and a route whose layout has @PreserveOnRefresh) still sharing beans across a refresh, and (c) navigating preserved → plain → preserved in one UI keeping beans whose owner stays in the chain.
Because this changes the lifecycle of route-scoped beans and has the re-scoping edge cases above, I'm leaving the implementation for a maintainer to confirm the approach rather than opening a PR right away. A related note: open PR #25901 (BrowserTab) also adds per-tab state keyed by window.name. It doesn't affect this fix, but the two should agree on what "same tab" means.
Activity
totally-not-ai commentedon Nov 2, 2026
Thanks for the detailed report and the reproduction. This is a real bug in
vaadin-spring, and the code onmainstill has it.Root cause
VaadinRouteScope.RouteStoreWrapper.getUIStoreKey(UI)keys the route bean store by"win-" + windowNamewheneverExtendedClientDetails.getWindowName()is non-null, and by"uid-" + uiIdotherwise. Up to Vaadin 24 a window name was only known for@PreserveOnRefreshchains, so this check was effectively a "preserve on refresh" detector. Since the client details are collected during UI initialization (Flow 25), every UI has a window name, so every route bean store is shared by all UIs of the tab.getBeanStore(UI)then hands the existing store to the new UI and re-pointscurrentUIto it (beanStore.currentUI = ui). The route-scoped component it returns still belongs to the old UI's state tree. If the old UI is still attached (noUNLOADbeacon: killed or discarded tab, offline reload, duplicated or restored tab), attaching that component to the new UI throwsCan't move a node from one state tree to another. After a normal reload the beacon has already closed the old UI, andRouteBeanStore.onComponentEvent(DetachEvent)/findPreservingUIhide the problem.Proposed fix (same rule as vaadin/cdi#527)
uid-<uiId>) by default, and destroy it when that UI detaches.win-<windowName>) only when the current navigation target or one of its layouts has@PreserveOnRefresh.NavigationListeneralready trackscurrentNavigationTargetandcurrentLayoutsfrombeforeEnter, so it can make that check before the beans are created. This is the same rule Flow applies inAbstractNavigationStateRenderer.isPreserveOnRefreshTarget.resetUI/findPreservingUI). A UI-keyed store is destroyed with its UI.RouteBeanStoreper UI/window rather than one per owner.retrieveExtendedClientDetails(... relocateStore ...)path only matters when the window name is still unknown (e.g. embedded web components). With UI-id keys by default it can likely be reduced to the preserved case.Tests: unit tests in
VaadinRouteScopeTestfor (a) two live UIs with the same window name on a plain route getting different bean instances, (b) a@PreserveOnRefreshroute (and a route whose layout has@PreserveOnRefresh) still sharing beans across a refresh, and (c) navigating preserved → plain → preserved in one UI keeping beans whose owner stays in the chain.Because this changes the lifecycle of route-scoped beans and has the re-scoping edge cases above, I'm leaving the implementation for a maintainer to confirm the approach rather than opening a PR right away. A related note: open PR #25901 (
BrowserTab) also adds per-tab state keyed bywindow.name. It doesn't affect this fix, but the two should agree on what "same tab" means.