Skip to content

Commit 361e789

Browse files
authored
feat(conversation): add useHistoryWindow and ConversationEarlier (#122)
* 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. * fix(conversation): hold the history window until the view is read An item committed synchronously in the frame the reader scrolled up in, before the conversation's scroll pass, slid or trimmed the window: atBottom and the freeze were still a frame old, so the oldest item was removed while the reader was scrolled up. A new item now commits with nothing removed. A layout effect then trims or slides the window as before, unless the view has moved towards the top since the last scroll snapshot; the next pass then freezes it if the scroll was the reader's. The snapshot subscription moves to a layout effect so the one sent when the container mounts is not missed. A loader that throws now stalls like one that rejects, and its error is reported once instead of escaping the marker's observer callback. The scroll helpers both conversation specs copied now live in one module. * fix(conversation): keep the window start when the last id is swapped The window remembers the last item it was trimmed through. When that id vanished in the same update as an append (an optimistic id swapped for the server's, plus the reply), the window slid by one during render. If the reader had just scrolled up, the next frame froze the slid start and the window's first item was gone. A missing trim id now falls back as a missing pin does: to the first item of the last window that still exists. At the bottom, the next commit trims back, as it does after a plain append. * fix(conversation): freeze the history window on any move up Only a scroll the reader made froze the window's start. A move the page made, such as a glide to an item, focus or find in page, left the start following the latest items, so the next appended item slid the window and unmounted the item the view had just been moved to. The window now freezes on any scroll snapshot that moved the view towards the top and ended away from the end. Content growing below a view that stays put leaves scrollTop unchanged, so the window still slides then. * fix(conversation): keep the history window's last slice in state The window start's fallback, used when the item it is kept by is gone, read the previous window from a ref during render. The ref was written by a layout effect, so it went stale while Activity hid the host: a start grown while hidden whose item was then deleted fell back to the window from before the host was hidden. The last slice now lives in the hook's state, recorded during render as atBottom already is, and the scroll listener reads the window's first item in the handler rather than in a state updater. * docs(conversation): say which failed loads the history window reports The onLoadEarlier doc said the hook never reports a failure. A loader that throws synchronously is reported through reportError, so that it does not escape the marker's observer callback; only a rejection is left to the host. * fix(conversation): forward ConversationEarlier's ref under React 18 ConversationEarlier read ref from its props, which React 19 passes but React 18 strips, so a ref never reached the marker on React 18, the lower end of the peer range. It is now wrapped in forwardRef.
1 parent ea976c4 commit 361e789

10 files changed

Lines changed: 1847 additions & 69 deletions

‎e2e/conversation-helpers.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Reads and drives the conversation scroll container the e2e scenarios render.
2+
import { expect, type Page } from "@playwright/test";
3+
4+
export const container = (page: Page) =>
5+
page.locator("[data-slot='conversation-content']");
6+
7+
export const view = (page: Page) =>
8+
container(page).evaluate((element) => ({
9+
scrollTop: element.scrollTop,
10+
top: element.getBoundingClientRect().top,
11+
distanceFromEnd:
12+
element.scrollHeight - element.scrollTop - element.clientHeight,
13+
}));
14+
15+
/** How far an item's top sits below the top of the view. */
16+
export const offsetOf = async (page: Page, id: string) => {
17+
const { top } = await view(page);
18+
const itemTop = await page
19+
.getByTestId(id)
20+
.evaluate((element) => element.getBoundingClientRect().top);
21+
return itemTop - top;
22+
};
23+
24+
/** Waits for the view to stop moving (a glide or a wheel scroll to land). */
25+
export const settled = async (page: Page) => {
26+
let previous = Number.NaN;
27+
await expect
28+
.poll(
29+
async () => {
30+
const { scrollTop } = await view(page);
31+
const still = scrollTop === previous;
32+
previous = scrollTop;
33+
return still;
34+
},
35+
{ intervals: [100] },
36+
)
37+
.toBe(true);
38+
};
39+
40+
/** The reader's own scroll: a wheel over the conversation. */
41+
export const wheel = async (page: Page, deltaY: number) => {
42+
const box = await container(page).boundingBox();
43+
if (box === null) throw new Error("expected the conversation on screen");
44+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
45+
await page.mouse.wheel(0, deltaY);
46+
await settled(page);
47+
};
48+
49+
export const atEnd = async (page: Page) => {
50+
await expect
51+
.poll(async () => (await view(page)).distanceFromEnd)
52+
.toBeLessThanOrEqual(1);
53+
};
54+
55+
/** The id of the topmost item in view. */
56+
export const firstVisible = (page: Page) =>
57+
container(page).evaluate((element) => {
58+
const top = element.getBoundingClientRect().top;
59+
const items = element.querySelectorAll<HTMLElement>("[data-testid]");
60+
for (const item of items) {
61+
if (item.getBoundingClientRect().bottom > top)
62+
return item.dataset.testid ?? "";
63+
}
64+
return "";
65+
});
66+
67+
/** Two frames: one for resize callbacks, one for any correction to land. */
68+
export const frames = (page: Page) =>
69+
page.evaluate(
70+
() =>
71+
new Promise((resolve) =>
72+
requestAnimationFrame(() => requestAnimationFrame(resolve)),
73+
),
74+
);

