Skip to content

Masonry: Enable dynamic batches #3875

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/integration-test-helpers/masonry/MasonryContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ export default class MasonryContainer extends Component<Props<Record<any, any>>,
const columnSpan = item.columnSpan as number | undefined;
return columnSpan ?? 1;
}}
_getModulePositioningConfig={() => ({
itemsBatchSize: 6,
whitespaceThreshold: 32,
})}
_logTwoColWhitespace={
logWhitespace
? // eslint-disable-next-line no-console
Expand Down
46 changes: 34 additions & 12 deletions packages/gestalt/src/Masonry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ import FetchItems from './FetchItems';
import styles from './Masonry.css';
import { Cache } from './Masonry/Cache';
import recalcHeights from './Masonry/dynamicHeightsUtils';
import getColumnCount from './Masonry/getColumnCount';
import getLayoutAlgorithm from './Masonry/getLayoutAlgorithm';
import ItemResizeObserverWrapper from './Masonry/ItemResizeObserverWrapper';
import MeasurementStore from './Masonry/MeasurementStore';
import { ColumnSpanConfig, MULTI_COL_ITEMS_MEASURE_BATCH_SIZE } from './Masonry/multiColumnLayout';
import {
calculateActualColumnSpan,
ColumnSpanConfig,
ModulePositioningConfig,
MULTI_COL_ITEMS_MEASURE_BATCH_SIZE,
} from './Masonry/multiColumnLayout';
import ScrollContainer from './Masonry/ScrollContainer';
import { getElementHeight, getRelativeScrollTop, getScrollPos } from './Masonry/scrollUtils';
import { Align, Layout, LoadingStateItem, Position } from './Masonry/types';
Expand Down Expand Up @@ -148,11 +154,13 @@ type Props<T> = {
*/
_dynamicHeights?: boolean;
/**
* Experimental prop to enable early bailout when positioning multicolumn modules
* 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
*/
_earlyBailout?: (columnSpan: number) => number;
_getModulePositioningConfig?: (gridSize: number, moduleSize: number) => ModulePositioningConfig;
};

type State<T> = {
Expand Down Expand Up @@ -589,7 +597,7 @@ export default class Masonry<T> extends ReactComponent<Props<T>, State<T>> {
_getColumnSpanConfig,
_loadingStateItems = [],
_renderLoadingStateItems,
_earlyBailout,
_getModulePositioningConfig,
} = this.props;
const { hasPendingMeasurements, measurementStore, width } = this.state;
const { positionStore } = this;
Expand All @@ -611,7 +619,7 @@ export default class Masonry<T> extends ReactComponent<Props<T>, State<T>> {
_logTwoColWhitespace,
_loadingStateItems,
renderLoadingState,
_earlyBailout,
_getModulePositioningConfig,
});

let gridBody;
Expand Down Expand Up @@ -701,15 +709,29 @@ export default class Masonry<T> extends ReactComponent<Props<T>, State<T>> {
// Full layout is possible
const itemsToRender = items.filter((item) => item && measurementStore.has(item));
const itemsWithoutPositions = items.filter((item) => item && !positionStore.has(item));
const hasMultiColumnItems =
const nextMultiColumnItem =
_getColumnSpanConfig &&
itemsWithoutPositions.some((item) => _getColumnSpanConfig(item) !== 1);
itemsWithoutPositions.find((item) => _getColumnSpanConfig(item) !== 1);

// If there are 2-col items, we need to measure more items to ensure we have enough possible layouts to find a suitable one
// we need the batch size (number of one column items for the graph) + 1 (two column item)
const itemsToMeasureCount = hasMultiColumnItems
? MULTI_COL_ITEMS_MEASURE_BATCH_SIZE + 1
: minCols;
let batchSize;
if (nextMultiColumnItem) {
const gridSize = getColumnCount({ gutter, columnWidth, width, minCols, layout });

const moduleSize = calculateActualColumnSpan({
Comment on lines +747 to +749
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the tricky part of this implementation. These two values are easy to find in multiColumnLayout.ts, but are not ready at this scope.

  • For grid size, I created a whole new util that abstract the way we calculate the column count from both DefaultLayout and FullWidthLayout (updating those utils to avoid code duplicity too).
  • For moduleSize, I exposed the calculateActualColumnSpan from multiColumnLayout.ts to be able to use it here.

All of this happens also in MasonryV2.

columnCount: gridSize,
item: nextMultiColumnItem,
_getColumnSpanConfig,
});

const { itemsBatchSize } = _getModulePositioningConfig?.(gridSize, moduleSize) || {
itemsBatchSize: MULTI_COL_ITEMS_MEASURE_BATCH_SIZE,
};
batchSize = itemsBatchSize;
}

// If there are multicolumn items, we need to measure more items to ensure we have enough possible layouts to find a suitable one
// we need the batch size (number of one column items for the graph) + 1 (multicolumn item)
const itemsToMeasureCount = batchSize ? batchSize + 1 : minCols;
const itemsToMeasure = items
.filter((item) => item && !measurementStore.has(item))
.slice(0, itemsToMeasureCount);
Expand Down
22 changes: 17 additions & 5 deletions packages/gestalt/src/Masonry/defaultLayout.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { Cache } from './Cache';
import getColumnCount, {
DEFAULT_LAYOUT_DEFAULT_COLUMN_WIDTH,
DEFAULT_LAYOUT_DEFAULT_GUTTER,
} from './getColumnCount';
import { getHeightAndGutter, offscreen } from './layoutHelpers';
import { isLoadingStateItem, isLoadingStateItems } from './loadingStateUtils';
import mindex from './mindex';
import multiColumnLayout, { ColumnSpanConfig } from './multiColumnLayout';
import multiColumnLayout, { ColumnSpanConfig, ModulePositioningConfig } from './multiColumnLayout';
import { Align, Layout, LoadingStateItem, Position } from './types';

const calculateCenterOffset = ({
Expand Down Expand Up @@ -38,14 +42,15 @@ const calculateCenterOffset = ({
const defaultLayout =
<T>({
align,
columnWidth = 236,
gutter = 14,
columnWidth = DEFAULT_LAYOUT_DEFAULT_COLUMN_WIDTH,
gutter = DEFAULT_LAYOUT_DEFAULT_GUTTER,
layout,
minCols = 2,
rawItemCount,
width,
measurementCache,
_getColumnSpanConfig,
_getModulePositioningConfig,
renderLoadingState,
...otherProps
}: {
Expand All @@ -59,7 +64,7 @@ const defaultLayout =
positionCache: Cache<T, Position>;
measurementCache: Cache<T, number>;
_getColumnSpanConfig?: (item: T) => ColumnSpanConfig;
earlyBailout?: (columnSpan: number) => number;
_getModulePositioningConfig?: (gridSize: number, moduleSize: number) => ModulePositioningConfig;
logWhitespace?: (
additionalWhitespace: ReadonlyArray<number>,
numberOfIterations: number,
Expand All @@ -73,7 +78,13 @@ const defaultLayout =
}

const columnWidthAndGutter = columnWidth + gutter;
const columnCount = Math.max(Math.floor((width + gutter) / columnWidthAndGutter), minCols);
const columnCount = getColumnCount({
gutter,
columnWidth,
width,
minCols,
layout,
});
// the total height of each column
const heights = new Array<number>(columnCount).fill(0);

Expand All @@ -96,6 +107,7 @@ const defaultLayout =
gutter,
measurementCache,
_getColumnSpanConfig,
_getModulePositioningConfig,
...otherProps,
})
: items.map((item) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/gestalt/src/Masonry/fullWidthLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe.each([undefined, getColumnSpanConfig])(
measurementCache: measurementStore,
positionCache,
gutter: 10,
layout: 'flexible',
idealColumnWidth: 240,
minCols: 2,
width: 1000,
Expand Down Expand Up @@ -65,6 +66,7 @@ describe.each([undefined, getColumnSpanConfig])(
measurementCache: measurementStore,
positionCache,
gutter: 10,
layout: 'flexible',
idealColumnWidth: 240,
minCols: 2,
width: 1000,
Expand Down Expand Up @@ -98,6 +100,7 @@ describe.each([undefined, getColumnSpanConfig])(
measurementCache: measurementStore,
positionCache,
gutter: 10,
layout: 'flexible',
idealColumnWidth: 240,
minCols: 2,
width: 1000,
Expand Down Expand Up @@ -134,6 +137,7 @@ describe('loadingStateItems', () => {
idealColumnWidth: 240,
minCols: 2,
width: 1000,
layout: 'flexible',
_getColumnSpanConfig: getColumnSpanConfig,
renderLoadingState: true,
});
Expand Down Expand Up @@ -170,6 +174,7 @@ describe('loadingStateItems', () => {
idealColumnWidth: 240,
minCols: 2,
width: 1000,
layout: 'flexible',
_getColumnSpanConfig: getColumnSpanConfig,
});

Expand Down
30 changes: 20 additions & 10 deletions packages/gestalt/src/Masonry/fullWidthLayout.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,35 @@
import { Cache } from './Cache';
import getColumnCount, {
FULL_WIDTH_DEFAULT_GUTTER,
FULL_WIDTH_LAYOUT_DEFAULT_IDEAL_COLUMN_WIDTH,
} from './getColumnCount';
import { getHeightAndGutter } from './layoutHelpers';
import { isLoadingStateItem, isLoadingStateItems } from './loadingStateUtils';
import mindex from './mindex';
import multiColumnLayout, { ColumnSpanConfig } from './multiColumnLayout';
import { LoadingStateItem, Position } from './types';
import multiColumnLayout, { ColumnSpanConfig, ModulePositioningConfig } from './multiColumnLayout';
import { Layout, LoadingStateItem, Position } from './types';

const fullWidthLayout = <T>({
width,
idealColumnWidth = 240,
gutter = 0,
idealColumnWidth = FULL_WIDTH_LAYOUT_DEFAULT_IDEAL_COLUMN_WIDTH,
gutter = FULL_WIDTH_DEFAULT_GUTTER,
minCols = 2,
layout,
measurementCache,
_getColumnSpanConfig,
_getModulePositioningConfig,
renderLoadingState,
...otherProps
}: {
idealColumnWidth?: number;
gutter?: number;
minCols?: number;
layout: Layout;
width?: number | null | undefined;
positionCache: Cache<T, Position>;
measurementCache: Cache<T, number>;
_getColumnSpanConfig?: (item: T) => ColumnSpanConfig;
earlyBailout?: (columnSpan: number) => number;
_getModulePositioningConfig?: (gridSize: number, moduleSize: number) => ModulePositioningConfig;
logWhitespace?: (
additionalWhitespace: ReadonlyArray<number>,
numberOfIterations: number,
Expand All @@ -40,11 +47,13 @@ const fullWidthLayout = <T>({
}));
}

// "This is kind of crazy!" - you
// Yes, indeed. The "guessing" here is meant to replicate the pass that the
// original implementation takes with CSS.
const colguess = Math.floor(width / idealColumnWidth);
const columnCount = Math.max(Math.floor((width - colguess * gutter) / idealColumnWidth), minCols);
const columnCount = getColumnCount({
gutter,
columnWidth: idealColumnWidth,
width,
minCols,
layout,
});
const columnWidth = Math.floor(width / columnCount) - gutter;
const columnWidthAndGutter = columnWidth + gutter;
const centerOffset = gutter / 2;
Expand All @@ -60,6 +69,7 @@ const fullWidthLayout = <T>({
gutter,
measurementCache,
_getColumnSpanConfig,
_getModulePositioningConfig,
...otherProps,
})
: items.reduce<Array<any>>((acc, item) => {
Expand Down
11 changes: 6 additions & 5 deletions packages/gestalt/src/Masonry/getLayoutAlgorithm.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Cache } from './Cache';
import defaultLayout from './defaultLayout';
import fullWidthLayout from './fullWidthLayout';
import { ColumnSpanConfig } from './multiColumnLayout';
import { ColumnSpanConfig, ModulePositioningConfig } from './multiColumnLayout';
import { Align, Layout, LoadingStateItem, Position } from './types';
import uniformRowLayout from './uniformRowLayout';

Expand All @@ -19,7 +19,7 @@ export default function getLayoutAlgorithm<T>({
_logTwoColWhitespace,
_loadingStateItems = [],
renderLoadingState,
_earlyBailout,
_getModulePositioningConfig,
}: {
align: Align;
columnWidth: number | undefined;
Expand All @@ -31,27 +31,28 @@ export default function getLayoutAlgorithm<T>({
positionStore: Cache<T, Position>;
width: number | null | undefined;
_getColumnSpanConfig?: (item: T) => ColumnSpanConfig;
_getModulePositioningConfig?: (gridSize: number, moduleSize: number) => ModulePositioningConfig;
_logTwoColWhitespace?: (
additionalWhitespace: ReadonlyArray<number>,
numberOfIterations: number,
columnSpan: number,
) => void;
_loadingStateItems?: ReadonlyArray<LoadingStateItem>;
renderLoadingState?: boolean;
_earlyBailout?: (columnSpan: number) => number;
}): (items: ReadonlyArray<T> | ReadonlyArray<LoadingStateItem>) => ReadonlyArray<Position> {
if ((layout === 'flexible' || layout === 'serverRenderedFlexible') && width !== null) {
return fullWidthLayout({
gutter,
layout,
measurementCache: measurementStore,
positionCache: positionStore,
minCols,
idealColumnWidth: columnWidth,
width,
logWhitespace: _logTwoColWhitespace,
_getColumnSpanConfig,
_getModulePositioningConfig,
renderLoadingState,
earlyBailout: _earlyBailout,
});
}
if (layout.startsWith('uniformRow')) {
Expand All @@ -77,7 +78,7 @@ export default function getLayoutAlgorithm<T>({
rawItemCount: renderLoadingState ? _loadingStateItems.length : items.length,
width,
_getColumnSpanConfig,
_getModulePositioningConfig,
renderLoadingState,
earlyBailout: _earlyBailout,
});
}
12 changes: 7 additions & 5 deletions packages/gestalt/src/Masonry/multiColumnLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,11 +334,13 @@ describe('multi column layout test cases', () => {

const gutter = 5;

const earlyBailout = (columnSpan: number) => {
if (columnSpan <= 3) {
return 2 * gutter;
const getModulePositioningConfig = (_: number, moduleSize: number) => {
let whitespaceThreshold = 3 * gutter;
if (moduleSize <= 3) {
whitespaceThreshold = 2 * gutter;
}
return 3 * gutter;

return { itemsBatchSize: 5, whitespaceThreshold };
};

const layout = (itemsToLayout: readonly Item[]) =>
Expand All @@ -350,7 +352,7 @@ describe('multi column layout test cases', () => {
centerOffset: 20,
measurementCache: measurementStore,
positionCache,
earlyBailout,
_getModulePositioningConfig: getModulePositioningConfig,
_getColumnSpanConfig: getColumnSpanConfig,
});

Expand Down
Loading
Loading