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 ff1b690449
commit 656905f8e9
6 changed files with 439 additions and 166 deletions
+61 -14
View File
@@ -58,9 +58,14 @@ const values = new Map<string, LiveValue>()
const statuses = new Map<string, LiveStatus>()
const health = new Map<string, NodeHealth>()
let engineEvents: EngineEvent[] = []
// How many times a node has emitted. The number itself means nothing; a change
// is what restarts the pulse.
// How many times this page has seen a node emit. The number itself means
// nothing; a change is what restarts the pulse.
const emits = new Map<string, number>()
// What the engine had already counted when we connected, from the snapshot.
// Kept apart from the live count on purpose: it says the node has been busy,
// which is worth drawing, but it happened before anyone was watching, and a
// pulse claims something is happening now.
const priorEmits = new Map<string, number>()
let logLines: LogLine[] = []
const paused = new Set<string>()
const listeners = new Map<string, Set<Listener>>()
@@ -133,6 +138,21 @@ export const liveStore = {
emits.set(nodeId, (emits.get(nodeId) ?? 0) + 1)
notify(`emit:${nodeId}`)
},
/**
* The counts a snapshot carries, so a page that connects into an engine
* already at work draws what has been busy rather than a graph of idle
* neurons.
*
* Absent keys are left alone rather than cleared: a restarted engine counts
* from zero again, which is no reason to forget what this page saw.
*/
setEmits(entries: Record<string, number>) {
for (const [nodeId, count] of Object.entries(entries)) {
if (priorEmits.get(nodeId) === count) continue
priorEmits.set(nodeId, count)
notify(`emit:${nodeId}`)
}
},
appendLog(line: LogLine) {
// A new array each time, so the hook's snapshot comparison sees the change.
logLines = [...logLines, line].slice(-LOG_LIMIT)
@@ -173,8 +193,10 @@ export const liveStore = {
values.clear()
for (const key of statuses.keys()) notify(`status:${key}`)
statuses.clear()
for (const key of emits.keys()) notify(`emit:${key}`)
for (const key of new Set([...emits.keys(), ...priorEmits.keys()]))
notify(`emit:${key}`)
emits.clear()
priorEmits.clear()
for (const key of health.keys()) notify(`health:${key}`)
health.clear()
engineEvents = []
@@ -251,6 +273,9 @@ export function useFlowPaused(flow: string): boolean {
const SEP = "\u0000"
/** Back to the array. `"".split(SEP)` is `[""]`, which is one empty name. */
const parts = (joined: string) => (joined ? joined.split(SEP) : [])
function subscribeAll(keys: string[], listener: Listener) {
const unsubscribes = keys.map((key) => subscribeKey(key, listener))
return () => {
@@ -265,13 +290,37 @@ export function useGroupEmits(ids: string[]): number {
useCallback(
(listener: Listener) =>
subscribeAll(
joined.split(SEP).map((id) => `emit:${id}`),
parts(joined).map((id) => `emit:${id}`),
listener,
),
[joined],
),
() => parts(joined).reduce((total, id) => total + (emits.get(id) ?? 0), 0),
)
}
/**
* Whether any of these nodes has ever published, this page or before it.
*
* Separate from the count above because it answers a different question: the
* count is what pulses, and this is what tells a neuron nobody has ever seen
* run from one that is merely quiet just now.
*/
export function useGroupActive(ids: string[]): boolean {
const joined = ids.join(SEP)
return useSyncExternalStore(
useCallback(
(listener: Listener) =>
subscribeAll(
parts(joined).map((id) => `emit:${id}`),
listener,
),
[joined],
),
() =>
joined.split(SEP).reduce((total, id) => total + (emits.get(id) ?? 0), 0),
parts(joined).some(
(id) => (emits.get(id) ?? 0) + (priorEmits.get(id) ?? 0) > 0,
),
)
}
@@ -282,12 +331,12 @@ export function useGroupError(ids: string[]): boolean {
useCallback(
(listener: Listener) =>
subscribeAll(
joined.split(SEP).map((id) => `status:${id}`),
parts(joined).map((id) => `status:${id}`),
listener,
),
[joined],
),
() => joined.split(SEP).some((id) => statuses.get(id)?.status === "error"),
() => parts(joined).some((id) => statuses.get(id)?.status === "error"),
)
}
@@ -298,18 +347,16 @@ export function useLatestTs(names: string[]): number {
useCallback(
(listener: Listener) =>
subscribeAll(
joined.split(SEP).map((name) => `value:${name}`),
parts(joined).map((name) => `value:${name}`),
listener,
),
[joined],
),
() =>
joined
.split(SEP)
.reduce(
(latest, name) => Math.max(latest, values.get(name)?.ts ?? 0),
0,
),
parts(joined).reduce(
(latest, name) => Math.max(latest, values.get(name)?.ts ?? 0),
0,
),
)
}