‎e2e/conversation.spec.ts‎

Lines changed: 11 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,15 @@
11
import { expect, test, type Page } from "@playwright/test";
22

3-
const container = (page: Page) =>
4-
page.locator("[data-slot='conversation-content']");
5-
6-
const view = (page: Page) =>
7-
container(page).evaluate((element) => ({
8-
scrollTop: element.scrollTop,
9-
top: element.getBoundingClientRect().top,
10-
distanceFromEnd:
11-
element.scrollHeight - element.scrollTop - element.clientHeight,
12-
}));
13-
14-
/** How far an item's top sits below the top of the view. */
15-
const offsetOf = async (page: Page, id: string) => {
16-
const { top } = await view(page);
17-
const itemTop = await page
18-
.getByTestId(id)
19-
.evaluate((element) => element.getBoundingClientRect().top);
20-
return itemTop - top;
21-
};
22-
23-
/** Waits for the view to stop moving (a glide or a wheel scroll to land). */
24-
const settled = async (page: Page) => {
25-
let previous = Number.NaN;
26-
await expect
27-
.poll(
28-
async () => {
29-
const { scrollTop } = await view(page);
30-
const still = scrollTop === previous;
31-
previous = scrollTop;
32-
return still;
33-
},
34-
{ intervals: [100] },
35-
)
36-
.toBe(true);
37-
};
38-
39-
/** The reader's own scroll: a wheel over the conversation. */
40-
const wheel = async (page: Page, deltaY: number) => {
41-
const box = await container(page).boundingBox();
42-
if (box === null) throw new Error("expected the conversation on screen");
43-
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
44-
await page.mouse.wheel(0, deltaY);
45-
await settled(page);
46-
};
47-
48-
const atEnd = async (page: Page) => {
49-
await expect
50-
.poll(async () => (await view(page)).distanceFromEnd)
51-
.toBeLessThanOrEqual(1);
52-
};
53-
54-
/** The id of the topmost item in view. */
55-
const firstVisible = (page: Page) =>
56-
container(page).evaluate((element) => {
57-
const top = element.getBoundingClientRect().top;
58-
const items = element.querySelectorAll<HTMLElement>("[data-testid]");
59-
for (const item of items) {
60-
if (item.getBoundingClientRect().bottom > top)
61-
return item.dataset.testid ?? "";
62-
}
63-
return "";
64-
});
3+
import {
4+
atEnd,
5+
container,
6+
firstVisible,
7+
frames,
8+
offsetOf,
9+
settled,
10+
view,
11+
wheel,
12+
} from "./conversation-helpers.js";
6513

