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,