From 59c6e4735c366f56b5b68a74270c26103de48d1b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 15:46:50 +0000 Subject: [PATCH 1/2] feat(shopify-assets)!: migrate react-photo-album to v3 catalog Upgrade the shared react-photo-album catalog to ^3.6.1 and migrate sanity-plugin-shopify-assets to the v3 RowsPhotoAlbum + scroll API (matching asset-source-unsplash), so it can use catalog: and drop the v2-only react-infinite-scroll-component/rxjs search path. --- .../shopify-assets-react-photo-album-v3.md | 5 + .../unsplash-react-photo-album-3-6-1.md | 5 + .../sanity-plugin-shopify-assets/package.json | 4 +- .../src/components/File.styled.tsx | 31 +- .../src/components/File.tsx | 43 +- .../src/components/Loader.tsx | 9 + .../src/components/ShopifyAssetPicker.tsx | 375 +++++++++++------- .../sanity-plugin-shopify-assets/src/css.d.ts | 1 + .../src/datastores/shopify.ts | 107 ++--- .../src/shopify-search.regression.test.ts | 71 ++-- pnpm-lock.yaml | 46 +-- pnpm-workspace.yaml | 2 +- 12 files changed, 338 insertions(+), 361 deletions(-) create mode 100644 .changeset/shopify-assets-react-photo-album-v3.md create mode 100644 .changeset/unsplash-react-photo-album-3-6-1.md create mode 100644 plugins/sanity-plugin-shopify-assets/src/components/Loader.tsx create mode 100644 plugins/sanity-plugin-shopify-assets/src/css.d.ts diff --git a/.changeset/shopify-assets-react-photo-album-v3.md b/.changeset/shopify-assets-react-photo-album-v3.md new file mode 100644 index 0000000000..2e22e5a748 --- /dev/null +++ b/.changeset/shopify-assets-react-photo-album-v3.md @@ -0,0 +1,5 @@ +--- +"sanity-plugin-shopify-assets": major +--- + +Upgrade `react-photo-album` to v3 (`RowsPhotoAlbum` + built-in infinite scroll) and align with the shared catalog dependency diff --git a/.changeset/unsplash-react-photo-album-3-6-1.md b/.changeset/unsplash-react-photo-album-3-6-1.md new file mode 100644 index 0000000000..36d2388558 --- /dev/null +++ b/.changeset/unsplash-react-photo-album-3-6-1.md @@ -0,0 +1,5 @@ +--- +"sanity-plugin-asset-source-unsplash": patch +--- + +fix(deps): update dependency react-photo-album to ^3.6.1 diff --git a/plugins/sanity-plugin-shopify-assets/package.json b/plugins/sanity-plugin-shopify-assets/package.json index a09cef4990..21e1cef239 100644 --- a/plugins/sanity-plugin-shopify-assets/package.json +++ b/plugins/sanity-plugin-shopify-assets/package.json @@ -46,9 +46,7 @@ "axios": "^1.18.1", "pretty-bytes": "^6.1.1", "pretty-ms": "^8.0.0", - "react-infinite-scroll-component": "^6.1.1", - "react-photo-album": "^2.4.1", - "rxjs": "catalog:", + "react-photo-album": "catalog:", "video.js": "catalog:" }, "devDependencies": { diff --git a/plugins/sanity-plugin-shopify-assets/src/components/File.styled.tsx b/plugins/sanity-plugin-shopify-assets/src/components/File.styled.tsx index 50eeafe09f..cc590f4cab 100644 --- a/plugins/sanity-plugin-shopify-assets/src/components/File.styled.tsx +++ b/plugins/sanity-plugin-shopify-assets/src/components/File.styled.tsx @@ -2,35 +2,6 @@ import {Card} from '@sanity/ui' import {getTheme_v2} from '@sanity/ui/theme' import {styled} from 'styled-components' -export const Root = styled.div` - ${({theme}) => { - const v2 = getTheme_v2({sanity: theme.sanity}) - return ` - background-color: ${v2.color.muted.bg}; - border: 1px solid ${v2.color.border}; - ` - }}; - overflow: hidden; - background-origin: content-box; - background-repeat: no-repeat; - background-clip: border-box; - background-size: cover; - position: relative; - outline: none !important; - box-sizing: content-box; - user-drag: none; - - &:hover { - opacity: 0.85; - } - - &:focus, - &:active { - border: 1px solid var(--input-border-color-focus); - box-shadow: inset 0 0 0 3px var(--input-border-color-focus); - } -` - export const InfoLine = styled(Card)` ${({theme}) => { const v2 = getTheme_v2({sanity: theme.sanity}) @@ -46,6 +17,7 @@ export const InfoLine = styled(Card)` left: 0; max-width: 65%; overflow-wrap: break-word; + pointer-events: none; [data-ui='Text'] { color: var(--infoline-fg); @@ -65,6 +37,7 @@ export const DurationLine = styled(Card)` background-color: var(--durationline-bg); top: 0; right: 0; + pointer-events: none; [data-ui='Text'] { color: var(--durationline-fg); diff --git a/plugins/sanity-plugin-shopify-assets/src/components/File.tsx b/plugins/sanity-plugin-shopify-assets/src/components/File.tsx index 2cae5c8800..ea3789cd1d 100644 --- a/plugins/sanity-plugin-shopify-assets/src/components/File.tsx +++ b/plugins/sanity-plugin-shopify-assets/src/components/File.tsx @@ -1,54 +1,29 @@ import {Text} from '@sanity/ui' import prettyBytes from 'pretty-bytes' import prettyMilliseconds from 'pretty-ms' -import {useCallback, useRef} from 'react' -import type {Asset, ShopifyFile} from '../types' +import type {ShopifyFile} from '../types' import {extractName} from '../utils/helpers' -import {DurationLine, InfoLine, Root} from './File.styled' +import {DurationLine, InfoLine} from './File.styled' -type Props = { - data: ShopifyFile - width: number - height: number - onClick: (file: Asset) => void -} - -export default function File(props: Props) { - const {onClick, data, width, height} = props - const rootElm = useRef(null) - - const {preview, meta} = data +export default function File({data}: {data: ShopifyFile}) { const filename = extractName(data.url) - - const handleClick = useCallback(() => { - onClick({...data, filename}) - }, [onClick, data, filename]) + const {meta} = data return ( - + <> - {filename} {meta.fileSize && `(${prettyBytes(meta.fileSize)})`} + {filename} {meta.fileSize ? `(${prettyBytes(meta.fileSize)})` : null} - {meta.duration && ( + {meta.duration ? ( {prettyMilliseconds(meta.duration, {colonNotation: true, secondsDecimalDigits: 0})} - )} - + ) : null} + ) } diff --git a/plugins/sanity-plugin-shopify-assets/src/components/Loader.tsx b/plugins/sanity-plugin-shopify-assets/src/components/Loader.tsx new file mode 100644 index 0000000000..b36161d239 --- /dev/null +++ b/plugins/sanity-plugin-shopify-assets/src/components/Loader.tsx @@ -0,0 +1,9 @@ +import {Flex, Spinner} from '@sanity/ui' + +export function Loader() { + return ( + + + + ) +} diff --git a/plugins/sanity-plugin-shopify-assets/src/components/ShopifyAssetPicker.tsx b/plugins/sanity-plugin-shopify-assets/src/components/ShopifyAssetPicker.tsx index c8b2d4f6fa..f491e2d706 100644 --- a/plugins/sanity-plugin-shopify-assets/src/components/ShopifyAssetPicker.tsx +++ b/plugins/sanity-plugin-shopify-assets/src/components/ShopifyAssetPicker.tsx @@ -1,20 +1,78 @@ import {ErrorOutlineIcon} from '@sanity/icons/ErrorOutline' -import {Card, Dialog, Flex, Inline, Spinner, Stack, Text, TextInput} from '@sanity/ui' -import {type ChangeEvent, useCallback, useEffect, useMemo, useState} from 'react' -import InfiniteScroll from 'react-infinite-scroll-component' -import {PhotoAlbum} from 'react-photo-album' -import {BehaviorSubject, type Subscription} from 'rxjs' +import {Box, Card, Dialog, Flex, Inline, Stack, Text, TextInput} from '@sanity/ui' +import { + type ChangeEvent, + useEffect, + useMemo, + useRef, + useState, + use, + Suspense, + Activity, +} from 'react' + +import 'react-photo-album/rows.css' +import {RowsPhotoAlbum} from 'react-photo-album' +import InfiniteScroll from 'react-photo-album/scroll' import {type ObjectInputProps, PatchEvent, set, useClient, useDataset, useProjectId} from 'sanity' +import {styled} from 'styled-components' -import {search} from '../datastores/shopify' -import type {Asset, PageInfo, ShopifyAPIResponse, ShopifyFile} from '../types' +import {fetchAssets} from '../datastores/shopify' +import type {Asset, ShopifyAPIResponse, ShopifyFile} from '../types' +import {extractName} from '../utils/helpers' import DialogHeader from './DialogHeader' import File from './File' +import {Loader} from './Loader' import {Search} from './ShopifyAssetInput.styled' const RESULTS_PER_PAGE = 42 const PHOTO_SPACING = 2 const PHOTO_PADDING = 1 +const SEARCH_DEBOUNCE_MS = 500 + +const StyledDialog = styled(Dialog)` + & > [data-ui='DialogCard'] > [data-ui='Card'] { + height: 100%; + } +` + +function mapShopifyFileToPhoto(file: ShopifyFile) { + return { + src: file.preview?.url || file.url, + width: file.preview?.width || 2048, + height: file.preview?.height || 2048, + key: file.id, + alt: extractName(file.url), + data: file, + } +} + +type ShopifyPhoto = ReturnType + +function createFetcher(params: {projectId: string; dataset: string; shop: string; token?: string}) { + return async function fetcher(query: string, cursor: string): Promise { + return fetchAssets({ + ...params, + query, + cursor, + resultsPerPage: RESULTS_PER_PAGE, + }) + } +} + +function getErrorMessage(err: unknown): string { + if (err && typeof err === 'object') { + const maybeAxiosError = err as { + response?: {data?: {message?: string}} + message?: string + } + const message = maybeAxiosError.response?.data?.message || maybeAxiosError.message + if (message) { + return `${message} - check plugin configuration` + } + } + return 'An error occurred - check plugin configuration' +} export interface AssetPickerProps extends ObjectInputProps { shopifyDomain: string @@ -29,105 +87,78 @@ export default function ShopifyAssetPicker(props: AssetPickerProps) { const client = useClient({apiVersion: '2021-06-07'}) const token = client.config().token - const [apiError, setApiError] = useState('') const [query, setQuery] = useState('') - const [searchResults, setSearchResults] = useState([]) - const [pageInfo, setPageInfo] = useState() - const [isLoading, setIsLoading] = useState(true) + const [debouncedQuery, setDebouncedQuery] = useState('') + const [apiError, setApiError] = useState('') + const scrollContainerRef = useRef(null) const error = shopifyDomain ? apiError : 'Please configure your Shopify domain in the plugin config' - const searchSubject$ = useMemo(() => new BehaviorSubject(''), []) - const cursorSubject$ = useMemo(() => new BehaviorSubject(''), []) - useEffect(() => { - const searchSubscription: Subscription = search({ - projectId, - dataset, - shop: shopifyDomain, - query: searchSubject$, - cursor: cursorSubject$, - resultsPerPage: RESULTS_PER_PAGE, - token, - }).subscribe({ - next: (results: ShopifyAPIResponse) => { - setSearchResults((prevResults) => [...prevResults, ...results.assets]) - setPageInfo(results.pageInfo) - setIsLoading(false) - }, - error: (err) => { - setApiError( - `${ - err.response?.data?.message || err.message || 'An error occurred' - } - check plugin configuration`, - ) - }, - }) + const timeout = window.setTimeout(() => { + setApiError('') + setDebouncedQuery(query) + }, SEARCH_DEBOUNCE_MS) - return () => searchSubscription.unsubscribe() - }, [searchSubject$, cursorSubject$, shopifyDomain, projectId, dataset, token]) - - const handleSearchTermChanged = useCallback( - (event: ChangeEvent) => { - const newQuery = event.currentTarget.value - setQuery(newQuery) - setSearchResults([]) - setPageInfo(undefined) - setIsLoading(true) - - cursorSubject$.next('') - searchSubject$.next(newQuery) - }, - [cursorSubject$, searchSubject$], - ) + return () => window.clearTimeout(timeout) + }, [query]) - const handleScollerLoadMore = useCallback(() => { - setIsLoading(true) - if (pageInfo) cursorSubject$.next(pageInfo.cursor) - searchSubject$.next(query) - }, [cursorSubject$, pageInfo, searchSubject$, query]) - - const handleSelect = useCallback( - (file: Asset) => { - const nextValue: Asset = {...file, _key: value?._key, _type: schemaType.name} - onChange(PatchEvent.from([set(nextValue)])) - onClose() - }, - [onChange, onClose, schemaType.name, value?._key], + const fetcher = useMemo( + () => + createFetcher({ + projectId, + dataset, + shop: shopifyDomain, + token, + }), + [projectId, dataset, shopifyDomain, token], ) - const renderFile = useCallback( - (fileProps: any) => { - const {photo, layout} = fileProps - return ( - - ) - }, - [handleSelect], + const initialDataPromise = useMemo( + () => + fetcher(debouncedQuery, '').catch((err: unknown) => { + setApiError(getErrorMessage(err)) + return { + assets: [], + pageInfo: {cursor: '', hasNextPage: false}, + } satisfies ShopifyAPIResponse + }), + [debouncedQuery, fetcher], ) - const handleWidth = useCallback((width: number) => { - if (width < 300) return 150 - else if (width < 600) return 200 - return 300 - }, []) + const handleSearchTermChanged = (event: ChangeEvent) => { + setQuery(event.currentTarget.value) + } + + const handleSelect = (file: ShopifyFile) => { + const nextValue: Asset = { + ...file, + filename: extractName(file.url), + _key: value?._key, + _type: schemaType.name, + } + onChange(PatchEvent.from([set(nextValue)])) + onClose() + } return ( - } onClose={onClose} open={isOpen} + height="100%" width={4} > - + {error ? ( @@ -141,66 +172,126 @@ export default function ShopifyAssetPicker(props: AssetPickerProps) { ) : ( <> - - - - Search Shopify for assets - - - - - {!isLoading && searchResults.length === 0 && ( - - No results found - - )} - - - - } - endMessage={ - - - No more results + + + + + Search Shopify for assets - - } - > - {searchResults && ( - ({ - src: file?.preview?.url, - width: file?.preview?.width || 2048, - height: file?.preview?.height || 2048, - key: file.id, - data: file, - }))} - renderPhoto={renderFile} - componentsProps={{ - containerProps: {style: {marginBottom: `${PHOTO_SPACING}px`}}, - }} - /> - )} - + + + + + }> + + )} - + + ) +} + +function ShopifyAssetGallery({ + query, + fetcher, + scrollContainerRef, + onSelect, + onError, + initialDataPromise, +}: { + query: string + fetcher: (query: string, cursor: string) => Promise + scrollContainerRef: React.RefObject + onSelect: (file: ShopifyFile) => void + onError: (message: string) => void + initialDataPromise: Promise +}) { + const data = use(initialDataPromise) + // Remounted per query via `key`, so the initial pageInfo is the pagination baseline. + const paginationRef = useRef({ + cursor: data.pageInfo.cursor, + hasNextPage: data.pageInfo.hasNextPage, + }) + + const initialPhotos = data.assets.map(mapShopifyFileToPhoto) + + return ( + <> + + + + {query ? `No results found for "${query}"` : 'No results found'} + + + + + scrollContainerRef.current} + fetch={async () => { + if (!paginationRef.current.hasNextPage) { + return null + } + + try { + const results = await fetcher(query, paginationRef.current.cursor) + paginationRef.current = { + cursor: results.pageInfo.cursor, + hasNextPage: results.pageInfo.hasNextPage, + } + + if (results.assets.length === 0) { + return null + } + + return results.assets.map(mapShopifyFileToPhoto) + } catch (err) { + onError(getErrorMessage(err)) + return null + } + }} + loading={} + finished={ + + + No more results + + + } + onClick={({photo}) => { + onSelect(photo.data) + }} + > + + photos={[]} + spacing={PHOTO_SPACING} + padding={PHOTO_PADDING} + targetRowHeight={(width) => { + if (width < 300) return 150 + if (width < 600) return 200 + return 300 + }} + render={{ + extras: (_, context) => , + }} + componentsProps={{container: {style: {marginBottom: PHOTO_SPACING}}}} + /> + + + ) } diff --git a/plugins/sanity-plugin-shopify-assets/src/css.d.ts b/plugins/sanity-plugin-shopify-assets/src/css.d.ts new file mode 100644 index 0000000000..503bef694e --- /dev/null +++ b/plugins/sanity-plugin-shopify-assets/src/css.d.ts @@ -0,0 +1 @@ +declare module 'react-photo-album/rows.css' diff --git a/plugins/sanity-plugin-shopify-assets/src/datastores/shopify.ts b/plugins/sanity-plugin-shopify-assets/src/datastores/shopify.ts index 62f94465aa..32c9cb25bf 100644 --- a/plugins/sanity-plugin-shopify-assets/src/datastores/shopify.ts +++ b/plugins/sanity-plugin-shopify-assets/src/datastores/shopify.ts @@ -1,95 +1,42 @@ import axios from 'axios' -import {BehaviorSubject, Observable, concat, defer} from 'rxjs' -import {debounceTime, distinctUntilChanged, map, switchMap, withLatestFrom} from 'rxjs/operators' -type SearchSubject = BehaviorSubject -type CursorSubject = BehaviorSubject +import type {ShopifyAPIResponse} from '../types' -interface fetchProps { +export interface FetchAssetsProps { projectId: string dataset: string shop: string - query: SearchSubject - cursor: CursorSubject - resultsPerPage: number - token?: string -} - -interface searchProps extends Omit { query: string cursor: string -} -interface listProps extends Omit { - cursor: string + resultsPerPage: number + token?: string } -const fetchSearch = (props: searchProps): Observable => { +export async function fetchAssets(props: FetchAssetsProps): Promise { const {projectId, dataset, shop, query, cursor, resultsPerPage, token} = props - const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' - const url = `https://${projectId}.api.sanity.io/v1/shopify/assets/${dataset}?shop=${encodeURIComponent( + const searchParams = new URLSearchParams({ shop, - )}&query=${encodeURIComponent(query)}${cursorParam}&limit=${resultsPerPage}` - - return defer(() => - axios.get(url, { - withCredentials: true, - method: 'GET', - headers: token - ? { - Authorization: `Bearer ${token}`, - } - : {}, - }), - ).pipe(map((result) => result.data)) -} - -const fetchList = (props: listProps): Observable => { - const {projectId, dataset, shop, cursor, resultsPerPage, token} = props - - const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : '' - const url = `https://${projectId}.api.sanity.io/v1/shopify/assets/${dataset}?shop=${encodeURIComponent( - shop, - )}${cursorParam}&limit=${resultsPerPage}` - - return defer(() => - axios.get(url, { - withCredentials: true, - method: 'GET', - headers: token - ? { - Authorization: `Bearer ${token}`, - } - : {}, - }), - ).pipe(map((result) => result.data)) -} - -export const search = (props: fetchProps): Observable => { - const {projectId, dataset, shop, query, cursor, resultsPerPage, token} = props - - // No value-based dedupe here on purpose: callers clear results and set their - // loading state before pushing to these subjects, so suppressing a repeated - // [query, cursor] pair would leave them loading forever. debounceTime already - // collapses rapid input and switchMap cancels superseded requests. - return concat( - query.pipe( - withLatestFrom(cursor), - debounceTime(500), - switchMap(([q, c]) => { - if (q) { - return fetchSearch({ - projectId, - dataset, - shop, - query: q, - cursor: c, - resultsPerPage, - token, - }).pipe(distinctUntilChanged()) + limit: `${resultsPerPage}`, + }) + if (query.trim()) { + searchParams.set('query', query.trim()) + } + if (cursor) { + searchParams.set('cursor', cursor) + } + + const url = `https://${projectId}.api.sanity.io/v1/shopify/assets/${dataset}?${searchParams}` + + const result = await axios.get(url, { + withCredentials: true, + method: 'GET', + headers: token + ? { + Authorization: `Bearer ${token}`, } - return fetchList({projectId, dataset, shop, cursor: c, resultsPerPage, token}) - }), - ), - ) + : {}, + }) + + return result.data } diff --git a/plugins/sanity-plugin-shopify-assets/src/shopify-search.regression.test.ts b/plugins/sanity-plugin-shopify-assets/src/shopify-search.regression.test.ts index 02f6ba7461..1b4ee7385f 100644 --- a/plugins/sanity-plugin-shopify-assets/src/shopify-search.regression.test.ts +++ b/plugins/sanity-plugin-shopify-assets/src/shopify-search.regression.test.ts @@ -1,49 +1,54 @@ -import {BehaviorSubject} from 'rxjs' +import type {AxiosRequestConfig} from 'axios' import {expect, test, vi} from 'vitest' -import {search} from './datastores/shopify' +import {fetchAssets} from './datastores/shopify' + +const axiosGet = vi.hoisted(() => + vi.fn((_url: string, _config?: AxiosRequestConfig) => + Promise.resolve({data: {assets: [], pageInfo: {cursor: '', hasNextPage: false}}}), + ), +) vi.mock('axios', () => ({ default: { - get: vi.fn(() => - Promise.resolve({data: {assets: [], pageInfo: {cursor: '', hasNextPage: false}}}), - ), + get: axiosGet, }, })) -// The picker clears its results and sets its loading state before pushing to -// these subjects, so `search` must emit for every push. Deduplicating repeated -// [query, cursor] pairs would leave the picker stuck loading on an empty grid. -test('emits again when a query settles back to the previous value', async () => { - vi.useFakeTimers() - - const query = new BehaviorSubject('') - const cursor = new BehaviorSubject('') - const emissions: unknown[] = [] - - const subscription = search({ +test('fetchAssets includes query and cursor search params', async () => { + await fetchAssets({ projectId: 'project', dataset: 'dataset', shop: 'example.myshopify.com', - query, - cursor, + query: 'abc', + cursor: 'cursor-1', resultsPerPage: 42, - }).subscribe((results) => emissions.push(results)) - - cursor.next('') - query.next('abc') - await vi.advanceTimersByTimeAsync(600) - expect(emissions).toHaveLength(1) + token: 'token', + }) + + expect(axiosGet).toHaveBeenCalledTimes(1) + expect(axiosGet.mock.calls[0]?.[0]).toContain('shop=example.myshopify.com') + expect(axiosGet.mock.calls[0]?.[0]).toContain('query=abc') + expect(axiosGet.mock.calls[0]?.[0]).toContain('cursor=cursor-1') + expect(axiosGet.mock.calls[0]?.[0]).toContain('limit=42') + expect(axiosGet.mock.calls[0]?.[1]?.headers).toMatchObject({ + Authorization: 'Bearer token', + }) +}) - // A typo corrected back to the previous query within the debounce window - cursor.next('') - query.next('abcd') - cursor.next('') - query.next('abc') - await vi.advanceTimersByTimeAsync(600) +test('fetchAssets omits empty query and cursor params', async () => { + axiosGet.mockClear() - expect(emissions).toHaveLength(2) + await fetchAssets({ + projectId: 'project', + dataset: 'dataset', + shop: 'example.myshopify.com', + query: ' ', + cursor: '', + resultsPerPage: 42, + }) - subscription.unsubscribe() - vi.useRealTimers() + expect(axiosGet.mock.calls[0]?.[0]).not.toContain('query=') + expect(axiosGet.mock.calls[0]?.[0]).not.toContain('cursor=') + expect(axiosGet.mock.calls[0]?.[1]?.headers).toEqual({}) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db0f143dfd..e97894e24e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,8 +146,8 @@ catalogs: specifier: ^19.2.8 version: 19.2.8 react-photo-album: - specifier: ^3.6.0 - version: 3.6.0 + specifier: ^3.6.1 + version: 3.6.1 react-rx: specifier: ^4.2.5 version: 4.2.5 @@ -1914,7 +1914,7 @@ importers: version: 4.18.1 react-photo-album: specifier: 'catalog:' - version: 3.6.0(@types/react@19.2.18)(react@19.2.8) + version: 3.6.1(@types/react@19.2.18)(react@19.2.8) devDependencies: '@sanity/tsconfig': specifier: 'catalog:' @@ -2858,15 +2858,9 @@ importers: pretty-ms: specifier: ^8.0.0 version: 8.0.0 - react-infinite-scroll-component: - specifier: ^6.1.1 - version: 6.1.1(react@19.2.8) react-photo-album: - specifier: ^2.4.1 - version: 2.4.1(react@19.2.8) - rxjs: specifier: 'catalog:' - version: 7.8.2 + version: 3.6.1(@types/react@19.2.18)(react@19.2.8) video.js: specifier: 'catalog:' version: 7.21.7 @@ -10395,11 +10389,6 @@ packages: peerDependencies: react: '*' - react-infinite-scroll-component@6.1.1: - resolution: {integrity: sha512-R8YoOyiNDynSWmfVme5LHslsKrP+/xcRUWR2ies8UgUab9dtyw5ECnMCVPPmnmjjF4MWQmfVdRwRWcWaDgeyMA==} - peerDependencies: - react: '>=16.0.0' - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -10415,14 +10404,8 @@ packages: peerDependencies: react: '>=16.13.1' - react-photo-album@2.4.1: - resolution: {integrity: sha512-dzqP5QbYAugA0uZTl3qsVldckzDXYDkDOvA8CpACl51hSEfhJmCfwhbnI4WBHnETQHv48nnNQ1jhrulst8njLA==} - engines: {node: '>=12'} - peerDependencies: - react: '>=16.8.0' - - react-photo-album@3.6.0: - resolution: {integrity: sha512-W9NgI+0XxOYF/FLQJ/ZiKsizNQtGtgDdiFgojmTmpBKDGeGiWKfSzmbw3v9WAqzimPeiFJ2sEb9DO0nHRHP/OA==} + react-photo-album@3.6.1: + resolution: {integrity: sha512-m4pTIpgEpdAJgRnf3DfISHfIHMjJkouWJdKUyGy9UaFBBePv1kolqKTzAmZrfQcoZBVfT/g9vvcc39o1RfwZgA==} engines: {node: '>=18'} peerDependencies: '@types/react': ^18 || ^19 @@ -11097,10 +11080,6 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - throttle-debounce@2.3.0: - resolution: {integrity: sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==} - engines: {node: '>=8'} - throttleit@2.1.0: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} @@ -19861,11 +19840,6 @@ snapshots: dependencies: react: 19.2.8 - react-infinite-scroll-component@6.1.1(react@19.2.8): - dependencies: - react: 19.2.8 - throttle-debounce: 2.3.0 - react-is@16.13.1: {} react-is@17.0.2: {} @@ -19877,11 +19851,7 @@ snapshots: jerrypick: 1.1.2 react: 19.2.8 - react-photo-album@2.4.1(react@19.2.8): - dependencies: - react: 19.2.8 - - react-photo-album@3.6.0(@types/react@19.2.18)(react@19.2.8): + react-photo-album@3.6.1(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 optionalDependencies: @@ -21062,8 +21032,6 @@ snapshots: transitivePeerDependencies: - react-native-b4a - throttle-debounce@2.3.0: {} - throttleit@2.1.0: {} through2@2.0.5: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7f25269857..442643d534 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -65,7 +65,7 @@ catalog: react-hook-form: ^7.84.0 react-icons: ^5.7.0 react-is: ^19.2.8 - react-photo-album: ^3.6.0 + react-photo-album: ^3.6.1 react-rx: ^4.2.5 rxjs: ^7.8.2 sanity: ^6.7.0 From ee950454bf845dc6dc3f6b1e74785a557b5e0d85 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 11 Aug 2026 13:48:34 +0000 Subject: [PATCH 2/2] fix(shopify-assets): scope picker fetch errors to their query Errors lived in component state written from async callbacks that were not tied to the query that produced them, so the search debounce timer could clear a fresh failure and a superseded query's rejection could replace newer results with a blocking error. Resolve each query's request to a loaded/failed result consumed through the per-query Suspense boundary, and let load-more rejections surface via the InfiniteScroll error slot so the search input stays usable. --- .../src/components/ShopifyAssetPicker.tsx | 108 +++++++++--------- 1 file changed, 57 insertions(+), 51 deletions(-) diff --git a/plugins/sanity-plugin-shopify-assets/src/components/ShopifyAssetPicker.tsx b/plugins/sanity-plugin-shopify-assets/src/components/ShopifyAssetPicker.tsx index f491e2d706..0cc37c7ed8 100644 --- a/plugins/sanity-plugin-shopify-assets/src/components/ShopifyAssetPicker.tsx +++ b/plugins/sanity-plugin-shopify-assets/src/components/ShopifyAssetPicker.tsx @@ -29,6 +29,7 @@ const RESULTS_PER_PAGE = 42 const PHOTO_SPACING = 2 const PHOTO_PADDING = 1 const SEARCH_DEBOUNCE_MS = 500 +const MISSING_DOMAIN_ERROR = 'Please configure your Shopify domain in the plugin config' const StyledDialog = styled(Dialog)` & > [data-ui='DialogCard'] > [data-ui='Card'] { @@ -49,6 +50,14 @@ function mapShopifyFileToPhoto(file: ShopifyFile) { type ShopifyPhoto = ReturnType +/** + * A failed request resolves to a `failed` result instead of setting shared error + * state, so a rejection from a superseded query cannot overwrite newer results. + */ +type GalleryData = + | {status: 'loaded'; response: ShopifyAPIResponse} + | {status: 'failed'; message: string} + function createFetcher(params: {projectId: string; dataset: string; shop: string; token?: string}) { return async function fetcher(query: string, cursor: string): Promise { return fetchAssets({ @@ -74,6 +83,21 @@ function getErrorMessage(err: unknown): string { return 'An error occurred - check plugin configuration' } +function ErrorCard({message}: {message: string}) { + return ( + + + + + + + {message} + + + + ) +} + export interface AssetPickerProps extends ObjectInputProps { shopifyDomain: string isOpen: boolean @@ -89,16 +113,10 @@ export default function ShopifyAssetPicker(props: AssetPickerProps) { const [query, setQuery] = useState('') const [debouncedQuery, setDebouncedQuery] = useState('') - const [apiError, setApiError] = useState('') const scrollContainerRef = useRef(null) - const error = shopifyDomain - ? apiError - : 'Please configure your Shopify domain in the plugin config' - useEffect(() => { const timeout = window.setTimeout(() => { - setApiError('') setDebouncedQuery(query) }, SEARCH_DEBOUNCE_MS) @@ -116,17 +134,16 @@ export default function ShopifyAssetPicker(props: AssetPickerProps) { [projectId, dataset, shopifyDomain, token], ) - const initialDataPromise = useMemo( - () => - fetcher(debouncedQuery, '').catch((err: unknown) => { - setApiError(getErrorMessage(err)) - return { - assets: [], - pageInfo: {cursor: '', hasNextPage: false}, - } satisfies ShopifyAPIResponse - }), - [debouncedQuery, fetcher], - ) + const initialDataPromise = useMemo>(() => { + if (!shopifyDomain) { + return Promise.resolve({status: 'failed', message: MISSING_DOMAIN_ERROR}) + } + + return fetcher(debouncedQuery, '').then( + (response) => ({status: 'loaded', response}), + (err: unknown) => ({status: 'failed', message: getErrorMessage(err)}), + ) + }, [debouncedQuery, fetcher, shopifyDomain]) const handleSearchTermChanged = (event: ChangeEvent) => { setQuery(event.currentTarget.value) @@ -159,18 +176,7 @@ export default function ShopifyAssetPicker(props: AssetPickerProps) { height="stretch" style={{overflow: 'hidden scroll', overflowX: 'clip', overflowY: 'scroll'}} > - {error ? ( - - - - - - - {error} - - - - ) : ( + {shopifyDomain ? ( <> @@ -194,11 +200,12 @@ export default function ShopifyAssetPicker(props: AssetPickerProps) { fetcher={fetcher} scrollContainerRef={scrollContainerRef} onSelect={handleSelect} - onError={setApiError} initialDataPromise={initialDataPromise} /> + ) : ( + )} @@ -210,24 +217,27 @@ function ShopifyAssetGallery({ fetcher, scrollContainerRef, onSelect, - onError, initialDataPromise, }: { query: string fetcher: (query: string, cursor: string) => Promise scrollContainerRef: React.RefObject onSelect: (file: ShopifyFile) => void - onError: (message: string) => void - initialDataPromise: Promise + initialDataPromise: Promise }) { const data = use(initialDataPromise) // Remounted per query via `key`, so the initial pageInfo is the pagination baseline. - const paginationRef = useRef({ - cursor: data.pageInfo.cursor, - hasNextPage: data.pageInfo.hasNextPage, - }) + const paginationRef = useRef( + data.status === 'loaded' + ? {cursor: data.response.pageInfo.cursor, hasNextPage: data.response.pageInfo.hasNextPage} + : {cursor: '', hasNextPage: false}, + ) - const initialPhotos = data.assets.map(mapShopifyFileToPhoto) + if (data.status === 'failed') { + return + } + + const initialPhotos = data.response.assets.map(mapShopifyFileToPhoto) return ( <> @@ -247,24 +257,20 @@ function ShopifyAssetGallery({ return null } - try { - const results = await fetcher(query, paginationRef.current.cursor) - paginationRef.current = { - cursor: results.pageInfo.cursor, - hasNextPage: results.pageInfo.hasNextPage, - } - - if (results.assets.length === 0) { - return null - } + const results = await fetcher(query, paginationRef.current.cursor) + paginationRef.current = { + cursor: results.pageInfo.cursor, + hasNextPage: results.pageInfo.hasNextPage, + } - return results.assets.map(mapShopifyFileToPhoto) - } catch (err) { - onError(getErrorMessage(err)) + if (results.assets.length === 0) { return null } + + return results.assets.map(mapShopifyFileToPhoto) }} loading={} + error={} finished={