Skip to content

Commit 3b385b8

Browse files
authored
fix: preserve scroll position of all scrollable elements after hot-swap (#23722)
Capture and restore scroll positions of all scrollable elements during hot-swap UI refresh and full page reload. Elements are identified by CSS selector paths built from nth-of-type selectors anchored to the nearest ancestor with an ID, so elements without explicit IDs are also supported. The window scroll position uses a special __window__ key. Only elements with non-zero scroll are captured, keeping the snapshot small. Both code paths are updated: the vaadin-dev-tools TypeScript (used for dev server WebSocket-triggered refreshes and full reloads via sessionStorage) and the Hotswapper Java inline JS (used for the vaadin-refresh-ui custom event). Fixes #20371
1 parent 24b3ad9 commit 3b385b8

File tree

6 files changed

+392
-51
lines changed

6 files changed

+392
-51
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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;
17+
18+
import com.vaadin.flow.component.html.Div;
19+
import com.vaadin.flow.component.html.NativeButton;
20+
import com.vaadin.flow.component.html.Span;
21+
import com.vaadin.flow.internal.BrowserLiveReload;
22+
import com.vaadin.flow.internal.BrowserLiveReloadAccessor;
23+
import com.vaadin.flow.router.Route;
24+
import com.vaadin.flow.server.VaadinService;
25+
import com.vaadin.flow.uitest.servlet.ViewTestLayout;
26+
27+
@Route(value = "com.vaadin.flow.uitest.ui.ScrollPositionLiveReloadView", layout = ViewTestLayout.class)
28+
public class ScrollPositionLiveReloadView extends AbstractLiveReloadView {
29+
30+
public ScrollPositionLiveReloadView() {
31+
// Button to trigger full-refresh (DOM patching, no full page reload)
32+
NativeButton refreshButton = new NativeButton(
33+
"Trigger UI Refresh (DOM patch)");
34+
refreshButton.addClickListener(e -> {
35+
BrowserLiveReloadAccessor liveReloadAccess = VaadinService
36+
.getCurrent().getInstantiator()
37+
.getOrCreate(BrowserLiveReloadAccessor.class);
38+
BrowserLiveReload browserLiveReload = liveReloadAccess
39+
.getLiveReload(VaadinService.getCurrent());
40+
browserLiveReload.refresh(true);
41+
});
42+
refreshButton.setId("refresh-button");
43+
add(refreshButton);
44+
45+
// Button to trigger full page reload
46+
NativeButton reloadButton = new NativeButton(
47+
"Trigger Full Page Reload");
48+
reloadButton.addClickListener(e -> {
49+
BrowserLiveReloadAccessor liveReloadAccess = VaadinService
50+
.getCurrent().getInstantiator()
51+
.getOrCreate(BrowserLiveReloadAccessor.class);
52+
BrowserLiveReload browserLiveReload = liveReloadAccess
53+
.getLiveReload(VaadinService.getCurrent());
54+
browserLiveReload.reload();
55+
});
56+
reloadButton.setId("reload-button");
57+
add(reloadButton);
58+
59+
// Outer scrollable container
60+
Div outerScroll = new Div();
61+
outerScroll.setId("outer-scroll");
62+
outerScroll.getStyle().set("height", "400px");
63+
outerScroll.getStyle().set("overflow", "auto");
64+
outerScroll.getStyle().set("border", "2px solid blue");
65+
66+
// Inner scrollable container nested inside outer (no ID, to test
67+
// scroll restoration for elements identified by DOM path)
68+
Div innerScroll = new Div();
69+
innerScroll.getStyle().set("height", "200px");
70+
innerScroll.getStyle().set("overflow", "auto");
71+
innerScroll.getStyle().set("border", "2px solid red");
72+
innerScroll.getStyle().set("margin", "10px");
73+
74+
// Items inside the inner scrollable container
75+
for (int i = 0; i < 50; i++) {
76+
Div item = new Div();
77+
item.setText("Inner item " + i);
78+
item.setId("inner-item-" + i);
79+
item.getStyle().set("padding", "8px");
80+
item.getStyle().set("border-bottom", "1px solid #eee");
81+
innerScroll.add(item);
82+
}
83+
84+
outerScroll.add(innerScroll);
85+
86+
// More items in the outer scrollable container (after the inner one)
87+
for (int i = 0; i < 50; i++) {
88+
Div item = new Div();
89+
item.setText("Outer item " + i);
90+
item.setId("outer-item-" + i);
91+
item.getStyle().set("padding", "8px");
92+
item.getStyle().set("border-bottom", "1px solid #ddd");
93+
outerScroll.add(item);
94+
}
95+
96+
add(outerScroll);
97+
98+
// Items below the scrollable containers for window-level scroll
99+
for (int i = 0; i < 100; i++) {
100+
Div item = new Div();
101+
item.setText("Item " + i);
102+
item.setId("item-" + i);
103+
item.getStyle().set("padding", "10px");
104+
item.getStyle().set("border-bottom", "1px solid #ccc");
105+
add(item);
106+
}
107+
108+
Span bottomMarker = new Span("Bottom of page");
109+
bottomMarker.setId("bottom-marker");
110+
add(bottomMarker);
111+
}
112+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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;
17+
18+
import net.jcip.annotations.NotThreadSafe;
19+
import org.junit.Assert;
20+
import org.junit.Test;
21+
import org.openqa.selenium.By;
22+
23+
@NotThreadSafe
24+
public class ScrollPositionLiveReloadIT extends AbstractLiveReloadIT {
25+
26+
// Inner scroll container has no ID — found by CSS selector to verify
27+
// scroll restoration works for elements identified by DOM path
28+
private static final String INNER_SCROLL_SELECTOR = "#outer-scroll > div:nth-of-type(1)";
29+
30+
@Test
31+
public void scrollPositionPreservedAfterUIRefresh() {
32+
open();
33+
waitForElementPresent(By.id("item-50"));
34+
35+
scrollAllContainers();
36+
37+
int windowScrollBefore = getScrollY();
38+
int outerScrollBefore = getScrollTop("#outer-scroll");
39+
int innerScrollBefore = getScrollTop(INNER_SCROLL_SELECTOR);
40+
41+
Assert.assertTrue("Window should be scrolled down",
42+
windowScrollBefore > 100);
43+
Assert.assertTrue("Outer container should be scrolled down",
44+
outerScrollBefore > 50);
45+
Assert.assertTrue("Inner container (no ID) should be scrolled down",
46+
innerScrollBefore > 50);
47+
48+
String attachIdBefore = getAttachId();
49+
50+
// Simulate hot-swap: directly trigger onReload on the dev-tools
51+
// WebSocket connection. In a real hot-swap, the server pushes the
52+
// reload message directly via WebSocket without a prior UIDL update.
53+
executeScript(
54+
"document.querySelector('vaadin-dev-tools').frontendConnection.onReload('full-refresh')");
55+
56+
// Wait for the UI to refresh
57+
waitUntil(d -> !attachIdBefore.equals(getAttachId()), 10);
58+
59+
waitForScrollRestoration("window", windowScrollBefore);
60+
waitForScrollRestoration("#outer-scroll", outerScrollBefore);
61+
waitForScrollRestoration(INNER_SCROLL_SELECTOR, innerScrollBefore);
62+
}
63+
64+
@Test
65+
public void scrollPositionPreservedAfterFullPageReload() {
66+
open();
67+
waitForElementPresent(By.id("item-50"));
68+
69+
scrollAllContainers();
70+
71+
int windowScrollBefore = getScrollY();
72+
int outerScrollBefore = getScrollTop("#outer-scroll");
73+
int innerScrollBefore = getScrollTop(INNER_SCROLL_SELECTOR);
74+
75+
Assert.assertTrue("Window should be scrolled down",
76+
windowScrollBefore > 100);
77+
Assert.assertTrue("Outer container should be scrolled down",
78+
outerScrollBefore > 50);
79+
Assert.assertTrue("Inner container (no ID) should be scrolled down",
80+
innerScrollBefore > 50);
81+
82+
// Simulate hot-swap full reload: saves scroll to sessionStorage
83+
// and calls window.location.reload().
84+
executeScript(
85+
"document.querySelector('vaadin-dev-tools').frontendConnection.onReload('reload')");
86+
87+
// Wait for the page to reload and render
88+
waitForElementPresent(By.id("item-50"));
89+
90+
waitForScrollRestoration("window", windowScrollBefore);
91+
waitForScrollRestoration("#outer-scroll", outerScrollBefore);
92+
waitForScrollRestoration(INNER_SCROLL_SELECTOR, innerScrollBefore);
93+
}
94+
95+
private void scrollAllContainers() {
96+
// Scroll the inner container (no ID, found by CSS selector)
97+
executeScript("document.querySelector(arguments[0]).scrollTop = 300",
98+
INNER_SCROLL_SELECTOR);
99+
// Scroll the outer container
100+
executeScript(
101+
"document.querySelector('#outer-scroll').scrollTop = 400");
102+
// Scroll the window
103+
executeScript("document.getElementById('item-50').scrollIntoView()");
104+
sleep(500);
105+
}
106+
107+
private int getScrollTop(String cssSelector) {
108+
return ((Number) executeScript(
109+
"return document.querySelector(arguments[0]).scrollTop",
110+
cssSelector)).intValue();
111+
}
112+
113+
private void waitForScrollRestoration(String target, int expectedScrollY) {
114+
if ("window".equals(target)) {
115+
waitUntil(d -> Math.abs(getScrollY() - expectedScrollY) < 5, 10);
116+
} else {
117+
waitUntil(d -> Math.abs(getScrollTop(target) - expectedScrollY) < 5,
118+
10);
119+
}
120+
}
121+
122+
private void sleep(int millis) {
123+
try {
124+
Thread.sleep(millis);
125+
} catch (InterruptedException e) {
126+
Thread.currentThread().interrupt();
127+
}
128+
}
129+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
export type ScrollSnapshot = Record<string, { scrollTop: number; scrollLeft: number }>;
2+
3+
// Matches ROOT_NODE_ID in Flow.ts / StateTree.java
4+
const ROOT_NODE_ID = 1;
5+
const REFRESH_UI_EVENT = 'vaadin-refresh-ui';
6+
7+
/**
8+
* Builds a CSS selector path for an element. Uses the element's ID if present,
9+
* otherwise walks up the DOM building nth-of-type selectors, stopping at the
10+
* nearest ancestor with an ID.
11+
*/
12+
function getElementPath(el: Element): string {
13+
if (el.id) return '#' + CSS.escape(el.id);
14+
const parts: string[] = [];
15+
let current: Element | null = el;
16+
while (current && current !== document.documentElement && current !== document.body) {
17+
if (current.id) {
18+
parts.unshift('#' + CSS.escape(current.id));
19+
break;
20+
}
21+
const parent = current.parentElement;
22+
if (!parent) break;
23+
let index = 1;
24+
let sibling: Element | null = current.previousElementSibling;
25+
while (sibling) {
26+
if (sibling.tagName === current.tagName) index++;
27+
sibling = sibling.previousElementSibling;
28+
}
29+
parts.unshift(current.tagName.toLowerCase() + ':nth-of-type(' + index + ')');
30+
current = parent;
31+
}
32+
return parts.length > 0 ? parts.join(' > ') : '';
33+
}
34+
35+
function getFlowClients(): any[] {
36+
const anyVaadin = (window as any).Vaadin;
37+
return Object.keys(anyVaadin?.Flow?.clients || {})
38+
.filter((key) => key !== 'TypeScript')
39+
.map((id) => anyVaadin.Flow.clients[id]);
40+
}
41+
42+
/**
43+
* Captures scroll positions of the window and all scrolled elements.
44+
* Elements are keyed by CSS selector path so they can be found after DOM rebuild.
45+
*/
46+
export function captureScrollPositions(): ScrollSnapshot {
47+
const snapshot: ScrollSnapshot = {};
48+
if (window.scrollX !== 0 || window.scrollY !== 0) {
49+
snapshot['__window__'] = { scrollTop: window.scrollY, scrollLeft: window.scrollX };
50+
}
51+
document.querySelectorAll('*').forEach((el) => {
52+
if (el.scrollTop > 0 || el.scrollLeft > 0) {
53+
const path = getElementPath(el);
54+
if (path) {
55+
snapshot[path] = { scrollTop: el.scrollTop, scrollLeft: el.scrollLeft };
56+
}
57+
}
58+
});
59+
return snapshot;
60+
}
61+
62+
/**
63+
* Captures scroll positions, sends a ui-refresh event to all Flow clients,
64+
* and restores scroll positions once the clients are idle.
65+
* Used by both the Push-based (vaadin-refresh-ui event) and WebSocket-based
66+
* hot-swap paths.
67+
*/
68+
export function refreshWithScrollPreservation(fullRefresh: boolean): void {
69+
const snapshot = captureScrollPositions();
70+
getFlowClients().forEach((client: any) => {
71+
if (client.sendEventMessage) {
72+
client.sendEventMessage(ROOT_NODE_ID, 'ui-refresh', { fullRefresh });
73+
}
74+
});
75+
restoreScrollPositions(snapshot);
76+
}
77+
78+
let refreshUIHandlerRegistered = false;
79+
80+
/**
81+
* Registers a window event listener for 'vaadin-refresh-ui' that triggers
82+
* a scroll-preserving UI refresh. Guards against double-registration.
83+
*/
84+
export function registerRefreshUIHandler(): void {
85+
if (refreshUIHandlerRegistered) {
86+
return;
87+
}
88+
refreshUIHandlerRegistered = true;
89+
window.addEventListener(REFRESH_UI_EVENT, (ev: any) => {
90+
refreshWithScrollPreservation(ev.detail?.fullRefresh === true);
91+
});
92+
}
93+
94+
/**
95+
* Restores scroll positions after a hot-swap UI refresh completes.
96+
* Polls Flow client isActive() to wait until UIDL processing is done,
97+
* then uses requestAnimationFrame to restore all captured scroll positions.
98+
*/
99+
export function restoreScrollPositions(snapshot: ScrollSnapshot): void {
100+
if (Object.keys(snapshot).length === 0) {
101+
return;
102+
}
103+
const MAX_POLL_ATTEMPTS = 200; // 200 * 50ms = 10 seconds
104+
let attempts = 0;
105+
106+
const applyScroll = () => {
107+
requestAnimationFrame(() => {
108+
for (const [key, pos] of Object.entries(snapshot)) {
109+
if (key === '__window__') {
110+
window.scrollTo(pos.scrollLeft, pos.scrollTop);
111+
} else {
112+
const el = document.querySelector(key);
113+
if (el) {
114+
el.scrollTop = pos.scrollTop;
115+
el.scrollLeft = pos.scrollLeft;
116+
}
117+
}
118+
}
119+
});
120+
};
121+
122+
const poll = () => {
123+
const clients = getFlowClients();
124+
const allIdle = clients.length > 0 && clients.every((c: any) => !c.isActive());
125+
if (allIdle || ++attempts >= MAX_POLL_ATTEMPTS) {
126+
applyScroll();
127+
} else {
128+
setTimeout(poll, 50);
129+
}
130+
};
131+
setTimeout(poll, 50);
132+
}

0 commit comments

Comments
 (0)