Skip to content

Commit 8bf081f

Browse files
committed
feat(virtualizer): refactor
1 parent 8ac9dd0 commit 8bf081f

20 files changed

Lines changed: 1191 additions & 226 deletions

File tree

e2e/virtualizer.e2e.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { expect, test } from "@playwright/test"
2+
import { Model } from "./models/model"
3+
4+
/** Standalone virtualizer examples (see shared/src/routes.ts). */
5+
const virtualizerRoutes = [
6+
"/virtualizer/list",
7+
"/virtualizer/grid",
8+
"/virtualizer/padding",
9+
"/virtualizer/sticky",
10+
"/virtualizer/window",
11+
] as const
12+
13+
/** Heavy perf comparison pages (large item counts). */
14+
const virtualizerPerfRoutes = ["/virtualizer/perf", "/virtualizer/perf-variable", "/virtualizer/perf-dynamic"] as const
15+
16+
/** Components integrating @zag-js/virtualizer. */
17+
const virtualizedComponentRoutes = [
18+
"/select/virtualized",
19+
"/combobox/virtualized",
20+
"/listbox/virtualized",
21+
"/gridlist/virtualized",
22+
"/tree-view/virtualized",
23+
"/dnd/virtualized",
24+
] as const
25+
26+
test.describe("virtualizer examples", () => {
27+
for (const path of virtualizerRoutes) {
28+
test(`smoke ${path}`, async ({ page }) => {
29+
const I = new Model(page)
30+
await page.goto(path)
31+
await page.waitForSelector("main", { state: "visible" })
32+
await I.dontSeeInConsole("flushSync was called from inside a lifecycle method", 3000)
33+
await I.checkAccessibility("main")
34+
35+
if (path === "/virtualizer/window") {
36+
// Layout uses `main { overflow-y: auto }` — scroll the main element, not the window.
37+
const main = page.locator("main")
38+
await main.evaluate((el) => {
39+
el.scrollTop = 400
40+
})
41+
await expect.poll(async () => main.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
42+
43+
// Verify a scroll-to button works (triggers smooth scroll on the real scroll target).
44+
await page.getByRole("button", { name: "Scroll to top" }).click()
45+
await expect.poll(async () => main.evaluate((el) => el.scrollTop), { timeout: 5000 }).toBeLessThan(10)
46+
} else {
47+
const overflow = page.locator("main").locator("div[style*='overflow']").first()
48+
if ((await overflow.count()) > 0) {
49+
await overflow.evaluate((el) => {
50+
el.scrollTop = 400
51+
})
52+
await expect.poll(async () => overflow.evaluate((el) => (el as HTMLElement).scrollTop)).toBeGreaterThan(0)
53+
}
54+
}
55+
})
56+
}
57+
58+
test.describe("perf comparisons", () => {
59+
test.describe.configure({ timeout: 120_000 })
60+
61+
for (const path of virtualizerPerfRoutes) {
62+
test(`smoke ${path} — Zag and TanStack panels`, async ({ page }) => {
63+
const I = new Model(page)
64+
await page.goto(path)
65+
await page.waitForSelector("main", { state: "visible" })
66+
await I.dontSeeInConsole("flushSync was called from inside a lifecycle method", 3000)
67+
await expect(page.getByRole("heading", { name: "@zag-js/virtualizer" })).toBeVisible()
68+
await expect(page.getByRole("heading", { name: "@tanstack/react-virtual" })).toBeVisible()
69+
await I.checkAccessibility("main")
70+
})
71+
}
72+
})
73+
74+
for (const path of virtualizedComponentRoutes) {
75+
test(`smoke ${path}`, async ({ page }) => {
76+
const I = new Model(page)
77+
await page.goto(path)
78+
await page.waitForSelector("main", { state: "visible" })
79+
await I.dontSeeInConsole("flushSync was called from inside a lifecycle method", 3000)
80+
await I.checkAccessibility("main")
81+
})
82+
}
83+
})
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useEffect, useState, type ReactNode } from "react"
2+
3+
type ClientOnlyProps = {
4+
children: ReactNode
5+
/** Shown until the client has mounted (SSR and first paint skip `children`). */
6+
fallback?: ReactNode
7+
}
8+
9+
/**
10+
* Renders `children` only after mount so browser-only APIs (e.g. `window`, portals)
11+
* and classes that depend on them are not executed during SSR.
12+
*/
13+
export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
14+
const [mounted, setMounted] = useState(false)
15+
useEffect(() => setMounted(true), [])
16+
if (!mounted) return <>{fallback}</>
17+
return <>{children}</>
18+
}
Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,67 @@
11
import {
22
GridVirtualizer,
33
ListVirtualizer,
4+
WindowVirtualizer,
45
type GridVirtualizerOptions,
56
type ListVirtualizerOptions,
7+
type WindowVirtualizerOptions,
68
} from "@zag-js/virtualizer"
7-
import { useCallback, useEffect, useReducer, useState } from "react"
8-
import { flushSync } from "react-dom"
9+
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"
910

