From ce30bbbb3248074867447f32b624c3de7f752810 Mon Sep 17 00:00:00 2001 From: Timothy Date: Wed, 22 Jul 2026 17:42:15 +0200 Subject: [PATCH] fix(552): don't reload the stale-token URL on preview Retry (review regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review of 60a0c505 caught a regression the prior fix introduced: onRetry cleared the token cache and bumped playToken but kept the old resolvedSrc, so HlsPlayer reloaded the stale-token URL before the remint resolved — a duplicate manifest session and a stale 401 that could stick the panel as failed even after the fresh stream succeeded. Null resolvedSrc in onRetry before bumping playToken so the player unmounts until the async effect resolves the freshly-minted URL. Added a controlled-async test proving the stale-token URL is never reloaded and the retry loads the new token (validated by negative control: the test fails with the fix removed, and only that test). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../channels/ChannelPreviewPanel.test.tsx | 23 +++++++++++++++++++ .../screens/channels/ChannelPreviewPanel.tsx | 5 ++++ 2 files changed, 28 insertions(+) diff --git a/web/src/screens/channels/ChannelPreviewPanel.test.tsx b/web/src/screens/channels/ChannelPreviewPanel.test.tsx index e470862bf..ce46e11e3 100644 --- a/web/src/screens/channels/ChannelPreviewPanel.test.tsx +++ b/web/src/screens/channels/ChannelPreviewPanel.test.tsx @@ -235,6 +235,29 @@ describe('ChannelPreviewPanel', () => { expect(resetIptvTokenCacheMock).toHaveBeenCalled(); }); + it('on Retry re-mints the token and never reloads the stale-token URL', async () => { + // withIptvToken returns a DIFFERENT token per call (simulating a remint after resetIptvTokenCache). + let n = 0; + withIptvTokenMock.mockImplementation((url: string) => Promise.resolve(`${url}?access_token=t${++n}`)); + + await renderPanel( + + ); + + expect(hlsMock.loadSource).toHaveBeenCalledTimes(1); + expect(hlsMock.loadSource).toHaveBeenNthCalledWith(1, '/iptv/channel/12.1.m3u8?access_token=t1'); + + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + await flush(); + + // Exactly two loads total: the initial (t1) and the retry (t2). The stale-token URL (t1) must NOT be + // reloaded when playToken bumps — the player unmounts until the fresh URL resolves. A regression that + // kept resolvedSrc across the bump would produce a third loadSource with the stale t1 URL. + expect(hlsMock.loadSource).toHaveBeenCalledTimes(2); + expect(hlsMock.loadSource).toHaveBeenNthCalledWith(2, '/iptv/channel/12.1.m3u8?access_token=t2'); + expect(hlsMock.loadSource.mock.calls.filter((c) => c[0] === '/iptv/channel/12.1.m3u8?access_token=t1')).toHaveLength(1); + }); + it('clicking Retry clears a prior error', async () => { await renderPanel( diff --git a/web/src/screens/channels/ChannelPreviewPanel.tsx b/web/src/screens/channels/ChannelPreviewPanel.tsx index f08d36279..dcc4b5fc8 100644 --- a/web/src/screens/channels/ChannelPreviewPanel.tsx +++ b/web/src/screens/channels/ChannelPreviewPanel.tsx @@ -118,6 +118,11 @@ export function ChannelPreviewPanel({ channel, nowPlaying, onClose, open }: Chan // token (key rotated) or a stale "JWT disabled" latch (backend reconfigured since load) can't wedge // playback. An actually-expired token would refresh on its own, but this also covers those edges (#552). resetIptvTokenCache(); + // Null resolvedSrc FIRST so the player unmounts and does not reload the OLD tokened URL when playToken + // bumps below — the fresh URL comes from the async effect after withIptvToken re-mints. Skipping this + // would load the stale token once (duplicate session / a stale 401 that sticks the panel as failed even + // after the fresh stream succeeds). + setResolvedSrc(null); failedRef.current = false; setError(null); setState('starting');