Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix smooth scroll to index on SSR hydration #591

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 66 additions & 45 deletions e2e/VList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1240,51 +1240,72 @@ test.describe("RTL", () => {
});
});

test("SSR and hydration", async ({ page }) => {
await page.goto(storyUrl("advanced-ssr--default"));

const component = await getScrollable(page);

const first = await getFirstItem(component);
const last = await getLastItem(component);

// check if SSR suceeded
const itemsSelector = '*[style*="top"]';
const items = component.locator(itemsSelector);
const initialLength = await items.count();
expect(initialLength).toBeGreaterThanOrEqual(30);
expect(await items.first().textContent()).toEqual("0");
expect(await items.last().textContent()).toEqual(String(initialLength - 1));
// check if items have styles for SSR
expect(await items.first().evaluate((e) => e.style.position)).not.toBe(
"absolute"
);

// should not change state with scroll before hydration
await component.evaluate((e) => e.scrollTo({ top: 1000 }));
expect(initialLength).toBe(await component.locator(itemsSelector).count());
await page.waitForTimeout(500);
await component.evaluate((e) => e.scrollTo({ top: 0 }));

// hydrate
await page.getByRole("button", { name: "hydrate" }).click();

// check if hydration suceeded but state is not changed
const hydratedItemsLength = await component.locator(itemsSelector).count();
expect(hydratedItemsLength).toBe(initialLength);
expect((await getFirstItem(component)).top).toBe(first.top);
expect((await getLastItem(component)).bottom).toBe(last.bottom);
// check if items do not have styles for SSR
expect(await items.first().evaluate((e) => e.style.position)).toBe(
"absolute"
);

// should change state with scroll after hydration
await component.evaluate((e) => e.scrollTo({ top: 1000 }));
await page.waitForTimeout(500);
expect(await component.locator(itemsSelector).count()).not.toBe(
initialLength
);
test.describe("SSR and hydration", () => {
test("check if hydration works", async ({ page }) => {
await page.goto(storyUrl("advanced-ssr--default"));

const component = await getScrollable(page);

const first = await getFirstItem(component);
const last = await getLastItem(component);

// check if SSR suceeded
const itemsSelector = '*[style*="top"]';
const items = component.locator(itemsSelector);
const initialLength = await items.count();
expect(initialLength).toBeGreaterThanOrEqual(30);
expect(await items.first().textContent()).toEqual("0");
expect(await items.last().textContent()).toEqual(String(initialLength - 1));
// check if items have styles for SSR
expect(await items.first().evaluate((e) => e.style.position)).not.toBe(
"absolute"
);

// should not change state with scroll before hydration
await component.evaluate((e) => e.scrollTo({ top: 1000 }));
expect(initialLength).toBe(await component.locator(itemsSelector).count());
await page.waitForTimeout(500);
await component.evaluate((e) => e.scrollTo({ top: 0 }));

// hydrate
await page.getByRole("button", { name: "hydrate" }).click();

// check if hydration suceeded but state is not changed
const hydratedItemsLength = await component.locator(itemsSelector).count();
expect(hydratedItemsLength).toBe(initialLength);
expect((await getFirstItem(component)).top).toBe(first.top);
expect((await getLastItem(component)).bottom).toBe(last.bottom);
// check if items do not have styles for SSR
expect(await items.first().evaluate((e) => e.style.position)).toBe(
"absolute"
);

// should change state with scroll after hydration
await component.evaluate((e) => e.scrollTo({ top: 1000 }));
await page.waitForTimeout(500);
expect(await component.locator(itemsSelector).count()).not.toBe(
initialLength
);
});

test("check if smooth scrolling works after hydration", async ({ page }) => {
await page.goto(storyUrl("advanced-ssr--default"));

const component = await getScrollable(page);

// turn scroll to index with smooth on
await page.getByRole("checkbox", { name: "scroll to index" }).check();
await page.getByRole("checkbox", { name: "smooth" }).check();

// set scroll index to 100
await page.locator("input[type=number]").fill("100");

// hydrate
await page.getByRole("button", { name: "hydrate" }).click();

await page.waitForTimeout(1000);
expect((await getFirstItem(component)).text).toEqual("100");
});
});

test.describe("emulated iOS WebKit", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ export const createVirtualStore = (
_scrollMode === SCROLL_BY_SHIFT ||
(_frozenRange
? // https://github.com/inokawa/virtua/issues/380
index < _frozenRange[0]
!isSSR && index < _frozenRange[0]
: // Otherwise we should maintain visible position
getItemOffset(index) +
// https://github.com/inokawa/virtua/issues/385
Expand Down
83 changes: 76 additions & 7 deletions stories/react/advanced/SSR.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Meta, StoryObj } from "@storybook/react";
import React, { useLayoutEffect, useRef, useState } from "react";
import { VList } from "../../../src";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { VList, type VListHandle } from "../../../src";
import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";

Expand All @@ -26,14 +26,40 @@ const createRows = (num: number) => {
});
};

const App = () => {
const COUNT = 1000;
return <VList ssrCount={30}>{createRows(COUNT)}</VList>;
const App = ({
scrollOnMount,
scrollToIndex,
smooth,
}: {
scrollOnMount?: boolean;
scrollToIndex?: number;
smooth?: boolean;
}) => {
const ref = useRef<VListHandle>(null);
useEffect(() => {
if (!ref.current || !scrollOnMount || !scrollToIndex) return;

ref.current.scrollToIndex(scrollToIndex, {
smooth: smooth,
});
}, []);

const COUNT = 10000;
return (
<>
<VList ref={ref} ssrCount={30}>
{createRows(COUNT)}
</VList>
</>
);
};

export const Default: StoryObj = {
name: "SSR",
render: () => {
const [scrollOnMount, setScrollOnMount] = useState(false);
const [scrollIndex, setScrollIndex] = useState(100);
const [smooth, setSmooth] = useState(true);
const [hydrated, setHydrated] = useState(false);
const ref = useRef<HTMLDivElement>(null);

Expand All @@ -43,15 +69,28 @@ export const Default: StoryObj = {
if (!hydrated) {
ref.current.innerHTML = renderToString(<App />);
} else {
hydrateRoot(ref.current, <App />);
hydrateRoot(
ref.current,
<App
scrollOnMount={scrollOnMount}
scrollToIndex={scrollIndex}
smooth={smooth}
/>
);
}
}, [hydrated]);

return (
<div
style={{ height: "100vh", display: "flex", flexDirection: "column" }}
>
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
padding: 8,
}}
>
<button
disabled={hydrated}
onClick={() => {
Expand All @@ -60,6 +99,36 @@ export const Default: StoryObj = {
>
hydrate
</button>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<label>On hydration:</label>
<label>
<input
type="checkbox"
checked={scrollOnMount}
onChange={() => {
setScrollOnMount((prev) => !prev);
}}
/>
scroll to index
</label>
<input
type="number"
value={scrollIndex}
onChange={(e) => {
setScrollIndex(Number(e.target.value));
}}
/>
<label>
<input
type="checkbox"
checked={smooth}
onChange={() => {
setSmooth((prev) => !prev);
}}
/>
smooth
</label>
</div>
</div>
<div ref={ref} style={{ flex: 1 }} />
</div>
Expand Down
Loading