LiveEdge pulse gaps: reset timer survives other producers, snapshots do not flash, flow: endpoints pulse as their node

This commit is contained in:
2026-10-15 16:26:20 +02:00
parent 08adc9ef1d
commit 5bd504ba9d
3 changed files with 29 additions and 6 deletions
@@ -4,7 +4,7 @@ import { memo, useEffect, useMemo, useRef, useState } from "react"
import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
import type { EndpointNodeData } from "./endpoints"
import { type EndpointNodeData, publisherOf } from "./endpoints"
import { handleOffset, nodeHeight } from "./layout"
import { useLiveValues } from "./liveStore"
@@ -57,7 +57,10 @@ function EndpointNodeComponent({ id, data, selected }: NodeProps) {
seen.current.set(name, live.ts)
if (first || live.prior) continue
// Published by this label, by the rule its own edges pulse on (LiveEdge).
if (provides.includes(name) && (!live.source || live.source.id === id)) {
if (
provides.includes(name) &&
(!live.source || live.source.id === publisherOf(id))
) {
dir = "in"
} else if (requires.includes(name)) {
dir ??= "out"
+10 -3
View File
@@ -89,6 +89,11 @@ function LiveEdgeComponent({
const zoom = useStore((state) => state.transform[2])
const [pulsing, setPulsing] = useState(false)
const lastTs = useRef<number | null>(null)
// Owned by the pulse rather than by the effect: a value from another
// producer re-runs the effect too, and must not cancel a reset it does not
// replace, or the edge stays lit and the next pulse cannot restart.
const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => () => clearTimeout(timer.current), [])
// Every rank sits further along the flow than the one before it, so an edge
// whose target has ended up behind its source is one closing a cycle.
@@ -113,13 +118,15 @@ function LiveEdgeComponent({
useEffect(() => {
if (!live?.ts || live.ts === lastTs.current) return
lastTs.current = live.ts
// A snapshot catching the page up is not a value passing now.
if (live.prior) return
const from = live.source
// No source at all is an older engine; pulse rather than go silent.
if (from && from.id !== producerId) return
setPulsing(true)
const timer = setTimeout(() => setPulsing(false), duration.pulse * 1000)
return () => clearTimeout(timer)
}, [live?.ts, live?.source, producerId])
clearTimeout(timer.current)
timer.current = setTimeout(() => setPulsing(false), duration.pulse * 1000)
}, [live?.ts, live?.source, live?.prior, producerId])
return (
<>
+14 -1
View File
@@ -37,6 +37,19 @@ export function isEndpointNode(node: { id: string }): boolean {
return PREFIXES.some((prefix) => node.id.startsWith(prefix))
}
/**
* Whom a value published through this endpoint is credited to.
*
* Every other kind publishes under its endpoint id, but a node in another
* flow publishes as itself — `other.node`, while its label here is
* `flow:other.node`.
*/
export function publisherOf(endpointId: string): string {
return endpointId.startsWith("flow:")
? endpointId.slice("flow:".length)
: endpointId
}
/**
* The flow's own boundary, as endpoints.
*
@@ -167,7 +180,7 @@ export function deriveEndpoints(
data: {
message,
flow,
producerId: endpoint.id,
producerId: publisherOf(endpoint.id),
} satisfies FlowEdgeData,
})
}