diff --git a/.changeset/stop-dispatch-after-close.md b/.changeset/stop-dispatch-after-close.md new file mode 100644 index 0000000..a39989f --- /dev/null +++ b/.changeset/stop-dispatch-after-close.md @@ -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 diff --git a/src/EventSource.ts b/src/EventSource.ts index 7588060..e7fd840 100644 --- a/src/EventSource.ts +++ b/src/EventSource.ts @@ -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`. diff --git a/test/client.test.ts b/test/client.test.ts index 7eb0fe8..a580110 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -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({ + 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({name: 'onOpen'})