Skip to content
Merged
3 changes: 3 additions & 0 deletions ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions ui/src/data/recent-feed.ts
Original file line number Diff line number Diff line change
@@ -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<readonly ConsoleEvent[]>;
}

const NOTHING: ResourceState<readonly ConsoleEvent[]> = {};

// 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<unknown>;
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);
};
},
};
}
20 changes: 11 additions & 9 deletions ui/src/state/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,23 @@ 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";

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 ConsoleEvent[]>;
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]);
Expand All @@ -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<readonly ConsoleEvent[]> {
return useEventFeed(false).state;
const recent = useRecentFeed();
useEffect(() => recent.acquire(), [recent]);
return useSyncExternalStore(recent.subscribe, recent.snapshot);
}

const NO_WORKERS: readonly WorkerView[] = [];
Expand All @@ -79,7 +81,7 @@ export interface EventsPage {
}

export function useEvents(): LoadView<EventsPage> {
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.
Expand Down
14 changes: 13 additions & 1 deletion ui/src/state/live.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}

Expand All @@ -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();
Expand Down Expand Up @@ -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(
Expand Down
183 changes: 183 additions & 0 deletions ui/src/state/recent-events.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("react")>()),
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<ApiResponse<unknown>> =>
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<T>(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(
<ConsoleProvider {...services}>
<LiveContext.Provider value={live}>
<Probe out={out} />
</LiveContext.Provider>
</ConsoleProvider>,
);
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<typeof useInbox>): 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<typeof useLeaseEvents>): 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();
});
});
Loading