Skip to content

Commit 072a0b8

Browse files
andrewserongramonjdntsekouras
authored andcommitted
DataViews: Measure the container as the data view mounts to avoid a single column flash (WordPress#83752)
* DataViews: Measure the grid as it attaches to avoid a one-column flash The grid layout derives its column count from the container width, which DataViews only learned from a resize observer. That observer is attached to the grid element itself, and that element renders only once there is data, so the first render with data always ran at width 0. Clamped to a minimum of one, that laid every item out in a full-width column of its own, at the media field's square aspect ratio, before the observer reported and the grid snapped to its real column count. Measure the element as it attaches instead, during the commit phase, so the first paint already has the width. The observer still keeps it current as the element resizes. Both DataViews and DataViewsPicker duplicated the measurement, so it moves into a shared `useContainerWidth` hook. * Update changelog entry * Simplify comments * Move changelog entry * Floor the inlineSize so that it's an integer and more closely matches offsetWidth * Simplify test * Move to a browser test --------- Co-authored-by: andrewserong <andrewserong@git.wordpress.org> Co-authored-by: ramonjd <ramonopoly@git.wordpress.org> Co-authored-by: ntsekouras <ntsekouras@git.wordpress.org>
1 parent 2e7edde commit 072a0b8

6 files changed

Lines changed: 127 additions & 20 deletions

File tree

‎packages/dataviews/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212

1313
- Keep filter and item actions menus dismissible when their triggers become disabled ([#83791](https://github.com/WordPress/gutenberg/pull/83791)).
1414

15+
### Bug Fixes
16+
17+
- Grid layout: measure the container as the grid attaches rather than waiting for the resize observer's first report, so the grid no longer renders a frame with every item in a full-width column of its own before snapping to its real column count ([#83752](https://github.com/WordPress/gutenberg/pull/83752)).
18+
1519
### Documentation
1620

1721
- Document that a custom `sort` receives the field values returned by `getValue`, not the items ([#83483](https://github.com/WordPress/gutenberg/pull/83483)).

‎packages/dataviews/src/dataviews-picker/index.tsx‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
useRef,
88
useState,
99
} from '@wordpress/element';
10-
import { useResizeObserver } from '@wordpress/compose';
1110
import { Stack } from '@wordpress/ui';
1211
import DataViewsContext from '../components/dataviews-context';
1312
import { VIEW_LAYOUTS } from '../components/dataviews-layouts';
@@ -34,6 +33,7 @@ import DataViewsViewConfig, {
3433
ViewTypeMenu,
3534
} from '../components/dataviews-view-config';
3635
import normalizeFields from '../field-types';
36+
import useContainerWidth from '../hooks/use-container-width';
3737
import useData from '../hooks/use-data';
3838
import { useInfiniteScroll } from '../hooks/use-infinite-scroll';
3939
import usePageClamp from '../hooks/use-page-clamp';
@@ -261,15 +261,7 @@ function DataViewsPicker< Item >( {
261261
setVisibleEntries?: React.Dispatch< React.SetStateAction< number[] > >;
262262
};
263263
const containerRef = useRef< HTMLDivElement >( null );
264-
const [ containerWidth, setContainerWidth ] = useState( 0 );
265-
const resizeObserverRef = useResizeObserver(
266-
( resizeObserverEntries: any ) => {
267-
setContainerWidth(
268-
resizeObserverEntries[ 0 ].borderBoxSize[ 0 ].inlineSize
269-
);
270-
},
271-
{ box: 'border-box' }
272-
);
264+
const [ containerWidth, resizeObserverRef ] = useContainerWidth();
273265
const [ openedFilter, setOpenedFilter ] = useState< string | null >( null );
274266
function setSelectionWithChange( value: SelectionOrUpdater ) {
275267
const newValue =

‎packages/dataviews/src/dataviews/index.tsx‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
useRef,
77
useState,
88
} from '@wordpress/element';
9-
import { useResizeObserver } from '@wordpress/compose';
109
import { Stack } from '@wordpress/ui';
1110
import DataViewsContext from '../components/dataviews-context';
1211
import { VIEW_LAYOUTS } from '../components/dataviews-layouts';
@@ -26,6 +25,7 @@ import DataViewsViewConfig, {
2625
ViewTypeMenu,
2726
} from '../components/dataviews-view-config';
2827
import normalizeFields from '../field-types';
28+
import useContainerWidth from '../hooks/use-container-width';
2929
import useData from '../hooks/use-data';
3030
import { useInfiniteScroll } from '../hooks/use-infinite-scroll';
3131
import usePageClamp from '../hooks/use-page-clamp';
@@ -136,15 +136,7 @@ function DataViews< Item >( {
136136
setVisibleEntries?: React.Dispatch< React.SetStateAction< number[] > >;
137137
};
138138
const containerRef = useRef< HTMLDivElement >( null );
139-
const [ containerWidth, setContainerWidth ] = useState( 0 );
140-
const resizeObserverRef = useResizeObserver(
141-
( resizeObserverEntries: any ) => {
142-
setContainerWidth(
143-
resizeObserverEntries[ 0 ].borderBoxSize[ 0 ].inlineSize
144-
);
145-
},
146-
{ box: 'border-box' }
147-
);
139+
const [ containerWidth, resizeObserverRef ] = useContainerWidth();
148140
const [ openedFilter, setOpenedFilter ] = useState< string | null >( null );
149141
function setSelectionWithChange( value: SelectionOrUpdater ) {
150142
const newValue =
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { screen, waitFor } from '@testing-library/react';
2+
import { render } from 'vitest-browser-react';
3+
import { describe, expect, it } from 'vitest';
4+
import { useLayoutEffect } from '@wordpress/element';
5+
import DataViews from '../index';
6+
import { LAYOUT_GRID } from '../../constants';
7+
import type { View } from '../../types';
8+
9+
type Data = {
10+
id: number;
11+
title: string;
12+
};
13+
14+
const view: View = {
15+
type: LAYOUT_GRID,
16+
search: '',
17+
page: 1,
18+
perPage: 10,
19+
layout: {},
20+
filters: [],
21+
fields: [ 'title' ],
22+
titleField: 'title',
23+
};
24+
25+
const data: Data[] = [
26+
{ id: 1, title: 'First item' },
27+
{ id: 2, title: 'Second item' },
28+
{ id: 3, title: 'Third item' },
29+
];
30+
31+
// Counts the grid rows in the first animation frame after mount. Animation
32+
// frame callbacks run before resize observer callbacks, so this is the layout
33+
// the browser paints first.
34+
function FirstFrameRowCount( {
35+
onCount,
36+
}: {
37+
onCount: ( count: number ) => void;
38+
} ) {
39+
useLayoutEffect( () => {
40+
requestAnimationFrame( () => {
41+
onCount( screen.queryAllByRole( 'row' ).length );
42+
} );
43+
}, [ onCount ] );
44+
return null;
45+
}
46+
47+
describe( 'DataViews grid columns', () => {
48+
it( 'paints the grid at its real column count on the first frame', async () => {
49+
let firstFrameRowCount: number | undefined;
50+
51+
// A 600px wide container fits two columns at the default 230px preview
52+
// size, so the three items render as two rows.
53+
await render(
54+
<div style={ { width: 600 } }>
55+
<DataViews< Data >
56+
view={ view }
57+
onChangeView={ () => {} }
58+
fields={ [ { id: 'title', label: 'Title', type: 'text' } ] }
59+
data={ data }
60+
getItemId={ ( item ) => item.id.toString() }
61+
paginationInfo={ { totalItems: 3, totalPages: 1 } }
62+
defaultLayouts={ { [ LAYOUT_GRID ]: true } }
63+
/>
64+
<FirstFrameRowCount
65+
onCount={ ( count ) => {
66+
if ( firstFrameRowCount === undefined ) {
67+
firstFrameRowCount = count;
68+
}
69+
} }
70+
/>
71+
</div>
72+
);
73+
74+
await waitFor( () => expect( firstFrameRowCount ).toBe( 2 ) );
75+
76+
// The observer reports the same width, so nothing changes.
77+
expect( screen.getAllByRole( 'row' ) ).toHaveLength( 2 );
78+
} );
79+
} );

‎packages/dataviews/src/hooks/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { default as useContainerWidth } from './use-container-width';
12
export { default as useFormValidity } from './use-form-validity';
23
export { default as useData } from './use-data';
34
export { useInfiniteScroll } from './use-infinite-scroll';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useCallback, useState } from '@wordpress/element';
2+
import { useMergeRefs, useResizeObserver } from '@wordpress/compose';
3+
4+
/**
5+
* Tracks the width of the element the returned ref is attached to.
6+
*
7+
* The element is measured when it attaches, so a width is available for the
8+
* first paint. The resize observer keeps the width up to date from then on.
9+
*
10+
* Detaching leaves the last width in place; reattaching measures again.
11+
*
12+
* @return The element's width, and the ref to attach to it.
13+
*/
14+
export default function useContainerWidth< T extends HTMLElement >(): [
15+
number,
16+
( element?: T | null ) => void,
17+
] {
18+
const [ width, setWidth ] = useState( 0 );
19+
20+
// `inlineSize` is only available on an observer entry, so it can't be read
21+
// here. `offsetWidth` reports the same border box and, like
22+
// `borderBoxSize`, is unaffected by transforms.
23+
const measureRef = useCallback( ( element?: T | null ) => {
24+
if ( element ) {
25+
setWidth( element.offsetWidth );
26+
}
27+
}, [] );
28+
29+
const observerRef = useResizeObserver< T >(
30+
( entries ) => {
31+
setWidth(
32+
Math.floor( entries[ 0 ].borderBoxSize[ 0 ].inlineSize )
33+
);
34+
},
35+
{ box: 'border-box' }
36+
);
37+
38+
return [ width, useMergeRefs( [ measureRef, observerRef ] ) ];
39+
}

0 commit comments

Comments
 (0)