diff --git a/lib/components/grid/Grid.test.tsx b/lib/components/grid/Grid.test.tsx index 2f984f25..391eb8ea 100644 --- a/lib/components/grid/Grid.test.tsx +++ b/lib/components/grid/Grid.test.tsx @@ -4,8 +4,8 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { EMPTY_OBJECT } from "../../../src/constants"; import { disableResizeObserverForCurrentTest, - simulateUnsupportedEnvironmentForTest, - updateMockResizeObserver + setDefaultElementSize, + simulateUnsupportedEnvironmentForTest } from "../../utils/test/mockResizeObserver"; import { Grid } from "./Grid"; import type { CellComponentProps, GridImperativeAPI } from "./types"; @@ -36,7 +36,7 @@ describe("Grid", () => { beforeEach(() => { CellComponent.mockReset(); - updateMockResizeObserver(new DOMRect(0, 0, 100, 40)); + setDefaultElementSize({ height: 40, width: 100 }); mountedCells = new Map(); }); @@ -273,7 +273,6 @@ describe("Grid", () => { const items = screen.queryAllByRole("gridcell"); expect(items).toHaveLength(8); - // TODO }); test("should call onCellsRendered", () => { diff --git a/lib/components/grid/Grid.tsx b/lib/components/grid/Grid.tsx index e0a7d595..18786b86 100644 --- a/lib/components/grid/Grid.tsx +++ b/lib/components/grid/Grid.tsx @@ -48,7 +48,7 @@ export function Grid< const isRtl = useIsRtl(element, dir); const { - getCellBounds: getColumnBounds, + cachedBounds: cachedColumnBounds, getEstimatedSize: getEstimatedWidth, startIndexOverscan: columnStartIndexOverscan, startIndexVisible: columnStartIndexVisible, @@ -69,7 +69,7 @@ export function Grid< }); const { - getCellBounds: getRowBounds, + cachedBounds: cachedRowBounds, getEstimatedSize: getEstimatedHeight, startIndexOverscan: rowStartIndexOverscan, startIndexVisible: rowStartIndexVisible, @@ -220,7 +220,8 @@ export function Grid< rowIndex <= rowStopIndexOverscan; rowIndex++ ) { - const rowBounds = getRowBounds(rowIndex); + const rowBounds = cachedRowBounds.getItemBounds(rowIndex); + const rowOffset = rowBounds?.scrollOffset; const columns: ReactNode[] = []; @@ -229,7 +230,8 @@ export function Grid< columnIndex <= columnStopIndexOverscan; columnIndex++ ) { - const columnBounds = getColumnBounds(columnIndex); + const columnBounds = cachedColumnBounds.getItemBounds(columnIndex); + const columnOffset = columnBounds?.scrollOffset; columns.push( ); @@ -262,13 +264,13 @@ export function Grid< } return children; }, [ + cachedColumnBounds, + cachedRowBounds, CellComponent, cellProps, columnCount, columnStartIndexOverscan, columnStopIndexOverscan, - getColumnBounds, - getRowBounds, isRtl, rowCount, rowStartIndexOverscan, diff --git a/lib/components/list/List.test.tsx b/lib/components/list/List.test.tsx index 61cb507f..31e69ec9 100644 --- a/lib/components/list/List.test.tsx +++ b/lib/components/list/List.test.tsx @@ -2,10 +2,12 @@ import { act, render, screen } from "@testing-library/react"; import { createRef, useLayoutEffect } from "react"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { EMPTY_OBJECT } from "../../../src/constants"; +import { assert } from "../../utils/assert"; import { disableResizeObserverForCurrentTest, - simulateUnsupportedEnvironmentForTest, - updateMockResizeObserver + setDefaultElementSize, + setElementSize, + simulateUnsupportedEnvironmentForTest } from "../../utils/test/mockResizeObserver"; import { List } from "./List"; import { type ListImperativeAPI, type RowComponentProps } from "./types"; @@ -34,7 +36,7 @@ describe("List", () => { beforeEach(() => { RowComponent.mockReset(); - updateMockResizeObserver(new DOMRect(0, 0, 50, 100)); + setDefaultElementSize({ height: 100, width: 50 }); mountedRows = new Map(); }); @@ -56,7 +58,7 @@ describe("List", () => { test("should render enough rows to fill the available height", () => { const onResize = vi.fn(); - render( + const { container } = render( { ); act(() => { - updateMockResizeObserver(new DOMRect(0, 0, 50, 75)); + const listElement = container.querySelector('[role="list"]'); + assert(listElement !== null); + + setElementSize({ + element: listElement, + height: 75, + width: 50 + }); }); items = screen.queryAllByRole("listitem"); @@ -107,7 +116,7 @@ describe("List", () => { }); test("should render enough rows to fill the available height with overscan", () => { - render( + const { container } = render( { expect(items[5]).toHaveTextContent("Row 5"); act(() => { - updateMockResizeObserver(new DOMRect(0, 0, 50, 75)); + const listElement = container.querySelector('[role="list"]'); + assert(listElement !== null); + + setElementSize({ + element: listElement, + height: 75, + width: 50 + }); }); items = screen.queryAllByRole("listitem"); diff --git a/lib/components/list/List.tsx b/lib/components/list/List.tsx index a3ce6be0..0d17d4bd 100644 --- a/lib/components/list/List.tsx +++ b/lib/components/list/List.tsx @@ -5,6 +5,7 @@ import { useImperativeHandle, useMemo, useState, + type CSSProperties, type ReactNode } from "react"; import { useVirtualizer } from "../../core/useVirtualizer"; @@ -41,7 +42,7 @@ export function List< const [element, setElement] = useState(null); const { - getCellBounds, + cachedBounds, getEstimatedSize, scrollToIndex, startIndexOverscan, @@ -114,6 +115,13 @@ export function List< stopIndexVisible ]); + const hasRowHeight = rowHeight !== undefined; + + const offset = + startIndexOverscan >= 0 + ? cachedBounds.getItemBounds(startIndexOverscan).scrollOffset + : 0; + const rows = useMemo(() => { const children: ReactNode[] = []; if (rowCount > 0) { @@ -122,7 +130,15 @@ export function List< index <= stopIndexOverscan; index++ ) { - const bounds = getCellBounds(index); + const bounds = cachedBounds.getItemBounds(index); + const style: CSSProperties = { + height: hasRowHeight ? bounds.size : undefined, + width: "100%" + }; + + if (index === startIndexOverscan) { + style.marginTop = `${offset}px`; + } children.push( ); } @@ -148,7 +158,9 @@ export function List< return children; }, [ RowComponent, - getCellBounds, + cachedBounds, + hasRowHeight, + offset, rowCount, rowProps, startIndexOverscan, diff --git a/lib/core/createCachedBounds.test.ts b/lib/core/createCachedBounds.test.ts index 367ac0fd..24dc4f1a 100644 --- a/lib/core/createCachedBounds.test.ts +++ b/lib/core/createCachedBounds.test.ts @@ -13,14 +13,14 @@ describe("createCachedBounds", () => { expect(itemSize).not.toHaveBeenCalled(); expect(cachedBounds.size).toBe(0); - expect(cachedBounds.get(2)).toEqual({ + expect(cachedBounds.getItemBounds(2)).toEqual({ scrollOffset: 21, size: 12 }); expect(itemSize).toHaveBeenCalledTimes(3); expect(cachedBounds.size).toBe(3); - expect(cachedBounds.get(3)).toEqual({ + expect(cachedBounds.getItemBounds(3)).toEqual({ scrollOffset: 33, size: 13 }); @@ -40,13 +40,13 @@ describe("createCachedBounds", () => { expect(itemSize).not.toHaveBeenCalled(); expect(cachedBounds.size).toBe(0); - cachedBounds.get(9); + cachedBounds.getItemBounds(9); expect(itemSize).toHaveBeenCalledTimes(10); expect(cachedBounds.size).toBe(10); for (let index = 0; index < 10; index++) { - cachedBounds.get(index); + cachedBounds.getItemBounds(index); } expect(itemSize).toHaveBeenCalledTimes(10); @@ -62,8 +62,10 @@ describe("createCachedBounds", () => { expect(cachedBounds.size).toBe(0); + expect(cachedBounds.getEstimatedSize()).toEqual(0); + expect(() => { - cachedBounds.get(1); + cachedBounds.getItemBounds(1); }).toThrow("Invalid index 1"); }); }); diff --git a/lib/core/createCachedBounds.ts b/lib/core/createCachedBounds.ts index 3e527473..487a1d89 100644 --- a/lib/core/createCachedBounds.ts +++ b/lib/core/createCachedBounds.ts @@ -12,14 +12,24 @@ export function createCachedBounds({ }): CachedBounds { const cache = new Map(); - return { - get(index: number) { + const api = { + getEstimatedSize() { + if (itemCount === 0) { + return 0; + } else { + const bounds = api.getItemBounds(cache.size === 0 ? 0 : cache.size - 1); + assert(bounds, "Unexpected bounds cache miss"); + + return (bounds.scrollOffset + bounds.size) / cache.size; + } + }, + getItemBounds(index: number) { assert(index < itemCount, `Invalid index ${index}`); while (cache.size - 1 < index) { const currentIndex = cache.size; - let size: number; + let size: number = 0; switch (typeof itemSize) { case "function": { size = itemSize(currentIndex, itemProps); @@ -33,8 +43,8 @@ export function createCachedBounds({ if (currentIndex === 0) { cache.set(currentIndex, { - size, - scrollOffset: 0 + scrollOffset: 0, + size }); } else { const previousRowBounds = cache.get(currentIndex - 1); @@ -59,11 +69,10 @@ export function createCachedBounds({ return bounds; }, - set(index: number, bounds: Bounds) { - cache.set(index, bounds); - }, get size() { return cache.size; } }; + + return api; } diff --git a/lib/core/getEstimatedSize.test.ts b/lib/core/getEstimatedSize.test.ts index 0b65fd0a..56090dd3 100644 --- a/lib/core/getEstimatedSize.test.ts +++ b/lib/core/getEstimatedSize.test.ts @@ -21,6 +21,20 @@ describe("getEstimatedSize", () => { ).toBe(0); }); + test("should return an estimate based on the first row if no sizes have yet been measured", () => { + expect( + getEstimatedSize({ + cachedBounds: createCachedBounds({ + itemCount: 1, + itemProps: EMPTY_OBJECT, + itemSize + }), + itemCount: 1, + itemSize + }) + ).toBe(10); + }); + test("should return an average size based on the first item if no measurements have been taken", () => { expect( getEstimatedSize({ @@ -41,7 +55,7 @@ describe("getEstimatedSize", () => { itemProps: EMPTY_OBJECT, itemSize }); - cachedBounds.get(4); + cachedBounds.getItemBounds(4); expect( getEstimatedSize({ @@ -59,7 +73,7 @@ describe("getEstimatedSize", () => { itemSize }); - cachedBounds.get(9); + cachedBounds.getItemBounds(9); expect( getEstimatedSize({ diff --git a/lib/core/getEstimatedSize.ts b/lib/core/getEstimatedSize.ts index c0c2bd51..b46491ce 100644 --- a/lib/core/getEstimatedSize.ts +++ b/lib/core/getEstimatedSize.ts @@ -1,5 +1,4 @@ import type { CachedBounds, SizeFunction } from "./types"; -import { assert } from "../utils/assert"; export function getEstimatedSize({ cachedBounds, @@ -12,17 +11,21 @@ export function getEstimatedSize({ }) { if (itemCount === 0) { return 0; - } else if (typeof itemSize === "number") { - return itemCount * itemSize; } else { - const bounds = cachedBounds.get( - cachedBounds.size === 0 ? 0 : cachedBounds.size - 1 - ); - assert(bounds !== undefined, "Unexpected bounds cache miss"); + switch (typeof itemSize) { + case "function": { + const bounds = cachedBounds.getItemBounds( + cachedBounds.size === 0 ? 0 : cachedBounds.size - 1 + ); - const averageItemSize = - (bounds.scrollOffset + bounds.size) / cachedBounds.size; + const averageItemSize = + (bounds.scrollOffset + bounds.size) / cachedBounds.size; - return itemCount * averageItemSize; + return itemCount * averageItemSize; + } + case "number": { + return itemCount * itemSize; + } + } } } diff --git a/lib/core/getOffsetForIndex.ts b/lib/core/getOffsetForIndex.ts index b27ee4f2..3362d8e6 100644 --- a/lib/core/getOffsetForIndex.ts +++ b/lib/core/getOffsetForIndex.ts @@ -25,7 +25,8 @@ export function getOffsetForIndex({ itemSize }); - const bounds = cachedBounds.get(index); + const bounds = cachedBounds.getItemBounds(index); + const maxOffset = Math.max( 0, Math.min(estimatedTotalSize - containerSize, bounds.scrollOffset) diff --git a/lib/core/getStartStopIndices.ts b/lib/core/getStartStopIndices.ts index c38d84a3..ac9d82dc 100644 --- a/lib/core/getStartStopIndices.ts +++ b/lib/core/getStartStopIndices.ts @@ -25,11 +25,14 @@ export function getStartStopIndices({ let startIndexOverscan = 0; let stopIndexOverscan = -1; let currentIndex = 0; + let currentOffset = 0; while (currentIndex < maxIndex) { - const bounds = cachedBounds.get(currentIndex); + const bounds = cachedBounds.getItemBounds(currentIndex); - if (bounds.scrollOffset + bounds.size > containerScrollOffset) { + currentOffset = bounds.scrollOffset + bounds.size; + + if (currentOffset > containerScrollOffset) { break; } @@ -40,12 +43,11 @@ export function getStartStopIndices({ startIndexOverscan = Math.max(0, startIndexVisible - overscanCount); while (currentIndex < maxIndex) { - const bounds = cachedBounds.get(currentIndex); + const bounds = cachedBounds.getItemBounds(currentIndex); + + currentOffset = bounds.scrollOffset + bounds.size; - if ( - bounds.scrollOffset + bounds.size >= - containerScrollOffset + containerSize - ) { + if (currentOffset >= containerScrollOffset + containerSize) { break; } diff --git a/lib/core/types.ts b/lib/core/types.ts index d9f409f6..eb721981 100644 --- a/lib/core/types.ts +++ b/lib/core/types.ts @@ -4,8 +4,8 @@ export type Bounds = { }; export type CachedBounds = { - get(index: number): Bounds; - set(index: number, bounds: Bounds): void; + getEstimatedSize(): number; + getItemBounds(index: number): Bounds; size: number; }; diff --git a/lib/core/useCachedBounds.test.ts b/lib/core/useCachedBounds.test.ts index 0c3dc2d3..42cc7290 100644 --- a/lib/core/useCachedBounds.test.ts +++ b/lib/core/useCachedBounds.test.ts @@ -4,7 +4,7 @@ import { EMPTY_OBJECT } from "../../src/constants"; import { useCachedBounds } from "./useCachedBounds"; describe("useCachedBounds", () => { - test("should cache the CachedBounds unless props change", () => { + test("should memoize CachedBounds value unless props change", () => { const { result, rerender } = renderHook( (props?: Partial>[0]) => useCachedBounds({ diff --git a/lib/core/useVirtualizer.test.ts b/lib/core/useVirtualizer.test.ts index 42a16c12..aa79f09d 100644 --- a/lib/core/useVirtualizer.test.ts +++ b/lib/core/useVirtualizer.test.ts @@ -1,57 +1,78 @@ import { renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, test } from "vitest"; import { EMPTY_OBJECT, NOOP_FUNCTION } from "../../src/constants"; -import { updateMockResizeObserver } from "../utils/test/mockResizeObserver"; +import { + setDefaultElementSize, + setElementSizeFunction +} from "../utils/test/mockResizeObserver"; import { useVirtualizer } from "./useVirtualizer"; describe("useVirtualizer", () => { - const DEFAULT_ARGS: Parameters[0] = { - containerElement: document.body, - defaultContainerSize: 100, - direction: "vertical", - itemCount: 25, - itemProps: EMPTY_OBJECT, - itemSize: 25, - onResize: NOOP_FUNCTION, - overscanCount: 0 - }; - + type Params = Parameters[0]; + + function testHelper(config: Partial = {}) { + const containerElement = document.createElement("div"); + containerElement.setAttribute("role", "list"); + + const args: Params = { + containerElement, + defaultContainerSize: 100, + direction: "vertical", + itemCount: 25, + itemProps: EMPTY_OBJECT, + itemSize: 25, + onResize: NOOP_FUNCTION, + overscanCount: 0, + ...config + }; + + for (let index = 0; index < args.itemCount; index++) { + const element = document.createElement("div"); + containerElement.appendChild(element); + } + + return renderHook(() => useVirtualizer(args)); + } beforeEach(() => { - updateMockResizeObserver(new DOMRect(0, 0, 50, 100)); + setDefaultElementSize({ height: 100, width: 50 }); + + setElementSizeFunction((element) => { + if (element.getAttribute("role") === "list") { + return new DOMRect(0, 0, 50, 100); + } else { + return new DOMRect(0, 0, 50, 25); + } + }); }); - describe("getCellBounds", () => { + describe("cachedBounds", () => { test("itemSize type: number", () => { - const { result } = renderHook(() => - useVirtualizer({ - ...DEFAULT_ARGS, - defaultContainerSize: 100, - itemSize: 25 - }) - ); - expect(result.current.getCellBounds(0)).toEqual({ + const { result } = testHelper({ + defaultContainerSize: 100, + itemSize: 25 + }); + + expect(result.current.cachedBounds.getItemBounds(0)).toEqual({ scrollOffset: 0, size: 25 }); - expect(result.current.getCellBounds(24)).toEqual({ + expect(result.current.cachedBounds.getItemBounds(24)).toEqual({ scrollOffset: 600, size: 25 }); }); test("itemSize type: string", () => { - const { result } = renderHook(() => - useVirtualizer({ - ...DEFAULT_ARGS, - defaultContainerSize: 100, - itemSize: "50%" - }) - ); - expect(result.current.getCellBounds(0)).toEqual({ + const { result } = testHelper({ + defaultContainerSize: 100, + itemSize: "50%" + }); + + expect(result.current.cachedBounds.getItemBounds(0)).toEqual({ scrollOffset: 0, size: 50 }); - expect(result.current.getCellBounds(24)).toEqual({ + expect(result.current.cachedBounds.getItemBounds(24)).toEqual({ scrollOffset: 1200, size: 50 }); @@ -60,19 +81,17 @@ describe("useVirtualizer", () => { test("itemSize type: function", () => { const itemSize = (index: number) => 20 + index * 20; - const { result } = renderHook(() => - useVirtualizer({ - ...DEFAULT_ARGS, - defaultContainerSize: 100, - itemCount: 10, - itemSize - }) - ); - expect(result.current.getCellBounds(0)).toEqual({ + const { result } = testHelper({ + defaultContainerSize: 100, + itemCount: 10, + itemSize + }); + + expect(result.current.cachedBounds.getItemBounds(0)).toEqual({ scrollOffset: 0, size: 20 }); - expect(result.current.getCellBounds(9)).toEqual({ + expect(result.current.cachedBounds.getItemBounds(9)).toEqual({ scrollOffset: 900, size: 200 }); @@ -81,61 +100,52 @@ describe("useVirtualizer", () => { describe("getEstimatedSize", () => { test("itemSize type: number", () => { - const { result } = renderHook(() => - useVirtualizer({ - ...DEFAULT_ARGS, - defaultContainerSize: 100, - itemSize: 25 - }) - ); + const { result } = testHelper({ + defaultContainerSize: 100, + itemSize: 25 + }); + expect(result.current.getEstimatedSize()).toBe(625); }); test("itemSize type: string", () => { - const { result } = renderHook(() => - useVirtualizer({ - ...DEFAULT_ARGS, - defaultContainerSize: 100, - itemSize: "50%" - }) - ); + const { result } = testHelper({ + defaultContainerSize: 100, + itemSize: "50%" + }); + expect(result.current.getEstimatedSize()).toBe(1250); }); test("itemSize type: function", () => { const itemSize = (index: number) => 20 + index * 20; - const { result } = renderHook(() => - useVirtualizer({ - ...DEFAULT_ARGS, - defaultContainerSize: 100, - itemCount: 10, - itemSize - }) - ); + const { result } = testHelper({ + defaultContainerSize: 100, + itemCount: 10, + itemSize + }); // Actual size is 1,100 // Based on the rows measured so far, the estimated size is 400 expect(result.current.getEstimatedSize()).toBe(400); // Finish measuring the rows and the actual size should be returned now - result.current.getCellBounds(9); + result.current.cachedBounds.getItemBounds(9); expect(result.current.getEstimatedSize()).toBe(1100); }); }); - // scrollToIndex is mostly covered by getOffsetForIndex tests + // The specific scroll index ranges are mostly covered by getOffsetForIndex tests describe("startIndex/stopIndex", () => { test("itemSize type: number", () => { - const { result } = renderHook(() => - useVirtualizer({ - ...DEFAULT_ARGS, - defaultContainerSize: 100, - itemSize: 25, - overscanCount: 2 - }) - ); + const { result } = testHelper({ + defaultContainerSize: 100, + itemSize: 25, + overscanCount: 2 + }); + expect(result.current.startIndexOverscan).toBe(0); expect(result.current.startIndexVisible).toBe(0); expect(result.current.stopIndexOverscan).toBe(5); @@ -143,14 +153,12 @@ describe("useVirtualizer", () => { }); test("itemSize type: string", () => { - const { result } = renderHook(() => - useVirtualizer({ - ...DEFAULT_ARGS, - defaultContainerSize: 100, - overscanCount: 2, - itemSize: "50%" - }) - ); + const { result } = testHelper({ + defaultContainerSize: 100, + overscanCount: 2, + itemSize: "50%" + }); + expect(result.current.startIndexOverscan).toBe(0); expect(result.current.startIndexVisible).toBe(0); expect(result.current.stopIndexOverscan).toBe(3); @@ -160,14 +168,12 @@ describe("useVirtualizer", () => { test("itemSize type: function", () => { const itemSize = (index: number) => 20 + index * 20; - const { result } = renderHook(() => - useVirtualizer({ - ...DEFAULT_ARGS, - defaultContainerSize: 100, - itemSize, - overscanCount: 2 - }) - ); + const { result } = testHelper({ + defaultContainerSize: 100, + itemSize, + overscanCount: 2 + }); + expect(result.current.startIndexOverscan).toBe(0); expect(result.current.startIndexVisible).toBe(0); expect(result.current.stopIndexOverscan).toBe(4); @@ -180,7 +186,7 @@ describe("useVirtualizer", () => { // @ts-expect-error Testing HTMLElement.prototype.scrollTo = undefined; - const { result } = renderHook(() => useVirtualizer(DEFAULT_ARGS)); + const { result } = testHelper(); result.current.scrollToIndex({ containerScrollOffset: 0, index: 5 }); }); diff --git a/lib/core/useVirtualizer.ts b/lib/core/useVirtualizer.ts index db427159..8bc47922 100644 --- a/lib/core/useVirtualizer.ts +++ b/lib/core/useVirtualizer.ts @@ -111,11 +111,6 @@ export function useVirtualizer({ itemSize }); - const getCellBounds = useCallback( - (index: number) => cachedBounds.get(index), - [cachedBounds] - ); - const getEstimatedSize = useCallback( () => getEstimatedSizeUtil({ @@ -251,7 +246,7 @@ export function useVirtualizer({ ); return { - getCellBounds, + cachedBounds, getEstimatedSize, scrollToIndex, startIndexOverscan, diff --git a/lib/hooks/useResizeObserver.test.ts b/lib/hooks/useResizeObserver.test.ts index c238701f..23812f8c 100644 --- a/lib/hooks/useResizeObserver.test.ts +++ b/lib/hooks/useResizeObserver.test.ts @@ -2,13 +2,14 @@ import { act, renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, test } from "vitest"; import { simulateUnsupportedEnvironmentForTest, - updateMockResizeObserver + setDefaultElementSize, + setElementSize } from "../utils/test/mockResizeObserver"; import { useResizeObserver } from "./useResizeObserver"; describe("useResizeObserver", () => { beforeEach(() => { - updateMockResizeObserver({ height: 100, width: 50 }); + setDefaultElementSize({ height: 100, width: 50 }); }); test("should use default width/height if disabled", () => { @@ -34,7 +35,7 @@ describe("useResizeObserver", () => { act(() => { // Updates should be ignored as well - updateMockResizeObserver({ height: 25, target: element }); + setElementSize({ element, height: 25, width: 50 }); }); expect(result.current).toEqual({ @@ -79,9 +80,10 @@ describe("useResizeObserver", () => { }); act(() => { - updateMockResizeObserver({ + setElementSize({ + element, height: 50, - target: element + width: 50 }); }); @@ -104,9 +106,10 @@ describe("useResizeObserver", () => { ); act(() => { - updateMockResizeObserver({ + setElementSize({ + element: otherElement, height: 50, - target: otherElement + width: 50 }); }); diff --git a/lib/utils/test/mockResizeObserver.ts b/lib/utils/test/mockResizeObserver.ts index 7320e9ed..e5b7b0b9 100644 --- a/lib/utils/test/mockResizeObserver.ts +++ b/lib/utils/test/mockResizeObserver.ts @@ -1,37 +1,55 @@ import EventEmitter from "node:events"; +type GetDOMRect = (element: HTMLElement) => DOMRectReadOnly | undefined | void; + const emitter = new EventEmitter(); -emitter.setMaxListeners(25); +emitter.setMaxListeners(100); + +const elementToDOMRect = new Map(); +let defaultDomRect: DOMRectReadOnly = new DOMRect(0, 0, 0, 0); let disabled: boolean = false; -let entrySize: DOMRectReadOnly = new DOMRect(0, 0, 0, 0); +let getDOMRect: GetDOMRect | undefined = undefined; export function disableResizeObserverForCurrentTest() { disabled = true; } -export function simulateUnsupportedEnvironmentForTest() { - // @ts-expect-error Simulate API being unsupported - window.ResizeObserver = null; +export function setDefaultElementSize({ + height, + width +}: { + height: number; + width: number; +}) { + defaultDomRect = new DOMRect(0, 0, width, height); + + emitter.emit("change"); +} + +export function setElementSizeFunction(value: GetDOMRect) { + getDOMRect = value; + + emitter.emit("change"); } -export function updateMockResizeObserver({ +export function setElementSize({ + element, height, - target, width }: { - height?: number; - target?: HTMLElement; - width?: number; -}): void { - entrySize = new DOMRect( - 0, - 0, - width ?? entrySize.width, - height ?? entrySize.height - ); - - emitter.emit("change", target); + element: HTMLElement; + height: number; + width: number; +}) { + elementToDOMRect.set(element, new DOMRect(0, 0, width, height)); + + emitter.emit("change", element); +} + +export function simulateUnsupportedEnvironmentForTest() { + // @ts-expect-error Simulate API being unsupported + window.ResizeObserver = null; } export function mockResizeObserver() { @@ -39,66 +57,103 @@ export function mockResizeObserver() { const originalResizeObserver = window.ResizeObserver; - window.ResizeObserver = class implements ResizeObserver { - readonly #callback: ResizeObserverCallback; - #disconnected: boolean = false; - #elements: Set = new Set(); + window.ResizeObserver = MockResizeObserver; - constructor(callback: ResizeObserverCallback) { - this.#callback = callback; + return function unmockResizeObserver() { + window.ResizeObserver = originalResizeObserver; - emitter.addListener("change", this.#onChange); - } + defaultDomRect = new DOMRect(0, 0, 0, 0); + disabled = false; + getDOMRect = undefined; - observe(element: HTMLElement) { - if (this.#disconnected) { - return; - } + elementToDOMRect.clear(); + }; +} - this.#elements.add(element); - this.#notify(element); - } +class MockResizeObserver implements ResizeObserver { + readonly #callback: ResizeObserverCallback; + #disconnected: boolean = false; + #elements: Set = new Set(); - unobserve(element: HTMLElement) { - this.#elements.delete(element); + constructor(callback: ResizeObserverCallback) { + this.#callback = callback; + + emitter.addListener("change", this.#onChange); + } + + observe(element: HTMLElement) { + if (this.#disconnected) { + return; } - disconnect() { - this.#disconnected = true; - this.#elements.clear(); + this.#elements.add(element); + this.#notify([element]); + } + + unobserve(element: HTMLElement) { + this.#elements.delete(element); + } - emitter.removeListener("change", this.#onChange); + disconnect() { + this.#disconnected = true; + this.#elements.clear(); + + emitter.removeListener("change", this.#onChange); + } + + #notify(elements: HTMLElement[]) { + if (disabled) { + return; } - #notify(target: HTMLElement) { - if (disabled) { - return; + const entries = elements.map((element) => { + const computedStyle = window.getComputedStyle(element); + const writingMode = computedStyle.writingMode; + + let contentRect: DOMRectReadOnly = + elementToDOMRect.get(element) ?? defaultDomRect; + + if (getDOMRect) { + const contentRectOverride = getDOMRect(element); + if (contentRectOverride) { + contentRect = contentRectOverride; + } } - this.#callback( - [ + let blockSize = 0; + let inlineSize = 0; + if (writingMode.includes("vertical")) { + blockSize = contentRect.width; + inlineSize = contentRect.height; + } else { + blockSize = contentRect.height; + inlineSize = contentRect.width; + } + + return { + borderBoxSize: [ { - borderBoxSize: [], - contentBoxSize: [], - contentRect: entrySize, - devicePixelContentBoxSize: [], - target + blockSize, + inlineSize } ], - this - ); - } - - #onChange = (target?: HTMLElement) => { - if (target) { - this.#notify(target); - } else { - this.#elements.forEach((element) => this.#notify(element)); + contentBoxSize: [], + contentRect, + devicePixelContentBoxSize: [], + target: element + }; + }); + + this.#callback(entries, this); + } + + #onChange = (target?: HTMLElement) => { + if (target) { + if (this.#elements.has(target)) { + this.#notify([target]); } - }; - }; - - return function unmockResizeObserver() { - window.ResizeObserver = originalResizeObserver; + } else { + this.#notify(Array.from(this.#elements)); + } }; }