From 39fdfef6f1c89be3162bbf043cde7084006c484c Mon Sep 17 00:00:00 2001 From: Jonas Braathen Date: Thu, 30 Jul 2026 01:45:32 +0200 Subject: [PATCH] Simplify fetchWithTimeout + add tests for utils --- README.md | 3 --- src/utils.test.ts | 44 +++++++++++++++++++++++++++++++++++++++++++- src/utils.ts | 19 ++----------------- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 3b967a5..87c72e9 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,6 @@ history, code and binary blobs related to React Native functionality filtered ou - try [`@expo/ui`](https://docs.expo.dev/versions/latest/sdk/ui/), production-ready as of SDK 56: real SwiftUI / Jetpack Compose components that could back the homegrown ones in `src/components/` and finally retire `react-native-simple-radio-button` - - simplify `fetchWithTimeout` in `src/utils.ts`: `expo/fetch` is now `globalThis.fetch`, so the - wrapper collapses to `fetch(url, { signal: AbortSignal.timeout(ms) })`. Opt back out with - `EXPO_PUBLIC_USE_RN_FETCH=1` if it misbehaves - swap `@react-native-async-storage/async-storage` (used by `redux-persist` in `src/reduxStore.ts`) for [`expo-sqlite/localStorage`](https://docs.expo.dev/versions/latest/sdk/sqlite/#localstorage), diff --git a/src/utils.test.ts b/src/utils.test.ts index 1a17722..cd4f03f 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,4 +1,4 @@ -import { withAppSource } from '@/utils'; +import { fetchWithTimeout, snakeToCamelCase, withAppSource } from '@/utils'; describe('withAppSource', () => { it('tags links to our own sites with UTM params', () => { @@ -40,3 +40,45 @@ describe('withAppSource', () => { expect(withAppSource('')).toBe(''); }); }); + +describe('snakeToCamelCase', () => { + it('converts snake_case to camelCase', () => { + expect(snakeToCamelCase('date_time_start')).toBe('dateTimeStart'); + expect(snakeToCamelCase('is_member')).toBe('isMember'); + }); + + it('leaves strings without underscores untouched', () => { + expect(snakeToCamelCase('events')).toBe('events'); + expect(snakeToCamelCase('dateTimeStart')).toBe('dateTimeStart'); + }); +}); + +describe('fetchWithTimeout', () => { + const realFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = realFetch; + }); + + it('forwards the url and options and attaches an abort signal', async () => { + const fetchMock = jest.fn().mockResolvedValue({ ok: true }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await fetchWithTimeout('https://galtinn.neuf.no/api/me/', { method: 'POST' }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://galtinn.neuf.no/api/me/', + expect.objectContaining({ method: 'POST', signal: expect.any(AbortSignal) }), + ); + }); + + it('rejects with a TimeoutError that aborts the request when the timeout elapses', async () => { + globalThis.fetch = ((_url: string, options?: RequestInit) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(options.signal?.reason)); + })) as unknown as typeof fetch; + + await expect(fetchWithTimeout('https://galtinn.neuf.no/api/me/', undefined, 10)).rejects.toMatchObject({ + name: 'TimeoutError', + }); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index b7a3b8e..f12276c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -25,22 +25,7 @@ export function snakeToCamelCase(val: string): string { } export function fetchWithTimeout(url: string, options?: RequestInit, timeout = 10000) { - /** Wraps fetch to allow it to time out */ + /** Fetch that rejects with a TimeoutError and aborts the request after `timeout` ms */ // FIXME: show timeout message in UI - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Fetch request timed out')); - }, timeout); - - fetch(url, options).then( - (response) => { - clearTimeout(timer); - resolve(response); - }, - (error) => { - clearTimeout(timer); - reject(error); - }, - ); - }); + return fetch(url, { ...options, signal: AbortSignal.timeout(timeout) }); }