diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 8b672c03f..03654e79a 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -125,6 +125,9 @@ Activity's `EventFeed` (`data/event-feed.ts`) holds the events: the stream, the newer ones, and `loadOlder` adds the 100 before the oldest held (`GET /v1/events?before=&limit=100`, or `?limit=100` with none held). It reports how the history stands (`HistoryState`: more, start, gone, failed); `ds/feed.tsx` calls `onNearEnd` within 20 rows of the end and draws the end row. + +The Inbox drawer and the lease drawer share one list of the last hour per tab (`data/recent-feed.ts`, held by `LiveProvider`): it starts at the first user, reads `since=1h` once, takes the stream and the reconnect gap, and is dropped 60 s after the last user closes (ADR 0044 §1). The worker's Events tab reads the same list until #911. + Lists page in the URL (`state/paging.ts`, `model/paging.ts`). ## Design system diff --git a/ui/src/data/recent-feed.ts b/ui/src/data/recent-feed.ts new file mode 100644 index 000000000..98600b65b --- /dev/null +++ b/ui/src/data/recent-feed.ts @@ -0,0 +1,73 @@ +import type { ApiPath } from "./api"; +import { EventFeed } from "./event-feed"; +import type { Clock, LiveConnection, ResourceState } from "./live/connection"; +import type { ConsoleEvent } from "../model/events"; + +// why: ADR 0044 §1: what no page uses stays a minute, so going back to a drawer shows it at once. +const KEEP_MS = 60_000; + +export interface RecentFeed { + readonly acquire: () => () => void; + readonly subscribe: (listener: () => void) => () => void; + readonly snapshot: () => ResourceState; +} + +const NOTHING: ResourceState = {}; + +// why: one feed of the last hour per tab, shared by every drawer that lists events. It starts at +// the first user, holds the stream while any user or the last minute keeps it, and is dropped after. +export function createRecentFeed(options: { + readonly get: (path: ApiPath) => Promise; + readonly clock: Clock; + readonly connection: LiveConnection; +}): RecentFeed { + const { clock, connection, get } = options; + const listeners = new Set<() => void>(); + let kept: { readonly feed: EventFeed; readonly stop: () => void } | undefined; + let users = 0; + let dropTimer: unknown; + const changed = (): void => { + for (const listener of listeners) listener(); + }; + const start = (): void => { + const feed = new EventFeed(get, clock); + const stops = [ + feed.subscribe(changed), + connection.onEvent(feed.streamEvent), + connection.onStreamOpened(feed.streamOpened), + feed.start(), + ]; + kept = { + feed, + stop: () => { + for (const stop of stops) stop(); + }, + }; + }; + const drop = (): void => { + kept?.stop(); + kept = undefined; + changed(); + }; + return { + acquire() { + clock.clearTimeout(dropTimer); + users += 1; + if (kept === undefined) start(); + let released = false; + return () => { + if (released) return; + released = true; + users -= 1; + if (users === 0) dropTimer = clock.setTimeout(drop, KEEP_MS); + }; + }, + snapshot: () => kept?.feed.snapshot() ?? NOTHING, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/ui/src/state/events.ts b/ui/src/state/events.ts index 883d70b57..10777ce7c 100644 --- a/ui/src/state/events.ts +++ b/ui/src/state/events.ts @@ -23,7 +23,7 @@ import type { LeaseList, LeaseRecord } from "../model/leases"; import { itemIdFrom } from "../model/paths"; import type { WaitingList, WaitingRequest } from "../model/waiting"; import type { WorkerList, WorkerView } from "../model/workers"; -import { useLiveEvents, useRouteQuery, useNow, useStreamOpened } from "./live"; +import { useLiveEvents, useRecentFeed, useRouteQuery, useNow, useStreamOpened } from "./live"; import { loadView, mapView } from "./resource"; import { setSearch, usePath, useSearch } from "./router"; import { useApi } from "./services"; @@ -31,17 +31,15 @@ import { useApi } from "./services"; export type { Chosen, EventDetail, EventRow, EventsChart, HistoryEnd, SubjectFilter }; export { SUBJECT_CHOICES }; -// why: each screen that reads events holds its own feed, filled from the last hour and then the -// stream while it is on screen, so a screen opened later still starts from a full hour. -function useEventFeed(showsHistory: boolean): { +// why: Activity holds its own feed with history, filled from the last hour and then the stream +// while it is on screen. +function useEventFeed(): { readonly feed: EventFeed; readonly state: ResourceState; readonly history: HistoryState; } { const api = useApi(); - const [feed] = useState( - () => new EventFeed((path) => api.getJson(path), browserClock, showsHistory), - ); + const [feed] = useState(() => new EventFeed((path) => api.getJson(path), browserClock, true)); useLiveEvents(feed.streamEvent); useStreamOpened(feed.streamOpened); useEffect(() => feed.start(), [feed]); @@ -52,8 +50,12 @@ function useEventFeed(showsHistory: boolean): { }; } +// why: the drawers of one tab share one list of the last hour, kept a minute after the last of +// them closes. export function useRecentEvents(): ResourceState { - return useEventFeed(false).state; + const recent = useRecentFeed(); + useEffect(() => recent.acquire(), [recent]); + return useSyncExternalStore(recent.subscribe, recent.snapshot); } const NO_WORKERS: readonly WorkerView[] = []; @@ -79,7 +81,7 @@ export interface EventsPage { } export function useEvents(): LoadView { - const { feed: source, history, state: feed } = useEventFeed(true); + const { feed: source, history, state: feed } = useEventFeed(); const events = feed.data; // why: the places an event links to are read for the links only; a route that is slow, refused // or not served here costs the feed nothing, only the link. diff --git a/ui/src/state/live.tsx b/ui/src/state/live.tsx index 7fd37f754..6190b8658 100644 --- a/ui/src/state/live.tsx +++ b/ui/src/state/live.tsx @@ -11,6 +11,7 @@ import { import { useQuery } from "@tanstack/react-query"; import type { ApiPath } from "../data/api"; +import { createRecentFeed, type RecentFeed } from "../data/recent-feed"; import { type DaemonFacts, type DaemonMode, type ModeStore, watchMode } from "../data/mode"; import { useApi, useSession } from "./services"; import { createQueries, type Queries, resourceOf } from "./queries"; @@ -33,6 +34,8 @@ export interface Live { readonly connection: LiveConnection; readonly mode: ModeStore; readonly queries: Queries; + // why: the last hour of events, read once for every drawer of the tab that shows them. + readonly recent: RecentFeed; readonly ticker: Ticker; } @@ -57,7 +60,12 @@ export function LiveProvider({ children }: { readonly children: ReactNode }) { connection, visibility: documentVisibility, }); - setLive({ connection, mode, queries, ticker }); + const recent = createRecentFeed({ + clock: browserClock, + connection, + get: (path) => api.getJson(path), + }); + setLive({ connection, mode, queries, recent, ticker }); return () => { queries.dispose(); mode.dispose(); @@ -92,6 +100,10 @@ export function useDaemonFacts(): DaemonFacts { return useSyncExternalStore(mode.subscribe, mode.daemon, mode.daemon); } +export function useRecentFeed(): RecentFeed { + return useLive().recent; +} + export function useConnection(): ConnectionState { const { connection } = useLive(); return useSyncExternalStore( diff --git a/ui/src/state/recent-events.test.tsx b/ui/src/state/recent-events.test.tsx new file mode 100644 index 000000000..650e05aeb --- /dev/null +++ b/ui/src/state/recent-events.test.tsx @@ -0,0 +1,183 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ApiClient, ApiResponse } from "../data/api"; +import { createRecentFeed } from "../data/recent-feed"; +import { connect, fakeClock, settle } from "../data/live/test-support"; +import { createQueries } from "./queries"; +import { type Live, LiveContext } from "./live"; +import { useInbox } from "./inbox"; +import { useLeaseEvents } from "./leases"; +import { ConsoleProvider } from "./services"; + +// why: a server render never runs effects and the console has no DOM to mount into, so the effects +// of each render are collected here for the test to run (mount) and to undo (unmount). +const rendered = vi.hoisted(() => ({ effects: [] as (() => void | (() => void))[] })); + +vi.mock("react", async (original) => ({ + ...(await original()), + useEffect: (effect: () => void | (() => void)) => { + rendered.effects.push(effect); + }, + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => snapshot(), +})); + +vi.mock("../data/live/browser", async () => ({ + browserClock: (await import("../data/live/test-support")).fakeClock, + documentVisibility: { hidden: () => false, subscribe: () => () => undefined }, +})); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +const LEASE = "l1"; + +function streamed(id: string, seq: number): string { + const data = { + event: "lease.renewed", + id, + payload: { leaseId: LEASE }, + seq, + timestamp: Date.now(), + }; + return `event: lease.renewed\ndata: ${JSON.stringify(data)}\n\n`; +} + +describe("the recent events of a tab", () => { + it("are shared by the Inbox and the lease drawer, and kept 60 seconds after the last of them closes", async () => { + vi.stubGlobal("window", { location: { pathname: "/inbox", search: "" } }); + const { connection, daemon, visibility } = connect(); + daemon.reads = (path): Promise> => + Promise.resolve({ body: path.startsWith("/v1/events") ? { events: [] } : {}, date: null }); + const queries = createQueries({ clock: fakeClock, connection, visibility }); + const now = Date.now(); + const live = { + connection, + queries, + recent: createRecentFeed({ + clock: fakeClock, + connection, + get: (path) => daemon.get(path).then((answer) => answer.body), + }), + ticker: { + dispose: () => undefined, + now: () => ({ browser: now, server: now }), + subscribe: () => () => undefined, + }, + } as unknown as Live; + queries.client.setQueryData(["workers"], { workers: [] }); + queries.client.setQueryData(["inbox"], { + items: [ + { + condition: "ending", + group: "Heads-up", + key: `lease/${LEASE}/ending`, + lease: { + deviceId: "d1", + holder: "agent", + id: LEASE, + model: "iPhone", + requesterId: "a1", + ttlDeadline: now + 60_000, + }, + }, + ], + }); + const services = { + api: {} as ApiClient, + confirm: () => Promise.resolve(), + fetch: () => Promise.reject(new Error("unused")), + notify: () => undefined, + preferences: {} as never, + session: {} as never, + }; + + // why: mounting renders the hook once and runs its effects; the render after shows what it holds. + function mount(hook: () => T): { read: () => T; unmount: () => void } { + const Probe = ({ out }: { out: { value?: T } }) => { + out.value = hook(); + return null; + }; + const render = (): { value?: T } => { + const out: { value?: T } = {}; + rendered.effects = []; + renderToStaticMarkup( + + + + + , + ); + return out; + }; + render(); + const cleanups = rendered.effects.map((effect) => effect()); + return { + read: () => render().value as T, + unmount: () => { + for (const cleanup of cleanups) if (typeof cleanup === "function") cleanup(); + }, + }; + } + const inboxEvents = (inbox: ReturnType): number => { + const { view } = inbox; + if (view.kind !== "ready" || typeof view.data.shown !== "object") return -1; + return view.data.shown.events.length; + }; + const leaseRows = (view: ReturnType): number => + view.kind === "ready" ? view.data.length : -1; + const recentReads = (): number => + daemon.gets.filter((get) => get.path === "/v1/events?since=1h").length; + + await settle(); + const inbox = mount(() => useInbox()); + const drawer = mount(() => useLeaseEvents(LEASE)); + await settle(); + daemon.openStream()?.send(streamed("e1", 1)); + await settle(); + expect(recentReads()).toBe(1); + expect([inboxEvents(inbox.read()), leaseRows(drawer.read())]).toEqual([1, 1]); + + inbox.unmount(); + await vi.advanceTimersByTimeAsync(61_000); + daemon.openStream()?.send(streamed("e1b", 2)); + await settle(); + expect(leaseRows(drawer.read())).toBe(2); + + drawer.unmount(); + await vi.advanceTimersByTimeAsync(30_000); + daemon.openStream()?.send(streamed("e2", 3)); + await vi.advanceTimersByTimeAsync(20_000); + const back = mount(() => useLeaseEvents(LEASE)); + await settle(); + expect(leaseRows(back.read())).toBe(3); + expect(recentReads()).toBe(1); + + await vi.advanceTimersByTimeAsync(15_000); + // why: the quiet stream was dropped at 40 s and is open again after its 1 s retry. + await vi.advanceTimersByTimeAsync(2_000); + daemon.openStream()?.send(streamed("e3", 4)); + await settle(); + // why: the reconnect after the quiet drop is read once, as a gap read, by the shared list. + const gapReads = (): number => + daemon.gets.filter( + (get) => get.path.startsWith("/v1/events?since=") && get.path !== "/v1/events?since=1h", + ).length; + expect(gapReads()).toBe(1); + expect(leaseRows(back.read())).toBe(4); + + back.unmount(); + await vi.advanceTimersByTimeAsync(61_000); + // why: a dropped list is stopped, so a stream that reconnects now is read by nobody. + const before = gapReads(); + daemon.openStream()?.break(); + await vi.advanceTimersByTimeAsync(5_000); + expect(gapReads()).toBe(before); + const late = mount(() => useLeaseEvents(LEASE)); + await settle(); + expect(recentReads()).toBe(2); + late.unmount(); + }); +});