Skip to content

Commit 821400e

Browse files
aggarwal-hinokawa
andauthored
Fix smooth scroll to index on SSR hydration (#591)
* Fix smooth scroll issue on SSR hydration * Update SSR story to include scroll on hydration * Simplify hydration options spacing with flex * Fix issue with smooth scrolling after hydration * Add test to check smooth scrolling after hydration * Fix miscs --------- Co-authored-by: inokawa <[email protected]>
1 parent c2cdf30 commit 821400e

File tree

3 files changed

+179
-49
lines changed

3 files changed

+179
-49
lines changed

e2e/VList.spec.ts

+66-45
Original file line numberDiff line numberDiff line change
@@ -1240,51 +1240,72 @@ test.describe("RTL", () => {
12401240
});
12411241
});
12421242

1243-
test("SSR and hydration", async ({ page }) => {
1244-
await page.goto(storyUrl("advanced-ssr--default"));
1245-
1246-
const component = await getScrollable(page);
1247-
1248-
const first = await getFirstItem(component);
1249-
const last = await getLastItem(component);
1250-
1251-
// check if SSR suceeded
1252-
const itemsSelector = '*[style*="top"]';
1253-
const items = component.locator(itemsSelector);
1254-
const initialLength = await items.count();
1255-
expect(initialLength).toBeGreaterThanOrEqual(30);
1256-
expect(await items.first().textContent()).toEqual("0");
1257-
expect(await items.last().textContent()).toEqual(String(initialLength - 1));
1258-
// check if items have styles for SSR
1259-
expect(await items.first().evaluate((e) => e.style.position)).not.toBe(
1260-
"absolute"
1261-
);
1262-
1263-
// should not change state with scroll before hydration
1264-
await component.evaluate((e) => e.scrollTo({ top: 1000 }));
1265-
expect(initialLength).toBe(await component.locator(itemsSelector).count());
1266-
await page.waitForTimeout(500);
1267-
await component.evaluate((e) => e.scrollTo({ top: 0 }));
1268-
1269-
// hydrate
1270-
await page.getByRole("button", { name: "hydrate" }).click();
1271-
1272-
// check if hydration suceeded but state is not changed
1273-
const hydratedItemsLength = await component.locator(itemsSelector).count();
1274-
expect(hydratedItemsLength).toBe(initialLength);
1275-
expect((await getFirstItem(component)).top).toBe(first.top);
1276-
expect((await getLastItem(component)).bottom).toBe(last.bottom);
1277-
// check if items do not have styles for SSR
1278-
expect(await items.first().evaluate((e) => e.style.position)).toBe(
1279-
"absolute"
1280-
);
1281-
1282-
// should change state with scroll after hydration
1283-
await component.evaluate((e) => e.scrollTo({ top: 1000 }));
1284-
await page.waitForTimeout(500);
1285-
expect(await component.locator(itemsSelector).count()).not.toBe(
1286-
initialLength
1287-
);
1243+
test.describe("SSR and hydration", () => {
1244+
test("check if hydration works", async ({ page }) => {
1245+
await page.goto(storyUrl("advanced-ssr--default"));
1246+
1247+
const component = await getScrollable(page);
1248+
1249+
const first = await getFirstItem(component);
1250+
const last = await getLastItem(component);
1251+
1252+
// check if SSR suceeded
1253+
const itemsSelector = '*[style*="top"]';
1254+
const items = component.locator(itemsSelector);
1255+
const initialLength = await items.count();
1256+
expect(initialLength).toBeGreaterThanOrEqual(30);
1257+
expect(await items.first().textContent()).toEqual("0");
1258+
expect(await items.last().textContent()).toEqual(String(initialLength - 1));
1259+
// check if items have styles for SSR
1260+
expect(await items.first().evaluate((e) => e.style.position)).not.toBe(
1261+
"absolute"
1262+
);
1263+
1264+
// should not change state with scroll before hydration
1265+
await component.evaluate((e) => e.scrollTo({ top: 1000 }));
1266+
expect(initialLength).toBe(await component.locator(itemsSelector).count());
1267+
await page.waitForTimeout(500);
1268+
await component.evaluate((e) => e.scrollTo({ top: 0 }));
1269+
1270+
// hydrate
1271+
await page.getByRole("button", { name: "hydrate" }).click();
1272+
1273+
// check if hydration suceeded but state is not changed
1274+
const hydratedItemsLength = await component.locator(itemsSelector).count();
1275+
expect(hydratedItemsLength).toBe(initialLength);
1276+
expect((await getFirstItem(component)).top).toBe(first.top);
1277+
expect((await getLastItem(component)).bottom).toBe(last.bottom);
1278+
// check if items do not have styles for SSR
1279+
expect(await items.first().evaluate((e) => e.style.position)).toBe(
1280+
"absolute"
1281+
);
1282+
1283+
// should change state with scroll after hydration
1284+
await component.evaluate((e) => e.scrollTo({ top: 1000 }));
1285+
await page.waitForTimeout(500);
1286+
expect(await component.locator(itemsSelector).count()).not.toBe(
1287+
initialLength
1288+
);
1289+
});
1290+
1291+
test("check if smooth scrolling works after hydration", async ({ page }) => {
1292+
await page.goto(storyUrl("advanced-ssr--scroll-to"));
1293+
1294+
const component = await getScrollable(page);
1295+
1296+
// turn scroll to index with smooth on
1297+
await page.getByRole("checkbox", { name: "scroll to index" }).check();
1298+
await page.getByRole("checkbox", { name: "smooth" }).check();
1299+
1300+
// set scroll index to 100
1301+
await page.locator("input[type=number]").fill("100");
1302+
1303+
// hydrate
1304+
await page.getByRole("button", { name: "hydrate" }).click();
1305+
1306+
await page.waitForTimeout(1000);
1307+
expect((await getFirstItem(component)).text).toEqual("100");
1308+
});
12881309
});
12891310

12901311
test.describe("emulated iOS WebKit", () => {

src/core/store.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,8 @@ export const createVirtualStore = (
334334
_scrollMode === SCROLL_BY_SHIFT ||
335335
(_frozenRange
336336
? // https://github.com/inokawa/virtua/issues/380
337-
index < _frozenRange[0]
337+
// https://github.com/inokawa/virtua/issues/590
338+
!isSSR && index < _frozenRange[0]
338339
: // Otherwise we should maintain visible position
339340
getItemOffset(index) +
340341
// https://github.com/inokawa/virtua/issues/385

stories/react/advanced/SSR.stories.tsx

+111-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Meta, StoryObj } from "@storybook/react";
2-
import React, { useLayoutEffect, useRef, useState } from "react";
3-
import { VList } from "../../../src";
2+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
3+
import { VList, type VListHandle } from "../../../src";
44
import { hydrateRoot } from "react-dom/client";
55
import { renderToString } from "react-dom/server";
66

@@ -32,7 +32,6 @@ const App = () => {
3232
};
3333

3434
export const Default: StoryObj = {
35-
name: "SSR",
3635
render: () => {
3736
const [hydrated, setHydrated] = useState(false);
3837
const ref = useRef<HTMLDivElement>(null);
@@ -66,3 +65,112 @@ export const Default: StoryObj = {
6665
);
6766
},
6867
};
68+
69+
const AppScrollOnMount = ({
70+
scrollOnMount,
71+
scrollToIndex,
72+
smooth,
73+
}: {
74+
scrollOnMount?: boolean;
75+
scrollToIndex?: number;
76+
smooth?: boolean;
77+
}) => {
78+
const ref = useRef<VListHandle>(null);
79+
useEffect(() => {
80+
if (!ref.current || !scrollOnMount || !scrollToIndex) return;
81+
82+
ref.current.scrollToIndex(scrollToIndex, {
83+
smooth: smooth,
84+
});
85+
}, []);
86+
87+
const COUNT = 10000;
88+
return (
89+
<>
90+
<VList ref={ref} ssrCount={30}>
91+
{createRows(COUNT)}
92+
</VList>
93+
</>
94+
);
95+
};
96+
97+
export const ScrollTo: StoryObj = {
98+
render: () => {
99+
const [scrollOnMount, setScrollOnMount] = useState(false);
100+
const [scrollIndex, setScrollIndex] = useState(100);
101+
const [smooth, setSmooth] = useState(true);
102+
const [hydrated, setHydrated] = useState(false);
103+
const ref = useRef<HTMLDivElement>(null);
104+
105+
useLayoutEffect(() => {
106+
if (!ref.current) return;
107+
108+
if (!hydrated) {
109+
ref.current.innerHTML = renderToString(<AppScrollOnMount />);
110+
} else {
111+
hydrateRoot(
112+
ref.current,
113+
<AppScrollOnMount
114+
scrollOnMount={scrollOnMount}
115+
scrollToIndex={scrollIndex}
116+
smooth={smooth}
117+
/>
118+
);
119+
}
120+
}, [hydrated]);
121+
122+
return (
123+
<div
124+
style={{ height: "100vh", display: "flex", flexDirection: "column" }}
125+
>
126+
<div
127+
style={{
128+
display: "flex",
129+
justifyContent: "space-between",
130+
padding: 8,
131+
}}
132+
>
133+
<button
134+
disabled={hydrated}
135+
onClick={() => {
136+
setHydrated(true);
137+
}}
138+
>
139+
hydrate
140+
</button>
141+
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
142+
<label>On hydration:</label>
143+
<label>
144+
<input
145+
type="checkbox"
146+
checked={scrollOnMount}
147+
onChange={() => {
148+
setScrollOnMount((prev) => !prev);
149+
}}
150+
/>
151+
scroll to index
152+
</label>
153+
<input
154+
type="number"
155+
value={scrollIndex}
156+
onChange={(e) => {
157+
setScrollIndex(Number(e.target.value));
158+
}}
159+
/>
160+
<label>
161+
<input
162+
type="checkbox"
163+
checked={smooth}
164+
onChange={() => {
165+
setSmooth((prev) => !prev);
166+
}}
167+
/>
168+
smooth
169+
</label>
170+
</div>
171+
</div>
172+
<div ref={ref} style={{ flex: 1 }} />
173+
</div>
174+
);
175+
},
176+
};

0 commit comments

Comments
 (0)