diff --git a/examples/network-status/README.md b/examples/network-status/README.md index 374c0070..1614864e 100644 --- a/examples/network-status/README.md +++ b/examples/network-status/README.md @@ -1,42 +1,127 @@ # Network Status Example -This example demonstrates how to detect a user's network connectivity. If the user is offline, the built-in status indicator will display `Offline ❌`. If online, it will show `Online ✅`. +This example demonstrates network status handling with sophisticated features for modern web applications that need to be resilient to network issues. -The `useOfflineStatus` hook provides an `isOffline` utility that detects the user's network connectivity. It uses the `navigator` browser API. Refer to the [MDN Navigator Docs](https://developer.mozilla.org/en-US/docs/Web/API/Navigator) for limitations. +## Key Features + +- **Real-time Network Status Detection**: Monitors online/offline state using browser APIs and custom network checks +- **Resilient Fetching**: Implements automatic retries with exponential backoff for failed network requests +- **Request Timeout Control**: Configurable timeout settings to prevent hanging requests +- **Fallback URLs**: Automatic redirection to alternative endpoints when primary endpoints fail +- **Custom Network Testing Tools**: Tools to simulate various network conditions for testing Learn more about RADFish examples at the official [documentation](https://nmfs-radfish.github.io/radfish/developer-documentation/examples-and-templates#examples). Refer to the [RADFish GitHub repo](https://nmfs-radfish.github.io/radfish/) for more information and code samples. ## Preview -This example will render as shown in this screenshot: +This example renders as shown in this screenshot: ![Network Status](./src/assets/network-status.png) -## Steps +## Implementation -### 1. Import Required Dependencies +### 1. Configuring Network Features -Import the following libraries in the `App.jsx` file: +Set up the Application with network handling options: ```jsx -import React, { useEffect } from "react"; -import { useOfflineStatus } from "@nmfs-radfish/react-radfish"; -import { Alert } from "@trussworks/react-uswds"; +const app = new Application({ + network: { + // Custom timeout in milliseconds (default is 30000) + timeout: 5000, + + // Fallback URLs to use when primary endpoints fail + fallbackUrls: { + "https://nonexistent-endpoint.example.com": "https://jsonplaceholder.typicode.com/users" + }, + + // Optional custom network status handler + setIsOnline: async (networkInfo, callback) => { + // Custom logic to determine network status + try { + const response = await fetch("https://api.github.com/users", { + method: "HEAD", + signal: AbortSignal.timeout(3000) + }); + callback(response.ok); + } catch (error) { + callback(false); + } + } + } +}); ``` -### 2. Use `useOfflineStatus` to Access Network State +### 2. Using Network Status Features -Within the `HomePage` component, use `useOfflineStatus` to retrieve the `isOffline` property, which indicates whether the application is currently offline: +Access the network status features through hooks: ```jsx const HomePage = () => { - const { isOffline } = useOfflineStatus(); // Retrieve the isOffline state - - return ( -
-

Network Status Example

-

Network Status: {isOffline ? "Offline ❌" : "Online ✅"}

