Skip to content

Commit 628cf5e

Browse files
committed
feat(conversation): add useHistoryWindow and ConversationEarlier
useHistoryWindow keeps a window of the latest items, adds earlier ones as the reader nears the top, freezes while the reader has scrolled away and trims back once they return to the end. It works with history the host pages in asynchronously, and the item in view stays put within a pixel as earlier items load. ConversationEarlier marks the top with a loading state and a retry when a page brings nothing. The doc comment records why this windows rather than virtualizes, and its ceiling.
1 parent 86a8632 commit 628cf5e

8 files changed

Lines changed: 1613 additions & 0 deletions

‎e2e/fixtures.tsx‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ import {
5858
MessageContent,
5959
MessageFold,
6060
MessageFooter,
61+
ConversationEarlier,
62+
useHistoryWindow,
6163
} from "@corbits/react-ui";
6264
import {
6365
CommandPalette,
@@ -939,6 +941,116 @@ function MessageActionsScenario() {
939941
);
940942
}
941943

944+
type HistoryRow = { readonly id: string; readonly lines: number };
945+
946+
/** Rows of fractional heights, so rounding shows up in the positions. */
947+
function historyWindowRows(from: number, to: number): HistoryRow[] {
948+
return Array.from({ length: to - from }, (_, index) => ({
949+
id: `r${from + index}`,
950+
lines: 1 + ((from + index) % 3),
951+
}));
952+
}
953+
954+
const HISTORY_PAGE = 10;
955+
956+
/** Renders the window; must sit inside the Conversation it follows. */
957+
function HistoryRows({
958+
rows,
959+
hasEarlier,
960+
onLoadEarlier,
961+
distance,
962+
}: {
963+
rows: readonly HistoryRow[];
964+
hasEarlier: boolean;
965+
onLoadEarlier: () => Promise<void>;
966+
distance: number | undefined;
967+
}) {
968+
const history = useHistoryWindow({
969+
items: rows,
970+
windowSize: 30,
971+
step: HISTORY_PAGE,
972+
hasEarlier,
973+
onLoadEarlier,
974+
});
975+
return (
976+
<>
977+
<ConversationContent>
978+
<ConversationEarlier
979+
hasEarlier={history.hasEarlier}
980+
loading={history.loading}
981+
stalled={history.stalled}
982+
onReachStart={history.reachStart}
983+
distance={distance}
984+
/>
985+
{history.items.map((row) => (
986+
<ConversationItem key={row.id} itemId={row.id} data-testid={row.id}>
987+
<p style={{ height: row.lines * 23.5, margin: 0 }}>Row {row.id}</p>
988+
</ConversationItem>
989+
))}
990+
</ConversationContent>
991+
<ConversationScrollButton />
992+
<div style={{ display: "flex", gap: 8 }}>
993+
<Button onClick={history.reachStart}>Load earlier</Button>
994+
<output aria-label="Shown">{history.items.length}</output>
995+
</div>
996+
</>
997+
);
998+
}
999+
1000+
/**
1001+
* `local` rows are loaded; `remote` older ones come from a pretend server a
1002+
* page at a time, when "Deliver" is pressed. "Deliver nothing" ends a load
1003+
* with no rows.
1004+
*/
1005+
function HistoryWindowScenario({
1006+
local,
1007+
remote,
1008+
distance,
1009+
}: {
1010+
local: number;
1011+
remote: number;
1012+
distance?: number;
1013+
}) {
1014+
const [from, setFrom] = useState(remote);
1015+
const [to, setTo] = useState(remote + local);
1016+
const [requests, setRequests] = useState(0);
1017+
const [pending, setPending] = useState<(() => void) | null>(null);
1018+
const settle = (rows: number) => {
1019+
if (pending === null) return;
1020+
setFrom((current) => Math.max(0, current - rows));
1021+
setPending(null);
1022+
pending();
1023+
};
1024+
return (
1025+
<div
1026+
style={{
1027+
display: "flex",
1028+
flexDirection: "column",
1029+
height: 400,
1030+
width: 600,
1031+
}}
1032+
>
1033+
<Conversation>
1034+
<HistoryRows
1035+
rows={historyWindowRows(from, to)}
1036+
hasEarlier={from > 0}
1037+
onLoadEarlier={() => {
1038+
setRequests((count) => count + 1);
1039+
return new Promise((resolve) => setPending(() => resolve));
1040+
}}
1041+
distance={distance}
1042+
/>
1043+
</Conversation>
1044+
<div style={{ display: "flex", gap: 8 }}>
1045+
<Button onClick={() => setTo((current) => current + 1)}>Append</Button>
1046+
<Button onClick={() => settle(HISTORY_PAGE)}>Deliver</Button>
1047+
<Button onClick={() => settle(0)}>Deliver nothing</Button>
1048+
<output aria-label="Requests">{requests}</output>
1049+
</div>
1050+
</div>
1051+
);
1052+
}
1053+
9421054
export const scenarios: Record<string, () => ReactNode> = {
9431055
smoke: () => <Button>Ready</Button>,
9441056
"approval-card": () => <Approval state="idle" />,
@@ -985,4 +1097,13 @@ export const scenarios: Record<string, () => ReactNode> = {
9851097
<MessageFoldConversationScenario policy="stick" />
9861098
),
9871099
"message-actions": () => <MessageActionsScenario />,
1100+
"history-window": () => (
1101+
<HistoryWindowScenario local={100} remote={0} distance={0} />
1102+
),
1103+
"history-window-paged": () => (
1104+
<HistoryWindowScenario local={20} remote={30} distance={0} />
1105+
),
1106+
"history-window-reach": () => (
1107+
<HistoryWindowScenario local={200} remote={0} />
1108+
),
9881109
};

