From 606f2bc20a68d233d2e7192c0e30051af31941a3 Mon Sep 17 00:00:00 2001 From: pacocartones Date: Thu, 20 Aug 2026 18:25:33 +0200 Subject: [PATCH 1/4] test: reproduce empty lastEventId when a later event omits id --- test/client.test.ts | 45 ++++++++++++++++++++---------------------- test/helpers/server.ts | 20 +++++++++++++++++++ 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/test/client.test.ts b/test/client.test.ts index 3eb2ae4..2f32088 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -318,34 +318,31 @@ test('message event contains correct properties', async () => { await deferClose(es) }) -test('will reconnect with last received message id if server disconnects', async () => { - const onMessage = getCallCounter({name: 'onMessage'}) - const onError = getCallCounter({name: 'onError'}) - const url = `${serverUrl}/counter` - const es = new OurEventSource(url, esInit) - es.addEventListener('counter', onMessage.listener) - es.addEventListener('error', onError.listener) - - // While still receiving messages (we receive 3 at a time before it disconnects) - await onMessage.waitForCallCount(1) - expect(es.readyState, 'readyState').toBe(OurEventSource.OPEN) // Open (connected) +test('message event `lastEventId` persists when a later event omits the `id` field', async () => { + // Record every event, not just the most recent: the two events arrive back to back, so + // `lastArg` would already point at the second one by the time the first is asserted. + const seen: MessageEvent[] = [] + const onMessage = getCallCounter({name: 'onMessage', onCall: () => {}}) + const es = new OurEventSource(`${serverUrl}/mixed-ids`, esInit) + + es.addEventListener('message', (event) => seen.push(event as MessageEvent)) + es.addEventListener('message', onMessage.listener) - // While waiting for reconnect (after 3 messages it will disconnect and reconnect) - await onError.waitForCallCount(1) - expect(es.readyState, 'readyState').toBe(OurEventSource.CONNECTING) // Connecting (reconnecting) - expect(onMessage.callCount).toBe(3) + await onMessage.waitForCallCount(2) - // Will reconnect infinitely, stop at 8 messages - await onMessage.waitForCallCount(8) + // First event carries `id: 1`, which updates the last event ID buffer. + expect(seen[0], 'first message').toMatchObject({ + data: 'First, with id', + lastEventId: '1', + }) - expect(es.url).toBe(url) - expect(onMessage.lastArg).toMatchObject({ - data: 'Counter is at 8', - type: 'counter', - lastEventId: '8', - origin: serverOrigin, + // The second event omits the `id` field. Per the spec ("dispatch the event" initializes + // `lastEventId` to the last event ID string, and only an `id` field updates that buffer), + // the event must still carry `lastEventId: '1'` - not an empty string. + expect(seen[1], 'second message').toMatchObject({ + data: 'Second, without id', + lastEventId: '1', }) - expect(onMessage.callCount).toBe(8) await deferClose(es) }) diff --git a/test/helpers/server.ts b/test/helpers/server.ts index ec72743..0dadcf8 100644 --- a/test/helpers/server.ts +++ b/test/helpers/server.ts @@ -59,6 +59,8 @@ export function handleRequest( return writeDefault(req, res) case '/counter': return writeCounter(req, res) + case '/mixed-ids': + return writeMixedIds(req, res) case '/identified': return writeIdentifiedListeners(req, res) case '/end-after-one': @@ -141,6 +143,24 @@ async function writeCounter(req: IncomingMessage, res: ServerResponse) { res.end() } +/** + * Writes two messages: one with an `id` field, then one without. Per the spec, the second + * event's `lastEventId` must still be `'1'`: the last event ID buffer is only updated by an + * explicit `id` field and is not reset when an event omits it. + */ +function writeMixedIds(_req: IncomingMessage, res: ServerResponse) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + tryWrite(res, encode({id: '1', data: 'First, with id'})) + tryWrite(res, encode({data: 'Second, without id'})) + + res.end() +} + async function writeIdentifiedListeners(req: IncomingMessage, res: ServerResponse) { const url = new URL(req.url || '/', 'http://localhost') const clientId = url.searchParams.get('client-id') From 79312f2f1aed514b775478932e7a26b12b6af623 Mon Sep 17 00:00:00 2001 From: pacocartones Date: Thu, 20 Aug 2026 18:26:33 +0200 Subject: [PATCH 2/4] fix: preserve lastEventId when a later event omits the id field --- src/EventSource.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/EventSource.ts b/src/EventSource.ts index b878b43..cefa293 100644 --- a/src/EventSource.ts +++ b/src/EventSource.ts @@ -594,7 +594,12 @@ class EventSourceImpl extends EventTarget implements EventSource { } const origin = this.#redirectUrl ? this.#redirectUrl.origin : this.#url.origin - const lastEventId = event.id || '' + // [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`. + // The buffer is only updated by an explicit `id` field (above) and must survive an + // event that omits `id`, so emitting `event.id || ''` here wrongly blanked it after + // such an event. + const lastEventId = this.#lastEventId ?? "" const messageEvent = new MessageEvent(event.event || 'message', { data: event.data, From 11c90743cea4bb4b0662a1a947037d4016d6b2ba Mon Sep 17 00:00:00 2001 From: pacocartones Date: Thu, 20 Aug 2026 19:56:17 +0200 Subject: [PATCH 3/4] chore: add changeset for lastEventId fix --- .changeset/lasteventid-persists-without-id.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/lasteventid-persists-without-id.md diff --git a/.changeset/lasteventid-persists-without-id.md b/.changeset/lasteventid-persists-without-id.md new file mode 100644 index 0000000..25012ff --- /dev/null +++ b/.changeset/lasteventid-persists-without-id.md @@ -0,0 +1,7 @@ +--- +'eventsource': patch +--- + +Fixed `lastEventId` being blanked by an event that omits the `id` field + +The `lastEventId` attribute is the last event ID string of the event source, so an explicit `id` field persists until another one replaces it. Message events were dispatched with the current event's own `id` instead of that buffer, which left `lastEventId` empty on every event that omitted `id`, even though the buffer still held the earlier value and would have been sent as `Last-Event-ID` on reconnect. From 83f966909bd216c45507ada070ea603c99cd8ecf Mon Sep 17 00:00:00 2001 From: Espen Hovlandsdal Date: Thu, 20 Aug 2026 20:31:37 +0200 Subject: [PATCH 4/4] refactor: apply suggestions from code review Co-authored-by: Espen Hovlandsdal --- .changeset/lasteventid-persists-without-id.md | 2 +- src/EventSource.ts | 3 +- test/client.test.ts | 31 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.changeset/lasteventid-persists-without-id.md b/.changeset/lasteventid-persists-without-id.md index 25012ff..ba07d7a 100644 --- a/.changeset/lasteventid-persists-without-id.md +++ b/.changeset/lasteventid-persists-without-id.md @@ -2,6 +2,6 @@ 'eventsource': patch --- -Fixed `lastEventId` being blanked by an event that omits the `id` field +Fixed `MessageEvent.lastEventId` being empty when a message omits the id field The `lastEventId` attribute is the last event ID string of the event source, so an explicit `id` field persists until another one replaces it. Message events were dispatched with the current event's own `id` instead of that buffer, which left `lastEventId` empty on every event that omitted `id`, even though the buffer still held the earlier value and would have been sent as `Last-Event-ID` on reconnect. diff --git a/src/EventSource.ts b/src/EventSource.ts index cefa293..60f29e9 100644 --- a/src/EventSource.ts +++ b/src/EventSource.ts @@ -597,8 +597,7 @@ class EventSourceImpl extends EventTarget implements EventSource { // [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`. // The buffer is only updated by an explicit `id` field (above) and must survive an - // event that omits `id`, so emitting `event.id || ''` here wrongly blanked it after - // such an event. + // event that omits `id`. const lastEventId = this.#lastEventId ?? "" const messageEvent = new MessageEvent(event.event || 'message', { diff --git a/test/client.test.ts b/test/client.test.ts index 2f32088..91cf743 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -318,6 +318,37 @@ test('message event contains correct properties', async () => { await deferClose(es) }) +test('will reconnect with last received message id if server disconnects', async () => { + const onMessage = getCallCounter({name: 'onMessage'}) + const onError = getCallCounter({name: 'onError'}) + const url = `${serverUrl}/counter` + const es = new OurEventSource(url, esInit) + es.addEventListener('counter', onMessage.listener) + es.addEventListener('error', onError.listener) + + // While still receiving messages (we receive 3 at a time before it disconnects) + await onMessage.waitForCallCount(1) + expect(es.readyState, 'readyState').toBe(OurEventSource.OPEN) // Open (connected) + + // While waiting for reconnect (after 3 messages it will disconnect and reconnect) + await onError.waitForCallCount(1) + expect(es.readyState, 'readyState').toBe(OurEventSource.CONNECTING) // Connecting (reconnecting) + expect(onMessage.callCount).toBe(3) + + // Will reconnect infinitely, stop at 8 messages + await onMessage.waitForCallCount(8) + + expect(es.url).toBe(url) + expect(onMessage.lastArg).toMatchObject({ + data: 'Counter is at 8', + type: 'counter', + lastEventId: '8', + origin: serverOrigin, + }) + expect(onMessage.callCount).toBe(8) + await deferClose(es) +}) + test('message event `lastEventId` persists when a later event omits the `id` field', async () => { // Record every event, not just the most recent: the two events arrive back to back, so // `lastArg` would already point at the second one by the time the first is asserted.