feat(sp6): useInboxLanes refetches on SP5 tail events
This commit is contained in:
@@ -1,8 +1,32 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbox lanes hook (sub-project 6, Phase 7 Task 19).
|
||||
//
|
||||
// Owns the four-lane payload (`rejected` / `candidates` / `unmatched` /
|
||||
// `done_today`) for the Inbox page. T19 replaced the original 5s polling
|
||||
// with the SP5 tail: the hook opens the claim and remittance streams via
|
||||
// `useTailStream`, and whenever the tail store grows (a new `item` event
|
||||
// arrived) it triggers a debounced refetch of the lanes payload. The
|
||||
// debounce coalesces a burst of events into one network round-trip.
|
||||
//
|
||||
// The streams themselves stay open as long as a consumer of this hook is
|
||||
// mounted (e.g. the `/inbox` page). When the user navigates away, the
|
||||
// hook unmounts and `useTailStream`'s cleanup aborts the SSE connection.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchInboxLanes, type InboxLanes } from "@/lib/inbox-api";
|
||||
import { useTailStream } from "./useTailStream";
|
||||
import { useTailStore } from "@/store/tail-store";
|
||||
|
||||
/** Default poll interval. Tail-integration (T19) will replace this. */
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
/**
|
||||
* Coalesce bursty tail events into a single network round-trip. With 5s
|
||||
* polling this was implicitly handled by the interval; with the tail we
|
||||
* have to do it explicitly. 250ms is short enough that the user sees the
|
||||
* new row within their natural attention span (well under the spec's
|
||||
* "live updates" SLO), and long enough that a 10-event burst doesn't
|
||||
* fire 10 fetches.
|
||||
*/
|
||||
const REFETCH_DEBOUNCE_MS = 250;
|
||||
|
||||
export function useInboxLanes() {
|
||||
const [lanes, setLanes] = useState<InboxLanes>({
|
||||
@@ -13,7 +37,22 @@ export function useInboxLanes() {
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Keep the SP5 tail connections alive while this hook is mounted. The
|
||||
// hook itself doesn't read their return value — events are dispatched
|
||||
// into the tail store, which we subscribe to below. The `useTailStream`
|
||||
// call is what opens the SSE stream and keeps it alive; without these
|
||||
// two lines the inbox would silently stop updating once the user
|
||||
// navigated away from a page that already mounted a tail.
|
||||
useTailStream("claims");
|
||||
useTailStream("remittances");
|
||||
|
||||
// Subscribe to the tail-store order arrays. We watch the *length* (not
|
||||
// the contents) because the store's `addClaim`/`addRemittance` returns
|
||||
// a new array reference on every insert, which is exactly what zustand
|
||||
// uses to flag a re-render.
|
||||
const claimOrderLen = useTailStore((s) => s.claimOrder.length);
|
||||
const remitOrderLen = useTailStore((s) => s.remitOrder.length);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
@@ -27,14 +66,51 @@ export function useInboxLanes() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Hold the latest `refetch` in a ref so the debounce timer always
|
||||
// closes over the current closure without forcing the effect below to
|
||||
// re-subscribe on every render.
|
||||
const refetchRef = useRef(refetch);
|
||||
refetchRef.current = refetch;
|
||||
|
||||
// Track the order lengths we last observed so the very first render
|
||||
// (with the store already at its initial size, possibly non-zero if a
|
||||
// sibling page has been populating it) doesn't trigger a spurious
|
||||
// refetch. -1 sentinel means "haven't observed yet".
|
||||
const prevLenRef = useRef<{ claims: number; remits: number }>({
|
||||
claims: -1,
|
||||
remits: -1,
|
||||
});
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevLenRef.current;
|
||||
const claimsChanged = prev.claims !== claimOrderLen;
|
||||
const remitsChanged = prev.remits !== remitOrderLen;
|
||||
prevLenRef.current = { claims: claimOrderLen, remits: remitOrderLen };
|
||||
|
||||
// Skip the initial observation — the lanes have already been
|
||||
// fetched via the mount effect below. Only react to *changes*.
|
||||
if (prev.claims === -1) return;
|
||||
if (!claimsChanged && !remitsChanged) return;
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
void refetchRef.current();
|
||||
}, REFETCH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [claimOrderLen, remitOrderLen]);
|
||||
|
||||
// Initial fetch on mount. Runs once; the tail-driven effect above
|
||||
// handles subsequent updates.
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
pollRef.current = setInterval(() => {
|
||||
void refetch();
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
return { lanes, loading, error, refetch };
|
||||
|
||||
Reference in New Issue
Block a user