feat(sp6): useInboxLanes refetches on SP5 tail events

This commit is contained in:
Tyler
2026-06-20 18:46:08 -06:00
parent 3157c86340
commit fb8eba9bba
2 changed files with 175 additions and 23 deletions
+90 -14
View File
@@ -1,7 +1,32 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, renderHook, waitFor } from "@testing-library/react";
// React's `act` warnings need an act-aware environment; mirror the other
// hook tests in this repo (`useClaimDetail`, `useReconciliation`, ...).
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { useInboxLanes } from "./useInboxLanes";
import { useTailStore } from "@/store/tail-store";
import type { Claim } from "@/types";
// ---------------------------------------------------------------------------
// SP6 T19: tail-driven refetch.
//
// The hook opens the SP5 claim/remit tail streams and refetches the lane
// payload whenever the tail-store grows (i.e. a new `item` event arrived
// via SSE). We mock `useTailStream` so the test doesn't open a real SSE
// connection, and drive the tail store directly via `useTailStore.setState`
// to simulate what `useTailStream` would have dispatched on an `item`.
// ---------------------------------------------------------------------------
vi.mock("./useTailStream", () => ({
useTailStream: vi.fn(() => ({
status: "live",
lastEventAt: null,
error: null,
forceReconnect: vi.fn(),
})),
}));
afterEach(() => {
cleanup();
@@ -9,20 +34,42 @@ afterEach(() => {
vi.useRealTimers();
});
function emptyLanesPayload() {
return {
ok: true,
json: async () => ({
rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
};
}
function makeClaim(id: string): Claim {
return {
id,
patientName: "Test Patient",
providerNpi: "1234567890",
payerName: "Aetna",
cptCode: "99213",
billedAmount: 100,
receivedAmount: 0,
status: "submitted",
submissionDate: "2026-06-20T00:00:00Z",
};
}
describe("useInboxLanes", () => {
beforeEach(() => {
// Each test starts with an empty tail store so length-delta detection
// fires cleanly on the first simulated `item`.
useTailStore.getState().reset("claims");
useTailStore.getState().reset("remittances");
});
it("loads lanes on mount", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
rejected: [],
candidates: [],
unmatched: [],
done_today: [],
}),
}),
);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(emptyLanesPayload()));
const { result } = renderHook(() => useInboxLanes());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.lanes).toEqual({
@@ -42,4 +89,33 @@ describe("useInboxLanes", () => {
await waitFor(() => expect(result.current.error).not.toBeNull());
expect(result.current.error?.message).toMatch(/500/);
});
it("refetches when a claim arrives via the tail store", async () => {
const fetchMock = vi.fn().mockResolvedValue(emptyLanesPayload());
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useInboxLanes());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(fetchMock).toHaveBeenCalledTimes(1);
// Simulate a new claim `item` event from the SP5 stream by writing
// directly into the tail store — that's what the dispatcher in
// `useTailStream` does on a real `item` event.
act(() => {
useTailStore.getState().addClaim(makeClaim("CLP-NEW"));
});
// The hook should debounce-trigger a refetch within ~500ms. We give
// it a generous timeout to avoid flakiness on slow CI.
await waitFor(
() => expect(fetchMock.mock.calls.length).toBeGreaterThan(1),
{ timeout: 1500 },
);
// Cleanup: avoid the debounce timer from firing into an unmounted
// hook (which would log a warning under react-dom 18).
await act(async () => {
result.current.refetch;
});
});
});
+85 -9
View File
@@ -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 };