Skip to content

Commit e10706d

Browse files
committed
Wait for older history when navigating versions with arrow keys
1 parent dc0356a commit e10706d

2 files changed

Lines changed: 167 additions & 2 deletions

File tree

‎packages/react/src/components/Versioning/VersioningSidebar.browser.test.tsx‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1811,6 +1811,83 @@ describe("VersioningSidebar", () => {
18111811
});
18121812
});
18131813

1814+
it.each([false, true])(
1815+
"waits for older pages before scrubbing past loaded history, namedOnly=%s",
1816+
async (namedOnly) => {
1817+
const olderPage = deferred<VersionResult<VersionSnapshotPage>>();
1818+
const start = { id: "first", createdAt: 0, name: "Beginning" };
1819+
const older = { id: "older", createdAt: 500, name: "Earlier" };
1820+
const fake = createFakeEndpoints(undefined, true);
1821+
fake.setSnapshots([NAMED, AUTOMATIC, older, start]);
1822+
const list = vi
1823+
.fn<VersionStorage<unknown[], unknown>["list"]>()
1824+
.mockResolvedValueOnce(
1825+
success({
1826+
snapshots: [NAMED, AUTOMATIC, start],
1827+
nextCursor: "hidden",
1828+
}),
1829+
)
1830+
.mockReturnValueOnce(olderPage.promise)
1831+
.mockResolvedValueOnce(success({ snapshots: [older] }));
1832+
const editor = createEditor({ ...fake.endpoints, list });
1833+
render(
1834+
<VersioningTestView editor={editor}>
1835+
<VersioningSidebar defaultNamedOnly={namedOnly} />
1836+
</VersioningTestView>,
1837+
);
1838+
await act(async () => {});
1839+
const index = namedOnly ? 1 : 2;
1840+
const selected = namedOnly ? NAMED : AUTOMATIC;
1841+
await click(rows()[index]!);
1842+
act(() => rows()[index]!.focus());
1843+
await act(async () => userEvent.keyboard("{ArrowDown}{ArrowDown}"));
1844+
expect(document.activeElement).toBe(rows()[index]);
1845+
expect(viewState(mode(editor))).toMatchObject({
1846+
snapshotId: selected.id,
1847+
});
1848+
1849+
// A page containing no new visible rows must not consume the arrow press.
1850+
await act(async () =>
1851+
olderPage.resolve(
1852+
success({ snapshots: [AUTOMATIC], nextCursor: "older" }),
1853+
),
1854+
);
1855+
await vi.waitFor(() =>
1856+
expect(viewState(mode(editor))).toMatchObject({ snapshotId: older.id }),
1857+
);
1858+
expect(document.activeElement).toBe(rows()[index + 1]);
1859+
await act(async () => userEvent.keyboard("{ArrowDown}"));
1860+
expect(viewState(mode(editor))).toMatchObject({ snapshotId: start.id });
1861+
},
1862+
);
1863+
1864+
it("cancels navigation across a pending page when moving elsewhere", async () => {
1865+
const olderPage = deferred<VersionResult<VersionSnapshotPage>>();
1866+
const start = { id: "first", createdAt: 0, name: "Beginning" };
1867+
const older = { id: "older", createdAt: 500, name: "Earlier" };
1868+
const fake = createFakeEndpoints(undefined, true);
1869+
fake.setSnapshots([NAMED, AUTOMATIC, older, start]);
1870+
const list = vi
1871+
.fn<VersionStorage<unknown[], unknown>["list"]>()
1872+
.mockResolvedValueOnce(
1873+
success({ snapshots: [NAMED, AUTOMATIC, start], nextCursor: "older" }),
1874+
)
1875+
.mockReturnValueOnce(olderPage.promise);
1876+
const editor = createEditor({ ...fake.endpoints, list });
1877+
render(
1878+
<VersioningTestView editor={editor}>
1879+
<VersioningSidebar />
1880+
</VersioningTestView>,
1881+
);
1882+
await act(async () => {});
1883+
await click(rows()[2]!);
1884+
act(() => rows()[2]!.focus());
1885+
await act(async () => userEvent.keyboard("{ArrowDown}{ArrowUp}"));
1886+
await act(async () => olderPage.resolve(success({ snapshots: [older] })));
1887+
expect(document.activeElement).toBe(rows()[1]);
1888+
expect(viewState(mode(editor))).toMatchObject({ snapshotId: NAMED.id });
1889+
});
1890+
18141891
it("scrubs only visible named rows and retains comparison at either end", async () => {
18151892
const fake = createFakeEndpoints();
18161893
const older = { id: "older", createdAt: 500, name: "First draft" };

‎packages/react/src/components/Versioning/VersioningSidebarList.tsx‎

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export function VersioningSidebarList() {
4848
const listRef = useRef<HTMLDivElement>(null);
4949
const paginationRef = useRef<HTMLDivElement>(null);
5050
const [activeIndex, setActiveIndex] = useState(0);
51+
const pendingNavigation = useRef<
52+
| {
53+
after: string;
54+
namedOnly: boolean;
55+
}
56+
| undefined
57+
>(undefined);
5158
const nextCursor = state.mode === "versions" ? state.nextCursor : undefined;
5259

5360
const focusRow = useCallback((index: number) => {
@@ -69,6 +76,73 @@ export function VersioningSidebarList() {
6976
[list, namedOnly],
7077
);
7178

79+
const needsOlderPage = useCallback(
80+
(index: number) =>
81+
nextCursor !== undefined &&
82+
(!rows[index] ||
83+
(versioning.historyIncludesBeginning &&
84+
rows[index]!.snapshot.id ===
85+
(list.loaded ? list.snapshots.at(-1)?.id : undefined))),
86+
[nextCursor, rows, versioning.historyIncludesBeginning, list],
87+
);
88+
89+
async function advanceAfterPage(after: string) {
90+
const navigation = { after, namedOnly };
91+
pendingNavigation.current = navigation;
92+
try {
93+
return await run(async () => {
94+
while (pendingNavigation.current === navigation) {
95+
const outcome = await versioning.loadMore();
96+
if (outcome.status !== "done") {
97+
return outcome;
98+
}
99+
// Let the newly loaded rows mount before moving native focus.
100+
await new Promise<void>((resolve) => {
101+
requestAnimationFrame(() => resolve());
102+
});
103+
if (
104+
pendingNavigation.current !== navigation ||
105+
focusedRowId.current !== after
106+
) {
107+
return { status: "cancelled" };
108+
}
109+
const latest = versioning.store.state;
110+
const latestList = getVersionList(latest);
111+
if (latest.mode !== "versions" || !latestList.loaded) {
112+
return { status: "cancelled" };
113+
}
114+
const latestRows = getVisibleVersionRows(latestList, namedOnly);
115+
const index = latestRows.findIndex(
116+
(row) => row.snapshot.id === after,
117+
);
118+
if (index === -1) {
119+
return { status: "cancelled" };
120+
}
121+
const next = latestRows[index + 1];
122+
if (
123+
latest.nextCursor !== undefined &&
124+
(!next ||
125+
(versioning.historyIncludesBeginning &&
126+
next.snapshot.id === latestList.snapshots.at(-1)?.id))
127+
) {
128+
continue;
129+
}
130+
if (!next) {
131+
return { status: "done" };
132+
}
133+
pendingNavigation.current = undefined;
134+
focusRow(index + 1);
135+
return previewRow(next.snapshot);
136+
}
137+
return { status: "cancelled" };
138+
});
139+
} finally {
140+
if (pendingNavigation.current === navigation) {
141+
pendingNavigation.current = undefined;
142+
}
143+
}
144+
}
145+
72146
useEffect(() => {
73147
if (!list.loaded || listing || listError || nextCursor === undefined) {
74148
return;
@@ -160,7 +234,15 @@ export function VersioningSidebarList() {
160234
return;
161235
}
162236

163-
function scrubTo(destination: number) {
237+
function scrubTo(destination: number, waitForPage = false) {
238+
if (waitForPage && pendingNavigation.current) {
239+
return;
240+
}
241+
pendingNavigation.current = undefined;
242+
if (waitForPage && needsOlderPage(destination)) {
243+
void advanceAfterPage(rows[index]!.snapshot.id);
244+
return;
245+
}
164246
const clamped = Math.max(0, Math.min(destination, rows.length - 1));
165247
if (event.repeat && clamped === index) {
166248
return;
@@ -175,7 +257,7 @@ export function VersioningSidebarList() {
175257
switch (event.key) {
176258
case "ArrowDown":
177259
event.preventDefault();
178-
scrubTo(index + 1);
260+
scrubTo(index + 1, true);
179261
break;
180262
case "ArrowUp":
181263
event.preventDefault();
@@ -192,6 +274,7 @@ export function VersioningSidebarList() {
192274
case "Enter":
193275
case " ":
194276
event.preventDefault();
277+
pendingNavigation.current = undefined;
195278
void run(() => previewRow(rows[index]!.snapshot));
196279
break;
197280
default:
@@ -207,6 +290,9 @@ export function VersioningSidebarList() {
207290
aria-label={dict.versioning.versions_list}
208291
aria-busy={listing || undefined}
209292
ref={listRef}
293+
onPointerDown={() => {
294+
pendingNavigation.current = undefined;
295+
}}
210296
onBlur={(event) => {
211297
// Registered editor menus can live outside the list's DOM.
212298
const target = event.relatedTarget;
@@ -216,6 +302,7 @@ export function VersioningSidebarList() {
216302
target.closest('[role="menu"]');
217303
if (!event.currentTarget.contains(target) && !inEditorMenu) {
218304
focusedRowId.current = undefined;
305+
pendingNavigation.current = undefined;
219306
}
220307
}}
221308
>
@@ -234,6 +321,7 @@ export function VersioningSidebarList() {
234321
}
235322
onKeyDown={(event) => handleKeyDown(event, index)}
236323
onFocus={() => {
324+
pendingNavigation.current = undefined;
237325
focusedRowId.current = snapshot.id;
238326
setActiveIndex(index);
239327
}}

0 commit comments

Comments
 (0)