From 20d0a2ec26b8ae0edba0a3c019d7aae3ddc4cebb Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:41:49 +0200 Subject: [PATCH 1/5] test: event lists are shared and bounded (#917) --- ui/src/data/recent-feed.ts | 23 ++++ ui/src/state/events.ts | 20 ++-- ui/src/state/live.tsx | 14 ++- ui/src/state/recent-events.test.tsx | 159 ++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 ui/src/data/recent-feed.ts create mode 100644 ui/src/state/recent-events.test.tsx diff --git a/ui/src/data/recent-feed.ts b/ui/src/data/recent-feed.ts new file mode 100644 index 000000000..931352086 --- /dev/null +++ b/ui/src/data/recent-feed.ts @@ -0,0 +1,23 @@ +import type { ApiPath } from "./api"; +import type { Clock, LiveConnection, ResourceState } from "./live/connection"; +import type { ConsoleEvent } from "../model/events"; + +export interface RecentFeed { + readonly acquire: () => () => void; + readonly subscribe: (listener: () => void) => () => void; + readonly snapshot: () => ResourceState; +} + +const NOTHING: ResourceState = {}; + +export function createRecentFeed(_options: { + readonly get: (path: ApiPath) => Promise; + readonly clock: Clock; + readonly connection: LiveConnection; +}): RecentFeed { + return { + acquire: () => () => undefined, + snapshot: () => NOTHING, + subscribe: () => () => undefined, + }; +} 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..2f3a3329a --- /dev/null +++ b/ui/src/state/recent-events.test.tsx @@ -0,0 +1,159 @@ +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; + + 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(); + drawer.unmount(); + await vi.advanceTimersByTimeAsync(30_000); + daemon.openStream()?.send(streamed("e2", 2)); + await vi.advanceTimersByTimeAsync(20_000); + const back = mount(() => useLeaseEvents(LEASE)); + await settle(); + expect(leaseRows(back.read())).toBe(2); + expect(recentReads()).toBe(1); + + back.unmount(); + await vi.advanceTimersByTimeAsync(61_000); + const late = mount(() => useLeaseEvents(LEASE)); + await settle(); + expect(recentReads()).toBe(2); + late.unmount(); + }); +}); From b750fde6e4a9d8d2a5d90a9cc2243f57e3c3f1bf Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:42:41 +0200 Subject: [PATCH 2/5] feat(ui): the Inbox and lease drawer share one recent-events list, kept a minute (#917) --- ui/src/data/recent-feed.ts | 58 +++++++++++++++++++++++++++-- ui/src/state/recent-events.test.tsx | 1 + 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/ui/src/data/recent-feed.ts b/ui/src/data/recent-feed.ts index 931352086..98600b65b 100644 --- a/ui/src/data/recent-feed.ts +++ b/ui/src/data/recent-feed.ts @@ -1,7 +1,11 @@ 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; @@ -10,14 +14,60 @@ export interface RecentFeed { const NOTHING: ResourceState = {}; -export function createRecentFeed(_options: { +// 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: () => () => undefined, - snapshot: () => NOTHING, - subscribe: () => () => undefined, + 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/recent-events.test.tsx b/ui/src/state/recent-events.test.tsx index 2f3a3329a..147489775 100644 --- a/ui/src/state/recent-events.test.tsx +++ b/ui/src/state/recent-events.test.tsx @@ -131,6 +131,7 @@ describe("the recent events of a tab", () => { 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(); From a7997e2b402d28f16762748aa7433cf6c7defff6 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:43:10 +0200 Subject: [PATCH 3/5] docs(ui): the manual names the shared recent-events list (#917) --- ui/AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/AGENTS.md b/ui/AGENTS.md index eff854d72..51efe9be6 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -118,6 +118,8 @@ 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 From fa5133d4d9165a12d76d111e65aefd76cf69c215 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:49:59 +0200 Subject: [PATCH 4/5] test: the shared event list stays while any user remains and a returning user cancels the drop (#917) --- ui/AGENTS.md | 1 + ui/src/state/recent-events.test.tsx | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 51efe9be6..97b44fe06 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -120,6 +120,7 @@ none held). It reports how the history stands (`HistoryState`: more, start, gone 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/state/recent-events.test.tsx b/ui/src/state/recent-events.test.tsx index 147489775..8b87d3167 100644 --- a/ui/src/state/recent-events.test.tsx +++ b/ui/src/state/recent-events.test.tsx @@ -141,15 +141,33 @@ describe("the recent events of a tab", () => { 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", 2)); + daemon.openStream()?.send(streamed("e2", 3)); await vi.advanceTimersByTimeAsync(20_000); const back = mount(() => useLeaseEvents(LEASE)); await settle(); - expect(leaseRows(back.read())).toBe(2); + 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(); + console.log( + "DBG", + daemon.streams.length, + daemon.openStream() === undefined, + daemon.gets.map((g) => g.path), + ); + expect(leaseRows(back.read())).toBe(4); + back.unmount(); await vi.advanceTimersByTimeAsync(61_000); const late = mount(() => useLeaseEvents(LEASE)); From 4d646ddb2bc67b95aa9366da24979e306261c428 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 23 Oct 2026 15:57:49 +0200 Subject: [PATCH 5/5] test(ui): the shared recent list reads a reconnect gap and stops a dropped feed (#917) --- ui/src/state/recent-events.test.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ui/src/state/recent-events.test.tsx b/ui/src/state/recent-events.test.tsx index 8b87d3167..650e05aeb 100644 --- a/ui/src/state/recent-events.test.tsx +++ b/ui/src/state/recent-events.test.tsx @@ -160,16 +160,21 @@ describe("the recent events of a tab", () => { await vi.advanceTimersByTimeAsync(2_000); daemon.openStream()?.send(streamed("e3", 4)); await settle(); - console.log( - "DBG", - daemon.streams.length, - daemon.openStream() === undefined, - daemon.gets.map((g) => g.path), - ); + // 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);