Skip to content

Commit e4fa9cb

Browse files
committed
fix: RN event source does not capture headers in a timely manner
1 parent e80752c commit e4fa9cb

2 files changed

Lines changed: 77 additions & 4 deletions

File tree

packages/sdk/react-native/__tests__/fromExternal/react-native-sse/EventSource.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,60 @@ describe('EventSource', () => {
167167
}),
168168
);
169169
});
170+
171+
test('calls onopen with parsed response headers during streaming (onprogress, pre-DONE)', () => {
172+
const onopen = jest.fn();
173+
eventSource.onopen = onopen;
174+
175+
mockXhr.getAllResponseHeaders = jest.fn(
176+
() => 'X-Ld-Fd-Fallback: true\r\nX-Ld-Fd-Fallback-Ttl: 60\r\nContent-Type: text/event-stream',
177+
);
178+
mockXhr.responseText = '';
179+
180+
jest.runAllTimers();
181+
182+
// Simulate a chunk arriving while the connection is still LOADING (readyState 3).
183+
// This is the real runtime path for a live stream: DONE (readyState 4) only
184+
// fires once the connection closes, so gating 'open' on DONE would mean the
185+
// event, and the fallback headers it carries, never fires during normal streaming.
186+
mockXhr.readyState = 3;
187+
mockXhr.status = 200;
188+
mockXhr.onprogress();
189+
190+
expect(onopen).toHaveBeenCalledTimes(1);
191+
expect(onopen).toHaveBeenCalledWith(
192+
expect.objectContaining({
193+
type: 'open',
194+
headers: expect.objectContaining({
195+
'x-ld-fd-fallback': 'true',
196+
'x-ld-fd-fallback-ttl': '60',
197+
}),
198+
}),
199+
);
200+
});
201+
202+
test('dispatches open exactly once when onprogress precedes an onreadystatechange DONE', () => {
203+
const onopen = jest.fn();
204+
eventSource.onopen = onopen;
205+
206+
mockXhr.getAllResponseHeaders = jest.fn(
207+
() => 'X-Ld-Fd-Fallback: true\r\nX-Ld-Fd-Fallback-Ttl: 60\r\nContent-Type: text/event-stream',
208+
);
209+
mockXhr.responseText = '';
210+
211+
jest.runAllTimers();
212+
213+
// onprogress observes the connection first (LOADING) and transitions to OPEN.
214+
mockXhr.readyState = 3;
215+
mockXhr.status = 200;
216+
mockXhr.onprogress();
217+
218+
// A later onreadystatechange at DONE must not re-dispatch open: the status is
219+
// already OPEN (no longer CONNECTING), so its open-dispatch guard is a no-op.
220+
mockXhr.readyState = 4;
221+
mockXhr.status = 200;
222+
mockXhr.onreadystatechange();
223+
224+
expect(onopen).toHaveBeenCalledTimes(1);
225+
});
170226
});

packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@
77
* 4. replaced all for of loops with foreach
88
*
99
* Additional changes:
10-
* 1. separated event handling to use onprogress for data changes
11-
* and onreadystatechange for status changes. This is to address
12-
* an issue with Vega OS where they do not fire a readyStatechange
13-
* event when the response is received.
10+
* 1. separated event handling to use onprogress for data changes and
11+
* onreadystatechange for status changes, since some platforms (e.g. Vega OS)
12+
* never fire a readystatechange event while the response is still streaming in.
13+
* 2. onprogress now also performs the CONNECTING -> OPEN transition and dispatches
14+
* open, guarded by the same this._status === CONNECTING check onreadystatechange
15+
* already used. Previously open only fired from onreadystatechange at
16+
* readyState DONE, which for a long-lived stream only happens at connection
17+
* close, so the parsed response headers carried on open were never seen while
18+
* the stream was actually running. onprogress fires as soon as the response
19+
* starts loading, so headers are available as soon as the connection opens
20+
* instead of only at teardown.
1421
*/
1522
import type { EventSourceEvent, EventSourceListener, EventSourceOptions, EventType } from './types';
1623

@@ -158,6 +165,16 @@ export default class EventSource<E extends string = never> {
158165
);
159166

160167
if (this._xhr.status >= 200 && this._xhr.status < 400) {
168+
if (this._status === this.CONNECTING) {
169+
this._retryCount = 0;
170+
this._status = this.OPEN;
171+
this.dispatch('open', {
172+
type: 'open',
173+
headers: this._parseResponseHeaders(this._xhr.getAllResponseHeaders()),
174+
});
175+
this._logger?.debug('[EventSource][onprogress][OPEN] Connection opened.');
176+
}
177+
161178
this._handleEvent(this._xhr.responseText || '');
162179
} else {
163180
this._status = this.ERROR;

0 commit comments

Comments
 (0)