{"repo":"NousResearch/hermes-agent","public_id":"36658","display_label":"Dashboard chat infinite re-render React error #301","summary":"Multiple reports and fixes targeting a React error #301 caused by infinite re-render loops in the dashboard chat tab, related to gateway event handling and chat resume parameter updates.","subsystem":"dashboard","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":4,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54116,"merged_at":"2026-06-28T09:20:07+00:00"},"resolution":{"state":"partially_resolved","by":"pr-54116"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T09:20:07+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.31999999999999995},"fix_edges":[{"issue_number":36658,"pr_number":54116,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-28T09:20:07+00:00"}],"members":[{"kind":"issue","number":36658,"title":"[Bug]: Dashboard chat feature is broken after hermes update","url":null,"state":"closed"},{"kind":"issue","number":41739,"title":"Dashboard: Infinite re-render crash (React error #301)","url":null,"state":"closed"},{"kind":"issue","number":41808,"title":"[Bug]: Dashboard Chat tab: React error #301","url":null,"state":"closed"},{"kind":"issue","number":43427,"title":"Chat tab throws React error #301 (infinite re-render) on dashboard","url":null,"state":"closed"},{"kind":"pr","number":42020,"title":"fix(dashboard): avoid chat resume param update loop","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":54116,"title":"fix(tui): defer buffered gateway events to stop dashboard chat #301 (#36658)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["ui-tui/src/__tests__/gatewayClient.test.ts","ui-tui/src/gatewayClient.ts"],"fix_hunks":[{"file":"ui-tui/src/__tests__/gatewayClient.test.ts","hunks":["@@ -154,6 +154,79 @@ describe('GatewayClient websocket attach mode', () => {\n     gw.kill()\n   })\n \n+  it('drains buffered events on a later microtask, not synchronously inside drain()', async () => {\n+    // Regression for #36658: in attach mode the already-running gateway\n+    // replays `gateway.ready` the instant the socket connects, so it lands in\n+    // bufferedEvents BEFORE the consumer's mount-time subscribe effect runs.\n+    // If drain() emitted those synchronously, the gateway.ready handler's\n+    // setState cascade would run inside React's first commit -> \"Too many\n+    // re-renders\" (#301). drain() must defer the buffered flush so the first\n+    // commit settles first.\n+    process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'\n+    const gw = new GatewayClient()\n+\n+    gw.start()\n+    const gatewaySocket = FakeWebSocket.instances[0]!\n+\n+    gatewaySocket.open()\n+    // Server replays ready BEFORE the consumer subscribes (attach-mode timing):\n+    gatewaySocket.message(\n+      JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } })\n+    )\n+\n+    const order: string[] = []\n+\n+    gw.on('event', ev => order.push(`event:${ev.type}`))\n+    gw.drain()\n+    order.push('after-drain')\n+\n+    // Buffered event must NOT have fired synchronously inside drain():\n+    expect(order).toEqual(['after-drain'])\n+\n+    // ...and must arrive on the next microtask.\n+    await vi.waitFor(() => expect(order).toContain('event:gateway.ready'))\n+    expect(order).toEqual(['after-drain', 'event:gateway.ready'])\n+\n+    gw.kill()\n+  })\n+\n+  it('preserves FIFO order when a live event arrives before the deferred flush', async () => {\n+    // #36658 hardening: `subscribed` must NOT flip synchronously in drain().\n+    // A live event delivered in the window between drain() returning and the\n+    // deferred microtask running must still queue BEHIND the chronologically\n+    // earlier buffered events, not jump ahead of them.\n+    process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'\n+    const gw = new GatewayClient()\n+\n+    gw.start()\n+    const gatewaySocket = FakeWebSocket.instances[0]!\n+\n+    gatewaySocket.open()\n+    // Buffered first (replayed on connect, before subscribe):\n+    gatewaySocket.message(\n+      JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } })\n+    )\n+\n+    const order: string[] = []\n+\n+    gw.on('event', ev => order.push(ev.type))\n+    gw.drain()\n+\n+    // A LIVE event arrives synchronously in the post-drain / pre-microtask gap:\n+    gatewaySocket.message(\n+      JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'session.info', payload: {} } })\n+    )\n+\n+    // Nothing emitted yet (subscribed stays false until the microtask):\n+    expect(order).toEqual([])\n+\n+    await vi.waitFor(() => expect(order.length).toBe(2))\n+    // FIFO preserved: the earlier-buffered gateway.ready precedes the live one.\n+    expect(order).toEqual(['gateway.ready', 'session.info'])\n+\n+    gw.kill()\n+  })\n+\n   it('mirrors event frames to sidecar websocket when configured', async () => {\n     process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'\n     process.env.HERMES_TUI_SIDECAR_URL = 'ws://gateway.test/api/pub?token=[redacted:secret-assignment]'","@@ -172,6 +245,9 @@ describe('GatewayClient websocket attach mode', () => {\n \n     sidecarSocket.open()\n     gw.drain()\n+    // drain() flips `subscribed` on a microtask now (#36658); let it settle so\n+    // the subsequent live event takes the synchronous publish path.\n+    await Promise.resolve()\n \n     const eventFrame = JSON.stringify({\n       jsonrpc: '2.0',","@@ -206,6 +282,8 @@ describe('GatewayClient websocket attach mode', () => {\n \n     sidecarSocket.open()\n     gw.drain()\n+    // drain() flips `subscribed` on a microtask now (#36658); let it settle.\n+    await Promise.resolve()\n \n     gw.publishLocalEvent({\n       payload: { reason: 'idle_exit_hotkey' },","@@ -227,7 +305,7 @@ describe('GatewayClient websocket attach mode', () => {\n     gw.kill()\n   })\n \n-  it('emits exit when attached websocket closes', () => {\n+  it('emits exit when attached websocket closes', async () => {\n     process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'\n     const gw = new GatewayClient()\n     const exits: Array<null | number> = []","@@ -239,6 +317,9 @@ describe('GatewayClient websocket attach mode', () => {\n \n     gatewaySocket.open()\n     gw.drain()\n+    // drain() flips `subscribed` on a microtask now (#36658); let it settle so\n+    // the close below takes the synchronous exit path.\n+    await Promise.resolve()\n     gatewaySocket.close(1011)\n \n     expect(exits).toEqual([1011])"]},{"file":"ui-tui/src/gatewayClient.ts","hunks":["@@ -146,6 +146,7 @@ export class GatewayClient extends EventEmitter {\n   private ready = false\n   private readyTimer: ReturnType<typeof setTimeout> | null = null\n   private subscribed = false\n+  private drainGeneration = 0\n   private stdoutRl: ReturnType<typeof createInterface> | null = null\n   private stderrRl: ReturnType<typeof createInterface> | null = null\n ","@@ -217,6 +218,10 @@ export class GatewayClient extends EventEmitter {\n     // attached to a discarded child / socket.\n     this.rejectPending(new Error('gateway restarting'))\n     this.ready = false\n+    this.subscribed = false\n+    // Invalidate any pending deferred drain() flush from a prior transport so\n+    // its queued microtask becomes a no-op (it captured the old generation).\n+    this.drainGeneration += 1\n     this.bufferedEvents.clear()\n     this.pendingExit = undefined\n     this.stdoutRl?.close()","@@ -611,18 +616,49 @@ export class GatewayClient extends EventEmitter {\n   }\n \n   drain() {\n-    this.subscribed = true\n+    // Defer the buffered-event replay to the next microtask, and DO NOT flip\n+    // `subscribed` until that microtask runs.\n+    //\n+    // `drain()` is called from the consumer's mount-time subscribe effect\n+    // (ui-tui/src/app/useMainApp.ts). In *attach* mode the gateway is already\n+    // running, so it replays `gateway.ready` / `session.info` the instant the\n+    // socket connects — those land in `bufferedEvents` *before* the consumer\n+    // subscribes. If we emitted them synchronously here, the `gateway.ready`\n+    // handler's `patchUiState` / `setHistoryItems` cascade would run while\n+    // React is still inside the first commit, tripping \"Too many re-renders\"\n+    // (Minified React error #301) — issue #36658. Spawn/inline/sidecar modes\n+    // don't hit this because `gateway.ready` only arrives after the Python\n+    // child boots, i.e. on a later async tick.\n+    //\n+    // Crucially, `subscribed` stays false until the flush so any LIVE event\n+    // arriving in the gap between here and the microtask keeps buffering\n+    // (publish() pushes when !subscribed) instead of emitting synchronously\n+    // and jumping ahead of the chronologically-earlier replayed events. The\n+    // flush re-drains the buffer right after flipping `subscribed`, so any\n+    // in-window arrivals are delivered in FIFO order. A generation token makes\n+    // the queued microtask a no-op if the transport was reset/killed meanwhile.\n+    const generation = this.drainGeneration\n+\n+    queueMicrotask(() => {\n+      if (this.drainGeneration !== generation) {\n+        return\n+      }\n \n-    for (const ev of this.bufferedEvents.drain()) {\n-      this.emit('event', ev)\n-    }\n+      this.subscribed = true\n \n-    if (this.pendingExit !== undefined) {\n-      const code = this.pendingExit\n+      // Replay everything buffered up to now, then any events that arrived in\n+      // the gap before this microtask ran — all in chronological order.\n+      for (const ev of this.bufferedEvents.drain()) {\n+        this.emit('event', ev)\n+      }\n \n-      this.pendingExit = undefined\n-      this.emit('exit', code)\n-    }\n+      if (this.pendingExit !== undefined) {\n+        const code = this.pendingExit\n+\n+        this.pendingExit = undefined\n+        this.emit('exit', code)\n+      }\n+    })\n   }\n \n   getLogTail(limit = 20): string {\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}