Skip to content

Commit 9b1efa0

Browse files
fix: Prevent Vite reload from canceling logout redirect (#23375) (#23391)
When a user logs out and the session is invalidated, the server closes WebSocket connections with close code 1008 (VIOLATED_POLICY). Vite detects this disconnection and starts polling for reconnection, then reloads the page - canceling any server-initiated redirect. The fix uses a Promise-based synchronization between Vite's HMR events: 1. On vite:ws:connect, we register a close listener and store a Promise on the WebSocket that resolves with the close code when triggered. 2. On vite:ws:disconnect, we await this Promise to get the close code. Since Vite's notifyListeners awaits our async handler, this creates a synchronization point before Vite checks its willUnload flag. 3. When close code is 1008, we dispatch a beforeunload event to set Vite's internal willUnload flag, preventing the reload. This approach works because Vite's close handler does not await the Promise returned by onMessage/handleMessage, so our async disconnect handler can complete (setting willUnload) before Vite's willUnload check runs. Fixes #20819 Co-authored-by: Marco Collovati <marco@vaadin.com>
1 parent 0426636 commit 9b1efa0

File tree

11 files changed

+600
-12
lines changed

11 files changed

+600
-12
lines changed

flow-server/src/main/java/com/vaadin/flow/component/page/Page.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,8 +472,12 @@ public void open(String url) {
472472
* the name of the window.
473473
*/
474474
public void open(String url, String windowName) {
475+
// The vaadin-redirect-pending event might be useful to block other
476+
// client side
477+
// reload/redirection triggered by other components, for example Vite.
475478
executeJs(
476-
"if ($1 == '_self') this.stopApplication(); window.open($0, $1)",
479+
"window.dispatchEvent(new CustomEvent('vaadin-redirect-pending', {detail: {url: $0}})); "
480+
+ "if ($1 == '_self') this.stopApplication(); window.open($0, $1)",
477481
url, windowName);
478482
}
479483

flow-server/src/main/resources/com/vaadin/flow/server/frontend/vite-devmode.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,64 @@ if (import.meta.hot) {
1414
};
1515

1616
let pendingNavigationTo: string | undefined = undefined;
17+
let redirectPending: boolean = false;
1718

1819
window.addEventListener('vaadin-router-go', (routerEvent: any) => {
1920
pendingNavigationTo = routerEvent.detail.pathname + routerEvent.detail.search;
2021
});
22+
23+
// Listen for server-initiated redirects via Page.setLocation()
24+
window.addEventListener('vaadin-redirect-pending', () => {
25+
redirectPending = true;
26+
});
27+
28+
// Register a close event listener and store a Promise on the WebSocket.
29+
// The Promise resolves with the close code when our listener runs.
30+
// This allows vite:ws:disconnect to await the close code even though
31+
// Vite's close listener runs before ours (due to registration order).
32+
hot.on('vite:ws:connect', (payload: any) => {
33+
const ws = payload.webSocket;
34+
35+
// Create Promise with resolver scoped to this closure.
36+
// Store on WebSocket so vite:ws:disconnect can access it.
37+
(ws as any)._closeCodePromise = new Promise<number>((resolve) => {
38+
ws.addEventListener('close', (event: any) => {
39+
resolve(event.code);
40+
});
41+
});
42+
});
43+
44+
// Async handler that waits for the close listener to run.
45+
// Vite's close handler calls notifyListeners which awaits this handler.
46+
// By awaiting the closeCodePromise, we ensure our close listener has
47+
// run and we can check the close code before Vite checks willUnload.
48+
hot.on('vite:ws:disconnect', async (payload: any) => {
49+
const ws = payload.webSocket;
50+
const closeCodePromise = (ws as any)?._closeCodePromise;
51+
52+
if (closeCodePromise) {
53+
const closeCode = await closeCodePromise;
54+
55+
// Close code 1008 (VIOLATED_POLICY) indicates authenticated HTTP session invalidation
56+
// that usually corresponds also to a server-initiated redirect.
57+
if (closeCode === 1008) {
58+
redirectPending = true;
59+
// Dispatch beforeunload to set Vite's internal willUnload flag,
60+
// which prevents Vite from reloading the page after reconnecting.
61+
window.dispatchEvent(new Event('beforeunload'));
62+
}
63+
}
64+
});
65+
2166
hot.on('vite:beforeFullReload', (payload: any) => {
2267
if (isLiveReloadDisabled()) {
2368
preventViteReload(payload);
2469
}
70+
// Prevent reload when a server-initiated redirect is pending
71+
if (redirectPending) {
72+
preventViteReload(payload);
73+
return;
74+
}
2575
if (pendingNavigationTo) {
2676
// Force reload with the new URL
2777
location.href = pendingNavigationTo;

flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,60 @@ public PendingJavaScriptResult executeJs(String expression,
297297
// self check
298298
Assert.assertEquals("_self", params.get(1));
299299

300-
MatcherAssert.assertThat(capture.get(), CoreMatchers
301-
.startsWith("if ($1 == '_self') this.stopApplication();"));
300+
MatcherAssert.assertThat(capture.get(),
301+
CoreMatchers.containsString("this.stopApplication();"));
302+
}
303+
304+
@Test
305+
public void setLocation_dispatchesRedirectPendingEvent() {
306+
AtomicReference<String> capture = new AtomicReference<>();
307+
List<Object> params = new ArrayList<>();
308+
Page page = new Page(new MockUI()) {
309+
@Override
310+
public PendingJavaScriptResult executeJs(String expression,
311+
Object... parameters) {
312+
capture.set(expression);
313+
params.addAll(Arrays.asList(parameters));
314+
return Mockito.mock(PendingJavaScriptResult.class);
315+
}
316+
};
317+
318+
page.setLocation("/logout-landing");
319+
320+
String expression = capture.get();
321+
Assert.assertTrue("Should dispatch vaadin-redirect-pending event",
322+
expression.contains("vaadin-redirect-pending"));
323+
Assert.assertTrue("Should call window.open",
324+
expression.contains("window.open"));
325+
Assert.assertEquals("URL parameter should be passed", "/logout-landing",
326+
params.get(0));
327+
}
328+
329+
@Test
330+
public void open_dispatchesRedirectPendingEventBeforeRedirect() {
331+
AtomicReference<String> capture = new AtomicReference<>();
332+
Page page = new Page(new MockUI()) {
333+
@Override
334+
public PendingJavaScriptResult executeJs(String expression,
335+
Object... parameters) {
336+
capture.set(expression);
337+
return Mockito.mock(PendingJavaScriptResult.class);
338+
}
339+
};
340+
341+
page.open("https://example.com", "_blank");
342+
343+
String expression = capture.get();
344+
// Verify event dispatch comes before window.open
345+
int eventDispatchIndex = expression.indexOf("vaadin-redirect-pending");
346+
int windowOpenIndex = expression.indexOf("window.open");
347+
Assert.assertTrue("Event dispatch should be present",
348+
eventDispatchIndex >= 0);
349+
Assert.assertTrue("window.open should be present",
350+
windowOpenIndex >= 0);
351+
Assert.assertTrue(
352+
"Event dispatch should come before window.open in the script",
353+
eventDispatchIndex < windowOpenIndex);
302354
}
303355

304356
@Test
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.uitest.ui.vitelogout;
17+
18+
import jakarta.servlet.ServletContextEvent;
19+
import jakarta.servlet.ServletContextListener;
20+
import jakarta.servlet.annotation.WebListener;
21+
import jakarta.servlet.http.HttpSessionEvent;
22+
import jakarta.servlet.http.HttpSessionListener;
23+
24+
import com.vaadin.base.devserver.viteproxy.ViteSessionTracker;
25+
26+
/**
27+
* Replicates Tomcat's behavior of closing WebSocket connections when an
28+
* authenticated HTTP session is invalidated. Jetty doesn't do this by default,
29+
* so this listener is needed to properly test the logout redirect fix in the
30+
* test environment.
31+
* <p>
32+
* Implements both ServletContextListener (to initialize the ViteSessionTracker)
33+
* and HttpSessionListener (to notify tracker when sessions are destroyed).
34+
*/
35+
@WebListener
36+
public class CloseViteWebsocketOnSessionExpiration
37+
implements HttpSessionListener, ServletContextListener {
38+
39+
private ViteSessionTracker tracker;
40+
41+
@Override
42+
public void contextInitialized(ServletContextEvent sce) {
43+
tracker = new ViteSessionTracker();
44+
sce.getServletContext().setAttribute(ViteSessionTracker.class.getName(),
45+
tracker);
46+
}
47+
48+
@Override
49+
public void sessionDestroyed(HttpSessionEvent se) {
50+
if (tracker != null) {
51+
// Simulate Tomcat behavior
52+
// Close code 1008 is VIOLATED_POLICY per WebSocket RFC
53+
tracker.close(se.getSession().getId(), 1008,
54+
"This connection was established under an authenticated HTTP session that has ended");
55+
}
56+
}
57+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.uitest.ui.vitelogout;
17+
18+
import com.vaadin.flow.component.html.Div;
19+
import com.vaadin.flow.component.html.NativeButton;
20+
import com.vaadin.flow.dom.Element;
21+
import com.vaadin.flow.router.Route;
22+
23+
/**
24+
* Login view for testing Vite logout redirect behavior.
25+
* <p>
26+
* Contains a native HTML form that posts to the login route, which is
27+
* intercepted by {@link MockAuthenticationFilter}.
28+
*/
29+
@Route("com.vaadin.flow.uitest.ui.vitelogout.LoginView")
30+
public class LoginView extends Div {
31+
32+
public LoginView() {
33+
Element form = new Element("form");
34+
form.setAttribute("action",
35+
"/view/com.vaadin.flow.uitest.ui.vitelogout.LoginView");
36+
form.setAttribute("method", "POST");
37+
38+
NativeButton submit = new NativeButton("Login");
39+
submit.setId("login-button");
40+
submit.getElement().setAttribute("type", "submit");
41+
42+
getElement().appendChild(form);
43+
form.appendChild(submit.getElement());
44+
}
45+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.uitest.ui.vitelogout;
17+
18+
import com.vaadin.flow.component.UI;
19+
import com.vaadin.flow.component.html.Div;
20+
import com.vaadin.flow.component.html.NativeButton;
21+
import com.vaadin.flow.component.html.Span;
22+
import com.vaadin.flow.router.Route;
23+
import com.vaadin.flow.server.VaadinSession;
24+
25+
/**
26+
* View for testing Vite logout redirect behavior.
27+
* <p>
28+
* Contains a logout button that sets the location to the session-ended route
29+
* and invalidates the session. The test verifies that Vite's page reload
30+
* doesn't cancel the server-initiated redirect when the session is invalidated.
31+
*/
32+
@Route("com.vaadin.flow.uitest.ui.vitelogout.LogoutTestView")
33+
public class LogoutTestView extends Div {
34+
35+
public LogoutTestView() {
36+
Span marker = new Span("Logout Test View");
37+
marker.setId("logout-test-marker");
38+
39+
NativeButton logoutButton = new NativeButton("Logout", e -> {
40+
UI.getCurrent().getPage().setLocation(
41+
"/view/com.vaadin.flow.uitest.ui.vitelogout.SessionEndedView");
42+
VaadinSession.getCurrent().getSession().invalidate();
43+
});
44+
logoutButton.setId("logout-button");
45+
46+
add(marker, logoutButton);
47+
}
48+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.uitest.ui.vitelogout;
17+
18+
import jakarta.servlet.Filter;
19+
import jakarta.servlet.FilterChain;
20+
import jakarta.servlet.ServletException;
21+
import jakarta.servlet.ServletRequest;
22+
import jakarta.servlet.ServletResponse;
23+
import jakarta.servlet.annotation.WebFilter;
24+
import jakarta.servlet.http.HttpServletRequest;
25+
import jakarta.servlet.http.HttpServletRequestWrapper;
26+
import jakarta.servlet.http.HttpServletResponse;
27+
import jakarta.servlet.http.HttpSession;
28+
29+
import java.io.IOException;
30+
import java.security.Principal;
31+
32+
/**
33+
* Mock authentication filter for testing Vite logout redirect behavior.
34+
* <p>
35+
* Intercepts login POST requests, sets an authenticated session attribute, and
36+
* wraps subsequent requests with a principal when authenticated.
37+
*/
38+
@WebFilter(urlPatterns = { "/view/*" })
39+
public class MockAuthenticationFilter implements Filter {
40+
41+
public static final String AUTHENTICATED_ATTR = "mock.authenticated";
42+
43+
@Override
44+
public void doFilter(ServletRequest request, ServletResponse response,
45+
FilterChain chain) throws IOException, ServletException {
46+
HttpServletRequest httpRequest = (HttpServletRequest) request;
47+
HttpServletResponse httpResponse = (HttpServletResponse) response;
48+
HttpSession session = httpRequest.getSession();
49+
String path = httpRequest.getRequestURI();
50+
51+
// On POST to login route, mark as authenticated and redirect
52+
if (path.endsWith(".LoginView")
53+
&& "POST".equals(httpRequest.getMethod())) {
54+
session.setAttribute(AUTHENTICATED_ATTR, true);
55+
httpResponse.sendRedirect(
56+
"/view/com.vaadin.flow.uitest.ui.vitelogout.LogoutTestView");
57+
return;
58+
}
59+
60+
// If authenticated, wrap request with principal
61+
if (Boolean.TRUE.equals(session.getAttribute(AUTHENTICATED_ATTR))) {
62+
chain.doFilter(new AuthenticatedRequestWrapper(httpRequest),
63+
response);
64+
} else {
65+
chain.doFilter(request, response);
66+
}
67+
}
68+
69+
private static class AuthenticatedRequestWrapper
70+
extends HttpServletRequestWrapper {
71+
72+
AuthenticatedRequestWrapper(HttpServletRequest request) {
73+
super(request);
74+
}
75+
76+
@Override
77+
public Principal getUserPrincipal() {
78+
return () -> "testuser";
79+
}
80+
}
81+
}

0 commit comments

Comments
 (0)