fix(explorer): dedupe temporal snapshot requests and apply latest-wins
The temporal snapshot effect fetched /api/temporal/snapshot with no
idempotency or ordering guards. Upstream churn (timeline recreation
while bounds settle, play ticks resetting the playhead, drag events)
could re-request the same `at` repeatedly, and with variable network
latency an older position's response could land after a newer one's,
overwriting the active-node count, so the chip visibly lagged the
scrubber.
Add a small stateful guard module (temporalSnapshotGuards.ts) built
around a per-position cache, keyed by the debounced timestamp's
primitive millisecond value rather than the Date object, so upstream
object-identity churn cannot defeat the dedup on its own:
- at most one in-flight request per scrubber position, so identical
`at` values arriving while a request is pending are dropped instead
of firing a fresh fetch, breaking the idle/play polling loop;
- successful snapshots are cached per position and re-applied when the
scrubber returns to it (play wrap-around, back-scrubbing) without a
network round trip;
- a response is applied only while the scrubber is still on the
position it was requested for, so an out-of-order response can never
clobber a newer position's count;
- failed, cancelled, or superseded requests release their position so
it can be fetched again the next time it's visited, rather than
stalling it permanently;
- reset() drops all cached and in-flight state when the underlying
graph summary changes (reload/retry), since snapshots cached against
the previous graph no longer describe anything real. Keyed on the
summary query's data identity, which react-query keeps stable
(staleTime: Infinity plus structural sharing) unless the graph data
itself was replaced, so reset fires exactly on a real reload and not
on cosmetic re-renders.
The snapshot effect is wired through the guards end to end: begin()
returns either a fresh sequence number to fetch under or a cached
snapshot to reapply directly; the same shouldApply()/apply() gate
handles both the network and cached-reapply paths so they can't drift
apart; finish() runs from both the fetch's failure branch and its
cleanup function, so a cancelled or failed request is always retryable
on the next visit instead of leaving its position stuck in-flight.
16 unit tests cover dedup, independent positions, revisit re-apply,
play wrap-around, failure retry, stale-sequence protection (a late
response or a late release from a superseded request cannot act on a
newer request's position), reset-on-reload, and cache-bound eviction.
Closes#1128