6614
/** Opens a scenario once its history (which arrives after mount) is in. */
6715
const open = async (page: Page, scenario: string) => {
@@ -74,13 +22,7 @@ const write = async (page: Page, times: number) => {
7422
for (let count = 0; count < times; count += 1) {
7523
await page.getByRole("button", { name: "Write" }).click();
7624
}
77-
// One frame for the resize callback, one for any correction to land.
78-
await page.evaluate(
79-
() =>
80-
new Promise((resolve) =>
81-
requestAnimationFrame(() => requestAnimationFrame(resolve)),
82-
),
83-
);
25+
await frames(page);
8426
};
8527

8628
test.describe("settle", () => {

‎e2e/fixtures.tsx‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ import {
6262
MessageContent,
6363
MessageFold,
6464
MessageFooter,
65+
ConversationEarlier,
66+
useHistoryWindow,
6567
} from "@corbits/react-ui";
6668
import {
6769
CommandPalette,
@@ -1193,6 +1195,116 @@ function MessageActionsScenario() {
11931195
);
11941196
}
11951197

1198+
type HistoryRow = { readonly id: string; readonly lines: number };
1199+
1200+
/** Rows of fractional heights, so rounding shows up in the positions. */
1201+
function historyWindowRows(from: number, to: number): HistoryRow[] {
1202+
return Array.from({ length: to - from }, (_, index) => ({
1203+
id: `r${from + index}`,
1204+
lines: 1 + ((from + index) % 3),
1205+
}));
1206+
}
1207+
1208+
const HISTORY_PAGE = 10;
1209+
1210+
/** Renders the window; must sit inside the Conversation it follows. */
1211+
function HistoryRows({
1212+
rows,
1213+
hasEarlier,
1214+
onLoadEarlier,
1215+
distance,
1216+
}: {
1217+
rows: readonly HistoryRow[];
1218+
hasEarlier: boolean;
1219+
onLoadEarlier: () => Promise<void>;
1220+
distance: number | undefined;
1221+
}) {
1222+
const history = useHistoryWindow({
1223+
items: rows,
1224+
windowSize: 30,
1225+
step: HISTORY_PAGE,
1226+
hasEarlier,
1227+
onLoadEarlier,
1228+
});
1229+
return (
1230+
<>
1231+
<ConversationContent>
1232+
<ConversationEarlier
1233+
hasEarlier={history.hasEarlier}
1234+
loading={history.loading}
1235+
stalled={history.stalled}
1236+
onReachStart={history.reachStart}
1237+
distance={distance}
1238+
/>
1239+
{history.items.map((row) => (
1240+
<ConversationItem key={row.id} itemId={row.id} data-testid={row.id}>
1241+
<p style={{ height: row.lines * 23.5, margin: 0 }}>Row {row.id}</p>
1242+
</ConversationItem>
1243+
))}
1244+
</ConversationContent>
1245+
<ConversationScrollButton />
1246+
<div style={{ display: "flex", gap: 8 }}>
1247+
<Button onClick={history.reachStart}>Load earlier</Button>
1248+
<output aria-label="Shown">{history.items.length}</output>
1249+
</div>
1250+
</>
1251+
);
1252+
}
1253+
1254+
/**
1255+
* `local` rows are loaded; `remote` older ones come from a pretend server a
1256+
* page at a time, when "Deliver" is pressed. "Deliver nothing" ends a load
1257+
* with no rows.
1258+
*/
1259+
function HistoryWindowScenario({
1260+
local,
1261+
remote,
1262+
distance,
1263+
}: {
1264+
local: number;
1265+
remote: number;
1266+
distance?: number;
1267+
}) {
1268+
const [from, setFrom] = useState(remote);
1269+
const [to, setTo] = useState(remote + local);
1270+
const [requests, setRequests] = useState(0);
1271+
const [pending, setPending] = useState<(() => void) | null>(null);
1272+
const settle = (rows: number) => {
1273+
if (pending === null) return;
1274+
setFrom((current) => Math.max(0, current - rows));
1275+
setPending(null);
1276+
pending();
1277+
};
1278+
return (
1279+
<div
1280+
style={{
1281+
display: "flex",
1282+
flexDirection: "column",
1283+
height: 400,
1284+
width: 600,
1285+
}}
1286+
>
1287+
<Conversation>
1288+
<HistoryRows
1289+
rows={historyWindowRows(from, to)}
1290+
hasEarlier={from > 0}
1291+
onLoadEarlier={() => {
1292+
setRequests((count) => count + 1);
1293+
return new Promise((resolve) => setPending(() => resolve));
1294+
}}
1295+
distance={distance}
1296+
/>
1297+
</Conversation>
1298+
<div style={{ display: "flex", gap: 8 }}>
1299+
<Button onClick={() => setTo((current) => current + 1)}>Append</Button>
1300+
<Button onClick={() => settle(HISTORY_PAGE)}>Deliver</Button>
1301+
<Button onClick={() => settle(0)}>Deliver nothing</Button>
1302+
<output aria-label="Requests">{requests}</output>
1303+
</div>
1304+
</div>
1305+
);
1306+
}
1307+
11961308
export const scenarios: Record<string, () => ReactNode> = {
11971309
smoke: () => <Button>Ready</Button>,
11981310
"approval-card": () => <Approval state="idle" />,
@@ -1245,4 +1357,13 @@ export const scenarios: Record<string, () => ReactNode> = {
12451357
<MessageFoldConversationScenario policy="stick" />
12461358
),
12471359
"message-actions": () => <MessageActionsScenario />,
1360+
"history-window": () => (
1361+
<HistoryWindowScenario local={100} remote={0} distance={0} />
1362+
),
1363+
"history-window-paged": () => (
1364+
<HistoryWindowScenario local={20} remote={30} distance={0} />
1365+
),
1366+
"history-window-reach": () => (
1367+
<HistoryWindowScenario local={200} remote={0} />
1368+
),
12481369
};

0 commit comments

Comments
 (0)