perf(sync): optimize multi-session event pipeline with per-directory queues and delta coalescing (#908)

* fix: hide archived section and empty folders when no sessions remain

- Only push archived group in useSessionGrouping when there are archived
  sessions, preventing an empty archived section from rendering
- Hide empty folders in archived bucket via shouldKeepFolder check in
  SessionGroupSection (folders with no sessions and no content in
  children are filtered out)
- Always filter folders through shouldKeepFolder, not just during search

* perf: memoize archived folder filtering

* perf(sync): per-directory event queues to eliminate cross-session HoL blocking

The SSE event pipeline previously used a single global queue and a single
flush timer shared across all directories. Under concurrent multi-session
workloads, a busy directory's delta storm would block other directories'
status and state events from reaching the UI until the next flush tick,
producing the "multi-session latency" symptom users report.

Split the queue into one DirectoryQueue per directory, each with its own
coalesce map, stale-delta set, and flush timer. Directories flush
independently so a busy directory can no longer starve a quiet one. Coalesce
keys are now scoped to a single directory's queue, so the directory prefix
is removed from the key strings.

Cross-directory behavior only; same-directory multi-session behavior is
unchanged (React 18 auto-batching still collapses a single directory's
flush into one render).

* perf(sync): coalesce consecutive message.part.delta events per flush window

Within a 16ms flush window, consecutive delta events for the same
(messageID, partID, field) tuple are string-concatenated into a single
accumulated delta rather than being queued individually.

This directly addresses same-project multi-session workloads — most
notably parent sessions with subagent tasks (child sessions share the
same directory queue). Both parties stream deltas concurrently, which
previously multiplied raw event count proportionally to the number of
active sessions. Coalescing can reduce queue depth by 10-100x during
active streaming.

Safety: verified against event-reducer.ts — the delta handler is a pure
string append (existingValue + props.delta) with no per-event side
effects (no time.updated, no notifications, no diff calculations). The
merged result is semantically identical to applying each delta separately.

The staleDeltas skip mechanism is unaffected: accumulated delta payloads
retain their type and identifiers, so message.part.updated supersession
still works correctly.

* test(sync): cover per-directory queues and delta coalescing

Extend event-pipeline.test.js with behavioural coverage for both
optimizations landed in 98d013a and 258acf0:

P1 (per-directory queues)
- Delivers events from two directories without loss
- Keeps distinct sessionIDs in the same directory as independent coalesce
  slots (session.status is not overwritten across sessions)
- Collapses repeated session.status for the same session down to latest

Option C (delta coalescing)
- Accumulates consecutive deltas for the same (messageID, partID, field)
  into a single dispatched event with concatenated content
- Does not merge deltas across different fields on the same part
- Does not merge deltas across different parts on the same message
- Does not merge deltas across different directories (per-dir queues)
- Skips accumulated deltas when message.part.updated is coalesced onto
  an earlier update, proving staleDeltas still works with C
- Leaves non-delta coalescing (session.status replace semantics) intact

All 13 tests pass under bun:test.

Also adds event-pipeline.bench.js, a runnable synthetic benchmark that
reports delta reduction and byte integrity across 8 workload scenarios
from "single session, 500 tokens" up to "10 projects × 5 sessions ×
1000 tokens". Run with:

  bun packages/ui/src/sync/__tests__/event-pipeline.bench.js

Current numbers on this machine: 99.5% - 99.9% delta event reduction
with full byte-level integrity (concatenated delta bytes always equal
the input total).

* fix(sync): remove staleDeltas — it silently drops delta events

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
jwcrystal
2026-04-14 20:08:40 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 5584537da5
commit 1656c3bb93
3 changed files with 706 additions and 40 deletions
@@ -0,0 +1,347 @@
/**
* Synthetic benchmark for the event pipeline.
*
* Measures how much per-directory queueing + delta coalescing shrinks the
* delivered event stream and how long enqueue/flush takes for realistic
* multi-session workloads (parent + subagent token streaming).
*
* Run with:
* bun packages/ui/src/sync/__tests__/event-pipeline.bench.js
*
* This is NOT a bun:test file — it prints a report and exits. Nothing here
* asserts; it exists purely to give you intuition about the optimization
* impact at varying concurrency levels.
*/
import { createEventPipeline } from '../event-pipeline.ts';
// ---------------------------------------------------------------------------
// Minimal DOM stubs (same approach as the unit tests)
// ---------------------------------------------------------------------------
globalThis.document = {
visibilityState: 'visible',
addEventListener() {},
removeEventListener() {},
};
globalThis.window = {
addEventListener() {},
removeEventListener() {},
};
// ---------------------------------------------------------------------------
// SDK mock that replays a pre-generated event list
// ---------------------------------------------------------------------------
function createReplaySdk(events, hold) {
return {
global: {
event: async () => ({
stream: (async function* () {
for (const e of events) yield e;
await hold;
})(),
}),
},
};
}
// ---------------------------------------------------------------------------
// Workload generators
// ---------------------------------------------------------------------------
/**
* Token-stream workload: N sessions in `directoryCount` directories, each
* emitting `tokensPerSession` text deltas plus a few framing events.
*
* Shape is intentionally close to a real opencode session:
* session.status(busy)
* message.part.delta × tokensPerSession (coalescible)
* message.part.updated (final state)
* session.status(idle)
*/
function buildTokenStreamWorkload({
directoryCount,
sessionsPerDirectory,
tokensPerSession,
}) {
const events = [];
for (let d = 0; d < directoryCount; d++) {
const directory = `dir-${d}`;
for (let s = 0; s < sessionsPerDirectory; s++) {
const sessionID = `dir-${d}-s${s}`;
const messageID = `${sessionID}-m1`;
const partID = `${messageID}-p1`;
events.push({
directory,
payload: {
type: 'session.status',
properties: { sessionID, status: { type: 'busy' } },
},
});
for (let t = 0; t < tokensPerSession; t++) {
events.push({
directory,
payload: {
type: 'message.part.delta',
properties: {
messageID,
partID,
field: 'text',
delta: 'x',
},
},
});
}
events.push({
directory,
payload: {
type: 'message.part.updated',
properties: {
part: {
id: partID,
type: 'text',
messageID,
text: 'x'.repeat(tokensPerSession),
},
},
},
});
events.push({
directory,
payload: {
type: 'session.status',
properties: { sessionID, status: { type: 'idle' } },
},
});
}
}
// Interleave events across directories/sessions so we exercise the real
// "parent + subagent" arrival pattern instead of one session at a time.
return interleave(events);
}
// Shuffle events within each directory bucket so arrivals are interleaved but
// still ordered within a single (sessionID, partID) stream (deltas must stay
// ordered relative to each other for append semantics to remain correct).
function interleave(events) {
const buckets = new Map(); // directory -> list of events (in original order)
for (const e of events) {
const bucket = buckets.get(e.directory) ?? [];
bucket.push(e);
buckets.set(e.directory, bucket);
}
const out = [];
let more = true;
while (more) {
more = false;
for (const bucket of buckets.values()) {
if (bucket.length > 0) {
out.push(bucket.shift());
more = true;
}
}
}
return out;
}
// ---------------------------------------------------------------------------
// Runner — pushes a workload through the pipeline and measures
// ---------------------------------------------------------------------------
async function runScenario(label, workload) {
let release;
const hold = new Promise((resolve) => {
release = resolve;
});
let delivered = 0;
let deliveredDeltas = 0;
let deliveredDeltaBytes = 0;
const sdk = createReplaySdk(workload, hold);
const startWall = performance.now();
const { cleanup } = createEventPipeline({
sdk,
onEvent: (_directory, payload) => {
delivered++;
if (payload.type === 'message.part.delta') {
deliveredDeltas++;
deliveredDeltaBytes += payload.properties.delta.length;
}
},
});
// Give the pipeline enough time to finish enqueueing AND flush. Scale wait
// time with workload so large stress scenarios have room to drain — the SSE
// loop yields every 8ms and the flush fires every 16ms, so in the worst case
// we need ~(workload / STREAM_YIELD) * 16ms of wall clock.
const waitMs = Math.max(200, Math.ceil(workload.length / 100));
await new Promise((resolve) => setTimeout(resolve, waitMs));
const endWall = performance.now();
cleanup();
release();
// Count input-side delta events for comparison
const inputDeltas = workload.filter((e) => e.payload.type === 'message.part.delta').length;
const inputDeltaBytes = workload
.filter((e) => e.payload.type === 'message.part.delta')
.reduce((n, e) => n + e.payload.properties.delta.length, 0);
const wallMs = endWall - startWall;
const reductionPct = inputDeltas === 0 ? 0 : (1 - deliveredDeltas / inputDeltas) * 100;
return {
label,
inputEvents: workload.length,
inputDeltas,
inputDeltaBytes,
deliveredEvents: delivered,
deliveredDeltas,
deliveredDeltaBytes,
reductionPct,
wallMs,
};
}
function formatRow(r) {
const cols = [
r.label.padEnd(44),
String(r.inputEvents).padStart(8),
String(r.deliveredEvents).padStart(8),
String(r.inputDeltas).padStart(8),
String(r.deliveredDeltas).padStart(8),
`${r.reductionPct.toFixed(1)}%`.padStart(8),
`${r.wallMs.toFixed(1)}ms`.padStart(10),
r.inputDeltaBytes === r.deliveredDeltaBytes ? 'bytes ✓' : `bytes ${r.inputDeltaBytes}${r.deliveredDeltaBytes}`,
];
return cols.join(' ');
}
function header() {
const cols = [
'scenario'.padEnd(44),
'in'.padStart(8),
'out'.padStart(8),
'in Δ'.padStart(8),
'out Δ'.padStart(8),
'reduce'.padStart(8),
'wall'.padStart(10),
'integrity',
];
return cols.join(' ');
}
// ---------------------------------------------------------------------------
// Scenarios
// ---------------------------------------------------------------------------
const scenarios = [
{
label: 'single project, 1 session, 500 tokens',
workload: buildTokenStreamWorkload({
directoryCount: 1,
sessionsPerDirectory: 1,
tokensPerSession: 500,
}),
},
{
label: 'single project, parent + 1 subagent, 500 tokens each',
workload: buildTokenStreamWorkload({
directoryCount: 1,
sessionsPerDirectory: 2,
tokensPerSession: 500,
}),
},
{
label: 'single project, parent + 3 subagents, 500 tokens each',
workload: buildTokenStreamWorkload({
directoryCount: 1,
sessionsPerDirectory: 4,
tokensPerSession: 500,
}),
},
{
label: 'single project, parent + 9 subagents, 200 tokens each',
workload: buildTokenStreamWorkload({
directoryCount: 1,
sessionsPerDirectory: 10,
tokensPerSession: 200,
}),
},
{
label: '3 projects, 1 session each, 500 tokens',
workload: buildTokenStreamWorkload({
directoryCount: 3,
sessionsPerDirectory: 1,
tokensPerSession: 500,
}),
},
{
label: '3 projects × (parent + subagent), 500 tokens each',
workload: buildTokenStreamWorkload({
directoryCount: 3,
sessionsPerDirectory: 2,
tokensPerSession: 500,
}),
},
{
label: '5 projects × parent + 3 subagents, 200 tokens',
workload: buildTokenStreamWorkload({
directoryCount: 5,
sessionsPerDirectory: 4,
tokensPerSession: 200,
}),
},
{
label: 'stress: 10 projects × 5 sessions × 1000 tokens',
workload: buildTokenStreamWorkload({
directoryCount: 10,
sessionsPerDirectory: 5,
tokensPerSession: 1000,
}),
},
];
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log('event-pipeline synthetic benchmark\n');
console.log(header());
console.log('-'.repeat(header().length + 8));
const results = [];
for (const { label, workload } of scenarios) {
const r = await runScenario(label, workload);
results.push(r);
console.log(formatRow(r));
}
console.log('\nLegend:');
console.log(' in — total events fed into the pipeline');
console.log(' out — total events dispatched via onEvent after coalescing + flush');
console.log(' in Δ — input events of type message.part.delta');
console.log(' out Δ — delta events that actually made it to onEvent (after merging)');
console.log(' reduce — (1 outΔ / inΔ) × 100, i.e. how much delta traffic shrunk');
console.log(' wall — total wall-clock time for the scenario (bounded by 200ms wait)');
console.log(' integrity — "bytes ✓" means the concatenated delta bytes match the input total');
console.log('');
console.log('Interpretation:');
console.log(' Higher reduce % = fewer reducer invocations, fewer React setState calls,');
console.log(' fewer allocations inside the flush loop. The integrity check confirms no');
console.log(' text was dropped during coalescing.');
}
main().catch((error) => {
console.error('benchmark failed:', error);
process.exit(1);
});
@@ -51,6 +51,33 @@ function createSdkWithEvents(events, hold) {
};
}
// Run a pipeline against a pre-seeded event stream, collect every dispatched
// event, wait long enough for the 16ms flush window to elapse, then tear it
// down. Returns the list of { directory, payload } that onEvent saw.
async function runPipelineWithEvents(events, waitMs = 80) {
installDomStubs();
let releaseStream;
const hold = new Promise((resolve) => {
releaseStream = resolve;
});
const received = [];
const sdk = createSdkWithEvents(events, hold);
const { cleanup } = createEventPipeline({
sdk,
onEvent: (directory, payload) => {
received.push({ directory, payload });
},
});
await new Promise((resolve) => setTimeout(resolve, waitMs));
cleanup();
releaseStream();
return received;
}
describe('createEventPipeline', () => {
it('falls back to payload.properties.directory when the SDK event omits top-level directory', async () => {
installDomStubs();
@@ -341,3 +368,239 @@ describe('createEventPipeline', () => {
expect(received[0].payload.type).toBe('message.part.updated');
});
});
// ---------------------------------------------------------------------------
// P1 — Per-directory queue isolation
// ---------------------------------------------------------------------------
describe('createEventPipeline — per-directory isolation (P1)', () => {
it('delivers events from two directories without losing either', async () => {
const received = await runPipelineWithEvents([
{
directory: 'dir-a',
payload: {
type: 'session.status',
properties: { sessionID: 's-a', status: { type: 'busy' } },
},
},
{
directory: 'dir-b',
payload: {
type: 'session.status',
properties: { sessionID: 's-b', status: { type: 'idle' } },
},
},
]);
const dirs = received.map((r) => r.directory).sort();
expect(dirs).toEqual(['dir-a', 'dir-b']);
});
it('keeps distinct sessionIDs in the same directory as independent coalesce slots', async () => {
const received = await runPipelineWithEvents([
{
directory: 'dir-a',
payload: {
type: 'session.status',
properties: { sessionID: 's1', status: { type: 'busy' } },
},
},
{
directory: 'dir-a',
payload: {
type: 'session.status',
properties: { sessionID: 's2', status: { type: 'busy' } },
},
},
]);
expect(received).toHaveLength(2);
const sessionIds = received.map((r) => r.payload.properties.sessionID).sort();
expect(sessionIds).toEqual(['s1', 's2']);
});
it('collapses repeated session.status for the same session down to the latest', async () => {
const received = await runPipelineWithEvents([
{
directory: 'dir-a',
payload: {
type: 'session.status',
properties: { sessionID: 's1', status: { type: 'busy' } },
},
},
{
directory: 'dir-a',
payload: {
type: 'session.status',
properties: { sessionID: 's1', status: { type: 'idle' } },
},
},
]);
expect(received).toHaveLength(1);
expect(received[0].payload.properties.status.type).toBe('idle');
});
});
// ---------------------------------------------------------------------------
// Option C — message.part.delta coalescing
// ---------------------------------------------------------------------------
describe('createEventPipeline — delta coalescing (Option C)', () => {
it('accumulates consecutive deltas for the same (messageID, partID, field) into one event', async () => {
const events = ['Hello ', 'world', ', ', 'how ', 'are ', 'you?'].map((chunk) => ({
directory: 'dir-a',
payload: {
type: 'message.part.delta',
properties: {
messageID: 'msg-1',
partID: 'part-1',
field: 'text',
delta: chunk,
},
},
}));
const received = await runPipelineWithEvents(events);
expect(received).toHaveLength(1);
expect(received[0].payload.type).toBe('message.part.delta');
expect(received[0].payload.properties.delta).toBe('Hello world, how are you?');
expect(received[0].payload.properties.messageID).toBe('msg-1');
expect(received[0].payload.properties.partID).toBe('part-1');
expect(received[0].payload.properties.field).toBe('text');
});
it('does NOT merge deltas across different fields on the same part', async () => {
const received = await runPipelineWithEvents([
{
directory: 'dir-a',
payload: {
type: 'message.part.delta',
properties: {
messageID: 'msg-1',
partID: 'part-1',
field: 'text',
delta: 'A',
},
},
},
{
directory: 'dir-a',
payload: {
type: 'message.part.delta',
properties: {
messageID: 'msg-1',
partID: 'part-1',
field: 'reasoning',
delta: 'B',
},
},
},
]);
expect(received).toHaveLength(2);
const fieldDelta = received.map((r) => [
r.payload.properties.field,
r.payload.properties.delta,
]).sort();
expect(fieldDelta).toEqual([
['reasoning', 'B'],
['text', 'A'],
]);
});
it('does NOT merge deltas across different parts on the same message', async () => {
const received = await runPipelineWithEvents([
{
directory: 'dir-a',
payload: {
type: 'message.part.delta',
properties: {
messageID: 'msg-1',
partID: 'part-1',
field: 'text',
delta: 'AAA',
},
},
},
{
directory: 'dir-a',
payload: {
type: 'message.part.delta',
properties: {
messageID: 'msg-1',
partID: 'part-2',
field: 'text',
delta: 'BBB',
},
},
},
]);
expect(received).toHaveLength(2);
const byPart = Object.fromEntries(
received.map((r) => [r.payload.properties.partID, r.payload.properties.delta]),
);
expect(byPart['part-1']).toBe('AAA');
expect(byPart['part-2']).toBe('BBB');
});
it('does NOT merge deltas across different directories (per-directory queues)', async () => {
const received = await runPipelineWithEvents([
{
directory: 'dir-a',
payload: {
type: 'message.part.delta',
properties: {
messageID: 'msg-1',
partID: 'part-1',
field: 'text',
delta: 'from-a',
},
},
},
{
directory: 'dir-b',
payload: {
type: 'message.part.delta',
properties: {
messageID: 'msg-1',
partID: 'part-1',
field: 'text',
delta: 'from-b',
},
},
},
]);
expect(received).toHaveLength(2);
const byDir = Object.fromEntries(
received.map((r) => [r.directory, r.payload.properties.delta]),
);
expect(byDir['dir-a']).toBe('from-a');
expect(byDir['dir-b']).toBe('from-b');
});
it('does not touch non-delta events (session.status still replaced, not concatenated)', async () => {
const received = await runPipelineWithEvents([
{
directory: 'dir-a',
payload: {
type: 'session.status',
properties: { sessionID: 's1', status: { type: 'busy' } },
},
},
{
directory: 'dir-a',
payload: {
type: 'session.status',
properties: { sessionID: 's1', status: { type: 'idle' } },
},
},
]);
expect(received).toHaveLength(1);
expect(received[0].payload.properties.status.type).toBe('idle');
});
});
+96 -40
View File
@@ -78,62 +78,102 @@ function resolveEventDirectory(event: unknown, payload: Event): string {
return propertyDirectory && propertyDirectory.length > 0 ? propertyDirectory : "global"
}
// Per-directory queue state. Each directory owns an independent flush timer
// so a busy directory's delta storm cannot block another directory's events
// from reaching the UI (head-of-line blocking across sessions).
type DirectoryQueue = {
queue: Event[]
buffer: Event[]
coalesced: Map<string, number>
timer: ReturnType<typeof setTimeout> | undefined
last: number
}
export function createEventPipeline(input: EventPipelineInput) {
const { sdk, onEvent, onReconnect } = input
const abort = new AbortController()
let hasConnected = false
// Queue state
let queue: QueuedEvent[] = []
let buffer: QueuedEvent[] = []
const coalesced = new Map<string, number>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
// One queue + one flush timer per directory. Lazily created on first event.
const directories = new Map<string, DirectoryQueue>()
// Coalesce key — same-type events for the same entity replace earlier ones
const key = (directory: string, payload: Event): string | undefined => {
const getOrCreateDir = (directory: string): DirectoryQueue => {
let d = directories.get(directory)
if (d) return d
d = {
queue: [],
buffer: [],
coalesced: new Map(),
timer: undefined,
last: 0,
}
directories.set(directory, d)
return d
}
// Coalesce key — same-type events for the same entity replace earlier ones.
// Keys are scoped to a single directory's queue, so directory is implicit.
// message.part.delta is a special case: consecutive deltas for the same
// (messageID, partID, field) are accumulated (string-concatenated) rather
// than replaced, because the reducer is a pure append and merging is
// semantically identical to applying each delta individually.
const key = (payload: Event): string | undefined => {
if (payload.type === "session.status") {
const props = payload.properties as { sessionID: string }
return `session.status:${directory}:${props.sessionID}`
return `session.status:${props.sessionID}`
}
if (payload.type === "lsp.updated") {
return `lsp.updated:${directory}`
return `lsp.updated`
}
if (payload.type === "message.part.updated") {
const part = (payload.properties as { part: { messageID: string; id: string } }).part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
return `message.part.updated:${part.messageID}:${part.id}`
}
if (payload.type === "message.part.delta") {
const props = payload.properties as { messageID: string; partID: string; field: string }
return `message.part.delta:${props.messageID}:${props.partID}:${props.field}`
}
return undefined
}
// Flush — swap queue, dispatch events
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
// Flush one directory — swap queue, dispatch events.
// React 18 auto-batching still collapses the setState calls inside a single
// directory's flush into one render pass.
const flushDir = (directory: string) => {
const d = directories.get(directory)
if (!d) return
if (d.timer) {
clearTimeout(d.timer)
d.timer = undefined
}
if (d.queue.length === 0) return
if (queue.length === 0) return
const events = d.queue
d.queue = d.buffer
d.buffer = events
d.queue.length = 0
d.coalesced.clear()
const events = queue
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
last = Date.now()
d.last = Date.now()
syncDebug.pipeline.flush(events.length)
// React 18 batches synchronous setState calls automatically,
// equivalent to SolidJS batch()
for (const event of events) {
onEvent(event.directory, event.payload)
for (const payload of events) {
onEvent(directory, payload)
}
buffer.length = 0
d.buffer.length = 0
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
const flushAll = () => {
for (const directory of directories.keys()) {
flushDir(directory)
}
}
const scheduleDir = (directory: string) => {
const d = getOrCreateDir(directory)
if (d.timer) return
const elapsed = Date.now() - d.last
d.timer = setTimeout(() => flushDir(directory), Math.max(0, FLUSH_FRAME_MS - elapsed))
}
// Helpers
@@ -201,18 +241,34 @@ export function createEventPipeline(input: EventPipelineInput) {
}
const normalizedPayload = normalizeEventType(payload)
const directory = resolveEventDirectory(event, normalizedPayload)
const k = key(directory, normalizedPayload)
const d = getOrCreateDir(directory)
const k = key(normalizedPayload)
if (k) {
const i = coalesced.get(k)
const i = d.coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload: normalizedPayload }
if (normalizedPayload.type === "message.part.delta") {
// Accumulate delta strings — append to the already-queued event
// rather than replacing it. The reducer is a pure string append so
// this is semantically identical to applying each delta separately.
const prev = d.queue[i] as unknown as { properties: { delta: string } }
const inc = normalizedPayload.properties as { delta: string }
d.queue[i] = {
...normalizedPayload,
properties: {
...(normalizedPayload.properties as object),
delta: prev.properties.delta + inc.delta,
},
} as unknown as Event
} else {
d.queue[i] = normalizedPayload
}
syncDebug.pipeline.coalesced(normalizedPayload.type, k)
continue
}
coalesced.set(k, queue.length)
d.coalesced.set(k, d.queue.length)
}
queue.push({ directory, payload: normalizedPayload })
schedule()
d.queue.push(normalizedPayload)
scheduleDir(directory)
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
@@ -232,7 +288,7 @@ export function createEventPipeline(input: EventPipelineInput) {
if (abort.signal.aborted) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(flush)
})().finally(flushAll)
// Visibility handler — abort SSE on heartbeat timeout so the loop reconnects.
// The reconnect triggers onReconnect above, which lets consumers resync state.
@@ -262,7 +318,7 @@ export function createEventPipeline(input: EventPipelineInput) {
window.removeEventListener("pageshow", onPageShow)
}
abort.abort()
flush()
flushAll()
}
return { cleanup }