diff --git a/src/__tests__/fetch-utils.spec.ts b/src/__tests__/fetch-utils.spec.ts new file mode 100644 index 00000000..67a453f7 --- /dev/null +++ b/src/__tests__/fetch-utils.spec.ts @@ -0,0 +1,51 @@ +import { parseReleaseVersions } from '../hooks/fetch-release-versions' +import { getFetchErrorState } from '../hooks/fetch-utils' + +describe('parseReleaseVersions', () => { + it('keeps valid semver release entries and drops invalid upstream payload lines', () => { + expect( + parseReleaseVersions( + ['0.85.0', '0.84.1', '404: Not Found', '', ' main'].join('\n') + ) + ).toEqual(['0.85.0', '0.84.1']) + }) +}) + +describe('getFetchErrorState', () => { + it('turns failed HTTP responses into a user-facing error state', () => { + const response = new Response('Not Found', { + status: 404, + statusText: 'Not Found', + }) + + expect( + getFetchErrorState({ + title: 'Could not load upstream content', + error: response, + url: 'https://example.com/RELEASES', + }) + ).toEqual({ + title: 'Could not load upstream content', + description: 'Request failed with 404 Not Found.', + status: 404, + url: 'https://example.com/RELEASES', + }) + }) + + it('keeps thrown error messages visible for invalid upstream payloads', () => { + expect( + getFetchErrorState({ + title: 'Could not parse upstream content', + error: new Error( + 'The upstream release list did not contain any valid semver versions.' + ), + url: 'https://example.com/RELEASES', + }) + ).toEqual({ + title: 'Could not parse upstream content', + description: + 'The upstream release list did not contain any valid semver versions.', + url: 'https://example.com/RELEASES', + }) + }) +}) diff --git a/src/components/common/DiffViewer.tsx b/src/components/common/DiffViewer.tsx index cb3bbec6..612e3fb9 100644 --- a/src/components/common/DiffViewer.tsx +++ b/src/components/common/DiffViewer.tsx @@ -15,6 +15,7 @@ import UsefulContentSection from './UsefulContentSection' import BinaryDownload from './BinaryDownload' import ViewStyleOptions from './Diff/DiffViewStyleOptions' import CompletedFilesCounter from './CompletedFilesCounter' +import FetchErrorAlert from './FetchErrorAlert' import { useFetchDiff } from '../../hooks/fetch-diff' import type { Theme } from '../../theme' import type { File } from 'gitdiff-parser' @@ -84,7 +85,7 @@ const DiffViewer = ({ appName, appPackage, }: DiffViewerProps) => { - const { isLoading, isDone, diff } = useFetchDiff({ + const { isLoading, isDone, diff, error, retry } = useFetchDiff({ shouldShowDiff, packageName, language, @@ -192,6 +193,14 @@ const DiffViewer = ({ ) } + if (error) { + return ( + + + + ) + } + const diffSectionProps = { diff: diff, getDiffKey: getDiffKey, diff --git a/src/components/common/FetchErrorAlert.tsx b/src/components/common/FetchErrorAlert.tsx new file mode 100644 index 00000000..815caf40 --- /dev/null +++ b/src/components/common/FetchErrorAlert.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import { Alert, Button, Typography } from 'antd' +import type { FetchErrorState } from '../../hooks/fetch-utils' + +const { Text } = Typography + +const buildDescription = (error: FetchErrorState) => ( +
+
{error.description}
+ {error.url && ( + + {error.url} + + )} +
+) + +const FetchErrorAlert = ({ + error, + onRetry, +}: { + error: FetchErrorState + onRetry: () => void +}) => ( + Retry} + /> +) + +export default FetchErrorAlert diff --git a/src/components/common/VersionSelector.tsx b/src/components/common/VersionSelector.tsx index c138a712..c90fd91f 100644 --- a/src/components/common/VersionSelector.tsx +++ b/src/components/common/VersionSelector.tsx @@ -5,6 +5,7 @@ import semver from 'semver/preload' import queryString from 'query-string' import { Select } from '.' import UpgradeButton from './UpgradeButton' +import FetchErrorAlert from './FetchErrorAlert' import { useFetchReleaseVersions } from '../../hooks/fetch-release-versions' import { updateURL } from '../../utils/update-url' import { deviceSizes } from '../../utils/device-sizes' @@ -234,9 +235,10 @@ const VersionSelector = ({ appPackage: string appName?: string }) => { - const { isLoading, isDone, releaseVersions } = useFetchReleaseVersions({ - packageName, - }) + const { isLoading, isDone, releaseVersions, error, retry } = + useFetchReleaseVersions({ + packageName, + }) const [allVersions, setAllVersions] = useState([]) const [fromVersionList, setFromVersionList] = useState([]) const [toVersionList, setToVersionList] = useState([]) @@ -308,11 +310,12 @@ const VersionSelector = ({ setHasVersionsFromURL(!!doesHaveVersionsInURL) } - if (isDone) { + if (isDone && !error) { fetchVersions() } }, [ isDone, + error, releaseVersions, setLocalFromVersion, setLocalToVersion, @@ -403,7 +406,11 @@ const VersionSelector = ({ /> - + {error ? ( + + ) : ( + + )} ) } diff --git a/src/hooks/fetch-diff.ts b/src/hooks/fetch-diff.ts index b59aa536..cc552c66 100644 --- a/src/hooks/fetch-diff.ts +++ b/src/hooks/fetch-diff.ts @@ -2,6 +2,11 @@ import { useEffect, useState } from 'react' import { parseDiff } from 'react-diff-view' import type { File } from 'gitdiff-parser' import { getDiffURL } from '../utils' +import { + fetchText, + getFetchErrorState, + type FetchErrorState, +} from './fetch-utils' const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)) @@ -25,35 +30,68 @@ export const useFetchDiff = ({ const [isLoading, setIsLoading] = useState(true) const [isDone, setIsDone] = useState(false) const [diff, setDiff] = useState([]) + const [error, setError] = useState(null) + const [retryCount, setRetryCount] = useState(0) + + const retry = () => + setRetryCount((currentRetryCount) => currentRetryCount + 1) useEffect(() => { const fetchDiff = async () => { + const diffURL = getDiffURL({ + packageName, + language, + fromVersion, + toVersion, + }) + setIsLoading(true) setIsDone(false) + setError(null) - const [response] = await Promise.all([ - fetch(getDiffURL({ packageName, language, fromVersion, toVersion })), - delay(300), - ]) - - const diff = await response.text() - - setDiff(movePackageJsonToTop(parseDiff(diff))) + try { + const [diffText] = await Promise.all([fetchText(diffURL), delay(300)]) + const parsedDiff = parseDiff(diffText) - setIsLoading(false) - setIsDone(true) + if (parsedDiff.length === 0) { + throw new Error( + 'The upstream diff file was empty or could not be parsed into file changes.' + ) + } - return + setDiff(movePackageJsonToTop(parsedDiff)) + } catch (error) { + setDiff([]) + setError( + getFetchErrorState({ + title: 'Could not load this upgrade diff', + error, + url: diffURL, + }) + ) + } finally { + setIsLoading(false) + setIsDone(true) + } } if (shouldShowDiff) { fetchDiff() } - }, [shouldShowDiff, packageName, language, fromVersion, toVersion]) + }, [ + shouldShowDiff, + packageName, + language, + fromVersion, + toVersion, + retryCount, + ]) return { isLoading, isDone, diff, + error, + retry, } } diff --git a/src/hooks/fetch-release-versions.ts b/src/hooks/fetch-release-versions.ts index 702ae87c..22a275f3 100644 --- a/src/hooks/fetch-release-versions.ts +++ b/src/hooks/fetch-release-versions.ts @@ -1,5 +1,17 @@ import { useEffect, useState } from 'react' +import semver from 'semver/preload' import { getReleasesFileURL } from '../utils' +import { + fetchText, + getFetchErrorState, + type FetchErrorState, +} from './fetch-utils' + +export const parseReleaseVersions = (releaseVersionsText: string): string[] => + releaseVersionsText + .split('\n') + .map((releaseVersion) => releaseVersion.trim()) + .filter((releaseVersion) => semver.valid(releaseVersion)) export const useFetchReleaseVersions = ({ packageName, @@ -9,32 +21,54 @@ export const useFetchReleaseVersions = ({ const [isLoading, setIsLoading] = useState(true) const [isDone, setIsDone] = useState(false) const [releaseVersions, setReleaseVersions] = useState([]) + const [error, setError] = useState(null) + const [retryCount, setRetryCount] = useState(0) + + const retry = () => + setRetryCount((currentRetryCount) => currentRetryCount + 1) useEffect(() => { const fetchReleaseVersions = async () => { + const releasesFileURL = getReleasesFileURL({ packageName }) + setIsLoading(true) setIsDone(false) + setError(null) - const response = await fetch(getReleasesFileURL({ packageName })) - - const releaseVersions = (await response.text()) - .split('\n') - .filter(Boolean) - - setReleaseVersions(releaseVersions) + try { + const releaseVersionsText = await fetchText(releasesFileURL) + const releaseVersions = parseReleaseVersions(releaseVersionsText) - setIsLoading(false) - setIsDone(true) + if (releaseVersions.length === 0) { + throw new Error( + 'The upstream release list did not contain any valid semver versions.' + ) + } - return + setReleaseVersions(releaseVersions) + } catch (error) { + setReleaseVersions([]) + setError( + getFetchErrorState({ + title: 'Could not load React Native versions', + error, + url: releasesFileURL, + }) + ) + } finally { + setIsLoading(false) + setIsDone(true) + } } fetchReleaseVersions() - }, [packageName]) + }, [packageName, retryCount]) return { isLoading, isDone, releaseVersions, + error, + retry, } } diff --git a/src/hooks/fetch-utils.ts b/src/hooks/fetch-utils.ts new file mode 100644 index 00000000..3b797af4 --- /dev/null +++ b/src/hooks/fetch-utils.ts @@ -0,0 +1,49 @@ +export interface FetchErrorState { + title: string + description: string + status?: number + url?: string +} + +export const getFetchErrorState = ({ + title, + error, + url, +}: { + title: string + error: unknown + url: string +}): FetchErrorState => { + if (error instanceof Response) { + return { + title, + description: `Request failed with ${error.status} ${error.statusText}.`, + status: error.status, + url, + } + } + + if (error instanceof Error) { + return { + title, + description: error.message, + url, + } + } + + return { + title, + description: 'An unexpected error occurred while loading upstream data.', + url, + } +} + +export const fetchText = async (url: string): Promise => { + const response = await fetch(url) + + if (!response.ok) { + throw response + } + + return response.text() +}