-
- ); + const { isOffline } = useOfflineStatus(); + const app = useApplication(); + + // Network status tag + const getNetworkStatusTag = () => { + if (isOffline) { + return Offline; + } else { + return Online; + } + }; + + // Using fetch with retry and fallback capabilities + const fetchWithRetry = async () => { + try { + const response = await app.fetchWithRetry( + "https://nonexistent-endpoint.example.com", + {}, + { + retries: 2, + retryDelay: 1000, + exponentialBackoff: true, + } + ); + const data = await response.json(); + // Handle successful response + } catch (error) { + // Handle failure after all retries + } + }; + + return ( +
+
Current Status: {getNetworkStatusTag()}
+ +
+ ); }; ``` + +## Testing with Browser DevTools + +To fully test the network resilience features, you can use browser DevTools: + +### Viewing Console Logs + +1. Open DevTools in your browser: + - **Chrome/Edge**: Press F12 or right-click and select "Inspect" + - **Firefox**: Press F12 or right-click and select "Inspect Element" + - **Safari**: Enable "Developer Tools" in preferences, then press Option+Command+I + +2. Go to the "Console" tab to view logs: + - Network status changes appear as color-coded logs + - Retry attempts are logged with blue backgrounds + +### Simulating Offline/Online States + +1. In DevTools, go to the "Network" tab +2. Look for the "Online" dropdown (may appear as "No throttling" in some browsers) +3. Select "Offline" to simulate a disconnected state +4. Return to "Online" or "No throttling" to restore connectivity + +### Testing Fetch with Retry and Fallbacks + +1. Click the "Test Fetch with Retry" button while watching the Console +2. You'll see logs of retry attempts and eventual success or failure +3. If using a fallback URL, you'll see the fallback request after the primary URL fails diff --git a/examples/network-status/src/App.jsx b/examples/network-status/src/App.jsx index 576690a8..611ebf44 100644 --- a/examples/network-status/src/App.jsx +++ b/examples/network-status/src/App.jsx @@ -5,9 +5,9 @@ import { Application } from "@nmfs-radfish/react-radfish"; import { GridContainer } from "@trussworks/react-uswds"; import HomePage from "./pages/Home"; -function App() { +function App({ app }) { return ( - + diff --git a/examples/network-status/src/index.css b/examples/network-status/src/index.css index 8d2be4f2..a2d6f821 100644 --- a/examples/network-status/src/index.css +++ b/examples/network-status/src/index.css @@ -30,3 +30,39 @@ body { .padding-4 { padding: 16px; } + +/* Remove bullet points from cards and grid items */ +.usa-card ul { + list-style: none; + padding-left: 0; +} + +.grid-col { + list-style-type: none; +} + +.grid-row { + list-style: none; + padding-left: 0; + padding-top: 10px; +} + +/* Equal height cards and layout fixes */ +.usa-card { + height: 100%; + display: flex; + flex-direction: column; +} + +.usa-card .usa-card__body { + display: flex; + flex-direction: column; + height: 100%; + flex: 1 1 auto; +} + +.usa-card .usa-button { + height: auto; + min-height: 40px; + margin-bottom: 0.5rem; +} \ No newline at end of file diff --git a/examples/network-status/src/index.jsx b/examples/network-status/src/index.jsx index 97e8e4f6..584ecf0f 100644 --- a/examples/network-status/src/index.jsx +++ b/examples/network-status/src/index.jsx @@ -7,9 +7,68 @@ import { ErrorBoundary } from "@nmfs-radfish/react-radfish"; const root = ReactDOM.createRoot(document.getElementById("root")); +// Initialize the radfish application with network features const app = new Application({ serviceWorker: { url: "/service-worker.js", + }, + + network: { + // Health check configuration + health: { + // Endpoint URL for health checks + endpointUrl: "https://api.github.com/users", + // Custom timeout in milliseconds (default is 30000) + timeout: 5000 + }, + + // Fallback URLs to use when primary endpoints fail + fallbackUrls: { + "https://nonexistent-endpoint.example.com": "https://jsonplaceholder.typicode.com/users" + }, + + // Optional custom network status handler + setIsOnline: async (networkInfo, callback) => { + console.log("Checking network with custom handler..."); + + try { + // Test connectivity to a reliable endpoint + // We perform a small HEAD request with a short timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + + const response = await fetch("https://api.github.com/users", { + method: "HEAD", + signal: controller.signal + }); + + clearTimeout(timeoutId); + console.log("Network check result:", response.ok); + callback(response.ok); + } catch (error) { + // Any errors indicate we're probably offline + console.log("Network status check failed:", error.message); + callback(false); + } + } + } +}); + +// Listen for all network-related events +app.addEventListener("online", (event) => { + console.log("%c NETWORK ONLINE ", "background: #4CAF50; color: #fff; font-weight: bold; padding: 4px;"); +}); + +app.addEventListener("offline", (event) => { + console.log("%c NETWORK OFFLINE ", "background: #F44336; color: #fff; font-weight: bold; padding: 4px;"); +}); + +app.addEventListener("networkRetry", (event) => { + console.warn("%c NETWORK RETRY ", "background: #3F51B5; color: #fff; font-weight: bold; padding: 4px;", event.detail); + + // Show an alert for first retry attempt + if (event.detail.attempt === 1) { + alert(`Network retry initiated! Will retry ${event.detail.maxRetries} times.`); } }); @@ -17,7 +76,7 @@ app.on("ready", () => { root.render( - + ); diff --git a/examples/network-status/src/pages/Home.jsx b/examples/network-status/src/pages/Home.jsx index 92537421..caec0e67 100644 --- a/examples/network-status/src/pages/Home.jsx +++ b/examples/network-status/src/pages/Home.jsx @@ -1,28 +1,130 @@ -import React from "react"; -import { Alert, Button, Link } from "@trussworks/react-uswds"; -import { useOfflineStatus } from "@nmfs-radfish/react-radfish"; +import React, { useState } from "react"; +import { + Alert, + Button, + Link, + Grid, + Card, + CardHeader, + CardBody, + Tag +} from "@trussworks/react-uswds"; +import { useOfflineStatus, useApplication } from "@nmfs-radfish/react-radfish"; const HomePage = () => { const { isOffline } = useOfflineStatus(); + const app = useApplication(); + const [loading, setLoading] = useState(false); + + const testRequestWithRetry = async () => { + setLoading(true); + console.log( + "%c STARTING REQUEST WITH RETRY TEST ", + "background: #4CAF50; color: #fff; font-weight: bold; padding: 4px;", + ); + + try { + // Simulate an endpoint that will fail initially but succeed on retry + const response = await app.requestWithRetry( + "https://nonexistent-endpoint.example.com", + {}, + { + retries: 2, + retryDelay: 1000, + exponentialBackoff: true, + }, + ); + + const data = await response.json(); + console.log( + "%c REQUEST SUCCESSFUL ", + "background: #4CAF50; color: #fff; font-weight: bold; padding: 4px;", + data, + ); + alert(`Request successful with retry! Retrieved ${data.length} users.`); + } catch (error) { + console.error( + "%c REQUEST FAILED ", + "background: #F44336; color: #fff; font-weight: bold; padding: 4px;", + error, + ); + alert(`Request failed after all retries: ${error.message}`); + } finally { + setLoading(false); + } + }; + + const getNetworkStatusTag = () => { + if (isOffline) { + return Offline; + } else { + return Online; + } + }; + return (

Network Status Example

+ - This is an example of a network status indicator. The application will display a toast - notification for 5 seconds when network is offline. -
+ This example demonstrates network status handling with support for request retries with exponential backoff, and request timeouts and fallback URLs. +

-

Network Status: {isOffline ? "Offline ❌" : "Online ✅"}

+ + {/* Top row - Network Status */} + + + + +

Network Status

+
+ +
+ Current Status: {getNetworkStatusTag()} +
+ +

+ The application automatically detects network connection status based on the browser's online/offline events. +

+
+
+
+
+ + {/* Bottom row - Resilient Network Features */} + + + + +

Resilient Network Features

+
+ +

Test network resilience features with retry logic and exponential backoff:

+
+
+ +
+
+
+
+
); }; diff --git a/package-lock.json b/package-lock.json index 7491319d..106121ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1107,6 +1107,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/@trussworks/react-uswds/-/react-uswds-9.1.0.tgz", "integrity": "sha512-vQsr73oMtDIzLHVtkgD81tL7YxzygTyH9e1P3Lv/C1tGlqoNEUmUgVEmUVzo/IwOvMN0XxxSkNkOpnM9rDzRMg==", + "dev": true, "engines": { "node": ">= 18" }, @@ -1780,6 +1781,30 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true }, + "node_modules/focus-trap": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.5.tgz", + "integrity": "sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==", + "dev": true, + "dependencies": { + "tabbable": "^6.2.0" + } + }, + "node_modules/focus-trap-react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.3.1.tgz", + "integrity": "sha512-PN4Ya9xf9nyj/Nd9VxBNMuD7IrlRbmaG6POAQ8VLqgtc6IY/Ln1tYakow+UIq4fihYYYFM70/2oyidE6bbiPgw==", + "dev": true, + "dependencies": { + "focus-trap": "^7.6.1", + "tabbable": "^6.2.0" + }, + "peerDependencies": { + "prop-types": "^15.8.1", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -1960,7 +1985,8 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "node_modules/jsdom": { "version": "25.0.1", @@ -2064,7 +2090,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "peer": true, + "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -2226,6 +2252,15 @@ "integrity": "sha512-cTGB9ptp9dY9A5VbMSe7fQBcl/tt22Vcqdq8+eN93rblOuE0aCFu4aZ2vMwct/2t+lFnosm8RkQW1I0Omb1UtQ==", "dev": true }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -2351,6 +2386,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -2396,7 +2448,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, + "dev": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2404,6 +2456,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -2536,6 +2601,15 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2667,6 +2741,12 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "dev": true + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3620,20 +3700,25 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@tanstack/react-table": "^8.16.0", - "@trussworks/react-uswds": "^9.0.0" + "@tanstack/react-table": "^8.16.0" }, "devDependencies": { "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "16.0.0", + "@trussworks/react-uswds": "^9.0.0", "@vitejs/plugin-react": "^4.3.1", + "focus-trap-react": "^10.3.1", "jsdom": "25.0.1", + "prop-types": "^15.8.1", + "react": "^18.2.0", + "react-dom": "^18.3.1", "vite-plugin-css-injected-by-js": "^3.5.1", "vitest": "2.1.2" }, "peerDependencies": { "@nmfs-radfish/radfish": "^1.1.0", + "@trussworks/react-uswds": "^9.0.0", "react": "^18.2.0" } } diff --git a/packages/radfish/Application.spec.js b/packages/radfish/Application.spec.js index 3d1ad100..43bc13b3 100644 --- a/packages/radfish/Application.spec.js +++ b/packages/radfish/Application.spec.js @@ -1,6 +1,23 @@ import { Application, IndexedDBMethod, LocalStorageMethod } from './index'; -describe('Application', () => { +// Mock fetch for tests +global.fetch = vi.fn(); +global.AbortController = vi.fn(() => ({ + abort: vi.fn(), + signal: {} +})); + +describe ('Application', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + global.fetch.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + describe('storage', () => { it('should return the storage method', () => { // IndexedDB Storage application @@ -66,4 +83,175 @@ describe('Application', () => { expect(localStorageApplication.storage).toBeInstanceOf(LocalStorageMethod); }); }); + + describe('network', () => { + it('should initialize with default network health settings', () => { + const app = new Application(); + expect(app._networkHealth.timeout).toBe(30000); + expect(app._networkHealth.endpointUrl).toBeNull(); + expect(app._fallbackUrls).toEqual({}); + }); + + it('should initialize with custom network health settings', () => { + const app = new Application({ + network: { + health: { + timeout: 5000, + endpointUrl: "https://api.example.com/health" + }, + fallbackUrls: { + "https://primary.com": "https://fallback.com" + } + } + }); + + expect(app._networkHealth.timeout).toBe(5000); + expect(app._networkHealth.endpointUrl).toBe("https://api.example.com/health"); + expect(app._fallbackUrls).toEqual({ + "https://primary.com": "https://fallback.com" + }); + }); + + it('should handle network status changes', () => { + const app = new Application(); + + const dispatchSpy = vi.spyOn(app, '_dispatch'); + + // First status change + app._handleNetworkStatusChange(false); + expect(app.isOnline).toBe(false); + expect(dispatchSpy).toHaveBeenCalledWith("offline"); + + // Change to online + app._handleNetworkStatusChange(true); + expect(app.isOnline).toBe(true); + expect(dispatchSpy).toHaveBeenCalledWith("online"); + }); + + it('should handle HTTP request with timeout', async () => { + const app = new Application({ + network: { + health: { + timeout: 5000 + } + } + }); + + // Mock successful fetch + global.fetch.mockResolvedValueOnce("response"); + + const result = await app.request("https://example.com"); + expect(result).toBe("response"); + expect(global.fetch).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ + signal: expect.any(Object) + })); + + // Should set timeout + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 5000); + }); + + it('should use fallback URL when primary fails', async () => { + const app = new Application({ + network: { + fallbackUrls: { + "https://primary.com": "https://backup.com" + } + } + }); + + // Mock primary failure and fallback success + global.fetch.mockImplementationOnce(() => Promise.reject(new Error("Failed"))) + .mockResolvedValueOnce("fallback response"); + + const result = await app.request("https://primary.com"); + + expect(result).toBe("fallback response"); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith(1, "https://primary.com", expect.any(Object)); + expect(global.fetch).toHaveBeenNthCalledWith(2, "https://backup.com", expect.any(Object)); + }); + + it('should handle HTTP request with retry logic', async () => { + const app = new Application(); + + const requestSpy = vi.spyOn(app, 'request'); + const dispatchSpy = vi.spyOn(app, '_dispatch'); + + // Mock failure on first attempt, success on second + requestSpy.mockRejectedValueOnce(new Error("Failed")) + .mockResolvedValueOnce("success"); + + const result = await app.requestWithRetry("https://example.com", {}, { + retries: 2, + retryDelay: 1000 + }); + + expect(result).toBe("success"); + expect(requestSpy).toHaveBeenCalledTimes(2); + expect(dispatchSpy).toHaveBeenCalledWith("networkRetry", expect.objectContaining({ + attempt: 1, + maxRetries: 2 + })); + + // Should wait between retries + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + }); + + it('should use exponential backoff for retries', async () => { + const app = new Application(); + + const requestSpy = vi.spyOn(app, 'request'); + + // Mock failures + requestSpy.mockRejectedValueOnce(new Error("Failed")) + .mockRejectedValueOnce(new Error("Failed again")) + .mockResolvedValueOnce("success"); + + // Use fake timers to advance through delays + const result = await app.requestWithRetry("https://example.com", {}, { + retries: 3, + retryDelay: 1000, + exponentialBackoff: true + }); + + expect(result).toBe("success"); + expect(requestSpy).toHaveBeenCalledTimes(3); + + // First retry should use 1000ms delay + expect(setTimeout).toHaveBeenNthCalledWith(1, expect.any(Function), 1000); + + // Second retry should use 2000ms delay (2^1 * 1000) + expect(setTimeout).toHaveBeenNthCalledWith(2, expect.any(Function), 2000); + }); + + it('should check network health', async () => { + const app = new Application({ + network: { + health: { + endpointUrl: "https://api.example.com/health" + } + } + }); + + const requestSpy = vi.spyOn(app, 'request'); + + // Mock successful health check + requestSpy.mockResolvedValueOnce({ ok: true }); + + const result = await app.checkNetworkHealth(); + + expect(result).toBe(true); + expect(requestSpy).toHaveBeenCalledWith( + "https://api.example.com/health", + { method: "HEAD" } + ); + + // Test failed health check + requestSpy.mockRejectedValueOnce(new Error("Network error")); + + const failedResult = await app.checkNetworkHealth(); + + expect(failedResult).toBe(false); + }); + }); }); \ No newline at end of file diff --git a/packages/radfish/index.js b/packages/radfish/index.js index 6d54249a..b46d66a7 100644 --- a/packages/radfish/index.js +++ b/packages/radfish/index.js @@ -27,10 +27,27 @@ export class Application { this.serviceWorker = null; this.isOnline = navigator.onLine; this._options = options; + this._networkHandler = options.network?.setIsOnline; + + // Network health check configuration + this._networkHealth = { + timeout: options.network?.health?.timeout || 30000, // Default 30s timeout + endpointUrl: options.network?.health?.endpointUrl || null + }; + + // Fallback URLs configuration + this._fallbackUrls = options.network?.fallbackUrls || {}; this._initializationPromise = null; // Register event listeners this._registerEventListeners(); + + // Check initial network status if handler provided + if (typeof this._networkHandler === 'function') { + this._networkHandler(navigator.connection, (status) => { + this._handleNetworkStatusChange(status); + }); + } // Initialize everything this._initializationPromise = this._initialize(); @@ -106,6 +123,14 @@ export class Application { return true; } + addEventListener(event, callback) { + return this.emitter.addEventListener(event, callback); + } + + removeEventListener(event, callback) { + return this.emitter.removeEventListener(event, callback); + } + get storage() { if (!this._options.storage) { return null; @@ -162,15 +187,25 @@ export class Application { this._dispatch("ready"); }); - const handleOnline = (event) => { - this.isOnline = true; - this._dispatch("online", { event }); + const handleOnline = async (event) => { + if (this._networkHandler) { + await this._networkHandler(navigator.connection, (status) => { + this._handleNetworkStatusChange(status); + }); + } else { + this._handleNetworkStatusChange(true); + } }; window.addEventListener("online", handleOnline, true); - const handleOffline = (event) => { - this.isOnline = false; - this._dispatch("offline", { event }); + const handleOffline = async (event) => { + if (this._networkHandler) { + await this._networkHandler(navigator.connection, (status) => { + this._handleNetworkStatusChange(status); + }); + } else { + this._handleNetworkStatusChange(false); + } }; window.addEventListener("offline", handleOffline, true); } @@ -190,6 +225,141 @@ export class Application { return null; } } + + /** + * Make HTTP request with fallback and timeout support + * @param {string|Request} resource - The URL or Request object + * @param {Object} [options] - Request options + * @returns {Promise} - Response from primary or fallback URL + */ + async request(resource, options = {}) { + const url = resource instanceof Request ? resource.url : resource; + const fallbackUrl = this._fallbackUrls[url]; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this._networkHealth.timeout); + + // Clone options and add abort signal + const fetchOptions = { + ...options, + signal: controller.signal + }; + + try { + // Try primary URL + const response = await fetch(resource, fetchOptions); + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + + if (!fallbackUrl) { + // No fallback available, update network status and throw error + this._handleNetworkStatusChange(false); + throw error; + } + + // Try fallback URL + try { + // Create a new controller for the fallback request + const fallbackController = new AbortController(); + const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), this._networkHealth.timeout); + + const fallbackOptions = { + ...options, + signal: fallbackController.signal + }; + + // Build fallback resource + const fallbackResource = resource instanceof Request + ? new Request(fallbackUrl, resource) + : fallbackUrl; + + const response = await fetch(fallbackResource, fallbackOptions); + clearTimeout(fallbackTimeoutId); + return response; + } catch (fallbackError) { + // Both primary and fallback failed, update network status + this._handleNetworkStatusChange(false); + throw fallbackError; + } + } + } + + /** + * Make HTTP request with automatic retry capability + * @param {string|Request} resource - The URL or Request object + * @param {Object} [options] - Request options + * @param {Object} [retryOptions] - Retry configuration + * @param {number} [retryOptions.retries=3] - Maximum number of retries + * @param {number} [retryOptions.retryDelay=1000] - Delay between retries in ms + * @param {boolean} [retryOptions.exponentialBackoff=true] - Whether to use exponential backoff + * @returns {Promise} - Response from successful request + */ + async requestWithRetry(resource, options = {}, retryOptions = {}) { + const { + retries = 3, + retryDelay = 1000, + exponentialBackoff = true + } = retryOptions; + + let lastError; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await this.request(resource, options); + } catch (error) { + lastError = error; + + if (attempt < retries) { + // Calculate delay - use exponential backoff if enabled + const delay = exponentialBackoff + ? retryDelay * Math.pow(2, attempt) + : retryDelay; + + // Emit event about retry + this._dispatch("networkRetry", { + resource: resource instanceof Request ? resource.url : resource, + attempt: attempt + 1, + maxRetries: retries, + delay + }); + + // Wait before next attempt + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + // All retries failed + throw lastError; + } + + /** + * Check network health by making a request to the configured health endpoint + * @returns {Promise} - True if the health check succeeds, false otherwise + */ + async checkNetworkHealth() { + const url = this._networkHealth.endpointUrl || 'https://api.github.com/users'; + + try { + const response = await this.request(url, { method: 'HEAD' }); + return response.ok; + } catch (error) { + return false; + } + } + + /** + * Handle network status changes + * @param {boolean} isOnline - Current network status + * @private + */ + _handleNetworkStatusChange(isOnline) { + this.isOnline = isOnline; + + // Dispatch appropriate event + this._dispatch(isOnline ? "online" : "offline"); + } } export * from "./on-device-storage/storage"; diff --git a/packages/react-radfish/Application/index.jsx b/packages/react-radfish/Application/index.jsx index b26846e6..df64ec3b 100644 --- a/packages/react-radfish/Application/index.jsx +++ b/packages/react-radfish/Application/index.jsx @@ -22,7 +22,14 @@ function ApplicationComponent(props) { return (
- {isOffline && } + {isOffline && ( + + )} {toasts.map((toast, i) => ( ))} diff --git a/packages/react-radfish/hooks/useOfflineStatus/useOfflineStatus.js b/packages/react-radfish/hooks/useOfflineStatus/useOfflineStatus.js index 2837fbc1..3748bf88 100644 --- a/packages/react-radfish/hooks/useOfflineStatus/useOfflineStatus.js +++ b/packages/react-radfish/hooks/useOfflineStatus/useOfflineStatus.js @@ -1,23 +1,55 @@ import { useState, useEffect } from "react"; +import { useApplication } from "../../Application"; export const useOfflineStatus = () => { - const [isOffline, setIsOffline] = useState(!navigator.onLine); + const application = useApplication(); + const [isOffline, setIsOffline] = useState(!application.isOnline); const updateOnlineStatus = () => { - setIsOffline(!navigator.onLine); + setIsOffline(!application.isOnline); }; useEffect(() => { updateOnlineStatus(); - window.addEventListener("online", updateOnlineStatus); - window.addEventListener("offline", updateOnlineStatus); + const setIsOfflineTrue = () => setIsOffline(true); + const setIsOfflineFalse = () => setIsOffline(false); + + application.addEventListener("online", setIsOfflineFalse); + application.addEventListener("offline", setIsOfflineTrue); return () => { - window.removeEventListener("online", updateOnlineStatus); - window.removeEventListener("offline", updateOnlineStatus); + application.removeEventListener("online", setIsOfflineFalse); + application.removeEventListener("offline", setIsOfflineTrue); }; - }, []); + }, [application]); - return { isOffline }; + // Method to check a URL with timeout + const checkEndpoint = async (url, options = {}) => { + try { + const { method = 'HEAD', timeout = application._networkTimeout } = options; + await application.fetch(url, { method, timeout }); + return true; + } catch (error) { + return false; + } + }; + + // Method to check multiple endpoints + const checkMultipleEndpoints = async (urls, options = {}) => { + const results = {}; + + const checks = urls.map(async (url) => { + results[url] = await checkEndpoint(url, options); + }); + + await Promise.all(checks); + return results; + }; + + return { + isOffline, + checkEndpoint, + checkMultipleEndpoints + }; }; diff --git a/packages/react-radfish/hooks/useOfflineStatus/useOfflineStatus.spec.js b/packages/react-radfish/hooks/useOfflineStatus/useOfflineStatus.spec.js index 44d3d498..75bb33bc 100644 --- a/packages/react-radfish/hooks/useOfflineStatus/useOfflineStatus.spec.js +++ b/packages/react-radfish/hooks/useOfflineStatus/useOfflineStatus.spec.js @@ -1,23 +1,161 @@ import { renderHook, act } from "@testing-library/react"; import { useOfflineStatus } from "./useOfflineStatus"; +import { useApplication } from "../../Application"; -describe("useToast", () => { - it("should trigger when naviagator online switches", async () => { - let dispatchEventSpy = vi.spyOn(window, "dispatchEvent"); - const onLineSpy = vi.spyOn(window.navigator, "onLine", "get"); +// Mock the useApplication hook +vi.mock("../../Application", () => ({ + useApplication: vi.fn() +})); - const { result, rerender } = renderHook(() => useOfflineStatus()); +describe("useOfflineStatus", () => { + let mockApplication; + beforeEach(() => { + vi.resetAllMocks(); + const emitter = new EventTarget(); + mockApplication = { + isOnline: false, + emitter, + _networkTimeout: 5000, + fetch: vi.fn(), + addEventListener: vi.fn((event, callback) => { + emitter.addEventListener(event, callback); + }), + dispatchEvent: vi.fn((event) => { + emitter.dispatchEvent(event); + }), + removeEventListener: vi.fn((event, callback) => { + emitter.removeEventListener(event, callback); + }) + }; + useApplication.mockReturnValue(mockApplication); + }); + + it("should initialize with offline status", () => { + const { result } = renderHook(() => useOfflineStatus()); + expect(result.current.isOffline).toBe(true); + }); + + it('should mount and unmount handlers', () => { + const {unmount } = renderHook(() => useOfflineStatus()); - onLineSpy.mockReturnValue(true); - window.dispatchEvent(new window.Event("online")); - rerender(); + expect(mockApplication.addEventListener).toHaveBeenCalledWith( + "online", + expect.any(Function) + ); + expect(mockApplication.addEventListener).toHaveBeenCalledWith( + "offline", + expect.any(Function) + ); + + unmount(); + + expect(mockApplication.removeEventListener).toHaveBeenCalledWith( + "online", + expect.any(Function) + ); + expect(mockApplication.removeEventListener).toHaveBeenCalledWith( + "offline", + expect.any(Function) + ); + + }); + + it("should trigger when navigator online switches", async () => { + const { result } = renderHook(() => useOfflineStatus()); + + // Must be wrapped in act callback to allow callstack to resolve + // before asserting value + act(() => { + mockApplication.dispatchEvent(new CustomEvent("online")); + }); expect(result.current.isOffline).toBe(false); - onLineSpy.mockReturnValue(false); - window.dispatchEvent(new window.Event("offline")); - rerender(); + // Must be wrapped in act callback to allow callstack to resolve + // before asserting value + act(() => { + mockApplication.dispatchEvent(new CustomEvent("offline")); + }); expect(result.current.isOffline).toBe(true); + + }); + + it("should add event listeners for network events", () => { + renderHook(() => useOfflineStatus()); + + expect(mockApplication.addEventListener).toHaveBeenCalledWith( + "online", + expect.any(Function) + ); + expect(mockApplication.addEventListener).toHaveBeenCalledWith( + "offline", + expect.any(Function) + ); + }); + + it("should cleanup event listeners on unmount", () => { + const { unmount } = renderHook(() => useOfflineStatus()); + unmount(); + + expect(mockApplication.removeEventListener).toHaveBeenCalledWith( + "online", + expect.any(Function) + ); + expect(mockApplication.removeEventListener).toHaveBeenCalledWith( + "offline", + expect.any(Function) + ); + }); + + it("should correctly check endpoint connectivity", async () => { + const { result } = renderHook(() => useOfflineStatus()); + + // Test successful fetch + mockApplication.fetch.mockResolvedValueOnce({}); + + let isReachable = await result.current.checkEndpoint("https://example.com"); + expect(isReachable).toBe(true); + expect(mockApplication.fetch).toHaveBeenCalledWith( + "https://example.com", + { method: "HEAD", timeout: 5000 } + ); + + // Test failed fetch + mockApplication.fetch.mockRejectedValueOnce(new Error("Network error")); + + isReachable = await result.current.checkEndpoint("https://example.com"); + expect(isReachable).toBe(false); + }); + + it("should check multiple endpoints correctly", async () => { + const { result } = renderHook(() => useOfflineStatus()); + + // Setup different responses for different endpoints + mockApplication.fetch + .mockImplementationOnce((url) => { + if (url === "https://working.example.com") { + return Promise.resolve({}); + } + return Promise.reject(new Error("Failed")); + }) + .mockImplementationOnce((url) => { + if (url === "https://api.example.com") { + return Promise.resolve({}); + } + return Promise.reject(new Error("Failed")); + }); + + const endpoints = [ + "https://working.example.com", + "https://down.example.com" + ]; + + const results = await result.current.checkMultipleEndpoints(endpoints); + + expect(results).toEqual({ + "https://working.example.com": true, + "https://down.example.com": false + }); }); }); diff --git a/packages/react-radfish/hooks/useToast/useToast.spec.js b/packages/react-radfish/hooks/useToast/useToast.spec.js index 493df398..5e36d12d 100644 --- a/packages/react-radfish/hooks/useToast/useToast.spec.js +++ b/packages/react-radfish/hooks/useToast/useToast.spec.js @@ -1,4 +1,4 @@ -import { renderHook } from "@testing-library/react"; +import { renderHook, act } from "@testing-library/react"; import { useToasts, dispatchToast } from "./useToast"; describe("useToast", () => { @@ -11,7 +11,9 @@ describe("useToast", () => { }, } = renderHook(() => useToasts()); - dispatchToast({ message: "Hello", status: "ok" }); + act(() => { + dispatchToast({ message: "Hello", status: "ok" }); + }); let dispatchedEvent = dispatchEventSpy.mock.calls[0][0]; expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(CustomEvent)); diff --git a/packages/react-radfish/package.json b/packages/react-radfish/package.json index 0f55e3e1..382f62b4 100644 --- a/packages/react-radfish/package.json +++ b/packages/react-radfish/package.json @@ -1,6 +1,6 @@ { "name": "@nmfs-radfish/react-radfish", - "version": "1.0.0", + "version": "1.1.0-rc.0", "type": "module", "main": "./dist/index.umd.js", "module": "./dist/index.es.js", @@ -25,20 +25,25 @@ "registry": "https://registry.npmjs.org/" }, "dependencies": { - "@tanstack/react-table": "^8.16.0", - "@trussworks/react-uswds": "^9.0.0" + "@tanstack/react-table": "^8.16.0" }, "peerDependencies": { "@nmfs-radfish/radfish": "^1.1.0", + "@trussworks/react-uswds": "^9.0.0", "react": "^18.2.0" }, "devDependencies": { "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "16.0.0", + "@trussworks/react-uswds": "^9.0.0", "@vitejs/plugin-react": "^4.3.1", + "focus-trap-react": "^10.3.1", "jsdom": "25.0.1", + "prop-types": "^15.8.1", + "react": "^18.2.0", + "react-dom": "^18.3.1", "vite-plugin-css-injected-by-js": "^3.5.1", "vitest": "2.1.2" } -} \ No newline at end of file +}