diff --git a/e2e/VList.spec.ts b/e2e/VList.spec.ts index 06f44646..1d2baa9b 100644 --- a/e2e/VList.spec.ts +++ b/e2e/VList.spec.ts @@ -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", () => { diff --git a/src/core/store.ts b/src/core/store.ts index d765be26..7f50c4b9 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -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 diff --git a/stories/react/advanced/SSR.stories.tsx b/stories/react/advanced/SSR.stories.tsx index 4444e4e9..daf054bd 100644 --- a/stories/react/advanced/SSR.stories.tsx +++ b/stories/react/advanced/SSR.stories.tsx @@ -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"; @@ -26,14 +26,40 @@ const createRows = (num: number) => { }); }; -const App = () => { - const COUNT = 1000; - return {createRows(COUNT)}; +const App = ({ + scrollOnMount, + scrollToIndex, + smooth, +}: { + scrollOnMount?: boolean; + scrollToIndex?: number; + smooth?: boolean; +}) => { + const ref = useRef(null); + useEffect(() => { + if (!ref.current || !scrollOnMount || !scrollToIndex) return; + + ref.current.scrollToIndex(scrollToIndex, { + smooth: smooth, + }); + }, []); + + const COUNT = 10000; + return ( + <> + + {createRows(COUNT)} + + > + ); }; 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(null); @@ -43,7 +69,14 @@ export const Default: StoryObj = { if (!hydrated) { ref.current.innerHTML = renderToString(); } else { - hydrateRoot(ref.current, ); + hydrateRoot( + ref.current, + + ); } }, [hydrated]); @@ -51,7 +84,13 @@ export const Default: StoryObj = { - + { @@ -60,6 +99,36 @@ export const Default: StoryObj = { > hydrate + + On hydration: + + { + setScrollOnMount((prev) => !prev); + }} + /> + scroll to index + + { + setScrollIndex(Number(e.target.value)); + }} + /> + + { + setSmooth((prev) => !prev); + }} + /> + smooth + +