-
Notifications
You must be signed in to change notification settings - Fork 0
Night Shift: fix YouTube API hang on script failure #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(<YouTubePlayer videoId="test123" />) | ||
|
|
||
| // 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(<YouTubePlayer videoId="test123" />) | ||
|
|
||
| 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(<YouTubePlayer videoId="retry123" />) | ||
|
|
||
| await waitFor(() => { | ||
| expect(MockYTPlayer).toHaveBeenCalled() | ||
| }) | ||
| }) | ||
|
Comment on lines
+201
to
+235
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Strengthen the retry assertion to verify the correct videoId. The assertion at line 233 only checks that ♻️ Proposed fix await waitFor(() => {
- expect(MockYTPlayer).toHaveBeenCalled()
+ const lastCall = MockYTPlayer.mock.calls[MockYTPlayer.mock.calls.length - 1]
+ expect((lastCall[1] as { videoId?: string }).videoId).toBe('retry123')
})🤖 Prompt for AI Agents |
||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,41 +83,62 @@ interface YouTubePlayerProps { | |
|
|
||
| let apiLoadPromise: Promise<void> | null = null | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check references to apiLoadPromise in both files
rg -n 'apiLoadPromise' src/components/YouTubePlayer.tsx
echo "---"
rg -n 'apiLoadPromise' src/components/YouTubePlayer.test.tsxRepository: EtanHey/songscript Length of output: 312 🏁 Script executed: #!/bin/bash
# Get the full test file to understand current test setup
wc -l src/components/YouTubePlayer.test.tsxRepository: EtanHey/songscript Length of output: 103 🏁 Script executed: #!/bin/bash
# Read the test file to understand test setup and state management
cat -n src/components/YouTubePlayer.test.tsxRepository: EtanHey/songscript Length of output: 8124 🏁 Script executed: #!/bin/bash
# Verify the loadYouTubeAPI function implementation to confirm the caching behavior
sed -n '84,145p' src/components/YouTubePlayer.tsxRepository: EtanHey/songscript Length of output: 1662 Reset The module-scoped 🤖 Prompt for AI Agents |
||
|
|
||
| const API_LOAD_TIMEOUT_MS = 10000 | ||
|
|
||
| function loadYouTubeAPI(): Promise<void> { | ||
| if (apiLoadPromise) return apiLoadPromise | ||
|
|
||
| if (typeof window !== 'undefined' && window.YT && window.YT.Player) { | ||
| 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')) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Failed script tag not removed, breaking retry mechanismMedium Severity The Additional Locations (1) |
||
|
|
||
| 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 | ||
| } | ||
|
Comment on lines
117
to
131
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Edge case: existing script that already errored before this code runs. If a previous attempt inserted the A simple mitigation: when an existing script is found and 🛡️ Suggested improvement } else {
+ // If the existing script already failed, reject immediately
+ if ((existingScript as HTMLScriptElement).error ||
+ ((existingScript as HTMLScriptElement).readyState === undefined &&
+ !(existingScript as HTMLScriptElement).src)) {
+ onError()
+ return
+ }
const originalCallback = window.onYouTubeIframeAPIReadyAlternatively, a simpler approach: remove the stale script tag and fall through to create a fresh one: ♻️ Alternative: remove stale script and re-create 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?.()
- onLoad()
- }
- existingScript.addEventListener('error', onError)
+ } else {
+ // Script exists but YT isn't ready — remove it and create a fresh one
+ existingScript.remove()
}
- return
+ if (window.YT && window.YT.Player) return
}This is a narrow edge case and the timeout does cover it, so this is not blocking. 🤖 Prompt for AI Agents |
||
|
|
||
| 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) | ||
| }) | ||
|
|
||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Missing test: timeout rejection path.
The
API_LOAD_TIMEOUT_MS(10 s) timeout is a core part of this fix — it's the fallback when neitherloadnorerrorfires. There's no test that verifies this behavior. Consider adding a test usingvi.useFakeTimers()to advance time past the timeout and assert that the promise rejects andapiLoadPromiseis reset for retry.Sketch:
🤖 Prompt for AI Agents