From bfed2213da078be6f5e1a22424a9d4f2ab8cb2ff Mon Sep 17 00:00:00 2001 From: Arian Moeini Date: Mon, 5 Oct 2026 13:36:02 +0200 Subject: [PATCH] fix: handle preview thumbnail fetch failures --- src/Preview.tsx | 51 ++++++++++++++++++++++++------------------------ test/Preview.tsx | 48 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 test/Preview.tsx diff --git a/src/Preview.tsx b/src/Preview.tsx index d1ef176a..9c391310 100644 --- a/src/Preview.tsx +++ b/src/Preview.tsx @@ -18,20 +18,8 @@ const Preview = ({ const [image, setImage] = useState(null); useEffect(() => { - if (!src || !light || !oEmbedUrl) return; - fetchImage({ src, light, oEmbedUrl }); - }, [src, light, oEmbedUrl]); - - const fetchImage = async ({ - src, - light, - oEmbedUrl, - }: { - src: string; - light: boolean | string | React.ReactElement; - oEmbedUrl: string; - }) => { - if (React.isValidElement(light)) { + if (!src || !light || !oEmbedUrl || React.isValidElement(light)) { + setImage(null); return; } if (typeof light === 'string') { @@ -44,17 +32,30 @@ const Preview = ({ } setImage(null); - const response = await fetch(oEmbedUrl.replace('{url}', src)); - const data = await response.json(); - - if (data.thumbnail_url) { - const fetchedImage = data.thumbnail_url - .replace('height=100', 'height=480') - .replace('-d_295x166', '-d_640'); - setImage(fetchedImage); - cache[src] = fetchedImage; - } - }; + let cancelled = false; + const fetchImage = async () => { + try { + const response = await fetch(oEmbedUrl.replace('{url}', src)); + if (!response.ok) return; + const data = await response.json(); + + if (!cancelled && data.thumbnail_url) { + const fetchedImage = data.thumbnail_url + .replace('height=100', 'height=480') + .replace('-d_295x166', '-d_640'); + setImage(fetchedImage); + cache[src] = fetchedImage; + } + } catch { + // A thumbnail is optional; keep the playable preview on fetch failure. + } + }; + + void fetchImage(); + return () => { + cancelled = true; + }; + }, [src, light, oEmbedUrl]); const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { diff --git a/test/Preview.tsx b/test/Preview.tsx new file mode 100644 index 00000000..63643530 --- /dev/null +++ b/test/Preview.tsx @@ -0,0 +1,48 @@ +import './helpers/server-safe-globals.js'; +import { test } from 'zora'; +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import Preview from '../src/Preview'; + +const preview = (src: string, light: boolean | string = true, onClickPreview?: () => void) => ( + +); + +test('failed and outdated thumbnail requests leave the preview usable', async (t) => { + const originalFetch = globalThis.fetch; + let clicks = 0; + globalThis.fetch = async () => { throw new Error('network unavailable'); }; + + try { + let wrapper; + act(() => { + wrapper = create(preview('https://example.com/video-a', true, () => { clicks++; })); + }); + await Promise.resolve(); + + const element = wrapper.root.findByProps({ className: 'react-player__preview' }); + t.equal(element.props.style.backgroundImage, undefined); + element.props.onClick({}); + t.equal(clicks, 1); + act(() => { wrapper.unmount(); }); + + let resolveFirst; + globalThis.fetch = () => new Promise((resolve) => { resolveFirst = resolve; }) as Promise; + act(() => { + wrapper = create(preview('https://example.com/video-b')); + }); + act(() => { + wrapper.update(preview('https://example.com/video-c', 'https://example.com/new.jpg')); + }); + resolveFirst({ ok: true, json: async () => ({ thumbnail_url: 'https://example.com/old.jpg' }) }); + await Promise.resolve(); + await Promise.resolve(); + + const updated = wrapper.root.findByProps({ className: 'react-player__preview' }); + t.equal(updated.props.style.backgroundImage, 'url(https://example.com/new.jpg)'); + act(() => { wrapper.unmount(); }); + } finally { + globalThis.fetch = originalFetch; + } +});