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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tricky-balloons-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@farfetched/core': patch
---

Support React-Native Response object
233 changes: 233 additions & 0 deletions packages/core/src/fetch/__tests__/react_native_compat.test.ts
Original file line number Diff line number Diff line change
@@ -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() }),
})
);
});
});
});
34 changes: 28 additions & 6 deletions packages/core/src/fetch/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
27 changes: 18 additions & 9 deletions packages/core/src/fetch/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Loading
Loading