Own the live socket outside React so leaving Home cannot orphan it

The socket belonged to whichever hook instance ran its effect first. Passive
effects run children before parents, so on Home that was the brain graph rather
than the shell: navigating to a sibling route unmounted the graph, which closed
the socket, while the shell kept the reference count above zero. From there the
page was deaf for the rest of its life, with nothing left to reconnect it.

A module-level connection with a real refcount replaces it — connect on the
first subscriber, disconnect on the last — and the hook is a thin subscription
with the same signature, correct under StrictMode's mount/unmount/mount.

A 1008 now reconnects instead of returning silently: the token is read afresh
per attempt, and three consecutive rejections fall through to the caller's auth
handler so a revoked session surfaces rather than spins.

The snapshot's emit counts are read into a store of their own, apart from the
live count, so a graph that connects into a busy engine is drawn as busy without
every neuron claiming it just fired. The neuron and edge pulses now key off a
change seen while they were mounted, so returning to Home no longer replays
every emission of the session.

Home gets a live indicator for the case none of this can fix: quiet while the
socket is up, and named in words when it is down, since HTTP polling keeps the
rest of the page looking current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
This commit is contained in:
2026-08-20 11:58:49 +02:00
co-authored by Claude Opus 5
parent 558e1cdf18
commit bbff78766e
6 changed files with 439 additions and 166 deletions
@@ -0,0 +1,73 @@
import { Activity } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState, useSyncExternalStore } from "react"
import { useLiveConnection } from "@/components/Flow/liveStore"
import { connectionStore } from "@/lib/connectionStore"
import { fadeIn } from "@/lib/motion"
/**
* How long the socket may be down before it is worth saying so.
*
* The first reconnect attempt is a second away, so a dropped frame is back
* before this elapses and nothing appears. Long enough to cover the opening
* handshake too: a page that has just loaded is connecting, not disconnected.
*/
const GRACE = 3000
/**
* Says when live values have stopped arriving.
*
* Everything else on Home is polled over HTTP, so a dead socket leaves the page
* looking perfectly current while nothing on it moves any more — the neurons
* stop pulsing and the values stop changing, with nothing to say why. Nothing
* at all while the socket is up: a page that works needs no chip saying so.
*
* Quiet too while `ConnectionBanner` is up, since an installation that cannot
* be reached has no socket either and one explanation of that is enough. Named
* in words rather than coloured, and deliberately not terracotta: the brain
* graph above it already owns that accent for a flow that cannot run.
*/
export function LiveIndicator() {
const connected = useLiveConnection()
const { offline } = useSyncExternalStore(
connectionStore.subscribe,
connectionStore.snapshot,
connectionStore.snapshot,
)
const [down, setDown] = useState(false)
useEffect(() => {
if (connected) {
setDown(false)
return
}
const timer = setTimeout(() => setDown(true), GRACE)
return () => clearTimeout(timer)
}, [connected])
return (
<AnimatePresence>
{down && !offline ? (
<motion.div
variants={fadeIn}
initial="hidden"
animate="visible"
exit="hidden"
role="status"
aria-live="polite"
data-testid="live-indicator"
// Wraps rather than widens: three phrases in a row do not fit a
// phone. See DESIGN-GUIDELINES.md → Responsive.
className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-border px-4 py-2 text-sm shadow-e1"
>
<Activity className="size-4 shrink-0 text-muted-foreground" />
<span className="font-medium">Live updates disconnected</span>
<span className="text-muted-foreground">
values and activity are frozen reconnecting
</span>
</motion.div>
) : null}
</AnimatePresence>
)
}