import { forwardRef, memo, ReactNode, startTransition, useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useRef, useState, useSyncExternalStore, } from 'react'; import debounce from './debounce'; import styles from './Masonry.css'; import { Cache } from './Masonry/Cache'; import recalcHeights from './Masonry/dynamicHeightsUtils'; import recalcHeightsV2 from './Masonry/dynamicHeightsV2Utils'; import getColumnCount, { DEFAULT_LAYOUT_DEFAULT_GUTTER, FULL_WIDTH_DEFAULT_GUTTER, } from './Masonry/getColumnCount'; import getLayoutAlgorithm from './Masonry/getLayoutAlgorithm'; import ItemResizeObserverWrapper from './Masonry/ItemResizeObserverWrapper'; import MeasurementStore from './Masonry/MeasurementStore'; import { calculateActualColumnSpan, ColumnSpanConfig, ModulePositioningConfig, MULTI_COL_ITEMS_MEASURE_BATCH_SIZE, ResponsiveModuleConfig, } from './Masonry/multiColumnLayout'; import { getElementHeight, getRelativeScrollTop, getScrollPos } from './Masonry/scrollUtils'; import { Align, Layout, Position } from './Masonry/types'; import throttle from './throttle'; const RESIZE_DEBOUNCE = 300; const layoutNumberToCssDimension = (n?: number | null) => (n !== Infinity ? n : undefined); type Props = { /** * Controls the horizontal alignment of items within the Masonry grid. The `align` property determines how items are aligned along the main-axis (horizontally) across multiple columns. * `start`: Aligns items to the start of the Masonry container. This is the default behavior where items are placed starting from the left side of the container. * `center`: Centers items in the Masonry grid. This will adjust the spacing on either side of the grid to ensure that the items are centered within the container. * `end`: Aligns items to the end of the Masonry container. Items will be placed starting from the right, moving leftwards, which may leave space on the left side of the container. * Using the `align` property can help control the visual balance and alignment of the grid, especially in responsive layouts or when dealing with varying item widths. * * _Note that layout='basic' must be set for align to take effect._ */ align?: Align; /** * The preferred/target item width in pixels. If `layout="flexible"` is set, the item width will * grow to fill column space, and shrink to fit if below the minimum number of columns. */ columnWidth?: number; /** * The amount of vertical and horizontal space between each item, specified in pixels. */ gutterWidth?: number; /** * An array of items to display that contains the data to be rendered by `renderItem`. */ items: ReadonlyArray; /** * `basic`: Left-aligned, fixed-column-width masonry layout. * `basicCentered`: Center-aligned, fixed-column-width masonry layout. * `flexible`: Item width grows to fill column space and shrinks to fit if below the minimum number of columns. * `serverRenderedFlexible`: Item width grows to fill column space and shrinks to fit if below the minimum number of columns. Main differerence with `flexible` is that we do not store the initial measurement. More context in [#2084](https://github.com/pinterest/gestalt/pull/2084) * `uniformRow`: Items are laid out in a single row, with all items having the same height. Note that Masonry does _not_ crop or alter items in any way — rows will take the height of the tallest item in the row, with additional whitespace shown below any shorter items. */ layout?: Layout; /** * A callback fired when the user scrolls past a given threshold, based on the height of the container. The callback should update the state of the items, which must be reflected in the `items` prop. * * _Note that `scrollContainer` must be specified._ */ loadItems?: ( arg1?: | { from: number; } | null | undefined, ) => void; /** * Masonry internally caches item heights using a measurement store. If `measurementStore` is provided, Masonry will use it as its cache and will keep it updated with future measurements. This is often used to prevent re-measurement when users navigate away from and back to a grid. Create a new measurement store with `Masonry.createMeasurementStore()`. */ measurementStore?: Cache; /** * Minimum number of columns to display, regardless of the container width. */ minCols?: number; /** * Masonry internally caches positions using a position store. If `positionStore` is provided, Masonry will use it as its cache and will keep it updated with future positions. */ positionStore?: Cache; /** * A function that renders the item you would like displayed in the grid. This function is passed three props: the item's data, the item's index in the grid, and a flag indicating if Masonry is currently measuring the item. * * If present, `heightAdjustment` indicates the number of pixels this item needs to grow/shrink to accommodate a 2-column item in the grid. Items must respond to this prop by adjusting their height or layout issues will occur. */ renderItem: (arg1: { readonly data: T; readonly itemIdx: number; readonly isMeasuring: boolean; }) => ReactNode; /** * A function that returns a DOM node that Masonry uses for scroll event subscription. This DOM node is intended to be the most immediate ancestor of Masonry in the DOM that will have a scroll bar; in most cases this will be the `window` itself, although sometimes Masonry is used inside containers that have `overflow: auto`. `scrollContainer` is optional, although it is required for features such as `virtualize` and `loadItems`. * * This is required if the grid is expected to be scrollable. */ scrollContainer?: () => HTMLElement | Window; /** * If `virtualize` is enabled, Masonry will only render items that fit in the viewport, plus some buffer. `virtualBoundsBottom` allows customization of the buffer size below the viewport, specified in pixels. */ virtualBoundsBottom?: number; /** * If `virtualize` is enabled, Masonry will only render items that fit in the viewport, plus some buffer. `virtualBoundsTop` allows customization of the buffer size above the viewport, specified in pixels. */ virtualBoundsTop?: number; /** * If `virtualize` is enabled, Masonry will only render items that fit in the viewport, plus some buffer. `virtualBufferFactor` allows customization of the buffer size, specified as a multiplier of the container height. It specifies the amount of extra buffer space for populating visible items. For example, if `virtualBufferFactor` is 2, then Masonry will render items that fit in the viewport, plus 2x the viewport height. */ virtualBufferFactor?: number; /** * Specifies whether or not Masonry dynamically adds/removes content from the grid based on the user's viewport and scroll position. Note that `scrollContainer` must be specified when virtualization is used. */ virtualize?: boolean; /** * Experimental prop to log the additional whitespace shown above multicolumn items and the number of iterations it took to position it. * * This is an experimental prop and may be removed in the future. */ _logTwoColWhitespace?: ( additionalWhitespace: ReadonlyArray, numberOfIterations: number, columnSpan: number, ) => void; /** * Experimental prop to measure all items in one batch */ _measureAll?: boolean; /** * Experimental prop to trigger rendering updates via requestAnimationFrame */ _useRAF?: boolean; /** * Experimental prop to define how many columns a module should span. This is also used to enable multi-column support * _getColumnSpanConfig is a function that takes an individual grid item as an input and returns a ColumnSpanConfig. ColumnSpanConfig can be one of two things: * - A number, which indicates a static number of columns the item should span * - An object, which allows for configuration of the item's column span across the following grid sizes: sm (2 columns), md (3-4 columns), lg (5-8 columns), xl (9+ columns) * * This is an experimental prop and may be removed or changed in the future. */ _getColumnSpanConfig?: (item: T) => ColumnSpanConfig; /** * Experimental prop to define the minimum and maximum limit a flexible width module could span. * This used to enable multi-column flexible width support ONLY ON SECOND ITEM OF THE ARRAY OF ITEMS. * Also, for the flexible width to work, the _getColumnSpanConfig prop should be set. * _getResponsiveModuleConfigForSecondItem is a function that takes an individual grid item as an input and returns a ResponsiveModuleConfig. * ResponsiveModuleConfig can be one of the following: * - A number, which indicates a static number of columns the item should span * - An object, which sets the minimum and maximum limits a multi-column item could span filling the empty columns in the first row of the grid (flexible width). * - Undefined, which is used to indicate that this prop isn't set. * * This is an experimental prop and may be removed or changed in the future. */ _getResponsiveModuleConfigForSecondItem?: (item: T) => ResponsiveModuleConfig; /** * Experimental flag to enable dynamic heights on items. This only works if multi column items are enabled. */ _dynamicHeights?: boolean; /** * Experimental flag to enable an experiment to use a revamped version of dynamic heights (This needs _dynamicHeights enabled) */ _dynamicHeightsV2Experiment?: boolean; /** * Experimental prop to enable dynamic batch sizing and early bailout when positioning a module * - Early bailout: How much whitespace is "good enough" * - Dynamic batch sizing: How many items it can use. If this prop isn't used, it uses 5 * This is an experimental prop and may be removed or changed in the future */ _getModulePositioningConfig?: (gridSize: number, moduleSize: number) => ModulePositioningConfig; /** * Experimental flag to enable new multi column position layout algorithm */ _multiColPositionAlgoV2?: boolean; }; type MasonryRef = { readonly handleResize: () => void; readonly reflow: () => void; readonly state: { width: number | null | undefined; }; }; /** * [Masonry](https://gestalt.pinterest.systems/web/masonry) creates a deterministic grid layout, positioning items based on available vertical space. It contains performance optimizations like virtualization and support for infinite scrolling. * * ![Masonry light mode](https://raw.githubusercontent.com/pinterest/gestalt/master/playwright/visual-test/Masonry.spec.ts-snapshots/Masonry-chromium-darwin.png) * */ function createMeasurementStore, T2>(): MeasurementStore { return new MeasurementStore(); } // helper hook to force update a component function useForceUpdate() { // @ts-expect-error - TS2554 - Expected 3 arguments, but got 2. | TS7006 - Parameter 'x' implicitly has an 'any' type. const [, forceUpdate] = useReducer((x) => x + 1, 0); return forceUpdate; } // useElementWidth returns the width of the gridWrapper element // and uses useSyncExternalStore to subscribe to window resize events function useElementWidth(element?: HTMLDivElement | null): [() => void, number | null | undefined] { const prevElementRef = useRef(null); const elementWidthRef = useRef(null); // update elementWidthRef whenever element changes if (element && element !== prevElementRef.current) { elementWidthRef.current = element.clientWidth; prevElementRef.current = element; } const updateElementWidth = useCallback(() => { if (element) { elementWidthRef.current = element.clientWidth; } }, [element]); const subscribeToResizeEvent = useCallback( (callback: () => void) => { const handler = debounce(() => { // update elementWidthRef whenever we have a resize event updateElementWidth(); callback(); }, RESIZE_DEBOUNCE); window.addEventListener('resize', handler); return () => { window.removeEventListener('resize', handler); }; }, [updateElementWidth], ); const width = useSyncExternalStore( subscribeToResizeEvent, () => elementWidthRef.current, () => null, ); return [updateElementWidth, width]; } function useScrollContainer({ gridWrapper, scrollContainer, }: { gridWrapper: HTMLElement | null | undefined; scrollContainer: HTMLElement | Window | null | undefined; }) { const containerHeight = useRef(0); const containerOffset = useRef(0); const scrollPos = useRef(0); useEffect(() => { // reset scroll position whenever the scrollContainer changes scrollPos.current = scrollContainer ? getScrollPos(scrollContainer) : 0; }, [scrollContainer]); const measureContainer = useCallback(() => { if (scrollContainer) { containerHeight.current = getElementHeight(scrollContainer); if (gridWrapper instanceof HTMLElement) { // todo - look into using IntersectionObserver instead containerOffset.current = gridWrapper.getBoundingClientRect().top + getRelativeScrollTop(scrollContainer); } } }, [gridWrapper, scrollContainer]); useEffect(() => { // measure the container whenever the gridWrapper or scrollContainer changes (we use the identity of the measureContainer function here as an indication of that) measureContainer(); }, [measureContainer]); // created a debounced version of measureContainer to avoid measuring the container on every render // this is mostly because the calls to getBoundingClientRect are expensive and result in forced reflows const measureContainerAsync = useMemo(() => debounce(measureContainer, 100), [measureContainer]); useEffect(() => { // trigger an async measurement whenever an update occurs // todo - followup on this and figure out a more ideal way to handle this. measureContainerAsync(); }); const subscribeToScrollEvent = useCallback( (callback: () => void) => { const handler = throttle(() => { // update elementWidthRef whenever we have a resize event scrollPos.current = scrollContainer ? getScrollPos(scrollContainer) : 0; callback(); }); scrollContainer?.addEventListener('scroll', handler); return () => { scrollContainer?.removeEventListener('scroll', handler); }; }, [scrollContainer], ); const scrollTop = useSyncExternalStore( subscribeToScrollEvent, () => scrollPos.current, () => 0, ); return { containerHeight: containerHeight.current, containerOffset: containerOffset.current, scrollTop, }; } function useFetchOnScroll({ containerHeight, containerOffset, hasPendingMeasurements, height, items, loadItems, scrollTop, scrollContainerElement, width, }: { containerHeight: number; containerOffset: number; hasPendingMeasurements: boolean; height: number; items: ReadonlyArray; loadItems: ( arg1?: | { from: number; } | null | undefined, ) => void; scrollTop: number; scrollContainerElement: HTMLElement | Window | null | undefined; width: number | null | undefined; }) { const isFetching = useRef(false); const itemLength = items.length; const scrollHeight = height + containerOffset; const scrollBuffer = containerHeight * 3; const shouldFetchMore = loadItems && typeof loadItems === 'function' && scrollContainerElement && width != null && !isFetching.current && !hasPendingMeasurements && scrollTop + scrollBuffer > scrollHeight; useEffect(() => { // indicate that we're no longer fetching once we've detected a change in item length isFetching.current = false; }, [itemLength]); useEffect(() => { // after each render, if we've indicated that we should fetch more items, do so if (shouldFetchMore) { isFetching.current = true; loadItems({ from: itemLength }); } }); } function useLayout({ align, columnWidth, gutter, items, layout, measurementStore, minCols, positionStore, width, maxHeight, heightUpdateTrigger, _logTwoColWhitespace, _measureAll, _useRAF, _getColumnSpanConfig, _getResponsiveModuleConfigForSecondItem, _getModulePositioningConfig, _multiColPositionAlgoV2, }: { align: Align; columnWidth: number; gutter: number; items: ReadonlyArray; layout: Layout; measurementStore: Cache; minCols: number; positionStore: Cache; width: number | null | undefined; maxHeight: number; heightUpdateTrigger: number; _logTwoColWhitespace?: ( additionalWhitespace: ReadonlyArray, numberOfIterations: number, columnSpan: number, ) => void; _measureAll?: boolean; _useRAF?: boolean; _getColumnSpanConfig?: (item: T) => ColumnSpanConfig; _getModulePositioningConfig?: (gridSize: number, moduleSize: number) => ModulePositioningConfig; _getResponsiveModuleConfigForSecondItem?: (item: T) => ResponsiveModuleConfig; _earlyBailout?: (columnSpan: number) => number; _multiColPositionAlgoV2?: boolean; }): { height: number; hasPendingMeasurements: boolean; positions: ReadonlyArray; updateMeasurement: (arg1: T, arg2: number) => void; } { const layoutFunction = getLayoutAlgorithm({ align, columnWidth, gutter, items, layout, measurementStore, positionStore, minCols, width, _getColumnSpanConfig, _getResponsiveModuleConfigForSecondItem, _logTwoColWhitespace, _getModulePositioningConfig, _multiColPositionAlgoV2, }); const itemsWithoutMeasurementsBatch = items.filter((item) => !measurementStore.has(item)); const nextMultiColumnItem = _getColumnSpanConfig && itemsWithoutMeasurementsBatch.find((item) => _getColumnSpanConfig(item) !== 1); const nextMultiColumnItemIndex = itemsWithoutMeasurementsBatch.indexOf(nextMultiColumnItem!); let batchSize; if (nextMultiColumnItem) { const responsiveModuleConfigForSecondItem = _getResponsiveModuleConfigForSecondItem && items[1] ? _getResponsiveModuleConfigForSecondItem(items[1]) : undefined; const isFlexibleWidthItem = !!responsiveModuleConfigForSecondItem && nextMultiColumnItem === items[1]; if (isFlexibleWidthItem) if (width) { const gridSize = getColumnCount({ gutter, columnWidth, width, minCols, layout }); const moduleSize = calculateActualColumnSpan({ columnCount: gridSize, firstItem: items[0]!, isFlexibleWidthItem, item: nextMultiColumnItem, responsiveModuleConfigForSecondItem, _getColumnSpanConfig, }); const { itemsBatchSize } = _getModulePositioningConfig?.(gridSize, moduleSize) || { itemsBatchSize: MULTI_COL_ITEMS_MEASURE_BATCH_SIZE, }; batchSize = itemsBatchSize; } else { batchSize = MULTI_COL_ITEMS_MEASURE_BATCH_SIZE; } } const itemToMeasureCount = batchSize && nextMultiColumnItemIndex <= batchSize ? batchSize + 1 : minCols; const itemMeasurements = items.filter((item) => measurementStore.has(item)); const itemMeasurementsCount = itemMeasurements.length; const hasPendingMeasurements = itemMeasurementsCount < items.length; const canPerformLayout = width != null; const positions: ReadonlyArray = useMemo(() => { if (!canPerformLayout) { return []; } // we currently calculate positions separately for items that have been measured and items that haven't // maintain this paradigm for now for parity but ideally we should just able to pass all items to the layout function const itemsWithMeasurements = items.filter((item) => measurementStore.has(item)); const itemsWithoutMeasurements = items.filter((item) => !measurementStore.has(item)); const rawPositions = layoutFunction(itemsWithMeasurements).concat( layoutFunction(itemsWithoutMeasurements), ); let measureItemCount = 0; return items.reduce>((acc: Array, item, i) => { const position = positionStore.get(item) ?? rawPositions[i]; if (!position) { acc.push(null); } else { const hasMeasurement = measurementStore.has(item); if (hasMeasurement) { acc.push(position); } else if (_measureAll || measureItemCount < itemToMeasureCount) { acc.push(position); measureItemCount += 1; } else { acc.push(null); } } return acc; }, []); // only recalculate positions when certain things change // - items: if we get new items, we should always recalculate positions // - itemMeasurementsCount: if we have a change in the number of items we've measured, we should always recalculage // - canPerformLayout: if we don't have a width, we can't calculate positions yet. so recalculate once we're able to // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps }, [itemMeasurementsCount, items, canPerformLayout, heightUpdateTrigger]); const forceUpdate = useForceUpdate(); const rafId = useRef(null); const updateMeasurement = useCallback( (item: T, itemHeight: number) => { measurementStore.set(item, itemHeight); // schedule state update either via startTransition or requestAnimationFrame depending on whether _useRAF is true. // requestAnimationFrame is to test parity with Masonry V1 if (!_useRAF) { startTransition(() => { forceUpdate(); }); } else if (!rafId.current) { rafId.current = requestAnimationFrame(() => { rafId.current = null; forceUpdate(); }); } }, [measurementStore, forceUpdate, _useRAF], ); // Math.max() === -Infinity when there are no positions const height = positions.length ? Math.max( ...positions.map((pos) => (pos && pos.top >= 0 ? pos.top + pos.height : 0)), maxHeight, ) : 0; return { hasPendingMeasurements, height, positions, updateMeasurement, }; } function useViewport({ containerHeight, containerOffset, scrollContainer, scrollTop, virtualBufferFactor, virtualBoundsTop, virtualBoundsBottom, virtualize, }: { containerHeight: number; containerOffset: number; scrollContainer: HTMLElement | Window | null | undefined; scrollTop: number; virtualBufferFactor: number; virtualBoundsTop: number | null | undefined; virtualBoundsBottom: number | null | undefined; virtualize: boolean; }) { if (virtualize && scrollContainer && Boolean(virtualBufferFactor)) { const virtualBuffer = containerHeight * virtualBufferFactor; const offsetScrollPos = scrollTop - containerOffset; const viewportTop = virtualBoundsTop ? offsetScrollPos - virtualBoundsTop : offsetScrollPos - virtualBuffer; const viewportBottom = virtualBoundsBottom ? offsetScrollPos + containerHeight + virtualBoundsBottom : offsetScrollPos + containerHeight + virtualBuffer; return { viewportTop, viewportBottom, }; } return { viewportTop: -Infinity, viewportBottom: Infinity, }; } function MasonryItem({ height, idx, isMeasurement, isServerRenderOrHydration, item, layout, left, renderItem, serializedColumnSpanConfig, top, updateMeasurement, width, resizeObserver, }: { height: number | null | undefined; idx: number; isMeasurement: boolean; isServerRenderOrHydration: boolean; item: T; left: number; layout: Layout; renderItem: Props['renderItem']; serializedColumnSpanConfig: string | number; top: number; updateMeasurement: (arg1: T, arg2: number) => void; width: number | null | undefined; resizeObserver: any; }) { // This isn't great since it currently returns false during server render/hydration and potentially true after // This should be revisited const isRtl = isServerRenderOrHydration || typeof document === 'undefined' ? false : document?.dir === 'rtl'; const className = isServerRenderOrHydration ? 'static' : styles.Masonry__Item; const refCallback = isServerRenderOrHydration ? (el?: HTMLDivElement | null) => { // purposely not checking for layout === 'serverRenderedFlexible' here if (el && layout !== 'flexible') { // if we're hydrating from the server, we should only measure items on the initial render pass // if we're not rendering a flexible layout. "serverRenderedFlexible" is an exception because we assume // that the caller has added the proper CSS to ensure the layout is correct during server render updateMeasurement(item, el.clientHeight); } } : (el?: HTMLDivElement | null) => { if (el && isMeasurement) { updateMeasurement(item, el.clientHeight); } }; const style = isMeasurement ? { visibility: 'hidden', position: 'absolute', top: layoutNumberToCssDimension(top), left: layoutNumberToCssDimension(left), width: layoutNumberToCssDimension(width), height: layoutNumberToCssDimension(height), } : { top: 0, ...(isRtl ? { right: 0 } : { left: 0 }), transform: `translateX(${isRtl ? left * -1 : left}px) translateY(${top}px)`, WebkitTransform: `translateX(${isRtl ? left * -1 : left}px) translateY(${top}px)`, width: layoutNumberToCssDimension(width), height: layoutNumberToCssDimension(height), }; return (
{renderItem({ data: item, itemIdx: idx, isMeasuring: isMeasurement })}
); } const MasonryItemMemo = memo(MasonryItem) as typeof MasonryItem; function Masonry( { align = 'center', columnWidth = 236, gutterWidth, items, layout = 'basic', loadItems = () => {}, measurementStore: measurementStoreProp, minCols = 3, positionStore: positionStoreProp, renderItem, scrollContainer, virtualBufferFactor = 0.7, virtualBoundsBottom, virtualBoundsTop, virtualize = false, _logTwoColWhitespace, _measureAll, _useRAF, _getColumnSpanConfig, _getResponsiveModuleConfigForSecondItem, _dynamicHeights, _dynamicHeightsV2Experiment, _getModulePositioningConfig, _multiColPositionAlgoV2, }: Props, ref: | { current: null | MasonryRef; } | ((arg1: null | MasonryRef) => unknown), ) { const hasSetInitialWidth = useRef(false); const [gridWrapperEl, setGridWrapperEl] = useState(null); const gridWrapperRef = useCallback((el?: HTMLDivElement | null) => { if (el) { setGridWrapperEl(el); } }, []); const gutter: number = useMemo(() => { let defaultGutter = DEFAULT_LAYOUT_DEFAULT_GUTTER; if (layout && (layout === 'flexible' || layout === 'serverRenderedFlexible')) { defaultGutter = FULL_WIDTH_DEFAULT_GUTTER; } return gutterWidth ?? defaultGutter; }, [gutterWidth, layout]); const measurementStore: Cache = useMemo( () => measurementStoreProp || createMeasurementStore(), [measurementStoreProp], ); const positionStore: Cache = useMemo( () => positionStoreProp || createMeasurementStore(), [positionStoreProp], ); const scrollContainerElement = useMemo( () => (typeof window !== 'undefined' ? scrollContainer?.() : null), [scrollContainer], ); const [updateElementWidth, width] = useElementWidth(gridWrapperEl); const { containerHeight, containerOffset, scrollTop } = useScrollContainer({ gridWrapper: gridWrapperEl, scrollContainer: scrollContainerElement, }); const forceUpdate = useForceUpdate(); const reflow = () => { measurementStore.reset(); positionStore.reset(); updateElementWidth(); startTransition(() => { forceUpdate(); }); }; // these are all for backwards compatibility with the old Masonry // will work on removing these once this lands useImperativeHandle(ref, () => ({ handleResize: () => updateElementWidth(), reflow, state: { width, }, })); // respond to changes in width (e.g. resize) useEffect(() => { if (hasSetInitialWidth.current && width != null) { // whenever the width changes, we need to reset all measurements // we only want to do this after the initial width has been set, so we use a ref to track that // reflow the grid reflow(); } if (!hasSetInitialWidth.current && width != null) { hasSetInitialWidth.current = true; } // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps }, [width]); const [heightUpdateTrigger, setHeightUpdateTrigger] = useState(0); const resizeObserver = useMemo( () => _dynamicHeights && typeof window !== 'undefined' && positionStore ? new ResizeObserver((entries) => { let triggerUpdate = false; entries.forEach(({ target, contentRect }) => { const idx = Number(target.getAttribute('data-grid-item-idx')); if (typeof idx === 'number') { const changedItem: T = items[idx]!; const newHeight = contentRect.height; if (_dynamicHeightsV2Experiment) { triggerUpdate = recalcHeightsV2({ items, changedItem, newHeight, positionStore, measurementStore, gutter, }) || triggerUpdate; } else { triggerUpdate = recalcHeights({ items, changedItem, newHeight, positionStore, measurementStore, }) || triggerUpdate; } } }); if (triggerUpdate) { setHeightUpdateTrigger((prev) => prev + 1); } }) : undefined, [_dynamicHeights, _dynamicHeightsV2Experiment, items, measurementStore, positionStore, gutter], ); const maxHeightRef = useRef(0); const { hasPendingMeasurements, height, positions, updateMeasurement } = useLayout({ align, columnWidth, gutter, items, layout, measurementStore, minCols, positionStore, width, maxHeight: maxHeightRef.current, heightUpdateTrigger, _logTwoColWhitespace, _measureAll, _useRAF, _getColumnSpanConfig, _getResponsiveModuleConfigForSecondItem, _getModulePositioningConfig, _multiColPositionAlgoV2, }); useEffect(() => { maxHeightRef.current = height; }, [height]); useFetchOnScroll({ containerHeight, containerOffset, hasPendingMeasurements, height, items, loadItems, scrollTop, scrollContainerElement, width, }); const isServerRenderOrHydration = width == null && hasPendingMeasurements; const canPerformFullLayout = width != null; const { viewportTop, viewportBottom } = useViewport({ containerHeight, containerOffset, scrollContainer: scrollContainerElement, scrollTop, virtualBufferFactor, virtualBoundsTop, virtualBoundsBottom, virtualize, }); let gridBody = null; if (isServerRenderOrHydration || canPerformFullLayout) { gridBody = items.filter(Boolean).map((item, i) => { const key = `item-${i}`; const columnSpanConfig = _getColumnSpanConfig?.(item) ?? 1; const position = canPerformFullLayout ? positions[i] : { top: 0, left: 0, // we don't know the height yet when server rendering or hydrating height: undefined, width: // eslint-disable-next-line no-nested-ternary layout === 'flexible' || layout === 'serverRenderedFlexible' || typeof columnSpanConfig === 'object' ? undefined // we can't set a width for server rendered flexible items : typeof columnSpanConfig === 'number' && columnWidth != null && gutter != null ? columnWidth * columnSpanConfig + gutter * (columnSpanConfig - 1) : columnWidth, }; if (!position) { return null; } const isMeasurement = canPerformFullLayout ? !measurementStore.has(item) : false; const isVisible = isServerRenderOrHydration || isMeasurement ? true : !(position.top + (position.height ?? 0) < viewportTop || position.top > viewportBottom); const serializedColumnSpanConfig = typeof columnSpanConfig === 'number' ? columnSpanConfig : btoa(JSON.stringify(columnSpanConfig)); return isVisible ? ( ) : null; }); } return (
{/* @ts-expect-error - TS2322 - Type 'number | null | undefined' is not assignable to type 'Width | undefined'. */}
{gridBody}
); } const MasonryWithForwardRef = forwardRef>( // @ts-expect-error - TS2345 Masonry, ); // @ts-expect-error - TS3339 MasonryWithForwardRef.createMeasurementStore = createMeasurementStore; export default MasonryWithForwardRef;