Skip to content

Commit 5ae5255

Browse files
authored
perf(ui): download only images and optimize image selection for document edit view, prioritize best-fit size (#11844)
### What? In the same vein as #11696, this PR optimizes how images are selected for display in the document edit view. It ensures that only image files are processed and selects the most appropriate size to minimize unnecessary downloads and improve performance. #### Previously: - Non-image files were being processed unnecessarily, despite not generating thumbnails. - Images without a `thumbnailURL` defaulted to their original full size, even when smaller, optimized versions were available. #### Now: - **Only images** are processed for thumbnails, avoiding redundant requests for non-images. - **The smallest available image within a target range** (`40px - 180px`) is prioritized for display. - **If no images fit within this range**, the logic selects: - The next smallest larger image (if available). - The **original** image if it is smaller than the next available larger size. - The largest **smaller** image if no better fit exists. ### Why? Prevents unnecessary downloads of non-image files, reduces bandwidth usage by selecting more efficient image sizes and improves load times and performance in the edit view. ### How? - **Filters out non-image files** when determining which assets to display. - Uses the same algorithm as in #11696 but turns it into a reusable function to be used in various areas around the codebase. Namely the upload field hasOne and hasMany components. Before (4.5mb transfer): ![edit-view-before](https://github.com/user-attachments/assets/ff3513b7-b874-48c3-bce7-8a9425243e00) After (15.9kb transfer): ![edit-view-after](https://github.com/user-attachments/assets/fce8c463-65ae-4f1d-81b5-8781e89f06f1)
1 parent 98e4db0 commit 5ae5255

File tree

8 files changed

+217
-50
lines changed

8 files changed

+217
-50
lines changed

packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx

+10-44
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,15 @@ import type {
66
UploadFieldClient,
77
} from 'payload'
88

9+
import { isImage } from 'payload/shared'
910
import React from 'react'
1011

11-
import { Thumbnail } from '../../../../Thumbnail/index.js'
1212
import './index.scss'
13+
import { getBestFitFromSizes } from '../../../../../utilities/getBestFitFromSizes.js'
14+
import { Thumbnail } from '../../../../Thumbnail/index.js'
1315

1416
const baseClass = 'file'
1517

16-
const targetThumbnailSizeMin = 40
17-
const targetThumbnailSizeMax = 180
18-
1918
export interface FileCellProps
2019
extends DefaultCellComponentProps<TextFieldClient | UploadFieldClient> {
2120
readonly collectionConfig: ClientCollectionConfig
@@ -33,46 +32,13 @@ export const FileCell: React.FC<FileCellProps> = ({
3332
if (previewAllowed) {
3433
let fileSrc: string | undefined = rowData?.thumbnailURL ?? rowData?.url
3534

36-
if (
37-
rowData?.url &&
38-
!rowData?.thumbnailURL &&
39-
typeof rowData?.mimeType === 'string' &&
40-
rowData?.mimeType.startsWith('image') &&
41-
rowData?.sizes
42-
) {
43-
const sizes = Object.values<{ url?: string; width?: number }>(rowData.sizes)
44-
45-
const bestFit = sizes.reduce(
46-
(closest, current) => {
47-
if (!current.width || current.width < targetThumbnailSizeMin) {
48-
return closest
49-
}
50-
51-
if (current.width >= targetThumbnailSizeMin && current.width <= targetThumbnailSizeMax) {
52-
return !closest.width ||
53-
current.width < closest.width ||
54-
closest.width < targetThumbnailSizeMin ||
55-
closest.width > targetThumbnailSizeMax
56-
? current
57-
: closest
58-
}
59-
60-
if (
61-
!closest.width ||
62-
(!closest.original &&
63-
closest.width < targetThumbnailSizeMin &&
64-
current.width > closest.width) ||
65-
(closest.width > targetThumbnailSizeMax && current.width < closest.width)
66-
) {
67-
return current
68-
}
69-
70-
return closest
71-
},
72-
{ original: true, url: rowData?.url, width: rowData?.width },
73-
)
74-
75-
fileSrc = bestFit.url || fileSrc
35+
if (isImage(rowData?.mimeType)) {
36+
fileSrc = getBestFitFromSizes({
37+
sizes: rowData?.sizes,
38+
thumbnailURL: rowData?.thumbnailURL,
39+
url: rowData?.url,
40+
width: rowData?.width,
41+
})
7642
}
7743

7844
return (

packages/ui/src/fields/Upload/HasMany/index.tsx

+15-2
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ import { UploadCard } from '../UploadCard/index.js'
1111

1212
const baseClass = 'upload upload--has-many'
1313

14-
import type { ReloadDoc } from '../types.js'
14+
import { isImage } from 'payload/shared'
1515

1616
import './index.scss'
1717

18+
import type { ReloadDoc } from '../types.js'
19+
20+
import { getBestFitFromSizes } from '../../../utilities/getBestFitFromSizes.js'
21+
1822
type Props = {
1923
readonly className?: string
2024
readonly displayPreview?: boolean
@@ -96,6 +100,15 @@ export function UploadComponentHasMany(props: Props) {
96100
}
97101
}
98102

103+
if (isImage(value.mimeType)) {
104+
thumbnailSrc = getBestFitFromSizes({
105+
sizes: value.sizes,
106+
thumbnailURL: thumbnailSrc,
107+
url: src,
108+
width: value.width,
109+
})
110+
}
111+
99112
return (
100113
<DraggableSortableItem disabled={!isSortable || readonly} id={id} key={id}>
101114
{(draggableSortableItemProps) => (
@@ -137,7 +150,7 @@ export function UploadComponentHasMany(props: Props) {
137150
onRemove={() => removeItem(index)}
138151
reloadDoc={reloadDoc}
139152
src={src}
140-
thumbnailSrc={thumbnailSrc || src}
153+
thumbnailSrc={thumbnailSrc}
141154
withMeta={false}
142155
x={value?.width as number}
143156
y={value?.height as number}

packages/ui/src/fields/Upload/HasOne/index.tsx

+13-2
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import type { JsonObject } from 'payload'
44

5+
import { isImage } from 'payload/shared'
56
import React from 'react'
67

78
import type { ReloadDoc } from '../types.js'
89

10+
import { getBestFitFromSizes } from '../../../utilities/getBestFitFromSizes.js'
11+
import './index.scss'
912
import { RelationshipContent } from '../RelationshipContent/index.js'
1013
import { UploadCard } from '../UploadCard/index.js'
11-
import './index.scss'
1214

1315
const baseClass = 'upload upload--has-one'
1416

@@ -49,6 +51,15 @@ export function UploadComponentHasOne(props: Props) {
4951
}
5052
}
5153

54+
if (isImage(value.mimeType)) {
55+
thumbnailSrc = getBestFitFromSizes({
56+
sizes: value.sizes,
57+
thumbnailURL: thumbnailSrc,
58+
url: src,
59+
width: value.width,
60+
})
61+
}
62+
5263
return (
5364
<UploadCard className={[baseClass, className].filter(Boolean).join(' ')}>
5465
<RelationshipContent
@@ -64,7 +75,7 @@ export function UploadComponentHasOne(props: Props) {
6475
onRemove={onRemove}
6576
reloadDoc={reloadDoc}
6677
src={src}
67-
thumbnailSrc={thumbnailSrc || src}
78+
thumbnailSrc={thumbnailSrc}
6879
x={value?.width as number}
6980
y={value?.height as number}
7081
/>

packages/ui/src/fields/Upload/RelationshipContent/index.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import type { TypeWithID } from 'payload'
44

5-
import { formatFilesize } from 'payload/shared'
5+
import { formatFilesize, isImage } from 'payload/shared'
66
import React from 'react'
77

88
import type { ReloadDoc } from '../types.js'
@@ -99,7 +99,7 @@ export function RelationshipContent(props: Props) {
9999
alt={alt}
100100
className={`${baseClass}__thumbnail`}
101101
filename={filename}
102-
fileSrc={thumbnailSrc}
102+
fileSrc={isImage(mimeType) && thumbnailSrc}
103103
size="small"
104104
/>
105105
)}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Takes image sizes and a target range and returns the url of the image within that range.
3+
* If no images fit within the range, it selects the next smallest adequate image, the original,
4+
* or the largest smaller image if no better fit exists.
5+
*
6+
* @param sizes The given FileSizes.
7+
* @param targetSizeMax The ideal image maximum width. Defaults to 180.
8+
* @param targetSizeMin The ideal image minimum width. Defaults to 40.
9+
* @param thumbnailURL The thumbnail url set in config. If passed a url, will return early with it.
10+
* @param url The url of the original file.
11+
* @param width The width of the original file.
12+
* @returns A url of the best fit file.
13+
*/
14+
export const getBestFitFromSizes = ({
15+
sizes,
16+
targetSizeMax = 180,
17+
targetSizeMin = 40,
18+
thumbnailURL,
19+
url,
20+
width,
21+
}: {
22+
sizes?: Record<string, { url?: string; width?: number }>
23+
targetSizeMax?: number
24+
targetSizeMin?: number
25+
thumbnailURL?: string
26+
url: string
27+
width?: number
28+
}) => {
29+
if (thumbnailURL) {
30+
return thumbnailURL
31+
}
32+
33+
if (!sizes) {
34+
return url
35+
}
36+
37+
const bestFit = Object.values(sizes).reduce<{
38+
original?: boolean
39+
url?: string
40+
width?: number
41+
}>(
42+
(closest, current) => {
43+
if (!current.width || current.width < targetSizeMin) {
44+
return closest
45+
}
46+
47+
if (current.width >= targetSizeMin && current.width <= targetSizeMax) {
48+
return !closest.width ||
49+
current.width < closest.width ||
50+
closest.width < targetSizeMin ||
51+
closest.width > targetSizeMax
52+
? current
53+
: closest
54+
}
55+
56+
if (
57+
!closest.width ||
58+
(!closest.original && closest.width < targetSizeMin && current.width > closest.width) ||
59+
(closest.width > targetSizeMax && current.width < closest.width)
60+
) {
61+
return current
62+
}
63+
64+
return closest
65+
},
66+
{ original: true, url, width },
67+
)
68+
69+
return bestFit.url || url
70+
}

test/uploads/config.ts

+25
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,31 @@ export default buildConfigWithDefaults({
735735
},
736736
],
737737
},
738+
{
739+
slug: 'best-fit',
740+
fields: [
741+
{
742+
name: 'withAdminThumbnail',
743+
type: 'upload',
744+
relationTo: 'admin-thumbnail-function',
745+
},
746+
{
747+
name: 'withinRange',
748+
type: 'upload',
749+
relationTo: enlargeSlug,
750+
},
751+
{
752+
name: 'nextSmallestOutOfRange',
753+
type: 'upload',
754+
relationTo: 'focal-only',
755+
},
756+
{
757+
name: 'original',
758+
type: 'upload',
759+
relationTo: 'focal-only',
760+
},
761+
],
762+
},
738763
],
739764
onInit: async (payload) => {
740765
const uploadsDir = path.resolve(dirname, './media')

test/uploads/e2e.spec.ts

+51
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ let uploadsOne: AdminUrlUtil
6666
let uploadsTwo: AdminUrlUtil
6767
let customUploadFieldURL: AdminUrlUtil
6868
let hideFileInputOnCreateURL: AdminUrlUtil
69+
let bestFitURL: AdminUrlUtil
6970
let consoleErrorsFromPage: string[] = []
7071
let collectErrorsFromPage: () => boolean
7172
let stopCollectingErrorsFromPage: () => boolean
@@ -99,6 +100,7 @@ describe('Uploads', () => {
99100
uploadsTwo = new AdminUrlUtil(serverURL, 'uploads-2')
100101
customUploadFieldURL = new AdminUrlUtil(serverURL, customUploadFieldSlug)
101102
hideFileInputOnCreateURL = new AdminUrlUtil(serverURL, hideFileInputOnCreateSlug)
103+
bestFitURL = new AdminUrlUtil(serverURL, 'best-fit')
102104

103105
const context = await browser.newContext()
104106
page = await context.newPage()
@@ -1349,4 +1351,53 @@ describe('Uploads', () => {
13491351

13501352
await expect(page.locator('.file-field .file-details__remove')).toBeHidden()
13511353
})
1354+
1355+
describe('imageSizes best fit', () => {
1356+
test('should select adminThumbnail if one exists', async () => {
1357+
await page.goto(bestFitURL.create)
1358+
await page.locator('#field-withAdminThumbnail button.upload__listToggler').click()
1359+
await page.locator('tr.row-1 td.cell-filename button.default-cell__first-cell').click()
1360+
const thumbnail = page.locator('#field-withAdminThumbnail div.thumbnail > img')
1361+
await expect(thumbnail).toHaveAttribute(
1362+
'src',
1363+
'https://payloadcms.com/images/universal-truth.jpg',
1364+
)
1365+
})
1366+
1367+
test('should select an image within target range', async () => {
1368+
await page.goto(bestFitURL.create)
1369+
await page.locator('#field-withinRange button.upload__createNewToggler').click()
1370+
const fileChooserPromise = page.waitForEvent('filechooser')
1371+
await page.getByText('Select a file').click()
1372+
const fileChooser = await fileChooserPromise
1373+
await fileChooser.setFiles(path.join(dirname, 'test-image.jpg'))
1374+
await page.locator('dialog button#action-save').click()
1375+
const thumbnail = page.locator('#field-withinRange div.thumbnail > img')
1376+
await expect(thumbnail).toHaveAttribute('src', '/api/enlarge/file/test-image-180x50.jpg')
1377+
})
1378+
1379+
test('should select next smallest image outside of range but smaller than original', async () => {
1380+
await page.goto(bestFitURL.create)
1381+
await page.locator('#field-nextSmallestOutOfRange button.upload__createNewToggler').click()
1382+
const fileChooserPromise = page.waitForEvent('filechooser')
1383+
await page.getByText('Select a file').click()
1384+
const fileChooser = await fileChooserPromise
1385+
await fileChooser.setFiles(path.join(dirname, 'test-image.jpg'))
1386+
await page.locator('dialog button#action-save').click()
1387+
const thumbnail = page.locator('#field-nextSmallestOutOfRange div.thumbnail > img')
1388+
await expect(thumbnail).toHaveAttribute('src', '/api/focal-only/file/test-image-400x300.jpg')
1389+
})
1390+
1391+
test('should select original if smaller than next available size', async () => {
1392+
await page.goto(bestFitURL.create)
1393+
await page.locator('#field-original button.upload__createNewToggler').click()
1394+
const fileChooserPromise = page.waitForEvent('filechooser')
1395+
await page.getByText('Select a file').click()
1396+
const fileChooser = await fileChooserPromise
1397+
await fileChooser.setFiles(path.join(dirname, 'small.png'))
1398+
await page.locator('dialog button#action-save').click()
1399+
const thumbnail = page.locator('#field-original div.thumbnail > img')
1400+
await expect(thumbnail).toHaveAttribute('src', '/api/focal-only/file/small.png')
1401+
})
1402+
})
13521403
})

0 commit comments

Comments
 (0)