From 27ba3fc433309aec294e64193030ac77ea07799d Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Sat, 21 Feb 2026 04:05:21 +0200 Subject: [PATCH] nightshift: fix YouTube API load hang on script failure The loadYouTubeAPI() function cached a promise that would never resolve if the script failed to load (network error, ad blocker, etc). This permanently broke the YouTube player until page reload. - Add script error handler that rejects promise and resets cache - Add 10s timeout as fallback for hung loads - Reset apiLoadPromise on failure so subsequent mounts can retry - Add tests for error handling and retry behavior Co-Authored-By: Claude Opus 4.6 --- src/components/YouTubePlayer.test.tsx | 61 +++++++++++++++++++++++++++ src/components/YouTubePlayer.tsx | 27 ++++++++++-- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/components/YouTubePlayer.test.tsx b/src/components/YouTubePlayer.test.tsx index 68189e7..1c6faee 100644 --- a/src/components/YouTubePlayer.test.tsx +++ b/src/components/YouTubePlayer.test.tsx @@ -172,4 +172,65 @@ describe('YouTubePlayer', () => { // Check destroy was called expect(mockPlayerInstance.destroy).toHaveBeenCalled() }) + + it('shows error when YouTube API script fails to load', async () => { + // Remove YT so the component tries to load the script + ;(window as unknown as { YT: unknown }).YT = undefined + + render() + + // The component should have created a script tag - fire error on it + await waitFor(() => { + const script = document.getElementById('youtube-iframe-api') + expect(script).not.toBeNull() + }) + + const script = document.getElementById('youtube-iframe-api')! + act(() => { + script.dispatchEvent(new Event('error')) + }) + + await waitFor(() => { + expect(screen.getByText('Failed to load YouTube player')).toBeInTheDocument() + }) + + // Clean up the script tag for other tests + script.remove() + }) + + it('allows retry after script load failure', async () => { + // First: fail the load + ;(window as unknown as { YT: unknown }).YT = undefined + + const { unmount } = render() + + await waitFor(() => { + const script = document.getElementById('youtube-iframe-api') + expect(script).not.toBeNull() + }) + + const script = document.getElementById('youtube-iframe-api')! + act(() => { + script.dispatchEvent(new Event('error')) + }) + + await waitFor(() => { + expect(screen.getByText('Failed to load YouTube player')).toBeInTheDocument() + }) + + unmount() + script.remove() + + // Second: retry with YT available should succeed + ;(window as unknown as { YT: unknown }).YT = { + Player: MockYTPlayer, + PlayerState: { UNSTARTED: -1, ENDED: 0, PLAYING: 1, PAUSED: 2, BUFFERING: 3, CUED: 5 }, + } + + render() + + await waitFor(() => { + expect(MockYTPlayer).toHaveBeenCalled() + }) + }) }) diff --git a/src/components/YouTubePlayer.tsx b/src/components/YouTubePlayer.tsx index b389be9..a3aa97b 100644 --- a/src/components/YouTubePlayer.tsx +++ b/src/components/YouTubePlayer.tsx @@ -83,6 +83,8 @@ interface YouTubePlayerProps { let apiLoadPromise: Promise | null = null +const API_LOAD_TIMEOUT_MS = 10000 + function loadYouTubeAPI(): Promise { if (apiLoadPromise) return apiLoadPromise @@ -90,34 +92,53 @@ function loadYouTubeAPI(): Promise { return Promise.resolve() } - apiLoadPromise = new Promise((resolve) => { + apiLoadPromise = new Promise((resolve, reject) => { if (typeof window === 'undefined') { resolve() return } + const timeout = setTimeout(() => { + apiLoadPromise = null + reject(new Error('YouTube API load timed out')) + }, API_LOAD_TIMEOUT_MS) + + const onLoad = () => { + clearTimeout(timeout) + resolve() + } + + const onError = () => { + clearTimeout(timeout) + apiLoadPromise = null + reject(new Error('Failed to load YouTube API script')) + } + const existingScript = document.getElementById('youtube-iframe-api') if (existingScript) { if (window.YT && window.YT.Player) { + clearTimeout(timeout) resolve() } else { const originalCallback = window.onYouTubeIframeAPIReady window.onYouTubeIframeAPIReady = () => { originalCallback?.() - resolve() + onLoad() } + existingScript.addEventListener('error', onError) } return } window.onYouTubeIframeAPIReady = () => { - resolve() + onLoad() } const script = document.createElement('script') script.id = 'youtube-iframe-api' script.src = 'https://www.youtube.com/iframe_api' script.async = true + script.addEventListener('error', onError) document.body.appendChild(script) })