diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx new file mode 100644 index 000000000000..59210f74273f --- /dev/null +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx @@ -0,0 +1,66 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vite-plus/test"; + +type ButtonProps = { label: string; disabled?: boolean; onPress: () => void }; + +vi.mock("react-native", () => ({ + View: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("../../components/ui/AppText", () => ({ + AppText: ({ children }: { children: ReactNode }) => {children}, +})); +vi.mock("../../components/ui/Button", () => ({ + Button: ({ label, disabled, onPress }: ButtonProps) => ( + + ), +})); +vi.mock("../../state/threads", () => ({ threadEnvironment: { updateMetadata: {} } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); + +import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function failedThread(resetAt: string): EnvironmentThreadShell { + return { + id: "thread-1", + updatedAt: new Date(0).toISOString(), + snoozedUntil: null, + latestRun: { runId: "run-1", completedAt: new Date(0).toISOString() }, + limitRecovery: null, + runtime: { status: "failed", lastErrorClass: "usage_limit", usageLimitResetAt: resetAt }, + } as unknown as EnvironmentThreadShell; +} + +describe("UsageLimitRecoveryCard", () => { + it("disables Snooze once a reset more than 24.8 days away passes", () => { + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + vi.useFakeTimers({ now: 0 }); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + const snooze = () => + [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Snooze until reset", + ); + expect(snooze()?.disabled).toBe(false); + + act(() => vi.advanceTimersByTime(30 * DAY_MS)); + + expect(snooze()?.disabled).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 93b581186d75..791dc2ab5d84 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -4,9 +4,10 @@ import { AsyncResult } from "effect/reactivity"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; +import { useDeadlinePassed } from "../../lib/clock"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -24,23 +25,7 @@ export function UsageLimitRecoveryCard({ const canSchedule = resetAt !== null && Date.parse(resetAt) > Date.parse(thread.latestRun?.completedAt ?? thread.updatedAt); - // Use timer-driven state; the compiler can cache render-time clock reads and leave Snooze enabled after reset. - const resetAtMs = resetAt === null ? null : Date.parse(resetAt); - const [resetPassedFor, setResetPassedFor] = useState(null); - const resetPassed = resetAtMs !== null && resetPassedFor === resetAtMs; - useEffect(() => { - if (resetAtMs === null || Number.isNaN(resetAtMs)) return; - const flip = () => setResetPassedFor(resetAtMs); - const delay = resetAtMs - Date.now(); - if (delay <= 0) { - flip(); - return; - } - // Beyond setTimeout's 32-bit range the card re-renders long before then. - if (delay > 2_147_483_647) return; - const timer = setTimeout(flip, delay); - return () => clearTimeout(timer); - }, [resetAtMs]); + const resetPassed = useDeadlinePassed(resetAt === null ? null : Date.parse(resetAt)); const runId = thread.latestRun?.runId; const recovery = thread.limitRecovery; const scheduled = diff --git a/apps/mobile/src/lib/clock.ts b/apps/mobile/src/lib/clock.ts new file mode 100644 index 000000000000..47227cda2d88 --- /dev/null +++ b/apps/mobile/src/lib/clock.ts @@ -0,0 +1,8 @@ +import { deadlineStore } from "@t3tools/client-runtime/clock"; +import { useSyncExternalStore } from "react"; + +/** Re-renders once when `deadlineMs` (epoch ms) is reached; `null` never passes. */ +export function useDeadlinePassed(deadlineMs: number | null): boolean { + const store = deadlineStore(deadlineMs); + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); +} diff --git a/apps/mobile/src/lib/useDelayedStatus.ts b/apps/mobile/src/lib/useDelayedStatus.ts index 3fbb49bbb963..2bf3aaa5538b 100644 --- a/apps/mobile/src/lib/useDelayedStatus.ts +++ b/apps/mobile/src/lib/useDelayedStatus.ts @@ -1,12 +1,18 @@ -import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status"; -import { useEffect, useState } from "react"; +import { + delayedStatusDeadline, + shownDelayedStatus, + stepDelayedStatus, +} from "@t3tools/client-runtime/delayed-status"; +import { useState } from "react"; + +import { useDeadlinePassed } from "./clock"; export function useDelayedStatus(key: string, value: A | null): A | null { - const [shown, setShown] = useState | null>(null); - const [status] = useState(() => createDelayedStatus(setShown)); - useEffect(() => () => status.dispose(), [status]); - useEffect(() => { - status.update(key, value); - }, [status, key, value]); - return shown?.key === key ? shown.value : null; + const [state, setState] = useState(() => + stepDelayedStatus(null, { key, value, deadlinePassed: false }, Date.now), + ); + const deadlinePassed = useDeadlinePassed(delayedStatusDeadline(state)); + const next = stepDelayedStatus(state, { key, value, deadlinePassed }, Date.now); + if (next !== state) setState(next); + return shownDelayedStatus(next, key); } diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 0a063e182c30..e3f79ba02c3c 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -10,6 +10,7 @@ const reactTests = [ "src/components/ui/Dialog.android.test.tsx", "src/features/settings/SettingsServerControlsRouteScreen.test.tsx", "src/features/settings/components/BranchNamingSettings.test.tsx", + "src/features/threads/UsageLimitRecoveryCard.test.tsx", "src/native/NativeHeaderToolbar.test.tsx", "src/native/useRestingTopOffset.test.tsx", "src/native/useRetryingImageSource.test.tsx", diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index ee37e39a4938..cbd7d0d7f0a8 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -4,8 +4,10 @@ import { type RunId, } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { deadlineStore } from "@t3tools/client-runtime/clock"; import { GaugeIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { useDeadlinePassed } from "../../hooks/useDeadline"; import { Button } from "../ui/button"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { toastManager } from "../ui/toast"; @@ -39,19 +41,12 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann function RecoveryActions({ runId, resetAt, recovery, snoozedUntil, onChange }: RecoveryProps) { const [pending, setPending] = useState(false); - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - const delay = Date.parse(resetAt ?? "") - Math.max(nowMs, Date.now()); - if (!Number.isFinite(delay) || delay <= 0) return; - const timer = window.setTimeout(() => setNowMs(Date.now()), Math.min(delay + 1, 2_147_483_647)); - return () => window.clearTimeout(timer); - }, [resetAt, nowMs]); const scheduled = recovery?.runId === runId && recovery.resetAt === resetAt && recovery.autoResume; // The server leaves snoozedUntil in place after the timer wakes the thread, // so a snooze whose reset has passed is no longer active. - const resetPassed = Date.parse(resetAt ?? "") <= nowMs; + const resetPassed = useDeadlinePassed(resetAt === null ? null : Date.parse(resetAt)); const snoozed = recovery?.snooze === true && recovery.runId === runId && @@ -68,7 +63,8 @@ function RecoveryActions({ runId, resetAt, recovery, snoozedUntil, onChange }: R title: "Could not snooze thread", description: "The reset time has passed. Retry the thread manually.", }); - setNowMs(Date.now()); + // The deadline timer can lag after a throttled tab or sleep; disable now. + deadlineStore(Date.parse(resetAt)).refresh(); return; } setPending(true); diff --git a/apps/web/src/hooks/useDeadline.ts b/apps/web/src/hooks/useDeadline.ts new file mode 100644 index 000000000000..47227cda2d88 --- /dev/null +++ b/apps/web/src/hooks/useDeadline.ts @@ -0,0 +1,8 @@ +import { deadlineStore } from "@t3tools/client-runtime/clock"; +import { useSyncExternalStore } from "react"; + +/** Re-renders once when `deadlineMs` (epoch ms) is reached; `null` never passes. */ +export function useDeadlinePassed(deadlineMs: number | null): boolean { + const store = deadlineStore(deadlineMs); + return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); +} diff --git a/apps/web/src/hooks/useDelayedStatus.ts b/apps/web/src/hooks/useDelayedStatus.ts index 3fbb49bbb963..718c0f03538e 100644 --- a/apps/web/src/hooks/useDelayedStatus.ts +++ b/apps/web/src/hooks/useDelayedStatus.ts @@ -1,12 +1,18 @@ -import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status"; -import { useEffect, useState } from "react"; +import { + delayedStatusDeadline, + shownDelayedStatus, + stepDelayedStatus, +} from "@t3tools/client-runtime/delayed-status"; +import { useState } from "react"; + +import { useDeadlinePassed } from "./useDeadline"; export function useDelayedStatus(key: string, value: A | null): A | null { - const [shown, setShown] = useState | null>(null); - const [status] = useState(() => createDelayedStatus(setShown)); - useEffect(() => () => status.dispose(), [status]); - useEffect(() => { - status.update(key, value); - }, [status, key, value]); - return shown?.key === key ? shown.value : null; + const [state, setState] = useState(() => + stepDelayedStatus(null, { key, value, deadlinePassed: false }, Date.now), + ); + const deadlinePassed = useDeadlinePassed(delayedStatusDeadline(state)); + const next = stepDelayedStatus(state, { key, value, deadlinePassed }, Date.now); + if (next !== state) setState(next); + return shownDelayedStatus(next, key); } diff --git a/apps/web/src/hooks/useNowMinute.ts b/apps/web/src/hooks/useNowMinute.ts index 09017975e702..cfb12ddf977c 100644 --- a/apps/web/src/hooks/useNowMinute.ts +++ b/apps/web/src/hooks/useNowMinute.ts @@ -1,56 +1,6 @@ +import { minuteTick } from "@t3tools/client-runtime/clock"; import { useSyncExternalStore } from "react"; -function currentMinute(): string { - return new Date().toISOString().slice(0, 16); -} - -let nowMinute = currentMinute(); -let timerId: number | null = null; -let timerIsInterval = false; -const listeners = new Set<() => void>(); - -function tick(): void { - const next = currentMinute(); - if (next !== nowMinute) { - nowMinute = next; - for (const listener of listeners) listener(); - } -} - -function startTimer(): void { - timerIsInterval = false; - timerId = window.setTimeout( - () => { - tick(); - timerIsInterval = true; - timerId = window.setInterval(tick, 60_000); - }, - 60_000 - (Date.now() % 60_000), - ); -} - -function subscribe(listener: () => void): () => void { - if (listeners.size === 0) { - startTimer(); - } - listeners.add(listener); - return () => { - listeners.delete(listener); - if (listeners.size === 0 && timerId !== null) { - if (timerIsInterval) window.clearInterval(timerId); - else window.clearTimeout(timerId); - timerId = null; - } - }; -} - -function getSnapshot(): string { - if (timerId === null) { - nowMinute = currentMinute(); - } - return nowMinute; -} - export function useNowMinute(): string { - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + return useSyncExternalStore(minuteTick.subscribe, minuteTick.getSnapshot, minuteTick.getSnapshot); } diff --git a/apps/web/src/hooks/useNowSecond.ts b/apps/web/src/hooks/useNowSecond.ts index 79aca0f8a821..486951b3a673 100644 --- a/apps/web/src/hooks/useNowSecond.ts +++ b/apps/web/src/hooks/useNowSecond.ts @@ -1,43 +1,6 @@ +import { secondTick } from "@t3tools/client-runtime/clock"; import { useSyncExternalStore } from "react"; -// Share wall-clock ticks so duration labels repaint in one frame. - -function currentSecond(): number { - return Math.floor(Date.now() / 1000) * 1000; -} - -let nowSecond = currentSecond(); -let timerId: number | null = null; -const listeners = new Set<() => void>(); - -function scheduleTick(): void { - timerId = window.setTimeout( - () => { - nowSecond = currentSecond(); - for (const listener of listeners) listener(); - scheduleTick(); - }, - 1000 - (Date.now() % 1000), - ); -} - -function subscribe(listener: () => void): () => void { - if (listeners.size === 0) scheduleTick(); - listeners.add(listener); - return () => { - listeners.delete(listener); - if (listeners.size === 0 && timerId !== null) { - window.clearTimeout(timerId); - timerId = null; - } - }; -} - -function getSnapshot(): number { - if (timerId === null) nowSecond = currentSecond(); - return nowSecond; -} - export function useNowSecond(): number { - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + return useSyncExternalStore(secondTick.subscribe, secondTick.getSnapshot, secondTick.getSnapshot); } diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index c6028a2832fe..03779b627eea 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -151,6 +151,10 @@ "types": "./src/queuedRunEdit.ts", "default": "./src/queuedRunEdit.ts" }, + "./clock": { + "types": "./src/clock.ts", + "default": "./src/clock.ts" + }, "./delayed-status": { "types": "./src/delayedStatus.ts", "default": "./src/delayedStatus.ts" diff --git a/packages/client-runtime/src/clock.test.ts b/packages/client-runtime/src/clock.test.ts new file mode 100644 index 000000000000..ca8130a379ad --- /dev/null +++ b/packages/client-runtime/src/clock.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { deadlineStore, secondTick } from "./clock.ts"; + +describe("deadlineStore", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("flips once at the deadline and notifies every subscriber from one timer", () => { + vi.useFakeTimers({ now: 10_000 }); + const store = deadlineStore(10_500); + const first = vi.fn(); + const second = vi.fn(); + const unsubscribeFirst = store.subscribe(first); + const unsubscribeSecond = deadlineStore(10_500).subscribe(second); + expect(vi.getTimerCount()).toBe(1); + expect(store.getSnapshot()).toBe(false); + + vi.advanceTimersByTime(499); + expect(store.getSnapshot()).toBe(false); + vi.advanceTimersByTime(1); + expect(store.getSnapshot()).toBe(true); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + unsubscribeFirst(); + unsubscribeSecond(); + }); + + it("shares one timer between callers that obtain the deadline before subscribing", () => { + vi.useFakeTimers({ now: 0 }); + const first = deadlineStore(2_000); + const second = deadlineStore(2_000); + const firstListener = vi.fn(); + const secondListener = vi.fn(); + const unsubscribeFirst = first.subscribe(firstListener); + const unsubscribeSecond = second.subscribe(secondListener); + expect(vi.getTimerCount()).toBe(1); + + // A click observing the passed deadline must update every subscriber. + vi.setSystemTime(5_000); + first.refresh(); + expect(second.getSnapshot()).toBe(true); + expect(secondListener).toHaveBeenCalledTimes(1); + expect(firstListener).toHaveBeenCalledTimes(1); + unsubscribeFirst(); + unsubscribeSecond(); + }); + + it("re-arms deadlines beyond the 32-bit timer range instead of firing early", () => { + vi.useFakeTimers({ now: 0 }); + const deadline = 2_147_483_647 + 1_000; + const store = deadlineStore(deadline); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + vi.advanceTimersByTime(2_147_483_647); + expect(store.getSnapshot()).toBe(false); + expect(listener).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1_000); + expect(store.getSnapshot()).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + }); + + it("refresh catches up when the timer runs late", () => { + vi.useFakeTimers({ now: 0 }); + const store = deadlineStore(2_000); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + store.refresh(); + expect(listener).not.toHaveBeenCalled(); + // Wall clock jumps past the deadline without the timer firing (sleep, throttling). + vi.setSystemTime(5_000); + expect(store.getSnapshot()).toBe(false); + store.refresh(); + expect(store.getSnapshot()).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + unsubscribe(); + }); + + it("stops its timer when the last subscriber leaves", () => { + vi.useFakeTimers({ now: 0 }); + const store = deadlineStore(1_000); + const unsubscribe = store.subscribe(vi.fn()); + unsubscribe(); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(1_000); + expect(store.getSnapshot()).toBe(true); + }); + + it("treats a missing or unparseable deadline as never passing", () => { + vi.useFakeTimers({ now: 0 }); + expect(deadlineStore(null).getSnapshot()).toBe(false); + const store = deadlineStore(Number.NaN); + store.subscribe(vi.fn()); + expect(vi.getTimerCount()).toBe(0); + expect(store.getSnapshot()).toBe(false); + }); +}); + +describe("secondTick", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("advances on wall-clock second boundaries", () => { + vi.useFakeTimers({ now: 5_250 }); + const listener = vi.fn(); + const unsubscribe = secondTick.subscribe(listener); + expect(secondTick.getSnapshot()).toBe(5_000); + vi.advanceTimersByTime(749); + expect(listener).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(secondTick.getSnapshot()).toBe(6_000); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("stops ticking when the last subscriber leaves during a tick", () => { + vi.useFakeTimers({ now: 0 }); + const unsubscribe = secondTick.subscribe(() => unsubscribe()); + vi.advanceTimersByTime(1_000); + expect(vi.getTimerCount()).toBe(0); + }); + + it("keeps one timer when a subscriber replaces itself during a tick", () => { + vi.useFakeTimers({ now: 0 }); + let unsubscribeNext = () => {}; + const unsubscribe = secondTick.subscribe(() => { + unsubscribe(); + unsubscribeNext = secondTick.subscribe(() => {}); + }); + vi.advanceTimersByTime(1_000); + expect(vi.getTimerCount()).toBe(1); + unsubscribeNext(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/client-runtime/src/clock.ts b/packages/client-runtime/src/clock.ts new file mode 100644 index 000000000000..2e36bb95a50c --- /dev/null +++ b/packages/client-runtime/src/clock.ts @@ -0,0 +1,151 @@ +// @effect-diagnostics globalTimers:off globalDate:off - Render clocks for React hooks, outside an Effect runtime. + +/** + * A `useSyncExternalStore`-shaped clock. Snapshots stay cached between + * notifications while subscribed, as `useSyncExternalStore` requires. + */ +export interface ClockStore { + readonly subscribe: (listener: () => void) => () => void; + readonly getSnapshot: () => A; +} + +// setTimeout fires immediately for delays beyond a signed 32-bit int. +const MAX_TIMEOUT_MS = 2_147_483_647; + +function makeTickStore(read: () => A, periodMs: number): ClockStore { + let value = read(); + let timer: ReturnType | undefined; + const listeners = new Set<() => void>(); + + // Each tick lands on a wall-clock boundary so every subscriber updates in + // the same frame instead of each drifting on its own phase. + const schedule = () => { + timer = setTimeout( + () => { + timer = undefined; + const next = read(); + if (next !== value) { + value = next; + for (const listener of listeners) listener(); + } + // Listeners may unsubscribe (stopping the clock) or resubscribe (arming + // a fresh timer) while notified; only continue a clock nobody re-armed. + if (listeners.size > 0 && timer === undefined) schedule(); + }, + periodMs - (Date.now() % periodMs), + ); + }; + + return { + subscribe: (listener) => { + if (listeners.size === 0) { + value = read(); + schedule(); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + clearTimeout(timer); + timer = undefined; + } + }; + }, + getSnapshot: () => { + if (timer === undefined) value = read(); + return value; + }, + }; +} + +/** Epoch ms floored to the second, advancing on each wall-clock second. */ +export const secondTick: ClockStore = makeTickStore( + () => Math.floor(Date.now() / 1000) * 1000, + 1000, +); + +/** ISO timestamp truncated to the minute (`YYYY-MM-DDTHH:mm`). */ +export const minuteTick: ClockStore = makeTickStore( + () => new Date().toISOString().slice(0, 16), + 60_000, +); + +export interface DeadlineStore extends ClockStore { + /** + * Re-checks `Date.now()` and notifies if the deadline has passed. For callers + * that observe the deadline first, e.g. a click after a throttled or slept + * timer, so the snapshot catches up without waiting for the late timer. + */ + readonly refresh: () => void; +} + +const neverPassed: DeadlineStore = { + subscribe: () => () => {}, + getSnapshot: () => false, + refresh: () => {}, +}; + +interface DeadlineEntry { + readonly store: DeadlineStore; + readonly listeners: Set<() => void>; + passed: boolean; + timer: ReturnType | undefined; +} + +// The single owner of each subscribed deadline. Handles obtained before the +// entry exists join it on subscribe, so they share its timer and notifications. +const deadlines = new Map(); + +function passDeadline(entry: DeadlineEntry) { + clearTimeout(entry.timer); + entry.timer = undefined; + entry.passed = true; + for (const listener of entry.listeners) listener(); +} + +function armDeadline(entry: DeadlineEntry, deadlineMs: number) { + const delay = deadlineMs - Date.now(); + if (delay <= 0) { + passDeadline(entry); + return; + } + entry.timer = setTimeout(() => armDeadline(entry, deadlineMs), Math.min(delay, MAX_TIMEOUT_MS)); +} + +/** + * Whether `Date.now()` has reached `deadlineMs`. One timer per distinct + * deadline, shared by its subscribers; once passed the snapshot stays true. + * Deadlines register only while subscribed, so renders React discards leak nothing. + * A non-finite deadline never passes. + */ +export function deadlineStore(deadlineMs: number | null): DeadlineStore { + if (deadlineMs === null || !Number.isFinite(deadlineMs)) return neverPassed; + const existing = deadlines.get(deadlineMs); + if (existing !== undefined) return existing.store; + + const store: DeadlineStore = { + subscribe: (listener) => { + let entry = deadlines.get(deadlineMs); + if (entry === undefined) { + entry = { store, listeners: new Set(), passed: Date.now() >= deadlineMs, timer: undefined }; + deadlines.set(deadlineMs, entry); + if (!entry.passed) armDeadline(entry, deadlineMs); + } + const joined = entry; + joined.listeners.add(listener); + return () => { + joined.listeners.delete(listener); + if (joined.listeners.size > 0) return; + clearTimeout(joined.timer); + joined.timer = undefined; + if (deadlines.get(deadlineMs) === joined) deadlines.delete(deadlineMs); + }; + }, + getSnapshot: () => deadlines.get(deadlineMs)?.passed ?? Date.now() >= deadlineMs, + refresh: () => { + const entry = deadlines.get(deadlineMs); + if (entry !== undefined && !entry.passed && Date.now() >= deadlineMs) passDeadline(entry); + }, + }; + return store; +} diff --git a/packages/client-runtime/src/delayedStatus.test.ts b/packages/client-runtime/src/delayedStatus.test.ts new file mode 100644 index 000000000000..f49e1ad78a81 --- /dev/null +++ b/packages/client-runtime/src/delayedStatus.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + delayedStatusDeadline, + shownDelayedStatus, + stepDelayedStatus, + type DelayedStatusState, +} from "./delayedStatus.ts"; + +function step( + state: DelayedStatusState | null, + value: string | null, + now: number, + key = "thread-1", +) { + const deadline = state === null ? null : delayedStatusDeadline(state); + return stepDelayedStatus( + state, + { key, value, deadlinePassed: deadline !== null && now >= deadline }, + () => now, + ); +} + +describe("stepDelayedStatus", () => { + it("shows a status only after it stays set for the show delay", () => { + const pending = step(null, "syncing", 0); + expect(delayedStatusDeadline(pending)).toBe(400); + expect(step(pending, "syncing", 399)).toBe(pending); + const shown = step(pending, "syncing", 400); + expect(shownDelayedStatus(shown, "thread-1")).toBe("syncing"); + }); + + it("never shows a status that clears before the show delay", () => { + const pending = step(null, "syncing", 0); + const cleared = step(pending, null, 200); + expect(cleared.phase).toBe("idle"); + expect(delayedStatusDeadline(step(cleared, "syncing", 300))).toBe(700); + }); + + it("shows the latest value when the show delay passes", () => { + const pending = step(null, "connecting", 0); + const changed = step(pending, "syncing", 200); + expect(changed).toBe(pending); + expect(shownDelayedStatus(step(changed, "syncing", 400), "thread-1")).toBe("syncing"); + }); + + it("holds a shown status for the minimum visible time after it clears", () => { + const shown = step(step(null, "syncing", 0), "syncing", 400); + expect(delayedStatusDeadline(shown)).toBe(800); + expect(step(shown, null, 500)).toBe(shown); + expect(step(shown, null, 800).phase).toBe("idle"); + }); + + it("hides immediately once the minimum visible time has passed", () => { + const shown = step(step(null, "syncing", 0), "syncing", 400); + expect(step(shown, "syncing", 900)).toBe(shown); + expect(step(shown, null, 1000).phase).toBe("idle"); + }); + + it("restarts the minimum visible time when the shown value changes", () => { + const shown = step(step(null, "syncing", 0), "syncing", 400); + const replaced = step(shown, "replaying", 600); + expect(shownDelayedStatus(replaced, "thread-1")).toBe("replaying"); + expect(delayedStatusDeadline(replaced)).toBe(1000); + }); + + it("hides the status and restarts the delay when the key changes", () => { + const shown = step(step(null, "syncing", 0), "syncing", 400); + expect(shownDelayedStatus(shown, "thread-2")).toBeNull(); + const other = step(shown, "syncing", 500, "thread-2"); + expect(shownDelayedStatus(other, "thread-2")).toBeNull(); + expect(delayedStatusDeadline(other)).toBe(900); + }); +}); diff --git a/packages/client-runtime/src/delayedStatus.ts b/packages/client-runtime/src/delayedStatus.ts index 3d1d815975f8..7de7962ef641 100644 --- a/packages/client-runtime/src/delayedStatus.ts +++ b/packages/client-runtime/src/delayedStatus.ts @@ -1,74 +1,75 @@ -// @effect-diagnostics globalTimers:off - Display timing for React hooks, outside an Effect runtime. - const STATUS_SHOW_DELAY_MS = 400; const STATUS_MIN_VISIBLE_MS = 400; -export interface ShownStatus { - readonly key: string; - readonly value: A; -} +/** + * A status shows only after it has stayed set for a moment, then stays shown + * for a minimum time so a quick flicker never flashes on screen. + * `deadlinePassed` tells whether `delayedStatusDeadline(state)` has been reached. + */ +export type DelayedStatusState = + | { readonly phase: "idle"; readonly key: string } + | { readonly phase: "pending"; readonly key: string; readonly showAt: number } + | { + readonly phase: "shown"; + readonly key: string; + readonly value: A; + readonly holdUntil: number; + }; -export interface DelayedStatus { - readonly update: (key: string, value: A | null) => void; - readonly dispose: () => void; +export interface DelayedStatusInput { + readonly key: string; + /** Compared by identity, so use strings or other stable values. */ + readonly value: A | null; + readonly deadlinePassed: boolean; } /** - * Values compare by identity, so use strings or other stable values. + * Returns `state` itself when nothing changes, so it can run on every render. + * `now` is read only to stamp a new deadline; it is a function so a memoizing + * caller (the React Compiler) cannot cache a stale clock reading. */ -export function createDelayedStatus( - onChange: (shown: ShownStatus | null) => void, -): DelayedStatus { - let key = ""; - let latest: A | null = null; - let shown: A | null = null; - let timer: ReturnType | undefined; - - const clearTimer = () => { - clearTimeout(timer); - timer = undefined; - }; - const hide = () => { - shown = null; - onChange(null); - }; - const show = (value: A) => { - shown = value; - onChange({ key, value }); - clearTimer(); - timer = setTimeout(() => { - timer = undefined; - if (latest === null) hide(); - }, STATUS_MIN_VISIBLE_MS); - }; +export function stepDelayedStatus( + state: DelayedStatusState | null, + { key, value, deadlinePassed }: DelayedStatusInput, + now: () => number, +): DelayedStatusState { + const idle = (): DelayedStatusState => ({ phase: "idle", key }); + const pending = (): DelayedStatusState => ({ + phase: "pending", + key, + showAt: now() + STATUS_SHOW_DELAY_MS, + }); + const shown = (shownValue: A): DelayedStatusState => ({ + phase: "shown", + key, + value: shownValue, + holdUntil: now() + STATUS_MIN_VISIBLE_MS, + }); - return { - update: (nextKey, value) => { - if (nextKey !== key) { - key = nextKey; - clearTimer(); - if (shown !== null) hide(); - } - latest = value; + if (state === null || state.key !== key) return value === null ? idle() : pending(); + switch (state.phase) { + case "idle": + return value === null ? state : pending(); + case "pending": + if (value === null) return idle(); + return deadlinePassed ? shown(value) : state; + case "shown": + if (value === null) return deadlinePassed ? idle() : state; + return value === state.value ? state : shown(value); + } +} - if (shown === null) { - if (value === null) { - clearTimer(); - } else if (timer === undefined) { - timer = setTimeout(() => { - timer = undefined; - if (latest !== null) show(latest); - }, STATUS_SHOW_DELAY_MS); - } - return; - } +export function delayedStatusDeadline(state: DelayedStatusState): number | null { + switch (state.phase) { + case "idle": + return null; + case "pending": + return state.showAt; + case "shown": + return state.holdUntil; + } +} - if (value !== null) { - if (value !== shown) show(value); - } else if (timer === undefined) { - hide(); - } - }, - dispose: clearTimer, - }; +export function shownDelayedStatus(state: DelayedStatusState, key: string): A | null { + return state.phase === "shown" && state.key === key ? state.value : null; }