diff --git a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx index cf3fb9c9..5f035f1a 100644 --- a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx +++ b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx @@ -72,34 +72,42 @@ export function NodeUpdatesSheet({ useEffect(() => { if (!open || loadingRelease) return; if (loadedForVersion === advertisedLatest) return; + // Drop any previously loaded notes before fetching for a (possibly) newer + // advertised version. If this fetch mismatches, returns null, errors, or + // rejects, the panel must fall to the empty state rather than keep showing + // the prior version's notes; a confirmed match repopulates below. The + // skeleton (loadingRelease) covers the in-flight gap, so this does not flash. + setReleaseNotes(null); + setReleaseHtmlUrl(null); + setReleaseVersion(null); setLoadingRelease(true); const recheck = recheckingUpdates ? '?recheck=true' : ''; apiFetch(`/fleet/update-status/release-notes${recheck}`, { localOnly: true }) .then(res => res.ok ? res.json() as Promise<{ version: string | null; releaseNotes: string | null; htmlUrl: string | null }> : null) .then(data => { - if (data) { - // Bind strictly to the advertised update: only render notes the - // endpoint confirms belong to the advertised version. The version - // lookup and the release-notes lookup use independent caches (and - // version can fall back to Docker Hub while notes are GitHub-only), - // so a drifted response must fall through to the empty state with - // the online changelog link rather than show another version's notes. - const matches = data.version !== null && data.version === advertisedLatest; - setReleaseNotes(matches ? data.releaseNotes : null); - setReleaseHtmlUrl(matches ? data.htmlUrl : null); - setReleaseVersion(matches ? data.version : null); + // Bind strictly to the advertised update: render only notes the + // endpoint confirms belong to the advertised version. The version + // lookup and the release-notes lookup use independent caches (and + // version can fall back to Docker Hub while notes are GitHub-only), + // so a drifted, null, or failed response keeps the cleared state set + // above and falls through to the empty state with the online link. + if (data && data.version !== null && data.version === advertisedLatest) { + setReleaseNotes(data.releaseNotes); + setReleaseHtmlUrl(data.htmlUrl); + setReleaseVersion(data.version); } }) .catch((err) => { - // Informational panel: a failure falls through to the empty - // state (with an online changelog link) rather than a toast, - // but leave a breadcrumb so the failure is diagnosable. + // Informational panel: a failure falls through to the empty state + // (notes already cleared above) rather than a toast, but leave a + // breadcrumb so the failure is diagnosable. console.warn('[Fleet] Release-notes fetch failed:', err); }) .finally(() => { setLoadingRelease(false); // Record the advertised version this fetch settled for, so a null - // result does not loop and a later version change forces a refetch. + // or failed result does not loop and a later version change forces + // a refetch. setLoadedForVersion(advertisedLatest); }); }, [open, advertisedLatest, loadedForVersion, loadingRelease, recheckingUpdates]); diff --git a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx index ba01aab1..6bddbf8a 100644 --- a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx @@ -133,6 +133,49 @@ describe('NodeUpdatesSheet', () => { await waitFor(() => expect(releaseCalls()).toBe(2)); }); + it('clears stale notes when the refetch after a version change returns a non-OK response', async () => { + apiFetchMock.mockResolvedValue({ ok: true, json: async () => ({ version: '1.1.0', releaseNotes: '## v1.1.0 notes', htmlUrl: 'https://example.com/v1.1.0' }) }); + const releaseCalls = () => apiFetchMock.mock.calls.filter(c => String(c[0]).includes('release-notes')).length; + const { rerender } = render(); + await screen.findByRole('heading', { name: 'v1.1.0 notes' }); + expect(releaseCalls()).toBe(1); + + // Advertised version moves to 1.2.0 but the refetch fails (HTTP 500). The + // previously loaded 1.1.0 notes must not linger as the 1.2.0 changelog. + apiFetchMock.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) }); + const bumped = STATUSES.map(s => ({ ...s, latestVersion: '1.2.0' })); + rerender(); + + expect(await screen.findByText('No release notes to show')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'v1.1.0 notes' })).not.toBeInTheDocument(); + expect(screen.queryByText('Release v1.1.0')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /View on GitHub/ })).not.toBeInTheDocument(); + // The failed refetch settles without looping. + await waitFor(() => expect(releaseCalls()).toBe(2)); + }); + + it('clears stale notes when the refetch after a version change rejects', async () => { + let calls = 0; + apiFetchMock.mockImplementation(() => { + calls += 1; + return calls === 1 + ? Promise.resolve({ ok: true, json: async () => ({ version: '1.1.0', releaseNotes: '## v1.1.0 notes', htmlUrl: null }) }) + : Promise.reject(new Error('network down')); + }); + const releaseCalls = () => apiFetchMock.mock.calls.filter(c => String(c[0]).includes('release-notes')).length; + const { rerender } = render(); + await screen.findByRole('heading', { name: 'v1.1.0 notes' }); + + // A rejected (network/JSON) refetch after the version change must also clear + // the stale notes rather than leave them mislabelled as the new version. + const bumped = STATUSES.map(s => ({ ...s, latestVersion: '1.2.0' })); + rerender(); + + expect(await screen.findByText('No release notes to show')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'v1.1.0 notes' })).not.toBeInTheDocument(); + await waitFor(() => expect(releaseCalls()).toBe(2)); + }); + it('does not refetch loaded notes on re-render when the version is unchanged', async () => { apiFetchMock.mockResolvedValue({ ok: true,