Skip to content

Commit 5afcef5

Browse files
fix(ui): thread cache tag to list view thumbnails (#11741)
<!-- Thank you for the PR! Please go through the checklist below and make sure you've completed all the steps. Please review the [CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md) document in this repository if you haven't already. The following items will ensure that your PR is handled as smoothly as possible: - PR Title must follow conventional commits format. For example, `feat: my new feature`, `fix(plugin-seo): my fix`. - Minimal description explained as if explained to someone not immediately familiar with the code. - Provide before/after screenshots or code diffs if applicable. - Link any related issues/discussions from GitHub or Discord. - Add review comments if necessary to explain to the reviewer the logic behind a change ### What? ### Why? ### How? Fixes # --> ### What? This PR threads a cache tag to the `Thumbnail` component through the default `File` cell in the list view if cache tags are enabled in upload config. These changes also adjust the `Thumbnail` component to use `useMemo` instead of constructing a `src` later due to: ``` const img = new Image() img.src = fileSrc ``` The above causes an extra request to be made if cache tags are enabled. ### Why? To thread cache tags through to the list view thumbnails. ### How? Changes the default `File` cell to pass the cache tag through if enabled, and changing a failing test to accommodate cache tags in the list view. Addresses cache tag issue in #11690 --------- Co-authored-by: Paul Popus <paul@payloadcms.com>
1 parent a7dd17c commit 5afcef5

7 files changed

Lines changed: 98 additions & 42 deletions

File tree

packages/ui/src/elements/EditUpload/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import 'react-image-crop/dist/ReactCrop.css'
1010
import { editDrawerSlug } from '../../elements/Upload/index.js'
1111
import { PlusIcon } from '../../icons/Plus/index.js'
1212
import { useTranslation } from '../../providers/Translation/index.js'
13+
import { appendCacheTag } from '../../utilities/appendCacheTag.js'
1314
import { Button } from '../Button/index.js'
1415
import './index.scss'
1516

@@ -169,8 +170,7 @@ export const EditUpload: React.FC<EditUploadProps> = ({
169170
setFocalPosition({ x: xCenter, y: yCenter })
170171
}
171172

172-
const queryChar = fileSrc?.includes('?') ? '&' : '?'
173-
const fileSrcToUse = imageCacheTag ? `${fileSrc}${queryChar}${encodeURIComponent(imageCacheTag)}` : fileSrc
173+
const fileSrcToUse = fileSrc ? appendCacheTag(fileSrc, imageCacheTag) : fileSrc
174174

175175
return (
176176
<div className={baseClass}>

packages/ui/src/elements/PreviewSizes/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Data, FileSize, SanitizedCollectionConfig, SanitizedUploadConfig }
33

44
import React, { useEffect, useMemo, useState } from 'react'
55

6+
import { appendCacheTag } from '../../utilities/appendCacheTag.js'
67
import { FileMeta } from '../FileDetails/FileMeta/index.js'
78
import './index.scss'
89

@@ -99,8 +100,7 @@ export const PreviewSizes: React.FC<PreviewSizesProps> = ({ doc, imageCacheTag,
99100
return null
100101
}
101102
if (doc.url) {
102-
const queryChar = doc.url.includes('?') ? '&' : '?'
103-
return `${doc.url}${imageCacheTag ? `${queryChar}${encodeURIComponent(imageCacheTag)}` : ''}`
103+
return appendCacheTag(doc.url, imageCacheTag)
104104
}
105105
}
106106
useEffect(() => {

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export const FileCell: React.FC<FileCellProps> = ({
4343
})
4444
}
4545

46+
const uploadConfig = collectionConfig?.upload
47+
const imageCacheTag = uploadConfig?.cacheTags && rowData?.updatedAt
48+
4649
return (
4750
<div className={baseClass}>
4851
<Thumbnail
@@ -53,9 +56,9 @@ export const FileCell: React.FC<FileCellProps> = ({
5356
filename,
5457
}}
5558
fileSrc={fileSrc}
56-
imageCacheTag={collectionConfig?.upload?.cacheTags && rowData?.updatedAt}
59+
imageCacheTag={imageCacheTag}
5760
size="small"
58-
uploadConfig={collectionConfig?.upload}
61+
uploadConfig={uploadConfig}
5962
/>
6063
<span className={`${baseClass}__filename`}>{String(filename)}</span>
6164
</div>

packages/ui/src/elements/Thumbnail/index.tsx

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const baseClass = 'thumbnail'
88
import type { SanitizedCollectionConfig } from 'payload'
99

1010
import { File } from '../../graphics/File/index.js'
11+
import { appendCacheTag } from '../../utilities/appendCacheTag.js'
1112
import { ShimmerEffect } from '../ShimmerEffect/index.js'
1213

1314
export type ThumbnailProps = {
@@ -36,33 +37,27 @@ export const Thumbnail: React.FC<ThumbnailProps> = (props) => {
3637

3738
const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ')
3839

40+
const src = React.useMemo(
41+
() => (fileSrc ? appendCacheTag(fileSrc, imageCacheTag) : null),
42+
[fileSrc, imageCacheTag],
43+
)
44+
3945
React.useEffect(() => {
40-
if (!fileSrc) {
46+
if (!src) {
4147
setFileExists(false)
4248
return
4349
}
4450
setFileExists(undefined)
4551

4652
const img = new Image()
47-
img.src = fileSrc
53+
img.src = src
4854
img.onload = () => {
4955
setFileExists(true)
5056
}
5157
img.onerror = () => {
5258
setFileExists(false)
5359
}
54-
}, [fileSrc])
55-
56-
let src: null | string = null
57-
58-
/**
59-
* If an imageCacheTag is provided, append it to the fileSrc
60-
* Check if the fileSrc already has a query string, if it does, append the imageCacheTag with an ampersand
61-
*/
62-
if (fileSrc) {
63-
const queryChar = fileSrc?.includes('?') ? '&' : '?'
64-
src = imageCacheTag ? `${fileSrc}${queryChar}${encodeURIComponent(imageCacheTag)}` : fileSrc
65-
}
60+
}, [src])
6661

6762
return (
6863
<div className={classNames}>
@@ -87,33 +82,27 @@ export function ThumbnailComponent(props: ThumbnailComponentProps) {
8782

8883
const classNames = [baseClass, `${baseClass}--size-${size || 'medium'}`, className].join(' ')
8984

85+
const src = React.useMemo(
86+
() => (fileSrc ? appendCacheTag(fileSrc, imageCacheTag) : null),
87+
[fileSrc, imageCacheTag],
88+
)
89+
9090
React.useEffect(() => {
91-
if (!fileSrc) {
91+
if (!src) {
9292
setFileExists(false)
9393
return
9494
}
9595
setFileExists(undefined)
9696

9797
const img = new Image()
98-
img.src = fileSrc
98+
img.src = src
9999
img.onload = () => {
100100
setFileExists(true)
101101
}
102102
img.onerror = () => {
103103
setFileExists(false)
104104
}
105-
}, [fileSrc])
106-
107-
let src: string = ''
108-
109-
/**
110-
* If an imageCacheTag is provided, append it to the fileSrc
111-
* Check if the fileSrc already has a query string, if it does, append the imageCacheTag with an ampersand
112-
*/
113-
if (fileSrc) {
114-
const queryChar = fileSrc?.includes('?') ? '&' : '?'
115-
src = imageCacheTag ? `${fileSrc}${queryChar}${encodeURIComponent(imageCacheTag)}` : fileSrc
116-
}
105+
}, [src])
117106

118107
return (
119108
<div className={classNames}>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { appendCacheTag } from './appendCacheTag.js'
4+
5+
describe('appendCacheTag', () => {
6+
it('should return the url unchanged when cacheTag is undefined', () => {
7+
expect(appendCacheTag('https://example.com/image.jpg', undefined)).toBe(
8+
'https://example.com/image.jpg',
9+
)
10+
})
11+
12+
it('should return the url unchanged when cacheTag is false', () => {
13+
expect(appendCacheTag('https://example.com/image.jpg', false)).toBe(
14+
'https://example.com/image.jpg',
15+
)
16+
})
17+
18+
it('should return the url unchanged when cacheTag is empty string', () => {
19+
expect(appendCacheTag('https://example.com/image.jpg', '')).toBe(
20+
'https://example.com/image.jpg',
21+
)
22+
})
23+
24+
it('should append the cache tag with ? when the url has no query string', () => {
25+
const result = appendCacheTag('https://example.com/image.jpg', '2024-01-01T00:00:00.000Z')
26+
expect(result).toBe('https://example.com/image.jpg?2024-01-01T00%3A00%3A00.000Z')
27+
})
28+
29+
it('should append the cache tag with & when the url already has a query string', () => {
30+
const result = appendCacheTag(
31+
'https://example.com/image.jpg?w=800&q=75',
32+
'2024-01-01T00:00:00.000Z',
33+
)
34+
expect(result).toBe('https://example.com/image.jpg?w=800&q=75&2024-01-01T00%3A00%3A00.000Z')
35+
})
36+
37+
it('should URI-encode the cache tag value', () => {
38+
const result = appendCacheTag('/image.jpg', '2024-06-15T12:30:00.000Z')
39+
expect(result).toBe('/image.jpg?2024-06-15T12%3A30%3A00.000Z')
40+
})
41+
42+
it('should work with relative urls', () => {
43+
const result = appendCacheTag('/uploads/photo.png', '2024-01-01T00:00:00.000Z')
44+
expect(result).toBe('/uploads/photo.png?2024-01-01T00%3A00%3A00.000Z')
45+
})
46+
47+
it('should work with relative urls that already have a query string', () => {
48+
const result = appendCacheTag('/uploads/photo.png?size=large', '2024-01-01T00:00:00.000Z')
49+
expect(result).toBe('/uploads/photo.png?size=large&2024-01-01T00%3A00%3A00.000Z')
50+
})
51+
})
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Appends a cache-busting tag to a URL as a query parameter.
3+
* If the URL already has a query string, the tag is appended with `&`, otherwise with `?`.
4+
*/
5+
export function appendCacheTag(url: string, cacheTag: false | string | undefined): string {
6+
if (!cacheTag) {
7+
return url
8+
}
9+
const queryChar = url.includes('?') ? '&' : '?'
10+
return `${url}${queryChar}${encodeURIComponent(cacheTag)}`
11+
}

test/uploads/e2e.spec.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ const dirname = path.dirname(filename)
6969
*/
7070
const cacheTagPattern = /\?\d{4}-\d{2}-\d{2}T\d{2}%3A\d{2}%3A\d{2}\.\d{3}Z/
7171

72+
const adminThumbnailFunctionSrcPattern = new RegExp(
73+
String.raw`^https://raw\.githubusercontent\.com/payloadcms/website/refs/heads/main/public/images/universal-truth\.jpg` +
74+
cacheTagPattern.source +
75+
'$',
76+
)
77+
7278
const { afterAll, beforeAll, beforeEach, describe } = test
7379

7480
let payload: PayloadTestSDK<Config>
@@ -681,10 +687,9 @@ describe('Uploads', () => {
681687

682688
// Ensure sure false or null shows generic file svg
683689
const genericUploadImage = page.locator('tr.row-1 .thumbnail img')
684-
await expect(genericUploadImage).toHaveAttribute(
685-
'src',
686-
/^https:\/\/raw\.githubusercontent\.com\/payloadcms\/website\/refs\/heads\/main\/public\/images\/universal-truth\.jpg(\?.*)?$/,
687-
)
690+
691+
// cacheTags defaults to true, so the cache tag is appended to the src in list view
692+
await expect(genericUploadImage).toHaveAttribute('src', adminThumbnailFunctionSrcPattern)
688693
})
689694

690695
test('should render adminThumbnail when using a custom thumbnail URL with additional queries', async () => {
@@ -1969,10 +1974,7 @@ describe('Uploads', () => {
19691974
await page.locator('#field-withAdminThumbnail button.upload__listToggler').click()
19701975
await page.locator('tr.row-1 td.cell-filename button.default-cell__first-cell').click()
19711976
const thumbnail = page.locator('#field-withAdminThumbnail div.thumbnail > img')
1972-
await expect(thumbnail).toHaveAttribute(
1973-
'src',
1974-
/^https:\/\/raw\.githubusercontent\.com\/payloadcms\/website\/refs\/heads\/main\/public\/images\/universal-truth\.jpg(\?.*)?$/,
1975-
)
1977+
await expect(thumbnail).toHaveAttribute('src', adminThumbnailFunctionSrcPattern)
19761978
})
19771979

19781980
test('should select an image within target range', async () => {

0 commit comments

Comments
 (0)