‎e2e/history-window.spec.ts‎

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
3+
const container = (page: Page) =>
4+
page.locator("[data-slot='conversation-content']");
5+
6+
const marker = (page: Page) =>
7+
page.locator("[data-slot='conversation-earlier']");
8+
9+
const shown = (page: Page) => page.getByRole("status", { name: "Shown" });
10+
11+
const requests = (page: Page) => page.getByRole("status", { name: "Requests" });
12+
13+
const view = (page: Page) =>
14+
container(page).evaluate((element) => ({
15+
scrollTop: element.scrollTop,
16+
top: element.getBoundingClientRect().top,
17+
distanceFromEnd:
18+
element.scrollHeight - element.scrollTop - element.clientHeight,
19+
}));
20+
21+
/** How far an item's top sits below the top of the view. */
22+
const offsetOf = async (page: Page, id: string) => {
23+
const { top } = await view(page);
24+
const itemTop = await page
25+
.getByTestId(id)
26+
.evaluate((element) => element.getBoundingClientRect().top);
27+
return itemTop - top;
28+
};
29+
30+
/** Waits for the view to stop moving (a glide or a wheel scroll to land). */
31+
const settled = async (page: Page) => {
32+
let previous = Number.NaN;
33+
await expect
34+
.poll(
35+
async () => {
36+
const { scrollTop } = await view(page);
37+
const still = scrollTop === previous;
38+
previous = scrollTop;
39+
return still;
40+
},
41+
{ intervals: [100] },
42+
)
43+
.toBe(true);
44+
};
45+
46+
/** The reader's own scroll: a wheel over the conversation. */
47+
const wheel = async (page: Page, deltaY: number) => {
48+
const box = await container(page).boundingBox();
49+
if (box === null) throw new Error("expected the conversation on screen");
50+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
51+
await page.mouse.wheel(0, deltaY);
52+
await settled(page);
53+
};
54+
55+
const atEnd = async (page: Page) => {
56+
await expect
57+
.poll(async () => (await view(page)).distanceFromEnd)
58+
.toBeLessThanOrEqual(1);
59+
};
60+
61+
/** Two frames: one for resize callbacks, one for any correction to land. */
62+
const frames = (page: Page) =>
63+
page.evaluate(
64+
() =>
65+
new Promise((resolve) =>
66+
requestAnimationFrame(() => requestAnimationFrame(resolve)),
67+
),
68+
);
69+
70+
/** The id of the topmost item in view. */
71+
const firstVisible = (page: Page) =>
72+
container(page).evaluate((element) => {
73+
const top = element.getBoundingClientRect().top;
74+
const items = element.querySelectorAll<HTMLElement>("[data-testid]");
75+
for (const item of items) {
76+
if (item.getBoundingClientRect().bottom > top)
77+
return item.dataset.testid ?? "";
78+
}
79+
return "";
80+
});
81+
82+
const open = async (page: Page, scenario: string, last: string) => {
83+
await page.goto(`/${scenario}`);
84+
await expect(page.getByTestId(last)).toBeVisible();
85+
await atEnd(page);
86+
};
87+
88+
const loadEarlier = (page: Page) =>
89+
page.getByRole("button", { name: "Load earlier", exact: true }).click();
90+
91+
/** Scrolls up a little and returns the item being read and where it sits. */
92+
const readSomewhereUp = async (page: Page) => {
93+
await wheel(page, -300);
94+
const reading = await firstVisible(page);
95+
expect(reading).toMatch(/^r\d+$/);
96+
return { reading, offset: await offsetOf(page, reading) };
97+
};
98+
99+
test.describe("in-memory history", () => {
100+
test("opens at the latest window of items", async ({ page }) => {
101+
await open(page, "history-window", "r99");
102+
await expect(shown(page)).toHaveText("30");
103+
await expect(page.getByTestId("r70")).toBeAttached();
104+
await expect(page.getByTestId("r69")).not.toBeAttached();
105+
});
106+
107+
test("three loads in a row keep the item in view within 1px", async ({
108+
page,
109+
}) => {
110+
await open(page, "history-window", "r99");
111+
const { reading, offset } = await readSomewhereUp(page);
112+
for (const row of ["r60", "r50", "r40"]) {
113+
await loadEarlier(page);
114+
await expect(page.getByTestId(row)).toBeAttached();
115+
await frames(page);
116+
// Against the position before the first load, so drift cannot add up.
117+
expect(
118+
Math.abs((await offsetOf(page, reading)) - offset),
119+
).toBeLessThanOrEqual(1);
120+
}
121+
await expect(shown(page)).toHaveText("60");
122+
});
123+
124+
test("an append and a trim that cancel out keep the item in view within 1px", async ({
125+
page,
126+
}) => {
127+
await open(page, "history-window", "r99");
128+
// Away and back: the view no longer follows the end, so it keeps the
129+
// item being read, and the window is the latest 30 again.
130+
await wheel(page, -300);
131+
await wheel(page, 100_000);
132+
await atEnd(page);
133+
await expect(shown(page)).toHaveText("30");
134+
const reading = await firstVisible(page);
135+
const offset = await offsetOf(page, reading);
136+
const height = await container(page).evaluate(
137+
(element) => element.scrollHeight,
138+
);
139+
140+
// Rows repeat every 3 and the window holds 30, so r100 is exactly as
141+
// tall as r70: the content never resizes, and no resize restores it.
142+
await page.getByRole("button", { name: "Append" }).click();
143+
await expect(page.getByTestId("r100")).toBeAttached();
144+
await expect(page.getByTestId("r70")).not.toBeAttached();
145+
await frames(page);
146+
expect(
147+
await container(page).evaluate((element) => element.scrollHeight),
148+
).toBe(height);
149+
expect(
150+
Math.abs((await offsetOf(page, reading)) - offset),
151+
).toBeLessThanOrEqual(1);
152+
});
153+
154+
test("scrolled up, new items never trim the window; back at the end it trims", async ({
155+
page,
156+
}) => {
157+
await open(page, "history-window", "r99");
158+
const { reading, offset } = await readSomewhereUp(page);
159+
await loadEarlier(page);
160+
await expect(shown(page)).toHaveText("40");
161+
162+
for (let count = 0; count < 3; count += 1) {
163+
await page.getByRole("button", { name: "Append" }).click();
164+
}
165+
await expect(shown(page)).toHaveText("43");
166+
await expect(page.getByTestId("r60")).toBeAttached();
167+
await frames(page);
168+
expect(
169+
Math.abs((await offsetOf(page, reading)) - offset),
170+
).toBeLessThanOrEqual(1);
171+
172+
await page.getByRole("button", { name: "Scroll to latest" }).click();
173+
await atEnd(page);
174+
await expect(shown(page)).toHaveText("30");
175+
await expect(page.getByTestId("r73")).toBeAttached();
176+
await expect(page.getByTestId("r72")).not.toBeAttached();
177+
await settled(page);
178+
await atEnd(page);
179+
await expect(page.getByTestId("r102")).toBeVisible();
180+
});
181+
});
182+
183+
test.describe("server paging", () => {
184+
test("asks once per load, and three pages keep the item in view within 1px", async ({
185+
page,
186+
}) => {
187+
await open(page, "history-window-paged", "r49");
188+
await expect(shown(page)).toHaveText("20");
189+
const { reading, offset } = await readSomewhereUp(page);
190+
191+
for (const [load, row] of [
192+
[1, "r20"],
193+
[2, "r10"],
194+
[3, "r0"],
195+
] as const) {
196+
await loadEarlier(page);
197+
await expect(requests(page)).toHaveText(String(load));
198+
await expect(marker(page)).toHaveAttribute("data-state", "loading");
199+
await expect(marker(page)).toHaveText("Loading earlier messages…");
200+
// Asking again while the page is on its way does nothing.
201+
await loadEarlier(page);
202+
await frames(page);
203+
await expect(requests(page)).toHaveText(String(load));
204+
205+
// The page lands above the window, then shows a step at a time.
206+
await page.getByRole("button", { name: "Deliver", exact: true }).click();
207+
await expect(marker(page)).toHaveAttribute("data-state", "idle");
208+
await loadEarlier(page);
209+
await expect(page.getByTestId(row)).toBeAttached();
210+
await frames(page);
211+
expect(
212+
Math.abs((await offsetOf(page, reading)) - offset),
213+
).toBeLessThanOrEqual(1);
214+
}
215+
await expect(marker(page)).not.toBeAttached();
216+
await expect(requests(page)).toHaveText("3");
217+
});
218+
219+
test("a load that brings nothing stops asking and offers a button", async ({
220+
page,
221+
}) => {
222+
await open(page, "history-window-paged", "r49");
223+
// At the very top the marker is in view, and asks by itself.
224+
await wheel(page, -100_000);
225+
await expect(requests(page)).toHaveText("1");
226+
await page.getByRole("button", { name: "Deliver nothing" }).click();
227+
228+
const retry = page.getByRole("button", { name: "Load earlier messages" });
229+
await expect(retry).toBeVisible();
230+
await frames(page);
231+
await frames(page);
232+
await expect(requests(page)).toHaveText("1");
233+
234+
await retry.focus();
235+
await page.keyboard.press("Enter");
236+
await expect(requests(page)).toHaveText("2");
237+
await expect(marker(page)).toHaveAttribute("data-state", "loading");
238+
// The button is gone; focus stays on the marker, not the page body.
239+
expect(
240+
await page.evaluate(() => document.activeElement === document.body),
241+
).toBe(false);
242+
await expect(marker(page)).toBeFocused();
243+
});
244+
});
245+
246+
test("scrolling up loads more before the reader reaches the top", async ({
247+
page,
248+
}) => {
249+
await open(page, "history-window-reach", "r199");
250+
await expect(shown(page)).toHaveText("30");
251+
await wheel(page, -600);
252+
await expect
253+
.poll(async () => Number(await shown(page).textContent()))
254+
.toBeGreaterThan(30);
255+
// The marker asked ahead of the reader, who never reached the top.
256+
expect((await view(page)).scrollTop).toBeGreaterThan(0);
257+
});

0 commit comments

Comments
 (0)