fix(web): SSE resilience + specs Dialog.Portal wrapper #247

Merged
code-lead merged 2 commits from fix/sse-reliability-and-dialog-portal into main 2026-04-21 14:59:04 +00:00
Collaborator

Summary

Three in-session fixes landed live earlier today via hot-edits but were never committed — consolidating so a cold restart picks them up from source.

  • apps/server/src/main.ts — disable Bun's default 10 s idleTimeout on /events and /architect/stream/* via srv.timeout(req, 0). Without it the server silently killed every SSE stream at ~10 s (heartbeat is a server-side write; Bun's idle timer only resets on client bytes, which EventSource never sends). Root cause of the "reconnecting for 122s" banner seen on 2026-04-21.
  • apps/web/src/lib/sse.ts — add an explicit 3 s reconnect timer in the useSSE hook. Firefox's native EventSource auto-retry is unreliable; on a graceful TCP close (rather than a network error) it treats the stream as finished and never reconnects. Our timer closes the old stream and opens a new one deterministically.
  • apps/web/src/routes/specs.tsx — wrap Dialog.Backdrop + Dialog.Popup in Dialog.Portal. Base UI's Dialog composition requires the Portal wrapper; without it the /app/specs route threw "Base UI error #26" on first render. Matches the pattern already used in issue-card.tsx.

Test plan

  • bun run --cwd apps/server typecheck clean
  • bun run --cwd apps/web typecheck clean
  • CI green
  • Manual: curl -sN --max-time 60 https://claude.jacquin.app/events holds the full 60 s (confirmed 2026-04-21)
  • Manual: /app/specs renders without Base UI error #26 (confirmed 2026-04-21)

🤖 Generated with Claude Code

## Summary Three in-session fixes landed live earlier today via hot-edits but were never committed — consolidating so a cold restart picks them up from source. - **`apps/server/src/main.ts`** — disable Bun's default 10 s `idleTimeout` on `/events` and `/architect/stream/*` via `srv.timeout(req, 0)`. Without it the server silently killed every SSE stream at ~10 s (heartbeat is a server-side write; Bun's idle timer only resets on client bytes, which EventSource never sends). Root cause of the "reconnecting for 122s" banner seen on 2026-04-21. - **`apps/web/src/lib/sse.ts`** — add an explicit 3 s reconnect timer in the `useSSE` hook. Firefox's native EventSource auto-retry is unreliable; on a graceful TCP close (rather than a network error) it treats the stream as finished and never reconnects. Our timer closes the old stream and opens a new one deterministically. - **`apps/web/src/routes/specs.tsx`** — wrap `Dialog.Backdrop` + `Dialog.Popup` in `Dialog.Portal`. Base UI's Dialog composition requires the Portal wrapper; without it the `/app/specs` route threw "Base UI error #26" on first render. Matches the pattern already used in `issue-card.tsx`. ## Test plan - [x] `bun run --cwd apps/server typecheck` clean - [x] `bun run --cwd apps/web typecheck` clean - [ ] CI green - [ ] Manual: `curl -sN --max-time 60 https://claude.jacquin.app/events` holds the full 60 s (confirmed 2026-04-21) - [ ] Manual: `/app/specs` renders without Base UI error #26 (confirmed 2026-04-21) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(web): SSE resilience + specs Dialog.Portal wrapper
Some checks failed
qa / qa (pull_request) Failing after 2m59s
qa / dockerfile (pull_request) Successful in 7s
9ef0147900
Three in-session fixes landed live earlier today via hot-edits but were
never committed; consolidating them here so a restart picks them up from
source rather than a cold reload leaving the SPA regressed.

