Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .changeset/stop-dispatch-after-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'eventsource': patch
---

Stop dispatching events once the EventSource has been closed

If multiple server-sent events were buffered in the same chunk and an event listener called `close()` while the first event was being dispatched, any remaining buffered events would still be dispatched even though `readyState` was `CLOSED`. Event dispatch now stops once the connection is closed, per the EventSource specification's event dispatch steps.

Fixes #362
8 changes: 8 additions & 0 deletions src/EventSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,14 @@ class EventSourceImpl extends EventTarget implements EventSource {
* @internal
*/
#onEvent = (event: EventSourceMessage) => {
// [spec] Once the EventSource has been closed - including by a listener during a
// previous event's dispatch, when several parsed events arrive in one chunk - the
// event dispatch steps stop: no further buffered events are dispatched.
// https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage
if (this.#readyState === this.CLOSED) {
return
}

const origin = this.#redirectUrl ? this.#redirectUrl.origin : this.#url.origin
// [spec] The `lastEventId` attribute is the last event ID string of the event
// source, i.e. the persisted buffer (`#lastEventId`) - not the current event's `id`.
Expand Down
39 changes: 39 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,45 @@ test('will not reconnect after explicit `close()` in `onError`', async () => {
expect(es.readyState, 'readyState').toBe(OurEventSource.CLOSED)
})

test('does not dispatch buffered events after close during event dispatch', async () => {
const url = `${serverUrl}/`

// Multiple SSE events delivered in a single chunk: a listener closing the EventSource
// while the first event is being dispatched must not observe the still-buffered later
// events being dispatched afterwards - the dispatch steps stop once the connection is
// closed (https://html.spec.whatwg.org/multipage/server-sent-events.html#dispatchMessage).
const chunkedFetch: FetchLike = async () => ({
body: new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder()
controller.enqueue(encoder.encode('data: first\n\ndata: second\n\ndata: third\n\n'))
},
}),
redirected: false,
status: 200,
headers: new Headers({'content-type': 'text/event-stream'}),
url,
})

const onMessage = getCallCounter({
name: 'onMessage',
onCall: ({numCalls}) => {
if (numCalls === 1) {
es.close()
}
},
})
const es = new OurEventSource(url, {fetch: chunkedFetch})

es.addEventListener('message', onMessage.listener)
await onMessage.waitForCallCount(1)
// Give any incorrectly-queued subsequent dispatches the chance to happen before asserting.
await new Promise((resolve) => setTimeout(resolve, 25))

expect(onMessage.callCount, 'messages dispatched').toBe(1)
expect(es.readyState, 'readyState').toBe(OurEventSource.CLOSED)
})

test('will have correct ready state throughout lifecycle', async () => {
const onMessage = getCallCounter({name: 'onMessage'})
const onOpen = getCallCounter<Event>({name: 'onOpen'})
Expand Down