diff --git a/e2e/tests/network-resilience.spec.ts b/e2e/tests/network-resilience.spec.ts new file mode 100644 index 000000000..510b198af --- /dev/null +++ b/e2e/tests/network-resilience.spec.ts @@ -0,0 +1,196 @@ +import { test, expect } from '@playwright/test'; + +const NECTAR_URL = process.env.NECTAR_URL || process.env.BASE_URL || 'http://127.0.0.1:8000'; + +// Dismiss all tour modals +const dismissAllTours = () => { + localStorage.setItem('seen-landing-tour', 'true'); + localStorage.setItem('seen-results-tour', 'true'); + localStorage.setItem('seen-abstract-tour', 'true'); +}; + +test.describe('Network Resilience', () => { + test.describe('Offline Indicator', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(dismissAllTours); + }); + + test('shows offline banner when network is disconnected', async ({ page, context }) => { + await page.goto(NECTAR_URL); + await expect(page.locator('body')).toBeVisible(); + + // Go offline + await context.setOffline(true); + await page.evaluate(() => window.dispatchEvent(new Event('offline'))); + + // Should show offline banner + await expect(page.getByText('You are offline. Some features may not be available.')).toBeVisible({ + timeout: 5000, + }); + }); + + test('shows reconnected banner when coming back online', async ({ page, context }) => { + await page.goto(NECTAR_URL); + + // Go offline first + await context.setOffline(true); + await page.evaluate(() => window.dispatchEvent(new Event('offline'))); + await expect(page.getByText('You are offline. Some features may not be available.')).toBeVisible({ + timeout: 5000, + }); + + // Go back online + await context.setOffline(false); + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + + // Should show reconnected banner + await expect(page.getByText('You are back online.')).toBeVisible({ timeout: 5000 }); + }); + + test('reconnected banner can be manually dismissed', async ({ page, context }) => { + await page.goto(NECTAR_URL); + await page.waitForLoadState('networkidle'); + + // Go offline then online + await context.setOffline(true); + await page.evaluate(() => window.dispatchEvent(new Event('offline'))); + await expect(page.getByText('You are offline. Some features may not be available.')).toBeVisible({ + timeout: 5000, + }); + + await context.setOffline(false); + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + + const reconnectedBanner = page.getByText('You are back online.'); + await expect(reconnectedBanner).toBeVisible({ timeout: 5000 }); + + // Click dismiss button + await page.getByRole('button', { name: 'Dismiss' }).click(); + + await expect(reconnectedBanner).not.toBeVisible(); + }); + }); + + test.describe('Search Page Error Handling', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(dismissAllTours); + }); + + test('shows error alert when search API fails with 500', async ({ page }) => { + // Intercept search API and return 500 for all requests + await page.route('**/search/query**', (route) => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { msg: 'Internal Server Error' } }), + }); + }); + + await page.goto(`${NECTAR_URL}/search?q=test`); + + // Should show "Something went wrong" error message + await expect(page.getByText('Something went wrong')).toBeVisible({ timeout: 20000 }); + + // Should have "Try Again" button + await expect(page.getByRole('button', { name: 'Try Again' })).toBeVisible(); + }); + + test('shows error when search API returns 503 service unavailable', async ({ page }) => { + await page.route('**/search/query**', (route) => { + route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: { msg: 'Service Unavailable' } }), + }); + }); + + await page.goto(`${NECTAR_URL}/search?q=test`); + + // Should show error state with Try Again button + await expect(page.getByRole('button', { name: 'Try Again' })).toBeVisible({ timeout: 20000 }); + }); + + test('automatic retry succeeds after transient failures', async ({ page }) => { + let requestCount = 0; + + await page.route('**/search/query**', (route) => { + requestCount++; + if (requestCount <= 2) { + // First 2 requests fail + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: { msg: 'Internal Server Error' } }), + }); + } else { + // 3rd request succeeds (within retry limit) + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + responseHeader: { status: 0 }, + response: { + numFound: 1, + start: 0, + docs: [ + { + bibcode: '2020Test..001A', + title: ['Test Article After Retry'], + author: ['Test Author'], + }, + ], + }, + }), + }); + } + }); + + await page.goto(`${NECTAR_URL}/search?q=test`); + + // Automatic retries should eventually succeed and show results + await expect(page.getByText('Test Article After Retry')).toBeVisible({ timeout: 20000 }); + }); + }); + + test.describe('Page Error Boundaries', () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(dismissAllTours); + }); + + test('search page handles malformed API response gracefully', async ({ page }) => { + await page.route('**/search/query**', (route) => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: 'not valid json {{{{', + }); + }); + + await page.goto(`${NECTAR_URL}/search?q=test`); + + // Page should still render without crashing + await expect(page.locator('body')).toBeVisible(); + + // Wait for error handling + await page.waitForTimeout(5000); + + // Should show some form of error handling - navigation should still work + const navVisible = await page + .getByRole('navigation') + .isVisible() + .catch(() => false); + const errorVisible = await page + .getByText(/something went wrong|error/i) + .first() + .isVisible() + .catch(() => false); + const tryAgainVisible = await page + .getByRole('button', { name: 'Try Again' }) + .isVisible() + .catch(() => false); + + // At minimum, navigation should be visible (page didn't completely crash) + expect(navVisible || errorVisible || tryAgainVisible).toBe(true); + }); + }); +}); diff --git a/src/api/search/search.ts b/src/api/search/search.ts index f89a05590..4abfeecd6 100644 --- a/src/api/search/search.ts +++ b/src/api/search/search.ts @@ -122,7 +122,6 @@ export function useSearch( queryFn: fetchSearch, meta: { params }, select, - retry: (failCount, error) => failCount < 1 && axios.isAxiosError(error) && error.response?.status !== 400, ...(options as Omit, 'queryKey' | 'queryFn' | 'select'>), }); } diff --git a/src/components/NetworkStatusIndicator/NetworkStatusIndicator.tsx b/src/components/NetworkStatusIndicator/NetworkStatusIndicator.tsx new file mode 100644 index 000000000..e9dc0f53c --- /dev/null +++ b/src/components/NetworkStatusIndicator/NetworkStatusIndicator.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import { Alert, AlertIcon, AlertDescription, CloseButton, Slide } from '@chakra-ui/react'; +import { useNetworkStatus } from '@/lib/useNetworkStatus'; + +export const NetworkStatusIndicator = () => { + const { isOnline, wasOffline } = useNetworkStatus(); + const [showReconnected, setShowReconnected] = useState(false); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + if (isOnline && wasOffline) { + setShowReconnected(true); + setDismissed(false); + const timer = setTimeout(() => { + setShowReconnected(false); + }, 5000); + return () => clearTimeout(timer); + } + }, [isOnline, wasOffline]); + + useEffect(() => { + if (!isOnline) { + setDismissed(false); + } + }, [isOnline]); + + const handleDismiss = () => { + setDismissed(true); + setShowReconnected(false); + }; + + if (dismissed && isOnline) { + return null; + } + + return ( + <> + + + + You are offline. Some features may not be available. + + + + + + + You are back online. + + + + + ); +}; diff --git a/src/components/NetworkStatusIndicator/index.ts b/src/components/NetworkStatusIndicator/index.ts new file mode 100644 index 000000000..8da19401f --- /dev/null +++ b/src/components/NetworkStatusIndicator/index.ts @@ -0,0 +1 @@ +export { NetworkStatusIndicator } from './NetworkStatusIndicator'; diff --git a/src/components/PageErrorBoundary/PageErrorBoundary.tsx b/src/components/PageErrorBoundary/PageErrorBoundary.tsx new file mode 100644 index 000000000..54ea0ea24 --- /dev/null +++ b/src/components/PageErrorBoundary/PageErrorBoundary.tsx @@ -0,0 +1,64 @@ +import { ReactNode } from 'react'; +import { QueryErrorResetBoundary } from '@tanstack/react-query'; +import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; +import { handleBoundaryError } from '@/lib/errorHandler'; +import { PageErrorFallback } from '@/components/PageErrorFallback'; +import { Center, Spinner } from '@chakra-ui/react'; + +interface PageErrorBoundaryProps { + children: ReactNode; + pageName?: string; + fallbackTitle?: string; + onReset?: () => void; + fallbackRender?: (props: FallbackProps) => ReactNode; + hideHomeButton?: boolean; +} + +const DefaultLoading = () => ( +
+ +
+); + +export const PageErrorBoundary = ({ + children, + pageName = 'Page', + fallbackTitle, + onReset, + fallbackRender, + hideHomeButton, +}: PageErrorBoundaryProps) => { + const renderFallback = + fallbackRender ?? + ((props: FallbackProps) => ( + + )); + + return ( + + {({ reset }) => ( + { + onReset?.(); + reset(); + }} + onError={(error, errorInfo) => { + handleBoundaryError(error, errorInfo, { + component: pageName, + }); + }} + fallbackRender={renderFallback} + > + {children} + + )} + + ); +}; + +export { DefaultLoading }; diff --git a/src/components/PageErrorBoundary/index.ts b/src/components/PageErrorBoundary/index.ts new file mode 100644 index 000000000..1bacc7d15 --- /dev/null +++ b/src/components/PageErrorBoundary/index.ts @@ -0,0 +1 @@ +export { PageErrorBoundary, DefaultLoading } from './PageErrorBoundary'; diff --git a/src/components/PageErrorFallback/PageErrorFallback.tsx b/src/components/PageErrorFallback/PageErrorFallback.tsx new file mode 100644 index 000000000..b11cbf335 --- /dev/null +++ b/src/components/PageErrorFallback/PageErrorFallback.tsx @@ -0,0 +1,136 @@ +import { + Box, + Button, + Center, + Code, + Container, + Heading, + Icon, + Link, + Text, + useColorModeValue, + VStack, +} from '@chakra-ui/react'; +import { WarningTwoIcon } from '@chakra-ui/icons'; +import { ArrowPathIcon, HomeIcon } from '@heroicons/react/24/solid'; +import { useRouter } from 'next/router'; +import { FallbackProps } from 'react-error-boundary'; +import { categorizeError, getErrorMessage, isTransientError } from '@/lib/retry'; + +interface PageErrorFallbackProps extends Partial { + title?: string; + showTechnicalDetails?: boolean; + hideHomeButton?: boolean; +} + +export const PageErrorFallback = ({ + error, + resetErrorBoundary, + title = 'Something went wrong', + showTechnicalDetails = process.env.NODE_ENV === 'development', + hideHomeButton = false, +}: PageErrorFallbackProps) => { + const cardBg = useColorModeValue('white', 'gray.800'); + const borderColor = useColorModeValue('gray.200', 'gray.700'); + const iconColor = useColorModeValue('red.400', 'red.300'); + const router = useRouter(); + + const errorCategory = categorizeError(error); + const userMessage = getErrorMessage(error); + const canRetry = isTransientError(error); + + const handleRetry = () => { + if (resetErrorBoundary) { + resetErrorBoundary(); + } else { + router.reload(); + } + }; + + const handleGoHome = () => { + void router.push('/'); + }; + + return ( +
+ + + + + + {title} + + + + {userMessage} + + + + {canRetry && ( + + )} + {!hideHomeButton && ( + + )} + + + {showTechnicalDetails && error && ( + + + Technical details ({errorCategory}): + + + {error.message || String(error)} + + + )} + + + + If this problem persists,{' '} + + contact us + + + + + +
+ ); +}; diff --git a/src/components/PageErrorFallback/index.ts b/src/components/PageErrorFallback/index.ts new file mode 100644 index 000000000..de41daa8f --- /dev/null +++ b/src/components/PageErrorFallback/index.ts @@ -0,0 +1 @@ +export { PageErrorFallback } from './PageErrorFallback'; diff --git a/src/lib/__tests__/retry.test.ts b/src/lib/__tests__/retry.test.ts new file mode 100644 index 000000000..9f122735f --- /dev/null +++ b/src/lib/__tests__/retry.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; +import { AxiosError } from 'axios'; +import { + shouldRetry, + isNetworkError, + isTimeoutError, + isRetryableStatusCode, + isNonRetryableError, + categorizeError, + getErrorMessage, + isTransientError, + calculateBackoffDelay, + DEFAULT_RETRY_CONFIG, +} from '../retry'; + +const createAxiosError = (status?: number, code?: string, hasResponse = true, hasRequest = true): AxiosError => { + const error = new Error('Test error') as AxiosError; + error.isAxiosError = true; + error.code = code; + error.response = hasResponse ? ({ status } as AxiosError['response']) : undefined; + error.request = hasRequest ? {} : undefined; + error.config = {} as AxiosError['config']; + error.toJSON = () => ({}); + return error; +}; + +describe('retry utilities', () => { + describe('isNetworkError', () => { + it('returns true for network errors (no response, has request)', () => { + const error = createAxiosError(undefined, undefined, false, true); + expect(isNetworkError(error)).toBe(true); + }); + + it('returns false when there is a response', () => { + const error = createAxiosError(500); + expect(isNetworkError(error)).toBe(false); + }); + + it('returns false for non-axios errors', () => { + expect(isNetworkError(new Error('test'))).toBe(false); + }); + }); + + describe('isTimeoutError', () => { + it('returns true for ECONNABORTED', () => { + const error = createAxiosError(undefined, 'ECONNABORTED'); + expect(isTimeoutError(error)).toBe(true); + }); + + it('returns true for ETIMEDOUT', () => { + const error = createAxiosError(undefined, 'ETIMEDOUT'); + expect(isTimeoutError(error)).toBe(true); + }); + + it('returns false for other codes', () => { + const error = createAxiosError(500, 'ENOTFOUND'); + expect(isTimeoutError(error)).toBe(false); + }); + }); + + describe('isRetryableStatusCode', () => { + it('returns true for 408 Request Timeout', () => { + expect(isRetryableStatusCode(408)).toBe(true); + }); + + it('returns true for 429 Too Many Requests', () => { + expect(isRetryableStatusCode(429)).toBe(true); + }); + + it('returns true for 5xx server errors', () => { + expect(isRetryableStatusCode(500)).toBe(true); + expect(isRetryableStatusCode(502)).toBe(true); + expect(isRetryableStatusCode(503)).toBe(true); + expect(isRetryableStatusCode(504)).toBe(true); + }); + + it('returns false for 4xx client errors', () => { + expect(isRetryableStatusCode(400)).toBe(false); + expect(isRetryableStatusCode(401)).toBe(false); + expect(isRetryableStatusCode(404)).toBe(false); + }); + + it('returns true for undefined status (no response)', () => { + expect(isRetryableStatusCode(undefined)).toBe(true); + }); + }); + + describe('isNonRetryableError', () => { + it('returns true for 400 Bad Request', () => { + const error = createAxiosError(400); + expect(isNonRetryableError(error)).toBe(true); + }); + + it('returns true for 404 Not Found', () => { + const error = createAxiosError(404); + expect(isNonRetryableError(error)).toBe(true); + }); + + it('returns false for 500 Server Error', () => { + const error = createAxiosError(500); + expect(isNonRetryableError(error)).toBe(false); + }); + }); + + describe('shouldRetry', () => { + it('returns false when max retries exceeded', () => { + const error = createAxiosError(500); + expect(shouldRetry(2, error)).toBe(false); + }); + + it('returns false for non-axios errors', () => { + expect(shouldRetry(0, new Error('test'))).toBe(false); + }); + + it('returns false for 400 Bad Request', () => { + const error = createAxiosError(400); + expect(shouldRetry(0, error)).toBe(false); + }); + + it('returns true for network errors', () => { + const error = createAxiosError(undefined, undefined, false, true); + expect(shouldRetry(0, error)).toBe(true); + }); + + it('returns true for timeout errors', () => { + const error = createAxiosError(undefined, 'ECONNABORTED'); + expect(shouldRetry(0, error)).toBe(true); + }); + + it('returns true for 500 Server Error', () => { + const error = createAxiosError(500); + expect(shouldRetry(0, error)).toBe(true); + }); + + it('returns true for 503 Service Unavailable', () => { + const error = createAxiosError(503); + expect(shouldRetry(0, error)).toBe(true); + }); + }); + + describe('categorizeError', () => { + it('returns network for network errors', () => { + const error = createAxiosError(undefined, undefined, false, true); + expect(categorizeError(error)).toBe('network'); + }); + + it('returns timeout for timeout errors', () => { + const error = createAxiosError(undefined, 'ECONNABORTED'); + expect(categorizeError(error)).toBe('timeout'); + }); + + it('returns not_found for 404', () => { + const error = createAxiosError(404); + expect(categorizeError(error)).toBe('not_found'); + }); + + it('returns unauthorized for 401', () => { + const error = createAxiosError(401); + expect(categorizeError(error)).toBe('unauthorized'); + }); + + it('returns rate_limited for 429', () => { + const error = createAxiosError(429); + expect(categorizeError(error)).toBe('rate_limited'); + }); + + it('returns server_error for 5xx', () => { + const error = createAxiosError(500); + expect(categorizeError(error)).toBe('server_error'); + }); + + it('returns unknown for non-axios errors', () => { + expect(categorizeError(new Error('test'))).toBe('unknown'); + }); + }); + + describe('getErrorMessage', () => { + it('returns network message for network errors', () => { + const error = createAxiosError(undefined, undefined, false, true); + expect(getErrorMessage(error)).toContain('internet connection'); + }); + + it('returns timeout message for timeout errors', () => { + const error = createAxiosError(undefined, 'ECONNABORTED'); + expect(getErrorMessage(error)).toContain('too long'); + }); + + it('returns rate limit message for 429', () => { + const error = createAxiosError(429); + expect(getErrorMessage(error)).toContain('Too many requests'); + }); + }); + + describe('isTransientError', () => { + it('returns true for network errors', () => { + const error = createAxiosError(undefined, undefined, false, true); + expect(isTransientError(error)).toBe(true); + }); + + it('returns true for server errors', () => { + const error = createAxiosError(500); + expect(isTransientError(error)).toBe(true); + }); + + it('returns false for client errors', () => { + const error = createAxiosError(400); + expect(isTransientError(error)).toBe(false); + }); + }); + + describe('calculateBackoffDelay', () => { + it('returns base delay for first retry', () => { + const delay = calculateBackoffDelay(0); + expect(delay).toBeGreaterThanOrEqual(DEFAULT_RETRY_CONFIG.baseDelay); + expect(delay).toBeLessThanOrEqual(DEFAULT_RETRY_CONFIG.baseDelay * 1.3); + }); + + it('returns exponentially increasing delays', () => { + const delay0 = calculateBackoffDelay(0); + const delay1 = calculateBackoffDelay(1); + const delay2 = calculateBackoffDelay(2); + + expect(delay1).toBeGreaterThan(delay0); + expect(delay2).toBeGreaterThan(delay1); + }); + + it('respects maxDelay', () => { + const delay = calculateBackoffDelay(10, { ...DEFAULT_RETRY_CONFIG, maxDelay: 5000 }); + expect(delay).toBeLessThanOrEqual(5000); + }); + }); +}); diff --git a/src/lib/__tests__/useNetworkStatus.test.ts b/src/lib/__tests__/useNetworkStatus.test.ts new file mode 100644 index 000000000..c5f700c15 --- /dev/null +++ b/src/lib/__tests__/useNetworkStatus.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useNetworkStatus } from '../useNetworkStatus'; + +describe('useNetworkStatus', () => { + const originalNavigator = global.navigator; + + beforeEach(() => { + vi.spyOn(window, 'addEventListener'); + vi.spyOn(window, 'removeEventListener'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(global, 'navigator', { + value: originalNavigator, + writable: true, + }); + }); + + it('returns online status from navigator', () => { + Object.defineProperty(global, 'navigator', { + value: { onLine: true }, + writable: true, + }); + + const { result } = renderHook(() => useNetworkStatus()); + expect(result.current.isOnline).toBe(true); + }); + + it('sets up event listeners on mount', () => { + renderHook(() => useNetworkStatus()); + + expect(window.addEventListener).toHaveBeenCalledWith('online', expect.any(Function)); + expect(window.addEventListener).toHaveBeenCalledWith('offline', expect.any(Function)); + }); + + it('removes event listeners on unmount', () => { + const { unmount } = renderHook(() => useNetworkStatus()); + unmount(); + + expect(window.removeEventListener).toHaveBeenCalledWith('online', expect.any(Function)); + expect(window.removeEventListener).toHaveBeenCalledWith('offline', expect.any(Function)); + }); + + it('updates isOnline when online event fires', () => { + Object.defineProperty(global, 'navigator', { + value: { onLine: false }, + writable: true, + }); + + const { result } = renderHook(() => useNetworkStatus()); + expect(result.current.isOnline).toBe(false); + + act(() => { + window.dispatchEvent(new Event('online')); + }); + + expect(result.current.isOnline).toBe(true); + }); + + it('updates isOnline when offline event fires', () => { + Object.defineProperty(global, 'navigator', { + value: { onLine: true }, + writable: true, + }); + + const { result } = renderHook(() => useNetworkStatus()); + expect(result.current.isOnline).toBe(true); + + act(() => { + window.dispatchEvent(new Event('offline')); + }); + + expect(result.current.isOnline).toBe(false); + }); + + it('sets wasOffline flag when reconnecting', () => { + Object.defineProperty(global, 'navigator', { + value: { onLine: false }, + writable: true, + }); + + const { result } = renderHook(() => useNetworkStatus()); + + act(() => { + window.dispatchEvent(new Event('online')); + }); + + expect(result.current.wasOffline).toBe(true); + }); + + it('updates lastOnlineAt when coming back online', () => { + Object.defineProperty(global, 'navigator', { + value: { onLine: false }, + writable: true, + }); + + const { result } = renderHook(() => useNetworkStatus()); + expect(result.current.lastOnlineAt).toBeNull(); + + act(() => { + window.dispatchEvent(new Event('online')); + }); + + expect(result.current.lastOnlineAt).toBeInstanceOf(Date); + }); +}); diff --git a/src/lib/retry.ts b/src/lib/retry.ts new file mode 100644 index 000000000..0b3580de2 --- /dev/null +++ b/src/lib/retry.ts @@ -0,0 +1,221 @@ +import axios from 'axios'; + +/** + * HTTP status codes that indicate transient failures that should be retried. + */ +const RETRYABLE_STATUS_CODES = new Set([ + 408, // Request Timeout + 429, // Too Many Requests + 500, // Internal Server Error + 502, // Bad Gateway + 503, // Service Unavailable + 504, // Gateway Timeout +]); + +/** + * HTTP status codes that indicate client errors that should NOT be retried. + */ +const NON_RETRYABLE_CLIENT_ERRORS = new Set([ + 400, // Bad Request (e.g., invalid query syntax) + 401, // Unauthorized + 403, // Forbidden + 404, // Not Found + 405, // Method Not Allowed + 409, // Conflict + 410, // Gone + 422, // Unprocessable Entity +]); + +export interface RetryConfig { + maxRetries: number; + baseDelay: number; + maxDelay: number; +} + +export const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxRetries: 2, + baseDelay: 1000, + maxDelay: 10000, +}; + +/** + * Calculates exponential backoff delay with jitter. + */ +export function calculateBackoffDelay(retryCount: number, config: RetryConfig = DEFAULT_RETRY_CONFIG): number { + const exponentialDelay = config.baseDelay * Math.pow(2, retryCount); + const jitter = Math.random() * 0.3 * exponentialDelay; + return Math.min(exponentialDelay + jitter, config.maxDelay); +} + +/** + * Determines if an error is a network/connection error (no response received). + */ +export function isNetworkError(error: unknown): boolean { + if (!axios.isAxiosError(error)) { + return false; + } + return !error.response && Boolean(error.request); +} + +/** + * Determines if an error is a timeout error. + */ +export function isTimeoutError(error: unknown): boolean { + if (!axios.isAxiosError(error)) { + return false; + } + return error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT'; +} + +/** + * Determines if an HTTP status code is retryable. + */ +export function isRetryableStatusCode(status: number | undefined): boolean { + if (status === undefined) { + return true; + } + return RETRYABLE_STATUS_CODES.has(status); +} + +/** + * Determines if an error should not be retried based on status code. + */ +export function isNonRetryableError(error: unknown): boolean { + if (!axios.isAxiosError(error)) { + return false; + } + const status = error.response?.status; + if (status === undefined) { + return false; + } + return NON_RETRYABLE_CLIENT_ERRORS.has(status); +} + +/** + * Determines if a request should be retried based on the error and retry count. + * This is the primary function to use for React Query retry configuration. + */ +export function shouldRetry(failCount: number, error: unknown, config: RetryConfig = DEFAULT_RETRY_CONFIG): boolean { + if (failCount >= config.maxRetries) { + return false; + } + + if (!axios.isAxiosError(error)) { + return false; + } + + if (isNonRetryableError(error)) { + return false; + } + + if (isNetworkError(error) || isTimeoutError(error)) { + return true; + } + + return isRetryableStatusCode(error.response?.status); +} + +/** + * Creates a retry function for React Query with custom configuration. + */ +export function createRetryFn(config: Partial = {}) { + const mergedConfig = { ...DEFAULT_RETRY_CONFIG, ...config }; + return (failCount: number, error: unknown) => shouldRetry(failCount, error, mergedConfig); +} + +/** + * Retry delay function for React Query. + * Calculates backoff delay based on the retry count. + */ +export function retryDelayFn(retryCount: number): number { + return calculateBackoffDelay(retryCount); +} + +/** + * Categorizes an error into a user-friendly type for display. + */ +export type ErrorCategory = + | 'network' + | 'timeout' + | 'not_found' + | 'unauthorized' + | 'forbidden' + | 'rate_limited' + | 'client_error' + | 'server_error' + | 'unknown'; + +export function categorizeError(error: unknown): ErrorCategory { + if (!axios.isAxiosError(error)) { + return 'unknown'; + } + + if (isNetworkError(error)) { + return 'network'; + } + + if (isTimeoutError(error)) { + return 'timeout'; + } + + const status = error.response?.status; + + if (status === undefined) { + return 'unknown'; + } + + switch (status) { + case 401: + return 'unauthorized'; + case 403: + return 'forbidden'; + case 404: + return 'not_found'; + case 429: + return 'rate_limited'; + default: + if (status >= 400 && status < 500) { + return 'client_error'; + } + if (status >= 500) { + return 'server_error'; + } + return 'unknown'; + } +} + +/** + * Returns a user-friendly error message based on error category. + */ +export function getErrorMessage(error: unknown): string { + const category = categorizeError(error); + + switch (category) { + case 'network': + return 'Unable to connect. Please check your internet connection and try again.'; + case 'timeout': + return 'The request took too long to complete. Please try again.'; + case 'not_found': + return 'The requested resource could not be found.'; + case 'unauthorized': + return 'Your session has expired. Please sign in again.'; + case 'forbidden': + return 'You do not have permission to access this resource.'; + case 'rate_limited': + return 'Too many requests. Please wait a moment and try again.'; + case 'client_error': + return 'There was a problem with your request. Please check your input and try again.'; + case 'server_error': + return 'The server encountered an error. Please try again later.'; + default: + return 'An unexpected error occurred. Please try again.'; + } +} + +/** + * Determines if an error category is likely transient and worth retrying. + */ +export function isTransientError(error: unknown): boolean { + const category = categorizeError(error); + return ['network', 'timeout', 'rate_limited', 'server_error'].includes(category); +} diff --git a/src/lib/useCreateQueryClient.ts b/src/lib/useCreateQueryClient.ts index 88a231976..9cb5329e8 100644 --- a/src/lib/useCreateQueryClient.ts +++ b/src/lib/useCreateQueryClient.ts @@ -1,6 +1,7 @@ import { useState } from 'react'; import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'; import { handleQueryError } from './errorHandler'; +import { shouldRetry, retryDelayFn } from './retry'; export const useCreateQueryClient = () => { const queryCache = new QueryCache({ @@ -42,9 +43,10 @@ export const useCreateQueryClient = () => { queries: { refetchOnWindowFocus: false, refetchOnMount: false, - refetchOnReconnect: false, + refetchOnReconnect: true, staleTime: Infinity, - retry: false, + retry: shouldRetry, + retryDelay: retryDelayFn, retryOnMount: false, }, }, diff --git a/src/lib/useNetworkStatus.ts b/src/lib/useNetworkStatus.ts new file mode 100644 index 000000000..068048ea7 --- /dev/null +++ b/src/lib/useNetworkStatus.ts @@ -0,0 +1,69 @@ +import { useState, useEffect, useCallback } from 'react'; + +export interface NetworkStatus { + isOnline: boolean; + wasOffline: boolean; + lastOnlineAt: Date | null; +} + +/** + * Hook to track network connectivity status. + * Returns current online status and whether the user was recently offline. + */ +export function useNetworkStatus(): NetworkStatus { + const [isOnline, setIsOnline] = useState(() => { + if (typeof navigator === 'undefined') { + return true; + } + return navigator.onLine; + }); + + const [wasOffline, setWasOffline] = useState(false); + const [lastOnlineAt, setLastOnlineAt] = useState(null); + + const handleOnline = useCallback(() => { + setIsOnline(true); + setLastOnlineAt(new Date()); + if (!isOnline) { + setWasOffline(true); + } + }, [isOnline]); + + const handleOffline = useCallback(() => { + setIsOnline(false); + }, []); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, [handleOnline, handleOffline]); + + return { + isOnline, + wasOffline, + lastOnlineAt, + }; +} + +/** + * Hook to clear the "was offline" flag after a reconnection. + * Useful for dismissing reconnection notifications. + */ +export function useResetWasOffline(): () => void { + const [, setResetFlag] = useState(0); + + const reset = useCallback(() => { + setResetFlag((prev) => prev + 1); + }, []); + + return reset; +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index ce75ea532..8982c1dc3 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -23,6 +23,8 @@ import api from '@/api/api'; import { userKeys } from '@/api/user/user'; import { Providers } from '@/providers'; import { isValidToken } from '@/auth-utils'; +import { ErrorBoundary } from 'react-error-boundary'; +import { PageErrorFallback } from '@/components/PageErrorFallback'; if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled' && process.env.NODE_ENV !== 'production') { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -64,7 +66,9 @@ const NectarApp = memo(({ Component, pageProps }: AppProps): ReactElement => { - + + + diff --git a/src/pages/_error.tsx b/src/pages/_error.tsx index a3d897e7c..028e958e9 100644 --- a/src/pages/_error.tsx +++ b/src/pages/_error.tsx @@ -1,19 +1,141 @@ import * as Sentry from '@sentry/nextjs'; -import type { NextPage } from 'next'; +import type { NextPage, NextPageContext } from 'next'; import type { ErrorProps } from 'next/error'; -import Error from 'next/error'; +import NextError from 'next/error'; +import { Box, Button, Center, Container, Heading, Icon, Link, Text, useColorModeValue, VStack } from '@chakra-ui/react'; +import { WarningTwoIcon } from '@chakra-ui/icons'; +import { ArrowPathIcon, HomeIcon } from '@heroicons/react/24/solid'; +import { useRouter } from 'next/router'; -const CustomErrorComponent: NextPage = (props) => { - return ; +interface CustomErrorProps extends ErrorProps { + hasGetInitialPropsRun?: boolean; + err?: Error; +} + +const getErrorMessage = (statusCode: number): string => { + switch (statusCode) { + case 404: + return 'The page you are looking for could not be found.'; + case 500: + return 'The server encountered an unexpected error. Please try again later.'; + case 502: + case 503: + case 504: + return 'The service is temporarily unavailable. Please try again in a moment.'; + default: + return 'An unexpected error occurred.'; + } }; -CustomErrorComponent.getInitialProps = async (contextData) => { - // In case this is running in a serverless function, await this in order to give Sentry - // time to send the error before the lambda exits - await Sentry.captureUnderscoreErrorException(contextData); +const getErrorTitle = (statusCode: number): string => { + switch (statusCode) { + case 404: + return 'Page Not Found'; + case 500: + return 'Server Error'; + case 502: + return 'Bad Gateway'; + case 503: + return 'Service Unavailable'; + case 504: + return 'Gateway Timeout'; + default: + return 'Error'; + } +}; + +const CustomErrorComponent: NextPage = ({ statusCode }) => { + const router = useRouter(); + const cardBg = useColorModeValue('white', 'gray.800'); + const borderColor = useColorModeValue('gray.200', 'gray.700'); + const iconColor = useColorModeValue('red.400', 'red.300'); + + const canRetry = statusCode >= 500; + + const handleRetry = () => { + router.reload(); + }; + + const handleGoHome = () => { + void router.push('/'); + }; + + const handleGoBack = () => { + router.back(); + }; - // This will contain the status code of the response - return Error.getInitialProps(contextData); + return ( +
+ + + + + + {statusCode} + + + + {getErrorTitle(statusCode)} + + + + {getErrorMessage(statusCode)} + + + + {canRetry && ( + + )} + + + + + + + If this problem persists,{' '} + + contact us + + + + + +
+ ); +}; + +CustomErrorComponent.getInitialProps = async (contextData: NextPageContext) => { + await Sentry.captureUnderscoreErrorException(contextData); + return NextError.getInitialProps(contextData); }; export default CustomErrorComponent; diff --git a/src/pages/abs/[id]/abstract.tsx b/src/pages/abs/[id]/abstract.tsx index 6e93512d1..c51960efa 100644 --- a/src/pages/abs/[id]/abstract.tsx +++ b/src/pages/abs/[id]/abstract.tsx @@ -26,6 +26,7 @@ import { LocalSettings } from '@/types'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; import { IADSApiSearchParams, IDocsEntity } from '@/api/search/types'; import { useGetAbstract } from '@/api/search/search'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const AllAuthorsModal = dynamic( () => @@ -189,7 +190,13 @@ const AbstractPage: NextPage = ({ initialDoc, isAuthenticated ); }; -export default AbstractPage; +const AbstractPageWithErrorBoundary: NextPage = (props) => ( + + + +); + +export default AbstractPageWithErrorBoundary; const useTour = () => { const Shepherd = useShepherd(); diff --git a/src/pages/abs/[id]/citations.tsx b/src/pages/abs/[id]/citations.tsx index 66599149f..f7258e52d 100644 --- a/src/pages/abs/[id]/citations.tsx +++ b/src/pages/abs/[id]/citations.tsx @@ -11,6 +11,7 @@ import { useGetAbstract, useGetCitations } from '@/api/search/search'; import { IDocsEntity } from '@/api/search/types'; import { getCitationsParams } from '@/api/search/models'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const CitationsPage: NextPage = () => { const router = useRouter(); @@ -58,7 +59,13 @@ const CitationsPage: NextPage = () => { ); }; -export default CitationsPage; +const CitationsPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default CitationsPageWithErrorBoundary; export const getServerSideProps = createAbsGetServerSideProps('citations'); // export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx) => { diff --git a/src/pages/abs/[id]/coreads.tsx b/src/pages/abs/[id]/coreads.tsx index 763e483ea..f71bf11dd 100644 --- a/src/pages/abs/[id]/coreads.tsx +++ b/src/pages/abs/[id]/coreads.tsx @@ -10,6 +10,7 @@ import { AbstractRefList } from '@/components/AbstractRefList'; import { useGetAbstract, useGetCoreads } from '@/api/search/search'; import { getCoreadsParams } from '@/api/search/models'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const CoreadsPage: NextPage = () => { const router = useRouter(); @@ -42,7 +43,13 @@ const CoreadsPage: NextPage = () => { ); }; -export default CoreadsPage; +const CoreadsPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default CoreadsPageWithErrorBoundary; export const getServerSideProps = createAbsGetServerSideProps('coreads'); // export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx) => { diff --git a/src/pages/abs/[id]/credits.tsx b/src/pages/abs/[id]/credits.tsx index 6f60e25eb..9798bd309 100644 --- a/src/pages/abs/[id]/credits.tsx +++ b/src/pages/abs/[id]/credits.tsx @@ -7,6 +7,7 @@ import { ItemsSkeleton } from '@/components/ResultList'; import { APP_DEFAULTS } from '@/config'; import { useGetAbstractParams } from '@/lib/useGetAbstractParams'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; import { Alert, AlertIcon } from '@chakra-ui/react'; import { NextPage } from 'next'; import { useRouter } from 'next/router'; @@ -63,6 +64,12 @@ const CreditsPage: NextPage = () => { ); }; -export default CreditsPage; +const CreditsPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default CreditsPageWithErrorBoundary; export const getServerSideProps = createAbsGetServerSideProps('credits'); diff --git a/src/pages/abs/[id]/exportcitation/[format].tsx b/src/pages/abs/[id]/exportcitation/[format].tsx index 37c97c457..592b32794 100644 --- a/src/pages/abs/[id]/exportcitation/[format].tsx +++ b/src/pages/abs/[id]/exportcitation/[format].tsx @@ -12,6 +12,7 @@ import { useGetAbstract } from '@/api/search/search'; import { IDocsEntity } from '@/api/search/types'; import { useExportFormats } from '@/lib/useExportFormats'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const ExportCitationPage: NextPage = () => { const router = useRouter(); @@ -64,7 +65,13 @@ const ExportCitationPage: NextPage = () => { ); }; -export default ExportCitationPage; +const ExportCitationPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default ExportCitationPageWithErrorBoundary; export const getServerSideProps = createAbsGetServerSideProps((ctx) => { const rawFormat = Array.isArray(ctx.params?.format) ? ctx.params?.format.join('/') : (ctx.params?.format as string); diff --git a/src/pages/abs/[id]/graphics.tsx b/src/pages/abs/[id]/graphics.tsx index a395db7a5..e689764be 100644 --- a/src/pages/abs/[id]/graphics.tsx +++ b/src/pages/abs/[id]/graphics.tsx @@ -13,6 +13,7 @@ import { useGetGraphics } from '@/api/graphics/graphics'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faImage } from '@fortawesome/free-solid-svg-icons'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const GraphicsPage: NextPage = () => { const router = useRouter(); @@ -87,7 +88,13 @@ const GraphicsPage: NextPage = () => { ); }; -export default GraphicsPage; +const GraphicsPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default GraphicsPageWithErrorBoundary; export const getServerSideProps = createAbsGetServerSideProps('graphics'); // export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx) => { diff --git a/src/pages/abs/[id]/mentions.tsx b/src/pages/abs/[id]/mentions.tsx index a40e376af..509ce03e6 100644 --- a/src/pages/abs/[id]/mentions.tsx +++ b/src/pages/abs/[id]/mentions.tsx @@ -7,6 +7,7 @@ import { ItemsSkeleton } from '@/components/ResultList'; import { APP_DEFAULTS } from '@/config'; import { useGetAbstractParams } from '@/lib/useGetAbstractParams'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; import { Alert, AlertIcon } from '@chakra-ui/react'; import { NextPage } from 'next'; import { useRouter } from 'next/router'; @@ -63,6 +64,12 @@ const MentionsPage: NextPage = () => { ); }; -export default MentionsPage; +const MentionsPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default MentionsPageWithErrorBoundary; export const getServerSideProps = createAbsGetServerSideProps('mentions'); diff --git a/src/pages/abs/[id]/metrics.tsx b/src/pages/abs/[id]/metrics.tsx index d2a1da33d..df7267eae 100644 --- a/src/pages/abs/[id]/metrics.tsx +++ b/src/pages/abs/[id]/metrics.tsx @@ -10,6 +10,7 @@ import { useGetAbstract } from '@/api/search/search'; import { IDocsEntity } from '@/api/search/types'; import { useGetMetrics } from '@/api/metrics/metrics'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const MetricsPage: NextPage = () => { const router = useRouter(); @@ -44,7 +45,13 @@ const MetricsPage: NextPage = () => { ); }; -export default MetricsPage; +const MetricsPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default MetricsPageWithErrorBoundary; export const getServerSideProps = createAbsGetServerSideProps('metrics'); // export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx) => { diff --git a/src/pages/abs/[id]/references.tsx b/src/pages/abs/[id]/references.tsx index 88ced4d93..2d3f3a7bf 100644 --- a/src/pages/abs/[id]/references.tsx +++ b/src/pages/abs/[id]/references.tsx @@ -11,6 +11,7 @@ import { useGetAbstract, useGetReferences } from '@/api/search/search'; import { IDocsEntity } from '@/api/search/types'; import { getReferencesParams } from '@/api/search/models'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const ReferencesPage: NextPage = () => { const router = useRouter(); @@ -57,7 +58,13 @@ const ReferencesPage: NextPage = () => { ); }; -export default ReferencesPage; +const ReferencesPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default ReferencesPageWithErrorBoundary; export const getServerSideProps = createAbsGetServerSideProps('references'); // export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx) => { diff --git a/src/pages/abs/[id]/similar.tsx b/src/pages/abs/[id]/similar.tsx index d5f6a942a..81ca92c9b 100644 --- a/src/pages/abs/[id]/similar.tsx +++ b/src/pages/abs/[id]/similar.tsx @@ -13,6 +13,7 @@ import { useGetAbstract, useGetSimilar } from '@/api/search/search'; import { IDocsEntity } from '@/api/search/types'; import { getSimilarParams } from '@/api/search/models'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const SimilarPage: NextPage = () => { const router = useRouter(); @@ -44,7 +45,13 @@ const SimilarPage: NextPage = () => { ); }; -export default SimilarPage; +const SimilarPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default SimilarPageWithErrorBoundary; export const getServerSideProps = createAbsGetServerSideProps('similar'); // export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx) => { diff --git a/src/pages/abs/[id]/toc.tsx b/src/pages/abs/[id]/toc.tsx index de56361ec..f1872b05b 100644 --- a/src/pages/abs/[id]/toc.tsx +++ b/src/pages/abs/[id]/toc.tsx @@ -12,6 +12,7 @@ import { useGetAbstract, useGetToc } from '@/api/search/search'; import { IDocsEntity } from '@/api/search/types'; import { getTocParams } from '@/api/search/models'; import { createAbsGetServerSideProps } from '@/lib/serverside/absCanonicalization'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const VolumePage: NextPage = () => { const router = useRouter(); @@ -48,7 +49,13 @@ const VolumePage: NextPage = () => { ); }; -export default VolumePage; +const VolumePageWithErrorBoundary: NextPage = () => ( + + + +); + +export default VolumePageWithErrorBoundary; export const getServerSideProps = createAbsGetServerSideProps('toc'); // export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx) => { diff --git a/src/pages/classic-form.tsx b/src/pages/classic-form.tsx index 1f9c75edb..509db950a 100644 --- a/src/pages/classic-form.tsx +++ b/src/pages/classic-form.tsx @@ -12,6 +12,7 @@ import { parseAPIError } from '@/utils/common/parseAPIError'; import { useRouter } from 'next/router'; import { AppMode } from '@/types'; import { syncUrlDisciplineParam } from '@/utils/appMode'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const ClassicFormPage: NextPage<{ ssrError?: string }> = ({ ssrError }) => { const router = useRouter(); @@ -55,7 +56,13 @@ const ClassicFormPage: NextPage<{ ssrError?: string }> = ({ ssrError }) => { ); }; -export default ClassicFormPage; +const ClassicFormPageWithErrorBoundary: NextPage<{ ssrError?: string }> = (props) => ( + + + +); + +export default ClassicFormPageWithErrorBoundary; type ReqWithBody = GetServerSidePropsContext['req'] & { body: IClassicFormState; diff --git a/src/pages/feedback/associatedarticles.tsx b/src/pages/feedback/associatedarticles.tsx index 198cab78a..0d001fc63 100644 --- a/src/pages/feedback/associatedarticles.tsx +++ b/src/pages/feedback/associatedarticles.tsx @@ -4,6 +4,7 @@ import { AssociatedArticlesForm, FeedbackAlert } from '@/components/FeedbackForm import { NextPage } from 'next'; import { useMemo, useState } from 'react'; import { FeedbackLayout } from '@/components/Layout'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const AssociatedArticles: NextPage = () => { const [alertDetails, setAlertDetails] = useState<{ status: AlertStatus; title: string; description?: string }>({ @@ -71,5 +72,11 @@ const AssociatedArticles: NextPage = () => { ); }; -export default AssociatedArticles; +const AssociatedArticlesWithErrorBoundary: NextPage = () => ( + + + +); + +export default AssociatedArticlesWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/feedback/general.tsx b/src/pages/feedback/general.tsx index 71839fca5..9e62cb37b 100644 --- a/src/pages/feedback/general.tsx +++ b/src/pages/feedback/general.tsx @@ -43,6 +43,7 @@ import { parseAPIError } from '@/utils/common/parseAPIError'; import { useFeedback } from '@/api/feedback/feedback'; import { logger } from '@/logger'; import { SimpleLink } from '@/components/SimpleLink'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; type FormValues = { name: string; @@ -259,5 +260,11 @@ const General: NextPage = () => { ); }; -export default General; +const GeneralWithErrorBoundary: NextPage = () => ( + + + +); + +export default GeneralWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/feedback/missingrecord.tsx b/src/pages/feedback/missingrecord.tsx index 8b74067db..001f7f33d 100644 --- a/src/pages/feedback/missingrecord.tsx +++ b/src/pages/feedback/missingrecord.tsx @@ -11,6 +11,7 @@ import { FeedbackLayout } from '@/components/Layout'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { getSingleRecordParams } from '@/api/search/models'; import { fetchSearch, searchKeys } from '@/api/search/search'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const Record: NextPage = () => { const [alertDetails, setAlertDetails] = useState<{ status: AlertStatus; title: string; description?: string }>({ @@ -105,7 +106,13 @@ const Record: NextPage = () => { ); }; -export default Record; +const RecordWithErrorBoundary: NextPage = () => ( + + + +); + +export default RecordWithErrorBoundary; export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx) => { const { bibcode } = ctx.query; diff --git a/src/pages/feedback/missingreferences.tsx b/src/pages/feedback/missingreferences.tsx index 6417e42d1..09a5f74f8 100644 --- a/src/pages/feedback/missingreferences.tsx +++ b/src/pages/feedback/missingreferences.tsx @@ -6,6 +6,7 @@ import { useMemo, useState } from 'react'; import { FeedbackLayout } from '@/components/Layout'; import { SimpleLink } from '@/components/SimpleLink'; import { feedbackItems } from '@/components/NavBar'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const MissingReferences: NextPage = () => { const [alertDetails, setAlertDetails] = useState<{ status: AlertStatus; title: string; description?: string }>({ @@ -66,5 +67,11 @@ const MissingReferences: NextPage = () => { ); }; -export default MissingReferences; +const MissingReferencesWithErrorBoundary: NextPage = () => ( + + + +); + +export default MissingReferencesWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index e83727cc5..8bdba5ab2 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -46,6 +46,7 @@ import { getHomeSteps } from '@/components/NavBar'; import { useShepherd } from 'react-shepherd'; import { useIsClient } from '@/lib/useIsClient'; import { useScreenSize } from '@/lib/useScreenSize'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const HomePage: NextPage = () => { const { settings } = useSettings(); @@ -194,7 +195,13 @@ const HomePage: NextPage = () => { ); }; -export default HomePage; +const HomePageWithErrorBoundary: NextPage = () => ( + + + +); + +export default HomePageWithErrorBoundary; const Carousel = (props: { onSelectExample: (text: string) => void }) => { const [initialPage, setInitialPage] = useState(0); diff --git a/src/pages/journalsdb.tsx b/src/pages/journalsdb.tsx index 70a108c4f..4a3758cd2 100644 --- a/src/pages/journalsdb.tsx +++ b/src/pages/journalsdb.tsx @@ -34,6 +34,7 @@ import Head from 'next/head'; import { useRouter } from 'next/router'; import { ascend, descend, prop, sortWith } from 'ramda'; import { ChangeEvent, useMemo, useState } from 'react'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const AbstractPage: NextPage = () => { const [searchType, setSearchType] = useState('journal'); @@ -297,5 +298,11 @@ const IssnSearch = () => { ); }; -export default AbstractPage; +const JournalsDBWithErrorBoundary: NextPage = () => ( + + + +); + +export default JournalsDBWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/paper-form.tsx b/src/pages/paper-form.tsx index 1657224d7..deec2fecc 100644 --- a/src/pages/paper-form.tsx +++ b/src/pages/paper-form.tsx @@ -42,6 +42,7 @@ import { SimpleLink } from '@/components/SimpleLink'; import { IADSApiSearchParams } from '@/api/search/types'; import { AppMode } from '@/types'; import { syncUrlDisciplineParam } from '@/utils/appMode'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const MAX_SIMPLE_QUERY_BIBCODES = 50; @@ -135,7 +136,13 @@ const PaperForm: NextPage<{ error?: IPaperFormServerError }> = ({ error: ssrErro ); }; -export default PaperForm; +const PaperFormWithErrorBoundary: NextPage<{ error?: IPaperFormServerError }> = (props) => ( + + + +); + +export default PaperFormWithErrorBoundary; const validateNotEmpty = pipe(isEmpty, not); diff --git a/src/pages/public-libraries/[[...id]].tsx b/src/pages/public-libraries/[[...id]].tsx index 42eaadcbb..6f6f70faa 100644 --- a/src/pages/public-libraries/[[...id]].tsx +++ b/src/pages/public-libraries/[[...id]].tsx @@ -8,6 +8,7 @@ import { LibraryEntityPane } from '@/components/Libraries'; import { unwrapStringValue } from '@/utils/common/formatters'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { useGetLibraryEntity } from '@/api/biblib/libraries'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const PublicLibraries: NextPage = () => { const router = useRouter(); @@ -30,6 +31,12 @@ const PublicLibraries: NextPage = () => { ); }; -export default PublicLibraries; +const PublicLibrariesWithErrorBoundary: NextPage = () => ( + + + +); + +export default PublicLibrariesWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/search/author_network.tsx b/src/pages/search/author_network.tsx index 1bcdfab35..60f3cd104 100644 --- a/src/pages/search/author_network.tsx +++ b/src/pages/search/author_network.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; import { VizPageLayout } from '@/components/Layout'; import { AuthorNetworkPageContainer } from '@/components/Visualizations'; import { makeSearchParams, parseQueryFromUrl } from '@/utils/common/search'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; export const AuthorNetworkPage: NextPage = () => { const router = useRouter(); @@ -24,5 +25,11 @@ export const AuthorNetworkPage: NextPage = () => { ); }; -export default AuthorNetworkPage; +const AuthorNetworkPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default AuthorNetworkPageWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/search/citation_helper.tsx b/src/pages/search/citation_helper.tsx index eb7b9e277..e94afda03 100644 --- a/src/pages/search/citation_helper.tsx +++ b/src/pages/search/citation_helper.tsx @@ -45,6 +45,7 @@ import { AppState, useStore } from '@/store'; import { NumPerPageType } from '@/types'; import { isNumPerPageType } from '@/utils/common/guards'; import { uniq } from 'ramda'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; interface ICitationHelperPageProps { query: IADSApiSearchParams; @@ -436,4 +437,10 @@ export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx } }); -export default CitationHelperPage; +const CitationHelperPageWithErrorBoundary: NextPage = (props) => ( + + + +); + +export default CitationHelperPageWithErrorBoundary; diff --git a/src/pages/search/concept_cloud.tsx b/src/pages/search/concept_cloud.tsx index 0aa3e6399..0a6731504 100644 --- a/src/pages/search/concept_cloud.tsx +++ b/src/pages/search/concept_cloud.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; import { VizPageLayout } from '@/components/Layout'; import { ConceptCloudPageContainer } from '@/components/Visualizations'; import { parseQueryFromUrl } from '@/utils/common/search'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const ConceptCloudPage: NextPage = () => { const router = useRouter(); @@ -24,5 +25,11 @@ const ConceptCloudPage: NextPage = () => { ); }; -export default ConceptCloudPage; +const ConceptCloudPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default ConceptCloudPageWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/search/exportcitation/[format].tsx b/src/pages/search/exportcitation/[format].tsx index 3c6a757ab..8356e31eb 100644 --- a/src/pages/search/exportcitation/[format].tsx +++ b/src/pages/search/exportcitation/[format].tsx @@ -23,6 +23,7 @@ import { ExportApiFormatKey } from '@/api/export/types'; import { IADSApiSearchParams } from '@/api/search/types'; import { fetchSearchInfinite, searchKeys, useSearchInfinite } from '@/api/search/search'; import { exportCitationKeys, fetchExportCitation, fetchExportFormats } from '@/api/export/export'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; interface IExportCitationPageProps { format: string; @@ -210,4 +211,10 @@ export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx } }); -export default ExportCitationPage; +const ExportCitationPageWithErrorBoundary: NextPage = (props) => ( + + + +); + +export default ExportCitationPageWithErrorBoundary; diff --git a/src/pages/search/index.tsx b/src/pages/search/index.tsx index 02dc81626..77df698be 100644 --- a/src/pages/search/index.tsx +++ b/src/pages/search/index.tsx @@ -65,6 +65,7 @@ import { useShepherd } from 'react-shepherd'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; import { QueryErrorResetBoundary } from '@tanstack/react-query'; import { handleBoundaryError } from '@/lib/errorHandler'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const YearHistogramSlider = dynamic( () => @@ -577,7 +578,13 @@ const NoResultsMsg = () => ( /> ); -export default SearchPage; +const SearchPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default SearchPageWithErrorBoundary; /** * Shows a warning if the returned search is flagged as having partial results. diff --git a/src/pages/search/metrics.tsx b/src/pages/search/metrics.tsx index ce7885cc6..43a28d95a 100644 --- a/src/pages/search/metrics.tsx +++ b/src/pages/search/metrics.tsx @@ -8,6 +8,7 @@ import { MetricsPageContainer } from '@/components/Visualizations'; import { makeSearchParams, parseQueryFromUrl } from '@/utils/common/search'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { IADSApiSearchParams } from '@/api/search/types'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; interface IMetricsProps { originalQuery: IADSApiSearchParams; @@ -78,4 +79,10 @@ export const getServerSideProps: GetServerSideProps = composeNextGSSP(async (ctx } }); -export default MetricsPage; +const MetricsPageWithErrorBoundary: NextPage = (props) => ( + + + +); + +export default MetricsPageWithErrorBoundary; diff --git a/src/pages/search/overview.tsx b/src/pages/search/overview.tsx index ff742d25c..f94a4612b 100644 --- a/src/pages/search/overview.tsx +++ b/src/pages/search/overview.tsx @@ -6,6 +6,7 @@ import { VizPageLayout } from '@/components/Layout'; import { OverviewPageContainer } from '@/components/Visualizations'; import { makeSearchParams, parseQueryFromUrl } from '@/utils/common/search'; import { IADSApiSearchParams } from '@/api/search/types'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const OverviewPage: NextPage = () => { const router = useRouter(); @@ -32,5 +33,11 @@ const OverviewPage: NextPage = () => { ); }; -export default OverviewPage; +const OverviewPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default OverviewPageWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/search/paper_network.tsx b/src/pages/search/paper_network.tsx index d578d492b..2942831a7 100644 --- a/src/pages/search/paper_network.tsx +++ b/src/pages/search/paper_network.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; import { VizPageLayout } from '@/components/Layout'; import { PaperNetworkPageContainer } from '@/components/Visualizations'; import { makeSearchParams, parseQueryFromUrl } from '@/utils/common/search'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const PaperMetworkPage: NextPage = () => { const router = useRouter(); @@ -24,5 +25,11 @@ const PaperMetworkPage: NextPage = () => { ); }; -export default PaperMetworkPage; +const PaperNetworkPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default PaperNetworkPageWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/search/results_graph.tsx b/src/pages/search/results_graph.tsx index 7697c15d0..b8a1511d6 100644 --- a/src/pages/search/results_graph.tsx +++ b/src/pages/search/results_graph.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router'; import { ResultsGraphPageContainer } from '@/components/Visualizations'; import { VizPageLayout } from '@/components/Layout'; import { makeSearchParams, parseQueryFromUrl } from '@/utils/common/search'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const ResultsGraphPage: NextPage = () => { const router = useRouter(); @@ -24,5 +25,11 @@ const ResultsGraphPage: NextPage = () => { ); }; -export default ResultsGraphPage; +const ResultsGraphPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default ResultsGraphPageWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/user/account/forgotpassword.tsx b/src/pages/user/account/forgotpassword.tsx index 056fe1a7f..a9e083c1c 100644 --- a/src/pages/user/account/forgotpassword.tsx +++ b/src/pages/user/account/forgotpassword.tsx @@ -13,6 +13,7 @@ import { RecaptchaMessage } from '@/components/RecaptchaMessage/RecaptchaMessage import { parseAPIError } from '@/utils/common/parseAPIError'; import { IUserForgotPasswordCredentials } from '@/api/user/types'; import { useForgotPassword } from '@/api/user/user'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; export { useQuery } from '@tanstack/react-query'; @@ -96,5 +97,11 @@ const ForgotPassword: NextPage = () => { ); }; -export default ForgotPassword; +const ForgotPasswordWithErrorBoundary: NextPage = () => ( + + + +); + +export default ForgotPasswordWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/user/account/login.tsx b/src/pages/user/account/login.tsx index 42023e0a9..9d5b38bc1 100644 --- a/src/pages/user/account/login.tsx +++ b/src/pages/user/account/login.tsx @@ -29,6 +29,7 @@ import { parseAPIError } from '@/utils/common/parseAPIError'; import { IUserCredentials } from '@/api/user/types'; import { NotificationId } from '@/store/slices'; import { useStore } from '@/store'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const initialParams: IUserCredentials = { email: '', password: '' }; @@ -199,6 +200,12 @@ const LoginErrorMessage = (props: { error: AxiosError | Error }) } }; -export default Login; +const LoginWithErrorBoundary: NextPage = () => ( + + + +); + +export default LoginWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/pages/user/account/register.tsx b/src/pages/user/account/register.tsx index fe6630e5e..5b2d5269f 100644 --- a/src/pages/user/account/register.tsx +++ b/src/pages/user/account/register.tsx @@ -28,6 +28,7 @@ import { StandardAlertMessage } from '@/components/Feedbacks'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { IUserRegistrationCredentials } from '@/api/user/types'; import { useRegisterUser } from '@/api/user/user'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const initialParams: IUserRegistrationCredentials = { givenName: '', @@ -196,7 +197,13 @@ const Register: NextPage = () => { ); }; -export default Register; +const RegisterWithErrorBoundary: NextPage = () => ( + + + +); + +export default RegisterWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; const RequirementsController = ({ control }: { control: Control }) => { diff --git a/src/pages/user/account/verify/reset-password/[[...verifyToken]].tsx b/src/pages/user/account/verify/reset-password/[[...verifyToken]].tsx index 13aa8bac7..a214da625 100644 --- a/src/pages/user/account/verify/reset-password/[[...verifyToken]].tsx +++ b/src/pages/user/account/verify/reset-password/[[...verifyToken]].tsx @@ -14,6 +14,7 @@ import { SimpleLink } from '@/components/SimpleLink'; import { parseAPIError } from '@/utils/common/parseAPIError'; import { IUserResetPasswordCredentials } from '@/api/user/types'; import { useResetPassword } from '@/api/user/user'; +import { PageErrorBoundary } from '@/components/PageErrorBoundary'; const ResetPasswordPage: NextPage = () => { const router = useRouter(); @@ -116,7 +117,13 @@ const ResetPasswordPage: NextPage = () => { ); }; -export default ResetPasswordPage; +const ResetPasswordPageWithErrorBoundary: NextPage = () => ( + + + +); + +export default ResetPasswordPageWithErrorBoundary; export { injectSessionGSSP as getServerSideProps } from '@/ssr-utils'; diff --git a/src/providers.tsx b/src/providers.tsx index 1039296bb..3609a4f30 100644 --- a/src/providers.tsx +++ b/src/providers.tsx @@ -13,6 +13,7 @@ import * as Sentry from '@sentry/nextjs'; import { IADSApiSearchParams } from './api/search/types'; import { useGlobalErrorHandler } from './lib/useGlobalErrorHandler'; import { ShepherdJourneyProvider } from 'react-shepherd'; +import { NetworkStatusIndicator } from './components/NetworkStatusIndicator'; const windowState = { navigationStart: performance?.timeOrigin || performance?.timing?.navigationStart || 0, @@ -36,6 +37,7 @@ export const Providers: FC<{ pageProps: AppPageProps }> = ({ children, pageProps + {children}