feat(web): resync state on SSE reconnect + always-fresh wake (#54) #59

Merged
dominik.polakovics merged 1 commit from afk/54 into main 2026-07-08 23:20:08 +02:00

Closes #54

Problem

Reconnect ≠ resync: the in-process bus has no replay, so every event emitted while an iOS PWA was backgrounded is lost, and on foreground the stream reconnected but nothing refetched — stale rail/chat until a manual reload. Plus the dead-but-green window: wake() declined to cycle a socket iOS had already killed for up to ~65s.

Changes (web only, no server changes)

sse.ts — resync on reconnect + always-fresh wake

  • Client-synthesized resync pseudo-event on every onopen except the app session's first (mount fetches cover that one). Never on the wire: absent from EVENT_TYPES, typed LabEventType | 'resync' on the subscribe surface. Does not reset backoff.
  • Wake (window online, visibilitychange→visible, plus new pageshow + focus listeners, removed in close()) force-cycles via fail(source, true) unless an event (heartbeats count) landed within FRESH_MS = 10s — the anti-flap guard; a fresh open stamps lastEventAt, so app-switcher peeks don't thrash. The 65s STALE_MS watchdog stays as the silent-death backstop. Net effect: returning to the app always yields a fresh stream + resync burst.

lib/liveResource.tscreateLiveResource wrapper (new)

  • Mirrors createResource (both arities, tuple passed through untouched — mutate included) plus LiveEventSpec[] (type, optional match read fresh per event, optional trailing-edge debounceMs).
  • resync → immediate unconditional refetch that cancels pending debounces. All subscriptions/timers torn down via onCleanup. Centralizes the solid/reactivity escape the raw handlers carried — migrated call sites shed their disable lines.

Migration (hybrid, per issue)

  • All plain resource+subscribe sites → wrapper: AppShell (run.changed + run.messages.changed debounced at MESSAGES_DEBOUNCE_MS), Repos (incl. OpenCRChip scoped), Credentials, NewRun, History, RepoIssues (3 resources; labels gated on builtin binding in match), IssueDetail, RepoLabels, RepoCRs, CRDetail, RepoSettings, ProviderAuthCard (defensive provider match preserved), ParkedSection, AFKStrip (one resource, 3 specs).
  • Deliberately bespoke: RunChat stays raw (accumulator is cursor/seq-merge, ADR-0016) and adds a hand-wired resyncrefetchRun + refetchMessages; stores/cloneProgress.ts untouched (data-bearing events, never fetches — Repos refetching on resync covers stale progress).

Tests (units only — agreed scope)

  • sse.test.ts (+8): resync on 2nd open not 1st; FRESH_MS wake cycling while nominally connected; no cycle under FRESH_MS; pageshow/focus wakes; close() removes new listeners; backoff not reset by resync; watchdog tests unchanged.
  • lib/liveResource.test.tsx (+10): matching/non-matching/no-match refetch; debounce coalesces; resync unconditional + cancels pending debounce; dispose cleanup; both arities; refetch+mutate passthrough.
  • Existing route tests pass unmodified (event-injection seams preserved).

Acceptance

npm test (478 passed), npm run lint, npm run build, npm run format:check all green in web/. No Go changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Xvfb3MNahsAeR8SH245jWV

Closes #54 ## Problem Reconnect ≠ resync: the in-process bus has no replay, so every event emitted while an iOS PWA was backgrounded is lost, and on foreground the stream reconnected but nothing refetched — stale rail/chat until a manual reload. Plus the dead-but-green window: `wake()` declined to cycle a socket iOS had already killed for up to ~65s. ## Changes (web only, no server changes) **`sse.ts` — resync on reconnect + always-fresh wake** - Client-synthesized `resync` pseudo-event on every `onopen` except the app session's first (mount fetches cover that one). Never on the wire: absent from `EVENT_TYPES`, typed `LabEventType | 'resync'` on the subscribe surface. Does not reset backoff. - Wake (window `online`, `visibilitychange→visible`, plus new `pageshow` + `focus` listeners, removed in `close()`) force-cycles via `fail(source, true)` unless an event (heartbeats count) landed within `FRESH_MS = 10s` — the anti-flap guard; a fresh open stamps `lastEventAt`, so app-switcher peeks don't thrash. The 65s `STALE_MS` watchdog stays as the silent-death backstop. Net effect: returning to the app always yields a fresh stream + resync burst. **`lib/liveResource.ts` — `createLiveResource` wrapper (new)** - Mirrors `createResource` (both arities, tuple passed through untouched — `mutate` included) plus `LiveEventSpec[]` (`type`, optional `match` read fresh per event, optional trailing-edge `debounceMs`). - `resync` → immediate unconditional refetch that cancels pending debounces. All subscriptions/timers torn down via `onCleanup`. Centralizes the `solid/reactivity` escape the raw handlers carried — migrated call sites shed their disable lines. **Migration (hybrid, per issue)** - All plain resource+subscribe sites → wrapper: AppShell (run.changed + run.messages.changed debounced at `MESSAGES_DEBOUNCE_MS`), Repos (incl. OpenCRChip scoped), Credentials, NewRun, History, RepoIssues (3 resources; labels gated on builtin binding in `match`), IssueDetail, RepoLabels, RepoCRs, CRDetail, RepoSettings, ProviderAuthCard (defensive provider match preserved), ParkedSection, AFKStrip (one resource, 3 specs). - Deliberately bespoke: RunChat stays raw (accumulator is cursor/seq-merge, ADR-0016) and adds a hand-wired `resync` → `refetchRun` + `refetchMessages`; `stores/cloneProgress.ts` untouched (data-bearing events, never fetches — Repos refetching on resync covers stale progress). ## Tests (units only — agreed scope) - `sse.test.ts` (+8): resync on 2nd open not 1st; FRESH_MS wake cycling while nominally connected; no cycle under FRESH_MS; `pageshow`/`focus` wakes; close() removes new listeners; backoff not reset by resync; watchdog tests unchanged. - `lib/liveResource.test.tsx` (+10): matching/non-matching/no-match refetch; debounce coalesces; resync unconditional + cancels pending debounce; dispose cleanup; both arities; refetch+mutate passthrough. - Existing route tests pass unmodified (event-injection seams preserved). ## Acceptance `npm test` (478 passed), `npm run lint`, `npm run build`, `npm run format:check` all green in `web/`. No Go changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Xvfb3MNahsAeR8SH245jWV
feat(web): resync state on SSE reconnect + always-fresh wake (#54)
All checks were successful
ci / native (pull_request) Successful in 6m10s
553ff8f5ea
The event bus is in-process fire-and-forget: events emitted while the
socket was down are lost for good, and reconnecting alone never
refetched anything — so an iOS PWA resumed from background showed stale
state until a manual reload, and a dead-but-green socket could sit
"connected" for up to ~65s after foregrounding.

Three parts, no server changes:

- sse.ts: synthesize a client-only `resync` pseudo-event on every
  reconnect open (never the session's first — mount fetches cover it;
  typed `LabEventType | 'resync'` on the subscribe surface, absent from
  EVENT_TYPES). Wake (online / visibilitychange / new pageshow + focus
  listeners) now force-cycles a nominally-connected stream unless an
  event landed within FRESH_MS=10s — anti-flap via the fresh open
  stamping lastEventAt; the 65s watchdog stays as the backstop.

- lib/liveResource.ts: createLiveResource wraps createResource + SSE
  subscriptions (scoped match predicates, per-spec trailing-edge
  debounce) + an unconditional immediate refetch on resync that cancels
  pending debounces, all torn down via onCleanup — future views cannot
  forget resync. Centralizes the solid/reactivity escape the raw
  handlers carried.

- Migrate every resource+subscribe site to the wrapper (AppShell,
  Repos, Credentials, NewRun, History, RepoIssues, IssueDetail,
  RepoLabels, RepoCRs, CRDetail, RepoSettings, ProviderAuthCard,
  ParkedSection, AFKStrip). RunChat stays deliberately raw (the message
  accumulator is a cursor/seq-merge, ADR-0016) and wires resync by
  hand; stores/cloneProgress.ts is untouched (data-bearing, never
  fetches).

Tests: sse.test.ts covers resync-on-2nd-open, FRESH_MS wake cycling,
pageshow/focus wakes, close() listener removal, watchdog + backoff
invariance; lib/liveResource.test.tsx covers match/debounce/resync/
dispose/arities/tuple passthrough. 478 tests, lint, build all green.

Closes #54

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xvfb3MNahsAeR8SH245jWV
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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
Cloonar/coding-lab!59
No description provided.