Skip to content

Commit 9affd26

Browse files
committed
Additional performance improvements
1 parent f1d3a8d commit 9affd26

File tree

15 files changed

+295
-85
lines changed

15 files changed

+295
-85
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"@babel/preset-react": "^7.24.1",
7575
"@babel/preset-typescript": "^7.24.1",
7676
"@babel/register": "^7.23.7",
77+
"@faker-js/faker": "^8.4.1",
7778
"@octokit/rest": "*",
7879
"@parcel/bundler-library": "2.11.1-dev.3224",
7980
"@parcel/optimizer-data-url": "2.0.0-dev.1601",

packages/@react-aria/utils/src/platform.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,40 +26,54 @@ function testPlatform(re: RegExp) {
2626
: false;
2727
}
2828

29-
export function isMac() {
30-
return testPlatform(/^Mac/i);
29+
function cached(fn: () => boolean) {
30+
if (process.env.NODE_ENV === 'test') {
31+
return fn;
32+
}
33+
34+
let res: boolean | null = null;
35+
return () => {
36+
if (res == null) {
37+
res = fn();
38+
}
39+
return res;
40+
};
3141
}
3242

33-
export function isIPhone() {
43+
export const isMac = cached(function () {
44+
return testPlatform(/^Mac/i);
45+
});
46+
47+
export const isIPhone = cached(function () {
3448
return testPlatform(/^iPhone/i);
35-
}
49+
});
3650

37-
export function isIPad() {
51+
export const isIPad = cached(function () {
3852
return testPlatform(/^iPad/i) ||
3953
// iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
4054
(isMac() && navigator.maxTouchPoints > 1);
41-
}
55+
});
4256

43-
export function isIOS() {
57+
export const isIOS = cached(function () {
4458
return isIPhone() || isIPad();
45-
}
59+
});
4660

47-
export function isAppleDevice() {
61+
export const isAppleDevice = cached(function () {
4862
return isMac() || isIOS();
49-
}
63+
});
5064

51-
export function isWebKit() {
65+
export const isWebKit = cached(function () {
5266
return testUserAgent(/AppleWebKit/i) && !isChrome();
53-
}
67+
});
5468

55-
export function isChrome() {
69+
export const isChrome = cached(function () {
5670
return testUserAgent(/Chrome/i);
57-
}
71+
});
5872

59-
export function isAndroid() {
73+
export const isAndroid = cached(function () {
6074
return testUserAgent(/Android/i);
61-
}
75+
});
6276

63-
export function isFirefox() {
77+
export const isFirefox = cached(function () {
6478
return testUserAgent(/Firefox/i);
65-
}
79+
});

packages/@react-spectrum/card/test/CardView.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1260,7 +1260,7 @@ describe('CardView', function () {
12601260
let cards = tree.getAllByRole('gridcell');
12611261
expect(cards).toBeTruthy();
12621262
let grid = tree.getByRole('grid');
1263-
await user.click(cards[cards.length - 1]);
1263+
await user.click(cards[4]);
12641264
act(() => {
12651265
jest.runAllTimers();
12661266
});

packages/@react-spectrum/listbox/test/ListBox.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -850,7 +850,7 @@ describe('ListBox', function () {
850850

851851
let listbox = getByRole('listbox');
852852
let options = within(listbox).getAllByRole('option');
853-
expect(options.length).toBe(5); // each row is 48px tall, listbox is 200px. 5 rows fit.
853+
expect(options.length).toBe(6); // each row is 48px tall, listbox is 200px. 5 rows fit. + 1/3 overscan
854854

855855
listbox.scrollTop = 250;
856856
fireEvent.scroll(listbox);

packages/@react-spectrum/table/src/TableViewBase.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,7 +1375,7 @@ function TableCellWrapper({layoutInfo, virtualizer, parent, children}) {
13751375
virtualizer={virtualizer}
13761376
parent={parent?.layoutInfo}
13771377
className={
1378-
classNames(
1378+
useMemo(() => classNames(
13791379
styles,
13801380
'spectrum-Table-cellWrapper',
13811381
classNames(
@@ -1385,7 +1385,7 @@ function TableCellWrapper({layoutInfo, virtualizer, parent, children}) {
13851385
'react-spectrum-Table-cellWrapper--dropTarget': isDropTarget || isRootDroptarget
13861386
}
13871387
)
1388-
)
1388+
), [layoutInfo.estimatedSize, isDropTarget, isRootDroptarget])
13891389
}>
13901390
{children}
13911391
</VirtualizerItem>
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import {ActionButton} from '@react-spectrum/button';
2+
import {Cell, Column, Row, TableBody, TableHeader, TableView} from '../src';
3+
import {Checkbox} from '@react-spectrum/checkbox';
4+
import {faker} from '@faker-js/faker';
5+
import {Icon} from '@react-spectrum/icon';
6+
import {Item, TagGroup} from '@react-spectrum/tag';
7+
import {Link} from '@react-spectrum/link';
8+
import {ProgressBar} from '@react-spectrum/progress';
9+
import React from 'react';
10+
import {SpectrumStatusLightProps} from '@react-types/statuslight';
11+
import {Text} from '@react-spectrum/text';
12+
13+
export const DATA_SIZE = 10000;
14+
export const COLUMN_SIZE = 50;
15+
export const TABLE_HEIGHT = 600;
16+
17+
const columnDefinitions = [
18+
{name: 'Airline', type: 'TEXT'},
19+
{name: 'Destinations', type: 'TAGS'},
20+
{name: 'Scheduled At', type: 'DATETIME'},
21+
{name: 'Status', type: 'STATUS'},
22+
{name: 'Rating', type: 'RATING'},
23+
{name: 'Progress', type: 'PROGRESS'},
24+
{name: 'URL', type: 'URL'},
25+
{name: 'Overbooked', type: 'CHECKBOX'},
26+
{name: 'Take Action', type: 'BUTTON'}
27+
] as const;
28+
29+
export const flightStatuses = {
30+
ON_SCHEDULE: 'On Schedule',
31+
DELAYED: 'Delayed',
32+
CANCELLED: 'Cancelled',
33+
BOARDING: 'Boarding'
34+
};
35+
36+
export const flightStatusVariant: Record<
37+
keyof typeof flightStatuses,
38+
SpectrumStatusLightProps['variant']
39+
> = {
40+
ON_SCHEDULE: 'positive',
41+
DELAYED: 'notice',
42+
CANCELLED: 'negative',
43+
BOARDING: 'info'
44+
};
45+
46+
const getData = (rowNumber: number, columnNumber: number) => {
47+
const columns = columnDefinitions.concat([...Array(columnNumber - columnDefinitions.length)].map(() =>
48+
faker.helpers.arrayElement(columnDefinitions)
49+
));
50+
return {
51+
columns,
52+
data: [...Array(rowNumber)].map(() => {
53+
return columns.map((column) => {
54+
switch (column.type) {
55+
case 'TEXT':
56+
return {rawValue: faker.airline.airline().name};
57+
case 'URL': {
58+
const url = faker.internet.url();
59+
return {rawValue: url, url};
60+
}
61+
case 'TAGS': {
62+
const airports = faker.helpers
63+
.multiple(faker.airline.airport, {
64+
count: {min: 1, max: 7}
65+
})
66+
.map((airport) => airport.iataCode);
67+
return {rawValue: airports.join(', '), data: airports};
68+
}
69+
case 'STATUS': {
70+
const [flightKey, flightStatus] =
71+
faker.helpers.objectEntry(flightStatuses);
72+
return {
73+
rawValue: flightStatus,
74+
variant: flightStatusVariant[flightKey]
75+
};
76+
}
77+
case 'DATETIME':
78+
return {rawValue: faker.date.future()};
79+
case 'RATING': {
80+
const rating = faker.number.int({min: 0, max: 5});
81+
return {rawValue: rating, data: rating};
82+
}
83+
case 'PROGRESS': {
84+
const progress = faker.number.int({min: 0, max: 100});
85+
return {rawValue: progress, data: progress};
86+
}
87+
case 'CHECKBOX':
88+
return {rawValue: faker.datatype.boolean()};
89+
case 'BUTTON':
90+
return {rawValue: 'View Details'};
91+
}
92+
});
93+
})
94+
};
95+
};
96+
97+
export function Performance() {
98+
const {data, columns} = getData(DATA_SIZE, COLUMN_SIZE);
99+
return (
100+
<TableView
101+
height={TABLE_HEIGHT}
102+
width={800}
103+
aria-label="Example table with static contents"
104+
selectionMode="multiple">
105+
<TableHeader>
106+
{columns.map((col, i) => (
107+
<Column key={`col-${i}`}>{col.name}</Column>
108+
))}
109+
</TableHeader>
110+
<TableBody>
111+
{data.map((row, rowId) => (
112+
<Row key={rowId}>
113+
{row.map((cell, colIndex) => {
114+
const cellId = `row${rowId}-col${colIndex}`;
115+
switch (columns[colIndex].type) {
116+
case 'TEXT':
117+
return <Cell key={cellId}>{cell.rawValue as string}</Cell>;
118+
case 'DATETIME':
119+
return (
120+
<Cell key={cellId}>{cell.rawValue.toLocaleString()}</Cell>
121+
);
122+
case 'STATUS':
123+
return (
124+
<Cell key={cellId}>
125+
<TagGroup aria-label="Flight Status">
126+
<Item textValue={cell.rawValue as string}>
127+
<CircleIcon color={cell.variant} />
128+
<Text>{cell.rawValue as string}</Text>
129+
</Item>
130+
</TagGroup>
131+
</Cell>
132+
);
133+
case 'URL':
134+
return (
135+
<Cell key={cellId}>
136+
<Link href={cell.url}>{cell.rawValue as string}</Link>
137+
</Cell>
138+
);
139+
case 'BUTTON':
140+
return (
141+
<Cell key={cellId}>
142+
<ActionButton>{cell.rawValue as string}</ActionButton>
143+
</Cell>
144+
);
145+
case 'CHECKBOX':
146+
return (
147+
<Cell key={cellId}>
148+
<Checkbox isEmphasized defaultSelected={cell.rawValue as boolean} aria-label="checkbox" />
149+
</Cell>
150+
);
151+
case 'TAGS':
152+
return (
153+
<Cell key={cellId}>
154+
<TagGroup aria-label="Cities">
155+
{(cell.data as string[]).map((tag, tagId) => (
156+
<Item key={tagId} textValue={tag}>
157+
<Text>{tag}</Text>
158+
</Item>
159+
))}
160+
</TagGroup>
161+
</Cell>
162+
);
163+
case 'RATING':
164+
return (
165+
<Cell key={cellId}>
166+
{[...Array(5)].map((_, rate) => (
167+
<StarIcon key={rate} isHidden={rate > (data as any).rate} />
168+
))}
169+
</Cell>
170+
);
171+
case 'PROGRESS':
172+
return (
173+
<Cell key={cellId}>
174+
<ProgressBar value={cell.data as number} aria-label="Progress" />
175+
</Cell>
176+
);
177+
default:
178+
return <Cell> </Cell>;
179+
}
180+
})}
181+
</Row>
182+
))}
183+
</TableBody>
184+
</TableView>
185+
);
186+
}
187+
188+
const CircleIcon = (props) => (
189+
<Icon {...props}>
190+
<svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
191+
<circle r="18" cx="18" cy="18" />
192+
</svg>
193+
</Icon>
194+
);
195+
196+
const StarIcon = (props) => {
197+
return (
198+
<Icon {...props}>
199+
<svg viewBox="0 0 36 36">
200+
<path d="M18.477.593,22.8,12.029l12.212.578a.51.51,0,0,1,.3.908l-9.54,7.646,3.224,11.793a.51.51,0,0,1-.772.561L18,26.805,7.78,33.515a.51.51,0,0,1-.772-.561l3.224-11.793L.692,13.515a.51.51,0,0,1,.3-.908L13.2,12.029,17.523.593A.51.51,0,0,1,18.477.593Z" />
201+
</svg>
202+
</Icon>
203+
);
204+
};

packages/@react-spectrum/table/stories/Table.stories.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2082,3 +2082,5 @@ function LoadingTable() {
20822082
</TableView>
20832083
);
20842084
}
2085+
2086+
export {Performance} from './Performance';

packages/@react-spectrum/table/test/Table.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4193,7 +4193,7 @@ export let tableTests = () => {
41934193
let scrollView = body.parentNode.parentNode;
41944194

41954195
let rows = within(body).getAllByRole('row');
4196-
expect(rows).toHaveLength(25); // each row is 41px tall. table is 1000px tall. 25 rows fit.
4196+
expect(rows).toHaveLength(34); // each row is 41px tall. table is 1000px tall. 25 rows fit. + 1/3 overscan
41974197

41984198
scrollView.scrollTop = 250;
41994199
fireEvent.scroll(scrollView);

packages/@react-stately/layout/src/ListLayout.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@ export class ListLayout<T> extends Layout<Node<T>, ListLayoutProps> implements K
110110
}
111111

112112
getVisibleLayoutInfos(rect: Rect) {
113+
// Adjust rect to keep number of visible rows consistent.
114+
// (only if height > 1 for getDropTargetFromPoint)
115+
if (rect.height > 1) {
116+
let rowHeight = (this.rowHeight ?? this.estimatedRowHeight);
117+
rect.y = Math.floor(rect.y / rowHeight) * rowHeight;
118+
rect.height = Math.ceil(rect.height / rowHeight) * rowHeight;
119+
}
120+
113121
// If layout hasn't yet been done for the requested rect, union the
114122
// new rect with the existing valid rect, and recompute.
115123
this.layoutIfNeeded(rect);

packages/@react-stately/layout/src/TableLayout.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,14 @@ export class TableLayout<T> extends ListLayout<T> {
404404
}
405405

406406
getVisibleLayoutInfos(rect: Rect) {
407+
// Adjust rect to keep number of visible rows consistent.
408+
// (only if height > 1 for getDropTargetFromPoint)
409+
if (rect.height > 1) {
410+
let rowHeight = (this.rowHeight ?? this.estimatedRowHeight) + 1; // +1 for border
411+
rect.y = Math.floor(rect.y / rowHeight) * rowHeight;
412+
rect.height = Math.ceil(rect.height / rowHeight) * rowHeight;
413+
}
414+
407415
// If layout hasn't yet been done for the requested rect, union the
408416
// new rect with the existing valid rect, and recompute.
409417
if (!this.validRect.containsRect(rect) && this.lastCollection) {
@@ -522,7 +530,7 @@ export class TableLayout<T> extends ListLayout<T> {
522530
let mid = (low + high) >> 1;
523531
let item = items[mid];
524532

525-
if ((axis === 'x' && item.layoutInfo.rect.maxX < point.x) || (axis === 'y' && item.layoutInfo.rect.maxY < point.y)) {
533+
if ((axis === 'x' && item.layoutInfo.rect.maxX <= point.x) || (axis === 'y' && item.layoutInfo.rect.maxY <= point.y)) {
526534
low = mid + 1;
527535
} else if ((axis === 'x' && item.layoutInfo.rect.x > point.x) || (axis === 'y' && item.layoutInfo.rect.y > point.y)) {
528536
high = mid - 1;

0 commit comments

Comments
 (0)