Description
A route target annotated with @RouteScoped (pseudo-scope, no client proxy) is handed out to a second UI when that UI reports the same window.name as the UI that created the bean — even though no @PreserveOnRefresh annotation is present anywhere.
Because the component instance still belongs to the first UI's state tree, showing it as the route target of the second UI fails with:
java.lang.IllegalStateException:
Can't move a node from one state tree to another.
If this is intentional, first remove the node from its current state tree by calling removeFromTree.
Offending component: created: Component 'TestView' at 'TestView.java' (<init> LINE 19),
attached: Component 'TestView' at 'TestView.java' (<init> LINE 19)
In the refresh variant the bean's route scope is even destroyed first — @PreDestroy runs — and the destroyed instance is subsequently used as the route target of the new UI (no new constructor / @PostConstruct invocation).
Two UIs share a window.name in everyday situations:
- the user duplicates a browser tab (Chrome copies
window.name into the duplicate),
- the browser restores the previous session ("Continue where you left off" keeps
window.name),
- the user refreshes several times in quick succession, so a new UI is initialized while older UIs of the same window are still awaiting cleanup.
The same code worked correctly with Vaadin 24 and Vaadin CDI 15.2.1.
The behavior surfaces in Vaadin 25 through the combination of two changes:
The window-name-based storage keying and preservation that these changes activate exists to support @PreserveOnRefresh — but it is applied unconditionally. Neither our applications nor the reproducer use @PreserveOnRefresh anywhere, yet the route-scope storage is still keyed and preserved per window name.
Environment
- Vaadin platform: 25.2.3 (Flow
flow-server 25.2.4)
- Vaadin CDI: 16.1.0 (latest; the relevant code is identical in 16.0.0, 16.0.1, 16.0.2)
- CDI implementation: Weld
weld-servlet-core 5.1.2.Final packaged in the WAR (also reproduced with 5.1.7.Final)
- Servlet container: Apache Tomcat 11.0.24 (Servlet 6.1)
- Servlet:
com.vaadin.cdi.CdiVaadinServlet mapped to /* in web.xml
beans.xml: bean-discovery-mode="all" (also reproduced with annotated + @CdiComponent on the view)
- Java: 21 (Temurin 21.0.9)
- Browser: Chrome on Windows 11 and Chrome on macOS 26.5.2
- No
@PreserveOnRefresh on the view, any superclass, or any router layout; no router layout at all in the reproducer
Minimal reproducer
import com.vaadin.cdi.annotation.RouteScoped;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.router.Route;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.UUID;
@Route("test")
@RouteScoped
public class TestView extends Div {
private final UUID id = UUID.randomUUID();
public TestView() {
System.err.println("CONSTRUCTOR " + id);
}
@PostConstruct
void init() {
System.err.println("POST_CONSTRUCT " + id);
}
@PreDestroy
void destroy() {
System.err.println("PRE_DESTROY " + id);
}
@Override
protected void onAttach(AttachEvent event) {
String text = "ATTACH " + id + " UI " + event.getUI().getUIId();
add(new Div(text));
System.err.println(text);
}
@Override
protected void onDetach(DetachEvent event) {
System.err.println("DETACH " + id + " UI " + event.getUI().getUIId());
}
}
Steps to reproduce
Refresh variant (timing-dependent, matches the behavior we originally observed in production):
- Open
/test.
- Press F5 repeatedly in quick succession. A handful of refreshes is often enough.
- Eventually a refresh destroys the current instance (
DETACH + PRE_DESTROY) and still uses it as the route target of the new UI — no new CONSTRUCTOR — and navigation fails with the same exception. Log excerpt from such a run (note the UI id jump caused by the rapid refreshes):
DETACH b0ff2469-c1a4-4245-9501-dec8507a14d6 UI 34
PRE_DESTROY b0ff2469-c1a4-4245-9501-dec8507a14d6
ERROR Can't move a node from one state tree to another. [...]
Offending component: created: Component 'TestView' at 'TestView.java' (<init> LINE 19), attached: [...]
A single calm F5 in a fresh, isolated tab does not trigger the problem — the old UI is cleaned up before the new UI registers its window name. This is why the issue looks browser- or machine-dependent: it follows the browser's window.name behavior (tab duplication, session restore) and request timing, not the browser itself.
Actual result
The @RouteScoped bean of the first UI is resolved as the route target for the second UI. Stack trace (Vaadin CDI 16.1.0, Flow 25.2.4, no instrumentation):
java.lang.IllegalStateException: Can't move a node from one state tree to another. [...]
at com.vaadin.flow.internal.StateNode.doSetTree(StateNode.java:835)
at com.vaadin.flow.internal.StateNode.lambda$setTree$3(StateNode.java:401)
at com.vaadin.flow.internal.StateNode.visitNodeTree(StateNode.java:771)
at com.vaadin.flow.internal.StateNode.setTree(StateNode.java:401)
at com.vaadin.flow.internal.StateNode.setParent(StateNode.java:283)
at com.vaadin.flow.internal.nodefeature.NodeFeature.attachPotentialChild(NodeFeature.java:79)
at com.vaadin.flow.internal.nodefeature.StateNodeNodeList.add(StateNodeNodeList.java:55)
at com.vaadin.flow.internal.nodefeature.ElementChildrenList.add(ElementChildrenList.java:43)
at com.vaadin.flow.dom.impl.AbstractNodeStateProvider.insertChild(AbstractNodeStateProvider.java:107)
at com.vaadin.flow.dom.Node.insertChild(Node.java:394)
at com.vaadin.flow.dom.Node.appendChild(Node.java:164)
at com.vaadin.flow.component.internal.UIInternalUpdater.updateRoot(UIInternalUpdater.java:73)
at com.vaadin.flow.component.internal.UIInternals.showRouteTarget(UIInternals.java:1023)
at com.vaadin.flow.router.internal.AbstractNavigationStateRenderer.handle(AbstractNavigationStateRenderer.java:247)
at com.vaadin.flow.component.internal.JavaScriptNavigationStateRenderer.handle(JavaScriptNavigationStateRenderer.java:80)
at com.vaadin.flow.router.Router.executeNavigation(Router.java:572)
at com.vaadin.flow.component.UI.handleNavigation(UI.java:2205)
at com.vaadin.flow.component.UI.renderViewForRoute(UI.java:2166)
at com.vaadin.flow.component.UI.browserNavigate(UI.java:2050)
[...]
at com.vaadin.cdi.CdiVaadinServlet.service(CdiVaadinServlet.java:65)
Expected result
Without @PreserveOnRefresh, a @RouteScoped route target must never be shared between UIs. Each navigation in a new UI should create a fresh contextual instance, and an instance whose @PreDestroy has run must never be attached again:
DETACH old-instance
PRE_DESTROY old-instance
CONSTRUCTOR new-instance
POST_CONSTRUCT new-instance
ATTACH new-instance UI new-ui-id
This is also the documented behavior ("by default, Vaadin recreates all routing components, including @UIScoped and @RouteScoped beans, when a user refreshes the page", https://vaadin.com/docs/latest/flow/integrations/cdi/contexts).
Description
A route target annotated with
@RouteScoped(pseudo-scope, no client proxy) is handed out to a second UI when that UI reports the samewindow.nameas the UI that created the bean — even though no@PreserveOnRefreshannotation is present anywhere.Because the component instance still belongs to the first UI's state tree, showing it as the route target of the second UI fails with:
In the refresh variant the bean's route scope is even destroyed first —
@PreDestroyruns — and the destroyed instance is subsequently used as the route target of the new UI (no new constructor /@PostConstructinvocation).Two UIs share a
window.namein everyday situations:window.nameinto the duplicate),window.name),The same code worked correctly with Vaadin 24 and Vaadin CDI 15.2.1.
The behavior surfaces in Vaadin 25 through the combination of two changes:
v-wnwindow name) are now collected synchronously with the?v-r=initrequest andUIInternals.getExtendedClientDetails()never returns null, so a second UI of the same browser window knows its window name immediately;ExtendedClientDetails.windowName.The window-name-based storage keying and preservation that these changes activate exists to support
@PreserveOnRefresh— but it is applied unconditionally. Neither our applications nor the reproducer use@PreserveOnRefreshanywhere, yet the route-scope storage is still keyed and preserved per window name.Environment
flow-server25.2.4)weld-servlet-core5.1.2.Final packaged in the WAR (also reproduced with 5.1.7.Final)com.vaadin.cdi.CdiVaadinServletmapped to/*inweb.xmlbeans.xml:bean-discovery-mode="all"(also reproduced withannotated+@CdiComponenton the view)@PreserveOnRefreshon the view, any superclass, or any router layout; no router layout at all in the reproducerMinimal reproducer
Steps to reproduce
Refresh variant (timing-dependent, matches the behavior we originally observed in production):
/test.DETACH+PRE_DESTROY) and still uses it as the route target of the new UI — no newCONSTRUCTOR— and navigation fails with the same exception. Log excerpt from such a run (note the UI id jump caused by the rapid refreshes):A single calm F5 in a fresh, isolated tab does not trigger the problem — the old UI is cleaned up before the new UI registers its window name. This is why the issue looks browser- or machine-dependent: it follows the browser's
window.namebehavior (tab duplication, session restore) and request timing, not the browser itself.Actual result
The
@RouteScopedbean of the first UI is resolved as the route target for the second UI. Stack trace (Vaadin CDI 16.1.0, Flow 25.2.4, no instrumentation):Expected result
Without
@PreserveOnRefresh, a@RouteScopedroute target must never be shared between UIs. Each navigation in a new UI should create a fresh contextual instance, and an instance whose@PreDestroyhas run must never be attached again:This is also the documented behavior ("by default, Vaadin recreates all routing components, including @UIScoped and @RouteScoped beans, when a user refreshes the page", https://vaadin.com/docs/latest/flow/integrations/cdi/contexts).