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()
+}