- apps/server/src/main.ts: disable Bun's default 10 s idleTimeout on
  /events and /architect/stream/* via srv.timeout(req, 0). Without it,
  the server silently killed every SSE stream at ~10 s (heartbeat is a
  server-side write; Bun's idle timer only resets on client bytes,
  which EventSource never sends). Root cause of the "reconnecting for
  122s" banner seen on 2026-04-21.
- apps/web/src/lib/sse.ts: add an explicit 3 s reconnect timer in the
  useSSE hook. Firefox's native EventSource auto-retry is unreliable —
  on a graceful TCP close (rather than a network error) it treats the
  stream as finished and never reconnects. Our timer closes the old
  stream and opens a new one deterministically.
- apps/web/src/routes/specs.tsx: wrap Dialog.Backdrop + Dialog.Popup
  in Dialog.Portal. Base UI's Dialog composition requires the Portal
  wrapper; without it the /app/specs route threw "Base UI error #26"
  on first render. Matches the pattern already in issue-card.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(web,ci): restore husky pre-commit hook + format specs.tsx ternary
All checks were successful
qa / qa (pull_request) Successful in 5m22s
qa / dockerfile (pull_request) Successful in 8s
d719d9e007
Two fixes bundled:

1. **specs.tsx ternary formatting** — after wrapping the dialog in
   Dialog.Portal (+1 indent), the createSpecMutation.error ternary
   exceeded biome's line width. Split across three lines per biome's
   autofix.
2. **Husky pre-commit hook was never active on this checkout** — which
   is why biome didn't catch the above locally. `devDependencies.husky`
   was declared but bun's install never exposed the `husky` binary on
   PATH, so the `prepare` script silently failed and `core.hooksPath`
   was never set. Ran `bun add -d husky` to force the bin into
   `node_modules/.bin/`, then `bun x husky` to register the hooks dir.
   Also restored `.husky/pre-commit` to run `bun x lint-staged` — the
   husky init default stomped it with a `bun test` stub.

Sanity: `bun x lint-staged` now fires on the staged delta and biome's
autofix reformats before commit, matching the intended gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
reviewer approved these changes 2026-04-21 14:58:31 +00:00
reviewer left a comment

Review — Round 1

CI: green (run #1907, 5m30s)

Three focused bug fixes — all correct.

apps/server/src/main.tssrv.timeout(req, 0)

The root cause analysis is right: Bun's idle timer resets on client-side bytes only, and EventSource/curl never sends bytes back. Calling srv.timeout(req, 0) on the original Request object (before handleRequest reconstructs it) is the only place this works correctly per Bun's API contract. The /architect/stream/* path is included too — good catch, same problem would apply there.

apps/web/src/lib/sse.ts — explicit reconnect timer

The 3 s timer correctly handles the Firefox graceful-close case without interfering with the existing debounce/hard-fail logic:

  • The timer nulls reconnectTimerRef.current before calling connect(), so a fresh onerror from the new stream arms a new 3 s timer — the retry loop is perpetual and self-resetting.
  • if (offlineSinceRef.current !== null) guards the timer callback from firing after onopen already cleared the offline state — no spurious reconnects.
  • clearTimers() in onopen is idempotent whether the timer already fired or not.

One minor observation (not a blocker): after connect() is called from the reconnect timer, if the old stream's .close() races with an onerror callback, the onerror would see reconnectTimerRef.current === null (already cleared) and arm another 3 s timer in parallel. In practice, EventSource.close() does not fire onerror in any major browser, so this race is theoretical. The code is safe as-is.

apps/web/src/routes/specs.tsxDialog.Portal wrapper

Wrapping Dialog.Backdrop + Dialog.Popup in Dialog.Portal is exactly what Base UI requires for its Dialog composition (error #26 is the missing-portal error). Pattern matches issue-card.tsx. Nothing else changed in the component logic.

## Review — Round 1 CI: ✅ green (run #1907, 5m30s) Three focused bug fixes — all correct. ### `apps/server/src/main.ts` — `srv.timeout(req, 0)` The root cause analysis is right: Bun's idle timer resets on client-side bytes only, and EventSource/`curl` never sends bytes back. Calling `srv.timeout(req, 0)` on the **original** `Request` object (before `handleRequest` reconstructs it) is the only place this works correctly per Bun's API contract. The `/architect/stream/*` path is included too — good catch, same problem would apply there. ### `apps/web/src/lib/sse.ts` — explicit reconnect timer The 3 s timer correctly handles the Firefox graceful-close case without interfering with the existing debounce/hard-fail logic: - The timer nulls `reconnectTimerRef.current` before calling `connect()`, so a fresh `onerror` from the new stream arms a new 3 s timer — the retry loop is perpetual and self-resetting. - `if (offlineSinceRef.current !== null)` guards the timer callback from firing after `onopen` already cleared the offline state — no spurious reconnects. - `clearTimers()` in `onopen` is idempotent whether the timer already fired or not. One minor observation (not a blocker): after `connect()` is called from the reconnect timer, if the old stream's `.close()` races with an `onerror` callback, the `onerror` would see `reconnectTimerRef.current === null` (already cleared) and arm another 3 s timer in parallel. In practice, `EventSource.close()` does not fire `onerror` in any major browser, so this race is theoretical. The code is safe as-is. ### `apps/web/src/routes/specs.tsx` — `Dialog.Portal` wrapper Wrapping `Dialog.Backdrop` + `Dialog.Popup` in `Dialog.Portal` is exactly what Base UI requires for its Dialog composition (error #26 is the missing-portal error). Pattern matches `issue-card.tsx`. Nothing else changed in the component logic.
code-lead deleted branch fix/sse-reliability-and-dialog-portal 2026-04-21 14:59:05 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!247
No description provided.