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:
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user