Skip to content

Commit c25bf99

Browse files
Support React-Native Response object
1 parent 570b8a1 commit c25bf99

File tree

5 files changed

+369
-16
lines changed

5 files changed

+369
-16
lines changed
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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.fn().mockResolvedValue(
54+
createReactNativeResponse(JSON.stringify({ data: 'test-value' }))
55+
);
56+
57+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
58+
const watcher = watchEffect(callJsonApiFx, scope);
59+
60+
await allSettled(callJsonApiFx, {
61+
scope,
62+
params: { body: { some: 'request' } },
63+
});
64+
65+
expect(watcher.listeners.onFailData).not.toBeCalled();
66+
expect(watcher.listeners.onDoneData).toBeCalledWith({
67+
result: { data: 'test-value' },
68+
meta: expect.anything(),
69+
});
70+
});
71+
72+
test('returns null for empty body when response.body is null', async () => {
73+
const callJsonApiFx = createJsonApiRequest({ request });
74+
75+
const fetchMock = vi
76+
.fn()
77+
.mockResolvedValue(createReactNativeResponse(''));
78+
79+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
80+
const watcher = watchEffect(callJsonApiFx, scope);
81+
82+
await allSettled(callJsonApiFx, {
83+
scope,
84+
params: {},
85+
});
86+
87+
expect(watcher.listeners.onFailData).not.toBeCalled();
88+
expect(watcher.listeners.onDoneData).toBeCalledWith({
89+
result: null,
90+
meta: expect.anything(),
91+
});
92+
});
93+
94+
test('returns null for Content-Length: 0 when response.body is null', async () => {
95+
const callJsonApiFx = createJsonApiRequest({ request });
96+
97+
const fetchMock = vi.fn().mockResolvedValue(
98+
createReactNativeResponse('', { headers: { 'Content-Length': '0' } })
99+
);
100+
101+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
102+
const watcher = watchEffect(callJsonApiFx, scope);
103+
104+
await allSettled(callJsonApiFx, {
105+
scope,
106+
params: {},
107+
});
108+
109+
expect(watcher.listeners.onFailData).not.toBeCalled();
110+
expect(watcher.listeners.onDoneData).toBeCalledWith({
111+
result: null,
112+
meta: expect.anything(),
113+
});
114+
});
115+
116+
test('handles 204 No Content when response.body is null', async () => {
117+
const callJsonApiFx = createJsonApiRequest({ request });
118+
119+
const fetchMock = vi
120+
.fn()
121+
.mockResolvedValue(createReactNativeResponse(null, { status: 204 }));
122+
123+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
124+
const watcher = watchEffect(callJsonApiFx, scope);
125+
126+
await allSettled(callJsonApiFx, {
127+
scope,
128+
params: {},
129+
});
130+
131+
expect(watcher.listeners.onFailData).not.toBeCalled();
132+
expect(watcher.listeners.onDoneData).toBeCalledWith({
133+
result: null,
134+
meta: expect.anything(),
135+
});
136+
});
137+
138+
test('throws preparation error on invalid json when response.body is null', async () => {
139+
const callJsonApiFx = createJsonApiRequest({ request });
140+
141+
const fetchMock = vi
142+
.fn()
143+
.mockResolvedValue(createReactNativeResponse('not valid json'));
144+
145+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
146+
const watcher = watchEffect(callJsonApiFx, scope);
147+
148+
await allSettled(callJsonApiFx, {
149+
scope,
150+
params: { body: {} },
151+
});
152+
153+
expect(watcher.listeners.onFailData).toBeCalledWith(
154+
expect.objectContaining({
155+
error: preparationError({
156+
response: 'not valid json',
157+
reason: expect.stringContaining('not valid json'),
158+
}),
159+
responseMeta: expect.objectContaining({ headers: expect.anything() }),
160+
})
161+
);
162+
});
163+
});
164+
165+
describe('createApiRequest', () => {
166+
const request = {
167+
method: 'GET' as const,
168+
url: 'https://api.example.com',
169+
credentials: 'same-origin' as const,
170+
mapBody: () => 'body',
171+
};
172+
173+
test('passes response to extract when response.body is null', async () => {
174+
const extractResult = vi.fn();
175+
const extractMock = vi.fn().mockImplementation(async (response: Response) => {
176+
const text = await response.text();
177+
extractResult(text);
178+
return text;
179+
});
180+
181+
const apiCallFx = createApiRequest({
182+
request,
183+
response: { extract: extractMock },
184+
});
185+
186+
const fetchMock = vi
187+
.fn()
188+
.mockResolvedValue(createReactNativeResponse('response-data'));
189+
190+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
191+
192+
await allSettled(apiCallFx, { scope, params: {} });
193+
194+
expect(extractResult).toBeCalledWith('response-data');
195+
});
196+
197+
test('includes response body in preparation error when response.body is null', async () => {
198+
const apiCallFx = createApiRequest({
199+
request,
200+
response: {
201+
extract: async () => {
202+
throw new Error('extraction failed');
203+
},
204+
},
205+
});
206+
207+
const fetchMock = vi
208+
.fn()
209+
.mockResolvedValue(createReactNativeResponse('error-body-content'));
210+
211+
const scope = fork({ handlers: [[fetchFx, fetchMock]] });
212+
const watcher = watchEffect(apiCallFx, scope);
213+
214+
await allSettled(apiCallFx, { scope, params: {} });
215+
216+
expect(watcher.listeners.onFailData).toBeCalledWith(
217+
expect.objectContaining({
218+
error: preparationError({
219+
response: 'error-body-content',
220+
reason: 'extraction failed',
221+
}),
222+
responseMeta: expect.objectContaining({ headers: expect.anything() }),
223+
})
224+
);
225+
});
226+
});
227+
});

packages/core/src/fetch/api.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,20 +166,45 @@ 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 (
178+
response.body &&
179+
typeof response.body.tee === 'function'
180+
) {
181+
// Streams API available (browsers, edge runtimes)
182+
const [forPrepare, forError] = response.body.tee();
183+
responseForPrepare = new Response(forPrepare, response);
184+
streamForError = forError;
185+
} else {
186+
// Fallback for React Native (no Streams API)
187+
responseForPrepare = response.clone();
188+
responseForError = response;
189+
}
190+
191+
const prepared = await prepareFx(responseForPrepare).then(
174192
async (result) => {
175-
await drain(forError);
193+
await drain(streamForError);
176194

177195
return result;
178196
},
179197
async (cause) => {
198+
let errorResponseText = '';
199+
if (streamForError) {
200+
errorResponseText = await new Response(streamForError).text();
201+
} else if (responseForError) {
202+
errorResponseText = await responseForError.text();
203+
}
204+
180205
throw {
181206
error: preparationError({
182-
response: forError ? await new Response(forError).text() : '',
207+
response: errorResponseText,
183208
reason: cause?.message ?? null,
184209
}),
185210
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)