commit f40684a54269b56b0881a9b29830e88e3adc3cb8
parent 33f338c4760dfe4776408a717138af0184f13dbb
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 23 Sep 2026 19:24:46 -0400
editor: one client poller — usePolledPayload moves to lib/, three loops fold onto it
`widget/lib/usePolledPayload.ts` → `lib/usePolledPayload.ts` (git mv). It is
the client side of the one polling route: every poll now goes to
`/api/view/<name>` through it, cadence still a call-site argument.
Folded:
- JobsTable (1 s): `usePolledPayload<ActiveJobsPayload>("/api/view/activeJobs",
polling, POLL_MS, null)`. `polling` mirrors `anyLive` via state adjusted during
render, because the poll's result feeds the rows `anyLive` is derived from.
An idle page still makes no requests; `data` is never nulled, so the last
poll's `recent` rows still hold a finished job; freshest-snapshot-wins and
mergeJobRows are untouched.
- useOperationsStatus (3 s) and SyncConsole (5 s): both were SSR-seeded and
waited one interval before the first fetch; the hook gains
`{ immediate: false }` to keep that, and its `refetch` gains the unmount guard
both loops carried by hand. They were setInterval loops (a slow response
could stack requests); the hook schedules each tick after the last settles.
Client URLs move to /api/view/*: pulse.ts, JobsTable, WorkersView,
useOperationsStatus, SyncConsole, DashboardCockpit (4), MonitorWidget (5).
`cache: "no-store"` stays on every call. auto-refresh.spec.ts counts the
BROWSER's pulse requests by path, so its one path check follows the client to
/api/view/pulse; no server-side assertion at an old path was touched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
10 files changed, 165 insertions(+), 148 deletions(-)
diff --git a/editor/app/components/dashboard/DashboardCockpit.tsx b/editor/app/components/dashboard/DashboardCockpit.tsx
@@ -4,7 +4,7 @@ import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJob
import type { WorkersPayload } from "yt-dlp-transcript-common/views/workers";
import type { WidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable";
import type { WidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync";
-import { usePolledPayload, useNow } from "../../widget/lib/usePolledPayload";
+import { usePolledPayload, useNow } from "../../lib/usePolledPayload";
import { PipelineBand } from "./PipelineBand";
import { NeedsWorkPanel } from "./NeedsWorkPanel";
import { QuickAddPanel } from "./QuickAddPanel";
@@ -37,26 +37,26 @@ export function DashboardCockpit({
const now = useNow();
const { data: jobs } = usePolledPayload<ActiveJobsPayload>(
- "/api/jobs/active",
+ "/api/view/activeJobs",
true,
FAST_MS,
initial.jobs,
);
const { data: workers, refetch: refetchWorkers } =
usePolledPayload<WorkersPayload>(
- "/api/workers",
+ "/api/view/workers",
true,
FAST_MS,
initial.workers,
);
const { data: actionable } = usePolledPayload<WidgetActionablePayload>(
- "/api/widget/actionable",
+ "/api/view/widgetActionable",
true,
SLOW_MS,
initial.actionable,
);
const { data: sync, refetch: refetchSync } = usePolledPayload<WidgetSyncPayload>(
- "/api/widget/sync",
+ "/api/view/widgetSync",
true,
SLOW_MS,
initial.sync,
diff --git a/editor/app/components/pulse.ts b/editor/app/components/pulse.ts
@@ -57,8 +57,8 @@ async function poll(): Promise<void> {
inFlight = true;
try {
const url = state.rev
- ? `/api/pulse?rev=${encodeURIComponent(state.rev)}`
- : "/api/pulse";
+ ? `/api/view/pulse?rev=${encodeURIComponent(state.rev)}`
+ : "/api/view/pulse";
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return;
const data = (await res.json()) as PulsePayload;
diff --git a/editor/app/jobs/components/JobsTable.tsx b/editor/app/jobs/components/JobsTable.tsx
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import type { ActiveJobsPayload } from "yt-dlp-transcript-common/views/activeJobs";
+import { usePolledPayload } from "../../lib/usePolledPayload";
import type { JobRowView } from "yt-dlp-transcript-common/views/jobRowView";
import { isLive, mergeJobRows } from "yt-dlp-transcript-common/views/jobRows";
import { CancelJobButton } from "./CancelJobButton";
@@ -121,12 +122,28 @@ export function JobsTable({
}) {
// null until mount → render everything (matches server HTML).
const [filters, setFilters] = useState<JobsFilterState | null>(null);
- const [polled, setPolled] = useState<ActiveJobsPayload | null>(null);
useEffect(() => {
setFilters(loadJobsFilters());
}, []);
+ // Whether the head poll runs. It is `anyLive` below, carried in state
+ // because the poll's result feeds the rows `anyLive` is derived from — the
+ // hook has to be called before the rows exist. Seeded from the props, which
+ // is exactly what `anyLive` is on the first render (no poll has landed).
+ const [polling, setPolling] = useState(
+ () =>
+ initial.jobs.some(isLive) ||
+ initial.recent.some(isLive) ||
+ history.some(isLive),
+ );
+ const { data: polled } = usePolledPayload<ActiveJobsPayload>(
+ "/api/view/activeJobs",
+ polling,
+ POLL_MS,
+ null,
+ );
+
// FRESHEST SNAPSHOT WINS. A /jobs render served from the router cache
// (staleTimes.dynamic) can be OLDER than the client's last poll, so naively
// adopting a new `initial` prop would show a finished job as running again.
@@ -146,29 +163,10 @@ export function JobsTable({
// the last poll's `recent` rows are what keeps a job that just finished on
// screen until the paged tail catches up.
const anyLive = head.some(isLive) || history.some(isLive);
-
- useEffect(() => {
- if (!anyLive) return;
- let cancelled = false;
- let timer: ReturnType<typeof setTimeout> | null = null;
- async function tick() {
- try {
- const res = await fetch("/api/jobs/active", { cache: "no-store" });
- if (res.ok && !cancelled) {
- setPolled((await res.json()) as ActiveJobsPayload);
- }
- } catch {
- // transient — keep polling
- } finally {
- if (!cancelled) timer = setTimeout(tick, POLL_MS);
- }
- }
- void tick();
- return () => {
- cancelled = true;
- if (timer) clearTimeout(timer);
- };
- }, [anyLive]);
+ // Adjust-state-during-render (React's documented pattern for state derived
+ // from the previous render): React re-renders before committing, so the poll
+ // starts and stops on the same render the old `useEffect(…, [anyLive])` did.
+ if (polling !== anyLive) setPolling(anyLive);
// Channel-less jobs by kind, so each lane's line can carry its own runner's
// log link and controls.
diff --git a/editor/app/lib/usePolledPayload.ts b/editor/app/lib/usePolledPayload.ts
@@ -0,0 +1,93 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+// Shared client polling primitives. Extracted from the monitor widget so the
+// dashboard cockpit could reuse the ~1s poll it proved out, and moved up to
+// lib/ in one-core phase 3 slice 2 when it became THE client poller: the
+// widget, the dashboard, /jobs, the operations board and the sync console all
+// poll `/api/view/<name>` through this one hook, each at its own cadence (the
+// cadence is a call-site argument on purpose — there is no batch endpoint).
+
+// Live wall-clock that re-renders once a second; null until mounted so SSR and
+// the first client render agree (no Date.now() hydration mismatch).
+export function useNow(): number | null {
+ const [now, setNow] = useState<number | null>(null);
+ useEffect(() => {
+ setNow(Date.now());
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, []);
+ return now;
+}
+
+// Generic poller: fetches `url` every `pollMs` while enabled, swallowing
+// transient errors. Disabled (enabled=false) stops the timer and KEEPS the last
+// value — it never nulls `data` — which /jobs relies on: its last poll's
+// `recent` rows hold a just-finished job on screen after polling stops.
+// Returns the latest payload plus a `refetch` so a control action can refresh
+// it immediately instead of waiting for the next poll tick.
+//
+// The next tick is scheduled only after the previous fetch settles, so a slow
+// response can never stack requests behind it.
+//
+// `immediate: false` skips the fetch on (re)subscribe and waits one interval
+// first — for surfaces whose SSR seed is by construction fresh, where the
+// first poll would re-fetch what the server just rendered.
+export type PollOptions = { immediate?: boolean };
+
+export function usePolledPayload<T>(
+ url: string,
+ enabled: boolean,
+ pollMs: number,
+ initial: T | null,
+ { immediate = true }: PollOptions = {},
+): { data: T | null; refetch: () => Promise<void> } {
+ const [data, setData] = useState<T | null>(initial);
+ // A `refetch` awaited by a control action can settle after the surface
+ // unmounted (the operations board and the sync console both guarded this by
+ // hand before they folded onto the hook).
+ const mounted = useRef(true);
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+ const refetch = useCallback(async () => {
+ try {
+ const res = await fetch(url, { cache: "no-store" });
+ if (!res.ok) return;
+ const next = (await res.json()) as T;
+ if (mounted.current) setData(next);
+ } catch {
+ // transient — ignore
+ }
+ }, [url]);
+ useEffect(() => {
+ if (!enabled) return;
+ let cancelled = false;
+ let timer: ReturnType<typeof setTimeout> | null = null;
+ async function tick() {
+ try {
+ const res = await fetch(url, { cache: "no-store" });
+ if (res.ok && !cancelled) setData((await res.json()) as T);
+ } catch {
+ // transient — keep polling
+ } finally {
+ if (!cancelled) timer = setTimeout(tick, pollMs);
+ }
+ }
+ // By default fetch immediately on (re)subscribe, then poll on the
+ // interval. Sections seeded from the server get a harmless refresh; those
+ // with a null initial render on the first tick instead of after a full
+ // interval.
+ if (immediate) void tick();
+ else timer = setTimeout(tick, pollMs);
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ };
+ }, [url, enabled, pollMs, immediate]);
+ return { data, refetch };
+}
diff --git a/editor/app/operations/components/sync/SyncConsole.tsx b/editor/app/operations/components/sync/SyncConsole.tsx
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { SchedulerStatusPayload } from "yt-dlp-transcript-common/views/schedulerStatus";
+import { usePolledPayload } from "../../../lib/usePolledPayload";
import { BulkCadenceBar } from "./BulkCadenceBar";
import { ChannelCadenceEditor } from "./ChannelCadenceEditor";
@@ -11,7 +12,7 @@ import { ChannelCadenceEditor } from "./ChannelCadenceEditor";
// OperationDetail renders it where a runner or a sweep lane would otherwise be.
//
// TWO POLLS ON THIS PAGE, deliberately not merged: this one is 5s over
-// /api/scheduler/status (the schedule, whose facts move on the order of
+// /api/view/schedulerStatus (the schedule, whose facts move on the order of
// minutes) and the rail above is 3s over the auto-queue payload (lane state,
// which moves per dispatch). One combined endpoint would make the cheaper
// reader pay the more expensive reader's cadence, and buildSchedulerStatusPayload
@@ -21,33 +22,31 @@ export function SyncConsole({
}: {
initial: SchedulerStatusPayload;
}) {
- const [data, setData] = useState<SchedulerStatusPayload>(initial);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
// Slugs ticked for a bulk cadence edit. Kept as a Set of slugs (not indices)
// so the 5s poll reordering or dropping a row can't retarget a selection.
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
+ // Seeded by SSR; the first poll waits one interval, as before.
+ const { data: polled, refetch: refresh } =
+ usePolledPayload<SchedulerStatusPayload>(
+ "/api/view/schedulerStatus",
+ true,
+ 5000,
+ initial,
+ { immediate: false },
+ );
+ // Never null: the hook starts from `initial` and never clears it.
+ const data = polled ?? initial;
+ // Guards the Run-now button's own state (busy/message) after unmount; the
+ // payload's guard is the hook's.
const mounted = useRef(true);
-
- const refresh = useCallback(async () => {
- try {
- const res = await fetch("/api/scheduler/status", { cache: "no-store" });
- if (!res.ok) return;
- const next = (await res.json()) as SchedulerStatusPayload;
- if (mounted.current) setData(next);
- } catch {
- /* transient; the next poll retries */
- }
- }, []);
-
useEffect(() => {
mounted.current = true;
- const id = setInterval(refresh, 5000);
return () => {
mounted.current = false;
- clearInterval(id);
};
- }, [refresh]);
+ }, []);
const runNow = useCallback(async () => {
setBusy(true);
diff --git a/editor/app/operations/components/useOperationsStatus.ts b/editor/app/operations/components/useOperationsStatus.ts
@@ -1,7 +1,8 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useEffect, useState } from "react";
import type { AutoQueueStatusPayload } from "yt-dlp-transcript-common/views/autoQueueStatus";
+import { usePolledPayload } from "../../lib/usePolledPayload";
// THE ONE POLL. Every operations surface — the board and each operation page —
// reads the same payload from the same endpoint on the same 3-second cadence,
@@ -11,37 +12,25 @@ import type { AutoQueueStatusPayload } from "yt-dlp-transcript-common/views/auto
// are read TOGETHER, and two polls would let the rail and the lane below it
// disagree about the same moment.
//
-// The endpoint keeps its /api/auto-queue/* path. It is addressed directly by
-// three specs and by nothing user-facing, so renaming it would be churn with a
-// test bill and no reader.
+// It polls /api/view/autoQueueStatus. The old /api/auto-queue/status path is a
+// rewrite onto the same handler (next.config.ts) — three specs still address
+// it there, and that is the rewrite's regression test.
export function useOperationsStatus(initial: AutoQueueStatusPayload): {
data: AutoQueueStatusPayload;
refresh: () => Promise<void>;
} {
- const [data, setData] = useState<AutoQueueStatusPayload>(initial);
- const mounted = useRef(true);
-
- const refresh = useCallback(async () => {
- try {
- const res = await fetch("/api/auto-queue/status", { cache: "no-store" });
- if (!res.ok) return;
- const next = (await res.json()) as AutoQueueStatusPayload;
- if (mounted.current) setData(next);
- } catch {
- /* transient; next poll retries */
- }
- }, []);
-
- useEffect(() => {
- mounted.current = true;
- const id = setInterval(refresh, 3000);
- return () => {
- mounted.current = false;
- clearInterval(id);
- };
- }, [refresh]);
-
- return { data, refresh };
+ // Seeded by SSR, so the first poll waits one interval (`immediate: false`),
+ // as the hand-rolled loop this replaced did. Always enabled: the board is
+ // live whenever it is open.
+ const { data, refetch } = usePolledPayload<AutoQueueStatusPayload>(
+ "/api/view/autoQueueStatus",
+ true,
+ 3000,
+ initial,
+ { immediate: false },
+ );
+ // Never null: the hook starts from `initial` and never clears it.
+ return { data: data ?? initial, refresh: refetch };
}
// "React is live on this subtree" — the same signal `now !== null` gives the
diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx
@@ -2,7 +2,7 @@
import { Fragment, useState, type CSSProperties, type ReactNode } from "react";
import { formatDuration, formatBytes } from "yt-dlp-transcript-common/lib/format";
-import { usePolledPayload, useNow } from "../lib/usePolledPayload";
+import { usePolledPayload, useNow } from "../../lib/usePolledPayload";
import { fmtTime } from "../lib/relativeTime";
import type {
ActiveJobsPayload,
@@ -69,14 +69,14 @@ export function MonitorWidget({
// no clue that it was the gate rather than the disk gate being off. Same
// pre-existing shape as the sync payload's gate below.
const { data: jobsPayload } = usePolledPayload<ActiveJobsPayload>(
- "/api/jobs/active",
+ "/api/view/activeJobs",
config.jobs || config.disk,
pollMs,
initialJobs,
);
const { data: workersPayload, refetch: refetchWorkers } =
usePolledPayload<WorkersPayload>(
- "/api/workers",
+ "/api/view/workers",
workersEnabled,
pollMs,
initialWorkers,
@@ -84,7 +84,7 @@ export function MonitorWidget({
// One poll serves both cleanable sections: the total-bytes strip and the
// per-channel "Needs cleaning" list come from the same payload.
const { data: cleanablePayload } = usePolledPayload<CleanablePayload>(
- "/api/widget/cleanable",
+ "/api/view/cleanable",
config.cleanable || config.cleanChannels,
pollMs,
null,
@@ -93,7 +93,7 @@ export function MonitorWidget({
// every channel snapshot each tick is heavier than the other endpoints — so
// poll it no faster than every 15s regardless of the configured cadence.
const { data: actionablePayload } = usePolledPayload<WidgetActionablePayload>(
- "/api/widget/actionable",
+ "/api/view/widgetActionable",
config.actionable,
Math.max(pollMs, 15000),
null,
@@ -108,7 +108,7 @@ export function MonitorWidget({
// list because the backfill pause reads its lane state from here.
const { data: syncData, refetch: refetchSync } =
usePolledPayload<WidgetSyncPayload>(
- "/api/widget/sync",
+ "/api/view/widgetSync",
config.lastSync || config.scheduler || config.backfill || config.controls,
Math.max(pollMs, 15000),
null,
diff --git a/editor/app/widget/lib/usePolledPayload.ts b/editor/app/widget/lib/usePolledPayload.ts
@@ -1,64 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useState } from "react";
-
-// Shared client polling primitives, extracted from the monitor widget so the
-// dashboard cockpit (editor/app/components/dashboard) can reuse the exact same
-// ~1s poll pattern the widget proved out against the /api endpoints.
-
-// Live wall-clock that re-renders once a second; null until mounted so SSR and
-// the first client render agree (no Date.now() hydration mismatch).
-export function useNow(): number | null {
- const [now, setNow] = useState<number | null>(null);
- useEffect(() => {
- setNow(Date.now());
- const id = setInterval(() => setNow(Date.now()), 1000);
- return () => clearInterval(id);
- }, []);
- return now;
-}
-
-// Generic poller: fetches `url` every `pollMs` while enabled, swallowing
-// transient errors. Disabled (enabled=false) leaves the initial value as-is.
-// Returns the latest payload plus a `refetch` so a control action can refresh
-// it immediately instead of waiting for the next poll tick.
-export function usePolledPayload<T>(
- url: string,
- enabled: boolean,
- pollMs: number,
- initial: T | null,
-): { data: T | null; refetch: () => Promise<void> } {
- const [data, setData] = useState<T | null>(initial);
- const refetch = useCallback(async () => {
- try {
- const res = await fetch(url, { cache: "no-store" });
- if (res.ok) setData((await res.json()) as T);
- } catch {
- // transient — ignore
- }
- }, [url]);
- useEffect(() => {
- if (!enabled) return;
- let cancelled = false;
- let timer: ReturnType<typeof setTimeout> | null = null;
- async function tick() {
- try {
- const res = await fetch(url, { cache: "no-store" });
- if (res.ok && !cancelled) setData((await res.json()) as T);
- } catch {
- // transient — keep polling
- } finally {
- if (!cancelled) timer = setTimeout(tick, pollMs);
- }
- }
- // Fetch immediately on (re)subscribe, then poll on the interval. Sections
- // seeded from the server get a harmless refresh; those with a null initial
- // render on the first tick instead of after a full interval.
- void tick();
- return () => {
- cancelled = true;
- if (timer) clearTimeout(timer);
- };
- }, [url, enabled, pollMs]);
- return { data, refetch };
-}
diff --git a/editor/app/workers/components/WorkersView.tsx b/editor/app/workers/components/WorkersView.tsx
@@ -44,7 +44,7 @@ export function WorkersView({ initial }: { initial: WorkersPayload }) {
const refetch = useCallback(async () => {
try {
- const res = await fetch("/api/workers", { cache: "no-store" });
+ const res = await fetch("/api/view/workers", { cache: "no-store" });
if (res.ok) setPayload((await res.json()) as WorkersPayload);
} catch {
// transient — keep polling
diff --git a/editor/e2e/auto-refresh.spec.ts b/editor/e2e/auto-refresh.spec.ts
@@ -104,7 +104,9 @@ test.describe("auto-refresh behavior", () => {
let pulses = 0;
page.on("request", (req) => {
const url = new URL(req.url());
- if (url.pathname === "/api/pulse") {
+ // The client polls /api/view/pulse; /api/pulse is a rewrite onto it
+ // for pollers we cannot update. This counts what the browser SENDS.
+ if (url.pathname === "/api/view/pulse") {
pulses++;
return;
}