feat: add quiet reading with rare confirmations

Part of the quiet-user-feedback change; docs and specs land in their own repositories.
This commit is contained in:
2026-10-06 07:01:58 +03:00
parent c096fc70ef
commit 5b30bb802a
77 changed files with 1352 additions and 1259 deletions
+4
View File
@@ -8,4 +8,8 @@ export {
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
export {
type InitialAvailability,
useInitialAvailability,
} from "./initial-availability";
export { useUrlPeriod } from "./useUrlPeriod";
@@ -0,0 +1,107 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useInitialAvailability } from "./initial-availability";
describe("useInitialAvailability", () => {
it("keeps loading until content, error, or the initial wait budget", async () => {
vi.useFakeTimers();
try {
const { rerender, result } = renderHook(
props => useInitialAvailability(props),
{
initialProps: {
hasContent: false,
identity: "account-a",
isError: false,
isFetching: true,
timeoutMs: 100,
},
},
);
expect(result.current).toBe("loading");
await act(() => vi.advanceTimersByTimeAsync(99));
expect(result.current).toBe("loading");
await act(() => vi.advanceTimersByTimeAsync(1));
expect(result.current).toBe("unavailable");
rerender({
hasContent: true,
identity: "account-a",
isError: false,
isFetching: false,
timeoutMs: 100,
});
expect(result.current).toBe("ready");
}
finally {
vi.useRealTimers();
}
});
it("does not flash loading during retry for the same identity", async () => {
vi.useFakeTimers();
try {
const { rerender, result } = renderHook(
props => useInitialAvailability(props),
{
initialProps: {
hasContent: false,
identity: "account-a",
isError: true,
isFetching: false,
},
},
);
await act(() => vi.runOnlyPendingTimersAsync());
expect(result.current).toBe("unavailable");
rerender({
hasContent: false,
identity: "account-a",
isError: false,
isFetching: true,
});
expect(result.current).toBe("unavailable");
}
finally {
vi.useRealTimers();
}
});
it("resets loading only when identity changes", async () => {
vi.useFakeTimers();
try {
const { rerender, result } = renderHook(
props => useInitialAvailability(props),
{
initialProps: {
hasContent: false,
identity: "account-a",
isError: true,
isFetching: false,
},
},
);
await act(() => vi.runOnlyPendingTimersAsync());
expect(result.current).toBe("unavailable");
rerender({
hasContent: false,
identity: "account-b",
isError: false,
isFetching: true,
});
expect(result.current).toBe("loading");
}
finally {
vi.useRealTimers();
}
});
});
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
const INITIAL_WAIT_BUDGET_MS = 10_000;
type InitialAvailability
= | "loading"
| "ready"
| "unavailable";
type InitialAvailabilityInput = {
hasContent: boolean;
identity: string;
isError: boolean;
isFetching: boolean;
timeoutMs?: number;
};
function useInitialAvailability(
input: InitialAvailabilityInput,
): InitialAvailability {
const timeoutMs = input.timeoutMs ?? INITIAL_WAIT_BUDGET_MS;
const [unavailableIdentity, setUnavailableIdentity] = useState<null | string>(
null,
);
useEffect(() => {
if (input.hasContent) return;
if (input.isError) {
const timer = setTimeout(() => setUnavailableIdentity(input.identity), 0);
return () => clearTimeout(timer);
}
const timer = setTimeout(
() => setUnavailableIdentity(input.identity),
timeoutMs,
);
return () => clearTimeout(timer);
}, [input.hasContent, input.identity, input.isError, timeoutMs]);
if (input.hasContent) return "ready";
return unavailableIdentity === input.identity ? "unavailable" : "loading";
}
export { type InitialAvailability, useInitialAvailability };