1011
export function useListVirtualizer(options: ListVirtualizerOptions) {
11-
const [, rerender] = useReducer(() => ({}), {})
12+
const [virtualizer] = useState(() => new ListVirtualizer(options))
13+
useSyncExternalStore(virtualizer.subscribe, virtualizer.getSnapshot, () => 0)
1214

13-
const [virtualizer] = useState(
14-
() =>
15-
new ListVirtualizer({
16-
...options,
17-
onRangeChange(...args) {
18-
flushSync(rerender)
19-
options.onRangeChange?.(...args)
20-
},
21-
}),
15+
const ref = useCallback(
16+
(el: HTMLElement | null) => {
17+
if (!el) return
18+
virtualizer.init(el)
19+
},
20+
[virtualizer],
2221
)
2322

23+
useEffect(() => {
24+
return () => virtualizer.destroy()
25+
}, [])
26+
27+
return { virtualizer, ref }
28+
}
29+
30+
export function useWindowVirtualizer(options: WindowVirtualizerOptions) {
31+
const [virtualizer] = useState(() => new WindowVirtualizer(options))
32+
useSyncExternalStore(virtualizer.subscribe, virtualizer.getSnapshot, () => 0)
33+
2434
const ref = useCallback(
2535
(el: HTMLElement | null) => {
2636
if (!el) return
2737
virtualizer.init(el)
28-
rerender()
2938
},
3039
[virtualizer],
3140
)
3241

33-
useEffect(() => () => virtualizer.destroy(), [virtualizer])
42+
useEffect(() => {
43+
return () => virtualizer.destroy()
44+
}, [virtualizer])
3445

3546
return { virtualizer, ref }
3647
}
3748

3849
export function useGridVirtualizer(options: GridVirtualizerOptions) {
39-
const [, rerender] = useReducer(() => ({}), {})
50+
const [virtualizer] = useState(() => new GridVirtualizer(options))
4051

41-
const [virtualizer] = useState(
42-
() =>
43-
new GridVirtualizer({
44-
...options,
45-
onRangeChange(...args) {
46-
flushSync(rerender)
47-
options.onRangeChange?.(...args)
48-
},
49-
}),
50-
)
52+
useSyncExternalStore(virtualizer.subscribe, virtualizer.getSnapshot, () => 0)
5153

5254
const ref = useCallback(
5355
(el: HTMLElement | null) => {
5456
if (!el) return
5557
virtualizer.init(el)
56-
rerender()
5758
},
5859
[virtualizer],
5960
)
6061

61-
useEffect(() => () => virtualizer.destroy(), [virtualizer])
62+
useEffect(() => {
63+
return () => virtualizer.destroy()
64+
}, [])
6265

6366
return { virtualizer, ref }
6467
}

examples/next-ts/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/types/routes.d.ts"
3+
import "./.next/dev/types/routes.d.ts"
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

examples/next-ts/pages/virtualizer/grid.tsx

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { GridVirtualizer } from "@zag-js/virtualizer"
2-
import { useCallback, useReducer, useState } from "react"
3-
import { flushSync } from "react-dom"
2+
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"
43

54
// Grid configuration
65
const TOTAL_ROWS = 1000
@@ -17,30 +16,32 @@ const generateCellData = (row: number, col: number) => ({
1716

1817
export default function Page() {
1918
const [isSmooth, setIsSmooth] = useState(true)
20-
const [, rerender] = useReducer(() => ({}), {})
21-
const [virtualizer] = useState(() => {
22-
return new GridVirtualizer({
23-
rowCount: TOTAL_ROWS,
24-
columnCount: TOTAL_COLUMNS,
25-
estimatedRowSize: () => CELL_HEIGHT,
26-
estimatedColumnSize: () => CELL_WIDTH,
27-
overscan: 3,
28-
gap: 0,
29-
paddingStart: 0,
30-
paddingEnd: 0,
31-
observeScrollElementSize: true,
32-
onRangeChange: () => {
33-
flushSync(rerender)
34-
},
35-
})
36-
})
19+
const [virtualizer] = useState(
20+
() =>
21+
new GridVirtualizer({
22+
rowCount: TOTAL_ROWS,
23+
columnCount: TOTAL_COLUMNS,
24+
estimatedRowSize: () => CELL_HEIGHT,
25+
estimatedColumnSize: () => CELL_WIDTH,
26+
overscan: 3,
27+
gap: 0,
28+
paddingStart: 0,
29+
paddingEnd: 0,
30+
observeScrollElementSize: true,
31+
}),
32+
)
33+
34+
useSyncExternalStore(virtualizer.subscribe, virtualizer.getSnapshot, () => 0)
35+
36+
const setScrollElementRef = useCallback(
37+
(element: HTMLDivElement | null) => {
38+
if (!element) return
39+
virtualizer.init(element)
40+
},
41+
[virtualizer],
42+
)
3743

38-
const setScrollElementRef = useCallback((element: HTMLDivElement | null) => {
39-
if (!element) return
40-
virtualizer.init(element)
41-
rerender()
42-
return () => virtualizer.destroy()
43-
}, [])
44+
useEffect(() => () => virtualizer.destroy(), [virtualizer])
4445

4546
const virtualRows = virtualizer.getVirtualRows()
4647
const totalWidth = virtualizer.getTotalWidth()

examples/next-ts/pages/virtualizer/list.tsx

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { ListVirtualizer } from "@zag-js/virtualizer"
2-
import { useCallback, useReducer, useState } from "react"
3-
import { flushSync } from "react-dom"
2+
import { useCallback, useEffect, useState, useSyncExternalStore } from "react"
43

54
const generateItems = (count: number) =>
65
Array.from({ length: count }, (_, i) => ({
@@ -13,30 +12,30 @@ const items = generateItems(10000)
1312

1413
export default function Page() {
1514
const [isSmooth, setIsSmooth] = useState(true)
16-
const [virtualizer] = useState<ListVirtualizer | null>(() => {
17-
return new ListVirtualizer({
18-
count: items.length,
19-
estimatedSize: () => 142,
20-
overscan: 5,
21-
gap: 0,
22-
paddingStart: 0,
23-
paddingEnd: 0,
24-
onRangeChange: () => {
25-
flushSync(rerender)
26-
},
27-
})
28-
})
29-
const [, rerender] = useReducer(() => ({}), {})
15+
const [virtualizer] = useState(
16+
() =>
17+
new ListVirtualizer({
18+
count: items.length,
19+
estimatedSize: () => 142,
20+
overscan: 5,
21+
gap: 0,
22+
paddingStart: 0,
23+
paddingEnd: 0,
24+
}),
25+
)
26+
27+
useSyncExternalStore(virtualizer.subscribe, virtualizer.getSnapshot, () => 0)
3028

3129
// Callback ref to measure when element mounts
32-
const setScrollElementRef = useCallback((element: HTMLDivElement | null) => {
33-
if (!element) return
34-
if (virtualizer) {
30+
const setScrollElementRef = useCallback(
31+
(element: HTMLDivElement | null) => {
32+
if (!element) return
3533
virtualizer.init(element)
36-
rerender()
37-
return () => virtualizer.destroy()
38-
}
39-
}, [])
34+
},
35+
[virtualizer],
36+
)
37+
38+
useEffect(() => () => virtualizer.destroy(), [virtualizer])
4039

4140
const virtualItems = virtualizer.getVirtualItems()
4241
const totalSize = virtualizer.getTotalSize()
@@ -53,18 +52,18 @@ export default function Page() {
5352
</label>
5453

5554
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
56-
<button type="button" onClick={() => virtualizer?.scrollToIndex(0, { smooth: isSmooth })}>
55+
<button type="button" onClick={() => virtualizer.scrollToIndex(0, { smooth: isSmooth })}>
5756
Scroll to Top
5857
</button>
5958
<button
6059
type="button"
6160
onClick={() =>
62-
virtualizer?.scrollToIndex(Math.floor(items.length / 2), { align: "center", smooth: isSmooth })
61+
virtualizer.scrollToIndex(Math.floor(items.length / 2), { align: "center", smooth: isSmooth })
6362
}
6463
>
6564
Scroll to Middle
6665
</button>
67-
<button type="button" onClick={() => virtualizer?.scrollToIndex(items.length - 1, { smooth: isSmooth })}>
66+
<button type="button" onClick={() => virtualizer.scrollToIndex(items.length - 1, { smooth: isSmooth })}>
6867
Scroll to Bottom
6968
</button>
7069
</div>

examples/next-ts/pages/virtualizer/perf-dynamic.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useVirtualizer } from "@tanstack/react-virtual"
22
import { ListVirtualizer } from "@zag-js/virtualizer"
3-
import { useCallback, useEffect, useReducer, useRef, useState } from "react"
4-
import { flushSync } from "react-dom"
3+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"
54

65
const ITEM_COUNT = 100_000
76

@@ -102,26 +101,23 @@ function MetricsDisplay({ label, metrics }: { label: string; metrics: Metrics |
102101

103102
function ZagVirtualizer({ onMetrics }: { onMetrics: (m: Metrics) => void }) {
104103
const scrollRef = useRef<HTMLDivElement>(null)
105-
const [, rerender] = useReducer(() => ({}), {})
106104

107105
const [virtualizer] = useState(
108106
() =>
109107
new ListVirtualizer({
110108
count: ITEM_COUNT,
111109
estimatedSize: (i) => getItemHeight(i),
112110
overscan: 5,
113-
onRangeChange() {
114-
flushSync(rerender)
115-
},
116111
}),
117112
)
118113

114+
useSyncExternalStore(virtualizer.subscribe, virtualizer.getSnapshot, () => 0)
115+
119116
const setRef = useCallback(
120117
(el: HTMLDivElement | null) => {
121118
if (!el) return
122-
;(scrollRef as any).current = el
119+
scrollRef.current = el
123120
virtualizer.init(el)
124-
rerender()
125121
},
126122
[virtualizer],
127123
)

0 commit comments

Comments
 (0)