diff --git a/.changeset/tricky-balloons-swim.md b/.changeset/tricky-balloons-swim.md new file mode 100644 index 000000000..dd1d99609 --- /dev/null +++ b/.changeset/tricky-balloons-swim.md @@ -0,0 +1,5 @@ +--- +'@farfetched/core': patch +--- + +Support React-Native Response object diff --git a/packages/core/src/fetch/__tests__/react_native_compat.test.ts b/packages/core/src/fetch/__tests__/react_native_compat.test.ts new file mode 100644 index 000000000..c81fb53fd --- /dev/null +++ b/packages/core/src/fetch/__tests__/react_native_compat.test.ts @@ -0,0 +1,233 @@ +import { allSettled, fork } from 'effector'; +import { describe, test, expect, vi } from 'vitest'; + +import { watchEffect } from '../../test_utils/watch_effect'; +import { fetchFx } from '../fetch'; +import { createJsonApiRequest } from '../json'; +import { createApiRequest } from '../api'; +import { preparationError } from '../../errors/create_error'; + +/** + * Creates a fake Response that mimics React Native's Response implementation. + * React Native's fetch doesn't implement the Streams API, so: + * - response.body is null/undefined + * - response.body.tee() is not available + * + * This helper creates a Response-like object that still supports + * clone(), text(), json(), and other standard Response methods. + */ +function createReactNativeResponse( + body: string | null, + init?: ResponseInit +): Response { + const realResponse = new Response(body, init); + + // Create a proxy that hides the body property to simulate React Native + return new Proxy(realResponse, { + get(target, prop) { + // React Native Response doesn't have body property + if (prop === 'body') { + return null; + } + const value = Reflect.get(target, prop); + // Bind methods to the original target + if (typeof value === 'function') { + return value.bind(target); + } + return value; + }, + }); +} + +describe('React Native compatibility (no Streams API)', () => { + describe('createJsonApiRequest', () => { + const request = { + method: 'POST' as const, + url: 'https://api.example.com', + credentials: 'same-origin' as const, + }; + + test('returns parsed json body when response.body is null', async () => { + const callJsonApiFx = createJsonApiRequest({ request }); + + const fetchMock = vi + .fn() + .mockResolvedValue( + createReactNativeResponse(JSON.stringify({ data: 'test-value' })) + ); + + const scope = fork({ handlers: [[fetchFx, fetchMock]] }); + const watcher = watchEffect(callJsonApiFx, scope); + + await allSettled(callJsonApiFx, { + scope, + params: { body: { some: 'request' } }, + }); + + expect(watcher.listeners.onFailData).not.toBeCalled(); + expect(watcher.listeners.onDoneData).toBeCalledWith({ + result: { data: 'test-value' }, + meta: expect.anything(), + }); + }); + + test('returns null for empty body when response.body is null', async () => { + const callJsonApiFx = createJsonApiRequest({ request }); + + const fetchMock = vi + .fn() + .mockResolvedValue(createReactNativeResponse('')); + + const scope = fork({ handlers: [[fetchFx, fetchMock]] }); + const watcher = watchEffect(callJsonApiFx, scope); + + await allSettled(callJsonApiFx, { + scope, + params: {}, + }); + + expect(watcher.listeners.onFailData).not.toBeCalled(); + expect(watcher.listeners.onDoneData).toBeCalledWith({ + result: null, + meta: expect.anything(), + }); + }); + + test('returns null for Content-Length: 0 when response.body is null', async () => { + const callJsonApiFx = createJsonApiRequest({ request }); + + const fetchMock = vi + .fn() + .mockResolvedValue( + createReactNativeResponse('', { headers: { 'Content-Length': '0' } }) + ); + + const scope = fork({ handlers: [[fetchFx, fetchMock]] }); + const watcher = watchEffect(callJsonApiFx, scope); + + await allSettled(callJsonApiFx, { + scope, + params: {}, + }); + + expect(watcher.listeners.onFailData).not.toBeCalled(); + expect(watcher.listeners.onDoneData).toBeCalledWith({ + result: null, + meta: expect.anything(), + }); + }); + + test('handles 204 No Content when response.body is null', async () => { + const callJsonApiFx = createJsonApiRequest({ request }); + + const fetchMock = vi + .fn() + .mockResolvedValue(createReactNativeResponse(null, { status: 204 })); + + const scope = fork({ handlers: [[fetchFx, fetchMock]] }); + const watcher = watchEffect(callJsonApiFx, scope); + + await allSettled(callJsonApiFx, { + scope, + params: {}, + }); + + expect(watcher.listeners.onFailData).not.toBeCalled(); + expect(watcher.listeners.onDoneData).toBeCalledWith({ + result: null, + meta: expect.anything(), + }); + }); + + test('throws preparation error on invalid json when response.body is null', async () => { + const callJsonApiFx = createJsonApiRequest({ request }); + + const fetchMock = vi + .fn() + .mockResolvedValue(createReactNativeResponse('not valid json')); + + const scope = fork({ handlers: [[fetchFx, fetchMock]] }); + const watcher = watchEffect(callJsonApiFx, scope); + + await allSettled(callJsonApiFx, { + scope, + params: { body: {} }, + }); + + expect(watcher.listeners.onFailData).toBeCalledWith( + expect.objectContaining({ + error: preparationError({ + response: 'not valid json', + reason: expect.stringContaining('not valid json'), + }), + responseMeta: expect.objectContaining({ headers: expect.anything() }), + }) + ); + }); + }); + + describe('createApiRequest', () => { + const request = { + method: 'GET' as const, + url: 'https://api.example.com', + credentials: 'same-origin' as const, + mapBody: () => 'body', + }; + + test('passes response to extract when response.body is null', async () => { + const extractResult = vi.fn(); + const extractMock = vi + .fn() + .mockImplementation(async (response: Response) => { + const text = await response.text(); + extractResult(text); + return text; + }); + + const apiCallFx = createApiRequest({ + request, + response: { extract: extractMock }, + }); + + const fetchMock = vi + .fn() + .mockResolvedValue(createReactNativeResponse('response-data')); + + const scope = fork({ handlers: [[fetchFx, fetchMock]] }); + + await allSettled(apiCallFx, { scope, params: {} }); + + expect(extractResult).toBeCalledWith('response-data'); + }); + + test('includes response body in preparation error when response.body is null', async () => { + const apiCallFx = createApiRequest({ + request, + response: { + extract: async () => { + throw new Error('extraction failed'); + }, + }, + }); + + const fetchMock = vi + .fn() + .mockResolvedValue(createReactNativeResponse('error-body-content')); + + const scope = fork({ handlers: [[fetchFx, fetchMock]] }); + const watcher = watchEffect(apiCallFx, scope); + + await allSettled(apiCallFx, { scope, params: {} }); + + expect(watcher.listeners.onFailData).toBeCalledWith( + expect.objectContaining({ + error: preparationError({ + response: 'error-body-content', + reason: 'extraction failed', + }), + responseMeta: expect.objectContaining({ headers: expect.anything() }), + }) + ); + }); + }); +}); diff --git a/packages/core/src/fetch/api.ts b/packages/core/src/fetch/api.ts index 0846c124e..ffc833a2d 100644 --- a/packages/core/src/fetch/api.ts +++ b/packages/core/src/fetch/api.ts @@ -166,20 +166,42 @@ export function createApiRequest< // For null body statuses (101, 103, 204, 205, 304), the Response constructor // throws if a body is provided, so we must use null body for these statuses. const hasNullBodyStatus = isNullBodyStatus(response.status); - const [forPrepare, forError] = hasNullBodyStatus - ? [null, null] - : (response.body?.tee() ?? [null, null]); - const prepared = await prepareFx(new Response(forPrepare, response)).then( + // Determine how to handle body cloning based on environment capabilities + let responseForPrepare: Response; + let responseForError: Response | null = null; + let streamForError: ReadableStream | null = null; + + if (hasNullBodyStatus) { + responseForPrepare = new Response(null, response); + } else if (response.body && typeof response.body.tee === 'function') { + // Streams API available (browsers, edge runtimes) + const [forPrepare, forError] = response.body.tee(); + responseForPrepare = new Response(forPrepare, response); + streamForError = forError; + } else { + // Fallback for React Native (no Streams API) + responseForPrepare = response.clone(); + responseForError = response; + } + + const prepared = await prepareFx(responseForPrepare).then( async (result) => { - await drain(forError); + await drain(streamForError); return result; }, async (cause) => { + let errorResponseText = ''; + if (streamForError) { + errorResponseText = await new Response(streamForError).text(); + } else if (responseForError) { + errorResponseText = await responseForError.text(); + } + throw { error: preparationError({ - response: forError ? await new Response(forError).text() : '', + response: errorResponseText, reason: cause?.message ?? null, }), responseMeta: { headers: responseHeaders }, diff --git a/packages/core/src/fetch/json.ts b/packages/core/src/fetch/json.ts index 65f7357a7..6807ed51f 100644 --- a/packages/core/src/fetch/json.ts +++ b/packages/core/src/fetch/json.ts @@ -115,23 +115,32 @@ async function checkEmptyResponse( return [true, null]; } - if (!response.body) { - return [true, null]; - } - const headerAsEmpty = response.headers.get('Content-Length') === '0'; if (headerAsEmpty) { return [true, null]; } - const [originalBody, clonedBody] = response.body.tee(); + // Streams API available (browsers, edge runtimes) + if (response.body && typeof response.body.tee === 'function') { + const [originalBody, clonedBody] = response.body.tee(); - const bodyAsText = await new Response(clonedBody).text(); - if (bodyAsText.length === 0) { - await drain(originalBody); + const bodyAsText = await new Response(clonedBody).text(); + if (bodyAsText.length === 0) { + await drain(originalBody); + + return [true, null]; + } + return [false, new Response(originalBody, response)]; + } + + // Fallback for React Native (no Streams API) + const clonedResponse = response.clone(); + const bodyAsText = await clonedResponse.text(); + + if (bodyAsText.length === 0) { return [true, null]; } - return [false, new Response(originalBody, response)]; + return [false, response]; } diff --git a/packages/core/src/libs/lohyphen/__tests__/drain.test.ts b/packages/core/src/libs/lohyphen/__tests__/drain.test.ts new file mode 100644 index 000000000..062f3ce38 --- /dev/null +++ b/packages/core/src/libs/lohyphen/__tests__/drain.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { drain } from '../drain'; + +describe('drain', () => { + test('handles null stream', async () => { + // Should not throw + await drain(null); + }); + + test('drains stream using WritableStream when available', async () => { + const mockStream = { + pipeTo: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn().mockResolvedValue(undefined), + } as unknown as ReadableStream; + + await drain(mockStream); + + expect(mockStream.pipeTo).toHaveBeenCalled(); + expect(mockStream.cancel).not.toHaveBeenCalled(); + }); + + test('catches errors from pipeTo', async () => { + const mockStream = { + pipeTo: vi.fn().mockRejectedValue(new Error('pipeTo failed')), + cancel: vi.fn().mockResolvedValue(undefined), + } as unknown as ReadableStream; + + // Should not throw + await drain(mockStream); + + expect(mockStream.pipeTo).toHaveBeenCalled(); + }); + + describe('without WritableStream (React Native)', () => { + let originalWritableStream: typeof WritableStream | undefined; + + beforeEach(() => { + originalWritableStream = globalThis.WritableStream; + // @ts-expect-error - simulating React Native environment + delete globalThis.WritableStream; + }); + + afterEach(() => { + if (originalWritableStream) { + globalThis.WritableStream = originalWritableStream; + } + }); + + test('falls back to stream.cancel() when WritableStream is unavailable', async () => { + const mockStream = { + pipeTo: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn().mockResolvedValue(undefined), + } as unknown as ReadableStream; + + await drain(mockStream); + + expect(mockStream.pipeTo).not.toHaveBeenCalled(); + expect(mockStream.cancel).toHaveBeenCalled(); + }); + + test('catches errors from cancel()', async () => { + const mockStream = { + pipeTo: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn().mockRejectedValue(new Error('cancel failed')), + } as unknown as ReadableStream; + + // Should not throw + await drain(mockStream); + + expect(mockStream.cancel).toHaveBeenCalled(); + }); + + test('handles stream without cancel method', async () => { + const mockStream = { + pipeTo: vi.fn().mockResolvedValue(undefined), + // No cancel method + } as unknown as ReadableStream; + + // Should not throw + await drain(mockStream); + }); + }); +}); diff --git a/packages/core/src/libs/lohyphen/drain.ts b/packages/core/src/libs/lohyphen/drain.ts index 6322e6ce6..a0c3c2ad2 100644 --- a/packages/core/src/libs/lohyphen/drain.ts +++ b/packages/core/src/libs/lohyphen/drain.ts @@ -1,3 +1,11 @@ export function drain(stream: ReadableStream | null) { - return stream?.pipeTo(new WritableStream({ write() {} })).catch(() => {}); + if (!stream) return; + + // Check if WritableStream is available (not available in React Native) + if (typeof WritableStream !== 'undefined') { + return stream.pipeTo(new WritableStream({ write() {} })).catch(() => {}); + } + + // Fallback: cancel the stream + return stream.cancel?.().catch(() => {}); }