Skip to content

Commit e309d0b

Browse files
Merge pull request #572 from effector/fix-react-native-body
Support React-Native Response object
2 parents 570b8a1 + f64389b commit e309d0b

File tree

6 files changed

+377
-16
lines changed

6 files changed

+377
-16
lines changed

.changeset/tricky-balloons-swim.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@farfetched/core': patch
3+
---
4+
5+
Support React-Native Response object
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import { allSettled, fork } from 'effector';
2+
import { describe, test, expect, vi } from 'vitest';
3+
4+
import { watchEffect } from '../../test_utils/watch_effect';
5+
import { fetchFx } from '../fetch';
6+
import { createJsonApiRequest } from '../json';
7+
import { createApiRequest } from '../api';
8+
import { preparationError } from '../../errors/create_error';
9+
10+
/**
11+
* Creates a fake Response that mimics React Native's Response implementation.
12+
* React Native's fetch doesn't implement the Streams API, so:
13+
* - response.body is null/undefined
14+
* - response.body.tee() is not available
15+
*
16+
* This helper creates a Response-like object that still supports
17+
* clone(), text(), json(), and other standard Response methods.
18+
*/
19+
function createReactNativeResponse(
20+
body: string | null,
21+
init?: ResponseInit
22+
): Response {
23+
const realResponse = new Response(body, init);
24+
25+
// Create a proxy that hides the body property to simulate React Native
26+
return new Proxy(realResponse, {
27+
get(target, prop) {
28+
// React Native Response doesn't have body property
29+
if (prop === 'body') {
30+
return null;
31+
}
32+
const value = Reflect.get(target, prop);
33+
// Bind methods to the original target
34+
if (typeof value === 'function') {
35+
return value.bind(target);
36+
}
37+
return value;
38+
},
39+
});
40+
}
41+
42+
describe('React Native compatibility (no Streams API)', () => {
43+
describe('createJsonApiRequest', () => {
44+
const request = {
45+
method: 'POST' as const,
46+
url: 'https://api.example.com',
47+
credentials: 'same-origin' as const,
48+
};
49+
50+
test('returns parsed json body when response.body is null', async () => {
51+
const callJsonApiFx = createJsonApiRequest({ request });
52+
53+
const fetchMock = vi
54+
.fn()
55+
.mockResolvedValue(
56+
createReactNativeResponse(JSON.stringify({ data: 'test-value' }))
57+
);
58+
59+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
60+
const watcher = watchEffect(callJsonApiFx, scope);
61+
62+
await allSettled(callJsonApiFx, {
63+
scope,
64+
params: { body: { some: 'request' } },
65+
});
66+
67+
expect(watcher.listeners.onFailData).not.toBeCalled();
68+
expect(watcher.listeners.onDoneData).toBeCalledWith({
69+
result: { data: 'test-value' },
70+
meta: expect.anything(),
71+
});
72+
});
73+
74+
test('returns null for empty body when response.body is null', async () => {
75+
const callJsonApiFx = createJsonApiRequest({ request });
76+
77+
const fetchMock = vi
78+
.fn()
79+
.mockResolvedValue(createReactNativeResponse(''));
80+
81+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
82+
const watcher = watchEffect(callJsonApiFx, scope);
83+
84+
await allSettled(callJsonApiFx, {
85+
scope,
86+
params: {},
87+
});
88+
89+
expect(watcher.listeners.onFailData).not.toBeCalled();
90+
expect(watcher.listeners.onDoneData).toBeCalledWith({
91+
result: null,
92+
meta: expect.anything(),
93+
});
94+
});
95+
96+
test('returns null for Content-Length: 0 when response.body is null', async () => {
97+
const callJsonApiFx = createJsonApiRequest({ request });
98+
99+
const fetchMock = vi
100+
.fn()
101+
.mockResolvedValue(
102+
createReactNativeResponse('', { headers: { 'Content-Length': '0' } })
103+
);
104+
105+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
106+
const watcher = watchEffect(callJsonApiFx, scope);
107+
108+
await allSettled(callJsonApiFx, {
109+
scope,
110+
params: {},
111+
});
112+
113+
expect(watcher.listeners.onFailData).not.toBeCalled();
114+
expect(watcher.listeners.onDoneData).toBeCalledWith({
115+
result: null,
116+
meta: expect.anything(),
117+
});
118+
});
119+
120+
test('handles 204 No Content when response.body is null', async () => {
121+
const callJsonApiFx = createJsonApiRequest({ request });
122+
123+
const fetchMock = vi
124+
.fn()
125+
.mockResolvedValue(createReactNativeResponse(null, { status: 204 }));
126+
127+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
128+
const watcher = watchEffect(callJsonApiFx, scope);
129+
130+
await allSettled(callJsonApiFx, {
131+
scope,
132+
params: {},
133+
});
134+
135+
expect(watcher.listeners.onFailData).not.toBeCalled();
136+
expect(watcher.listeners.onDoneData).toBeCalledWith({
137+
result: null,
138+
meta: expect.anything(),
139+
});
140+
});
141+
142+
test('throws preparation error on invalid json when response.body is null', async () => {
143+
const callJsonApiFx = createJsonApiRequest({ request });
144+
145+
const fetchMock = vi
146+
.fn()
147+
.mockResolvedValue(createReactNativeResponse('not valid json'));
148+
149+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
150+
const watcher = watchEffect(callJsonApiFx, scope);
151+
152+
await allSettled(callJsonApiFx, {
153+
scope,
154+
params: { body: {} },
155+
});
156+
157+
expect(watcher.listeners.onFailData).toBeCalledWith(
158+
expect.objectContaining({
159+
error: preparationError({
160+
response: 'not valid json',
161+
reason: expect.stringContaining('not valid json'),
162+
}),
163+
responseMeta: expect.objectContaining({ headers: expect.anything() }),
164+
})
165+
);
166+
});
167+
});
168+
169+
describe('createApiRequest', () => {
170+
const request = {
171+
method: 'GET' as const,
172+
url: 'https://api.example.com',
173+
credentials: 'same-origin' as const,
174+
mapBody: () => 'body',
175+
};
176+
177+
test('passes response to extract when response.body is null', async () => {
178+
const extractResult = vi.fn();
179+
const extractMock = vi
180+
.fn()
181+
.mockImplementation(async (response: Response) => {
182+
const text = await response.text();
183+
extractResult(text);
184+
return text;
185+
});
186+
187+
const apiCallFx = createApiRequest({
188+
request,
189+
response: { extract: extractMock },
190+
});
191+
192+
const fetchMock = vi
193+
.fn()
194+
.mockResolvedValue(createReactNativeResponse('response-data'));
195+
196+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
197+
198+
await allSettled(apiCallFx, { scope, params: {} });
199+
200+
expect(extractResult).toBeCalledWith('response-data');
201+
});
202+
203+
test('includes response body in preparation error when response.body is null', async () => {
204+
const apiCallFx = createApiRequest({
205+
request,
206+
response: {
207+
extract: async () => {
208+
throw new Error('extraction failed');
209+
},
210+
},
211+
});
212+
213+
const fetchMock = vi
214+
.fn()
215+
.mockResolvedValue(createReactNativeResponse('error-body-content'));
216+
217+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
218+
const watcher = watchEffect(apiCallFx, scope);
219+
220+
await allSettled(apiCallFx, { scope, params: {} });
221+
222+
expect(watcher.listeners.onFailData).toBeCalledWith(
223+
expect.objectContaining({
224+
error: preparationError({
225+
response: 'error-body-content',
226+
reason: 'extraction failed',
227+
}),
228+
responseMeta: expect.objectContaining({ headers: expect.anything() }),
229+
})
230+
);
231+
});
232+
});
233+
});

