All files / src/features/sorting PendingDecisionsGate.tsx

100% Statements 34/34
100% Branches 19/19
100% Functions 7/7
100% Lines 30/30

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 1054x                   4x       4x   4x                                       27x 27x       27x 27x 27x 27x 27x   27x 27x 11x 11x 3x 3x   8x       8x 8x 8x 8x     8x 1x 1x       7x 7x 7x         27x       27x                                              
"use client";
 
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { listPendingKeysForCurator, sweepExpired, type QueueKey } from "./decisionQueue";
import { dismissNotice, drain, getNoticeSet } from "./decisionFlusher";
import { PendingNotices } from "./pendingNotices";
 
// Reveal is time-bounded (spec §3.4/F8): after one backoff step, availability
// beats the ordering guarantee; the post-ack refresh restores it.
export const GATE_REVEAL_BOUND_MS = 5_000;
 
// Person-side copy — exported constants so tests assert the exact strings;
// TONE.md register, never "error"/"failed" (final wording is a TONE-review item).
export const GATE_WAITING_LINE = "Gathering up the answers you already gave — just a moment";
export const GATE_CATCHUP_LINE =
  "A few of your answers are still on their way — everything here catches up in a moment";
 
/**
 * Crash/pause/completion recovery (spec §3.4): before any sort surface renders
 * from stale server-selected data, drain this curator's pending queues through
 * the SAME module engine (a drain already started by a trigger hook is awaited,
 * never raced — F12), refresh, and only then mount the children — a conditional
 * mount, never CSS-hide, so a round component can never copy a stale queue into
 * client state. The children are the already-fetched server subtree; this gate
 * defers mounting, it fetches nothing.
 */
export function PendingDecisionsGate({
  curatorId,
  collectionId,
  children,
}: {
  curatorId: string;
  collectionId: string;
  children: React.ReactNode;
}) {
  const router = useRouter();
  const [isPending, startTransition] = useTransition();
  // Read once at first client render. On the server (and any storage-less
  // environment) this is [] — the normal, zero-cost path renders children
  // immediately.
  const [pendingKeys] = useState<QueueKey[]>(() => listPendingKeysForCurator(curatorId));
  const recovering = pendingKeys.length > 0;
  const [drained, setDrained] = useState(!recovering);
  const [boundedReveal, setBoundedReveal] = useState(false);
  const ran = useRef(false);
 
  useEffect(() => {
    if (ran.current) return;
    ran.current = true;
    if (!recovering) {
      sweepExpired(Date.now()); // GC even on the happy path (F18)
      return;
    }
    const bound = setTimeout(() => setBoundedReveal(true), GATE_REVEAL_BOUND_MS);
    // Only THIS drain may hold the refresh back for sign-in: a line left over from an earlier drain
    // goes first (a 401 in this drain raises it again), so a recovery whose answers are let go
    // (409, no 2xx to clear it) still refreshes.
    dismissNotice({ kind: "auth_required" });
    void drain(pendingKeys, { recovery: true }).finally(() => {
      clearTimeout(bound);
      sweepExpired(Date.now());
      // Still signed out: nothing landed and the queue waits for sign-in (spec §3.2) — reveal
      // with the re-auth line, no refresh.
      if (getNoticeSet().some((n) => n.kind === "auth_required")) {
        setDrained(true);
        return;
      }
      // Mark drained INSIDE the transition so children mount only when the
      // refresh transition settles — they render from the post-flush payload.
      startTransition(() => {
        router.refresh();
        setDrained(true);
      });
    });
  }, [recovering, pendingKeys, router]);
 
  const revealed = !recovering || boundedReveal || (drained && !isPending);
 
  // One fixed tree shape. The notice slot exists only when a recovery actually
  // happened, so ordinary mounts add nothing.
  return (
    <>
      {recovering && (
        <div className="mx-auto max-w-xl space-y-2 empty:hidden">
          {/* Before reveal the gate is the only voice; after it, the surface's own single
              PendingNotices speaks (it reads the engine's sticky notices on mount), so the
              line can never be doubled. */}
          {!revealed && <PendingNotices collectionId={collectionId} />}
          {revealed && !drained && (
            <p role="status" className="rounded-control bg-content-soft px-4 py-3 text-sm text-ink-soft">
              {GATE_CATCHUP_LINE}
            </p>
          )}
        </div>
      )}
      {revealed ? children : (
        <p role="status" className="mx-auto max-w-xl text-sm text-ink-soft">
          {GATE_WAITING_LINE}
        </p>
      )}
    </>
  );
}