mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 08:27:42 +00:00
fix: clear stale node-update changelog notes when a refetch fails (#1494)
When the advertised version changed and the follow-up release-notes request failed (non-OK response, network rejection, or unparseable JSON), the previously loaded notes, version label, and link were never cleared, so the changelog kept showing the prior version's notes as if they described the new advertised update and did not retry. The settled-version latch then suppressed an automatic refetch. The changelog now drops the loaded notes, version, and link the moment it commits to fetching for a different advertised version, so any non-matching, null, or failed result falls through to the empty state with the online changelog link. The skeleton covers the in-flight gap, and the settled-version latch is preserved so a failure does not loop.
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog' })} />);
|
||||
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(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog', updateStatuses: bumped })} />);
|
||||
|
||||
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(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog' })} />);
|
||||
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(<NodeUpdatesSheet {...baseProps({ initialTab: 'changelog', updateStatuses: bumped })} />);
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user