Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/__tests__/fetch-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
})
})
})
11 changes: 10 additions & 1 deletion src/components/common/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -84,7 +85,7 @@ const DiffViewer = ({
appName,
appPackage,
}: DiffViewerProps) => {
const { isLoading, isDone, diff } = useFetchDiff({
const { isLoading, isDone, diff, error, retry } = useFetchDiff({
shouldShowDiff,
packageName,
language,
Expand Down Expand Up @@ -192,6 +193,14 @@ const DiffViewer = ({
)
}

if (error) {
return (
<Container>
<FetchErrorAlert error={error} onRetry={retry} />
</Container>
)
}

const diffSectionProps = {
diff: diff,
getDiffKey: getDiffKey,
Expand Down
34 changes: 34 additions & 0 deletions src/components/common/FetchErrorAlert.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div>
<div>{error.description}</div>
{error.url && (
<Text type="secondary" copyable={{ text: error.url }}>
{error.url}
</Text>
)}
</div>
)

const FetchErrorAlert = ({
error,
onRetry,
}: {
error: FetchErrorState
onRetry: () => void
}) => (
<Alert
type="error"
showIcon
message={error.title}
description={buildDescription(error)}
action={<Button onClick={onRetry}>Retry</Button>}
/>
)

export default FetchErrorAlert
17 changes: 12 additions & 5 deletions src/components/common/VersionSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string[]>([])
const [fromVersionList, setFromVersionList] = useState<string[]>([])
const [toVersionList, setToVersionList] = useState<string[]>([])
Expand Down Expand Up @@ -308,11 +310,12 @@ const VersionSelector = ({
setHasVersionsFromURL(!!doesHaveVersionsInURL)
}

if (isDone) {
if (isDone && !error) {
fetchVersions()
}
}, [
isDone,
error,
releaseVersions,
setLocalFromVersion,
setLocalToVersion,
Expand Down Expand Up @@ -403,7 +406,11 @@ const VersionSelector = ({
/>
</Selectors>

<UpgradeButton ref={upgradeButtonEl} onShowDiff={onShowDiff} />
{error ? (
<FetchErrorAlert error={error} onRetry={retry} />
) : (
<UpgradeButton ref={upgradeButtonEl} onShowDiff={onShowDiff} />
)}
</Fragment>
)
}
Expand Down
62 changes: 50 additions & 12 deletions src/hooks/fetch-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -25,35 +30,68 @@ export const useFetchDiff = ({
const [isLoading, setIsLoading] = useState<boolean>(true)
const [isDone, setIsDone] = useState<boolean>(false)
const [diff, setDiff] = useState<File[]>([])
const [error, setError] = useState<FetchErrorState | null>(null)
const [retryCount, setRetryCount] = useState<number>(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,
}
}
56 changes: 45 additions & 11 deletions src/hooks/fetch-release-versions.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,32 +21,54 @@ export const useFetchReleaseVersions = ({
const [isLoading, setIsLoading] = useState<boolean>(true)
const [isDone, setIsDone] = useState<boolean>(false)
const [releaseVersions, setReleaseVersions] = useState<string[]>([])
const [error, setError] = useState<FetchErrorState | null>(null)
const [retryCount, setRetryCount] = useState<number>(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,
}
}
49 changes: 49 additions & 0 deletions src/hooks/fetch-utils.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
const response = await fetch(url)

if (!response.ok) {
throw response
}

return response.text()
}