Skip to content

Commit

Permalink
Fix smooth scroll to index on SSR hydration (#591)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
aggarwal-h and inokawa authored Jan 1, 2025
1 parent c2cdf30 commit 821400e
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 49 deletions.
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--scroll-to"));

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
3 changes: 2 additions & 1 deletion src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ export const createVirtualStore = (
_scrollMode === SCROLL_BY_SHIFT ||
(_frozenRange
? // https://github.com/inokawa/virtua/issues/380
index < _frozenRange[0]
// https://github.com/inokawa/virtua/issues/590
!isSSR && index < _frozenRange[0]
: // Otherwise we should maintain visible position
getItemOffset(index) +
// https://github.com/inokawa/virtua/issues/385
Expand Down
114 changes: 111 additions & 3 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 Down Expand Up @@ -32,7 +32,6 @@ const App = () => {
};

export const Default: StoryObj = {
name: "SSR",
render: () => {
const [hydrated, setHydrated] = useState(false);
const ref = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -66,3 +65,112 @@ export const Default: StoryObj = {
);
},
};

const AppScrollOnMount = ({
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 ScrollTo: StoryObj = {
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);

useLayoutEffect(() => {
if (!ref.current) return;

if (!hydrated) {
ref.current.innerHTML = renderToString(<AppScrollOnMount />);
} else {
hydrateRoot(
ref.current,
<AppScrollOnMount
scrollOnMount={scrollOnMount}
scrollToIndex={scrollIndex}
smooth={smooth}
/>
);
}
}, [hydrated]);

return (
<div
style={{ height: "100vh", display: "flex", flexDirection: "column" }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
padding: 8,
}}
>
<button
disabled={hydrated}
onClick={() => {
setHydrated(true);
}}
>
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>
);
},
};

0 comments on commit 821400e

Please sign in to comment.