packages/core/src/fetch/api.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,20 +166,42 @@ export function createApiRequest<
166166
// For null body statuses (101, 103, 204, 205, 304), the Response constructor
167167
// throws if a body is provided, so we must use null body for these statuses.
168168
const hasNullBodyStatus = isNullBodyStatus(response.status);
169-
const [forPrepare, forError] = hasNullBodyStatus
170-
? [null, null]
171-
: (response.body?.tee() ?? [null, null]);
172169

173-
const prepared = await prepareFx(new Response(forPrepare, response)).then(
170+
// Determine how to handle body cloning based on environment capabilities
171+
let responseForPrepare: Response;
172+
let responseForError: Response | null = null;
173+
let streamForError: ReadableStream | null = null;
174+
175+
if (hasNullBodyStatus) {
176+
responseForPrepare = new Response(null, response);
177+
} else if (response.body && typeof response.body.tee === 'function') {
178+
// Streams API available (browsers, edge runtimes)
179+
const [forPrepare, forError] = response.body.tee();
180+
responseForPrepare = new Response(forPrepare, response);
181+
streamForError = forError;
182+
} else {
183+
// Fallback for React Native (no Streams API)
184+
responseForPrepare = response.clone();
185+
responseForError = response;
186+
}
187+
188+
const prepared = await prepareFx(responseForPrepare).then(
174189
async (result) => {
175-
await drain(forError);
190+
await drain(streamForError);
176191

177192
return result;
178193
},
179194
async (cause) => {
195+
let errorResponseText = '';
196+
if (streamForError) {
197+
errorResponseText = await new Response(streamForError).text();
198+
} else if (responseForError) {
199+
errorResponseText = await responseForError.text();
200+
}
201+
180202
throw {
181203
error: preparationError({
182-
response: forError ? await new Response(forError).text() : '',
204+
response: errorResponseText,
183205
reason: cause?.message ?? null,
184206
}),
185207
responseMeta: { headers: responseHeaders },

packages/core/src/fetch/json.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,23 +115,32 @@ async function checkEmptyResponse(
115115
return [true, null];
116116
}
117117

118-
if (!response.body) {
119-
return [true, null];
120-
}
121-
122118
const headerAsEmpty = response.headers.get('Content-Length') === '0';
123119
if (headerAsEmpty) {
124120
return [true, null];
125121
}
126122

127-
const [originalBody, clonedBody] = response.body.tee();
123+
// Streams API available (browsers, edge runtimes)
124+
if (response.body && typeof response.body.tee === 'function') {
125+
const [originalBody, clonedBody] = response.body.tee();
128126

129-
const bodyAsText = await new Response(clonedBody).text();
130-
if (bodyAsText.length === 0) {
131-
await drain(originalBody);
127+
const bodyAsText = await new Response(clonedBody).text();
128+
if (bodyAsText.length === 0) {
129+
await drain(originalBody);
130+
131+
return [true, null];
132+
}
132133

134+
return [false, new Response(originalBody, response)];
135+
}
136+
137+
// Fallback for React Native (no Streams API)
138+
const clonedResponse = response.clone();
139+
const bodyAsText = await clonedResponse.text();
140+
141+
if (bodyAsText.length === 0) {
133142
return [true, null];
134143
}
135144

136-
return [false, new Response(originalBody, response)];
145+
return [false, response];
137146
}

0 commit comments

Comments
 (0)