From 5c6b8190543d43b6fa4faed65ad0ec5d7dbb8ba9 Mon Sep 17 00:00:00 2001 From: MatthewHsu1 Date: Sun, 30 Aug 2026 18:22:35 -0600 Subject: [PATCH] feat: add support for partial row load failures in DataGrid and GridHeaderBar --- src/features/dataGrid/DataGrid.tsx | 16 +++++++- src/features/dataGrid/GridHeaderBar.test.tsx | 34 ++++++++++++++++ src/features/dataGrid/GridHeaderBar.tsx | 20 +++++++++- src/features/dataGrid/hooks/useGridData.ts | 11 +++++- .../dataGrid/hooks/useRowPages.test.tsx | 39 ++++++++++++++++++- src/features/dataGrid/hooks/useRowPages.ts | 12 +++++- 6 files changed, 126 insertions(+), 6 deletions(-) diff --git a/src/features/dataGrid/DataGrid.tsx b/src/features/dataGrid/DataGrid.tsx index eefdfbc..496f05e 100644 --- a/src/features/dataGrid/DataGrid.tsx +++ b/src/features/dataGrid/DataGrid.tsx @@ -141,8 +141,19 @@ export function DataGrid { expect(screen.getByRole("alert")).toHaveTextContent(/could not load/i); }); + it("offers a retry when only SOME of the visible rows failed to load", async () => { + // The case that used to reach the screen silently: `status` reads "ready" + // because one page of the window loaded, and the rows of the page that + // failed draw as empty cells that look exactly like rows still on the way. + const onRetry = vi.fn(); + renderQuiet({ status: "ready", partialFailure: true, onRetry }); + + expect(screen.getByRole("alert")).toHaveTextContent(/some rows could not load/i); + + await userEvent.click(screen.getByRole("button", { name: /retry/i })); + + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("says nothing about rows when nothing in view failed", () => { + renderQuiet({ status: "ready", partialFailure: false }); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("lets a partial failure outrank a stale hold, because blank rows are the worse lie", () => { + // A stale grid draws real rows one state behind; a partly failed one draws + // rows that are simply absent. The second needs the button. + renderQuiet({ status: "ready", partialFailure: true, stale: true }); + + expect(screen.getByRole("alert")).toHaveTextContent(/some rows could not load/i); + }); + + it("prefers the whole-window failure over the partial one", () => { + renderQuiet({ status: "error", partialFailure: true }); + + expect(screen.getByRole("alert")).toHaveTextContent(/could not load rows/i); + }); + it("holds the slot row's space open while the group is not known yet", () => { // Null means "there will be components here". A row that came and went as // the grid worked out which group it was in changed the bar's height, and diff --git a/src/features/dataGrid/GridHeaderBar.tsx b/src/features/dataGrid/GridHeaderBar.tsx index 7a6beaa..a0c6591 100644 --- a/src/features/dataGrid/GridHeaderBar.tsx +++ b/src/features/dataGrid/GridHeaderBar.tsx @@ -16,6 +16,12 @@ export interface BannerGroup { export interface GridHeaderBarProps { status: "loading" | "ready" | "error"; + + /** + * Whether at least one page failed to load. + */ + partialFailure?: boolean; + error: string | null; /** Rows the grid is currently drawing. Zero means there are no cells to speak for a load. */ @@ -82,6 +88,7 @@ function Wrong({ children }: { children: ReactNode }) { */ function StatusZone({ status, + partialFailure, error, rowCount, stale, @@ -89,7 +96,7 @@ function StatusZone({ onDismissError, }: Pick< GridHeaderBarProps, - "status" | "error" | "rowCount" | "stale" | "onRetry" | "onDismissError" + "status" | "partialFailure" | "error" | "rowCount" | "stale" | "onRetry" | "onDismissError" >) { if (status === "error") { return ( @@ -102,6 +109,17 @@ function StatusZone({ ); } + if (partialFailure === true) { + return ( + + Some rows could not load. + + + ); + } + if (stale === true) { return Loading…; } diff --git a/src/features/dataGrid/hooks/useGridData.ts b/src/features/dataGrid/hooks/useGridData.ts index f2723fa..c039420 100644 --- a/src/features/dataGrid/hooks/useGridData.ts +++ b/src/features/dataGrid/hooks/useGridData.ts @@ -91,6 +91,14 @@ export interface UseGridDataResult void; @@ -296,7 +304,7 @@ export function useGridData void = () => {}; let fetchGate: Promise = Promise.resolve(); let failNext = false; + let deadOffset: number | null = null; const fetchRows = vi.fn(async (p: Parameters[0]) => { await fetchGate; - if (failNext) { + if (failNext || p.offset === deadOffset) { failNext = false; throw new Error("boom"); } @@ -69,6 +70,10 @@ function makeHarness(total: number) { failNextFetch() { failNext = true; }, + /** Fails one page for good, while every other page of the window loads. */ + killOffset(offset: number) { + deadOffset = offset; + }, requested() { return fetchRows.mock.calls.map((c) => ({ offset: c[0].offset, limit: c[0].limit })); }, @@ -364,6 +369,37 @@ describe("useRowPages", () => { expect(store.pageState(0)).toBe("failed"); }); + it("reports a failed page while the rest of the window draws", async () => { + // `status` reads "ready" here, because a window with one page loaded has + // something to draw. `failedInView` is what tells the bar that the blank + // rows above those are blank for a reason worth a retry button — before it, + // a single dropped page reached the screen as rows that never filled, under + // a bar that said nothing at all. + const h = makeHarness(1_000); + const store = createRowStore(PAGE, (r) => r.id); + + h.killOffset(0); + + const { result } = renderHook( + () => + useRowPages({ + descriptor: h.descriptor, + store, + range: { offset: 0, limit: 2 * PAGE }, + sort: null, + collapsedGroups: [], + onRowsLoaded: () => {}, + }), + { wrapper: h.wrapper }, + ); + + await waitFor(() => expect(store.pageState(0)).toBe("failed")); + + expect(store.pageState(1)).toBe("loaded"); + expect(result.current.status).toBe("ready"); + expect(result.current.failedInView).toBe(true); + }); + it("reloads a failed page on retry", async () => { const h = makeHarness(1_000); const store = createRowStore(PAGE, (r) => r.id); @@ -388,6 +424,7 @@ describe("useRowPages", () => { await waitFor(() => expect(result.current.status).toBe("ready")); expect(store.getRow(0)?.name).toBe("row-0"); + expect(result.current.failedInView).toBe(false); }); it("sends the sort and the collapse set to the server", async () => { diff --git a/src/features/dataGrid/hooks/useRowPages.ts b/src/features/dataGrid/hooks/useRowPages.ts index 169e4d0..0d597b8 100644 --- a/src/features/dataGrid/hooks/useRowPages.ts +++ b/src/features/dataGrid/hooks/useRowPages.ts @@ -82,6 +82,11 @@ export interface UseRowPagesArgs store.pageState(page) === "failed"), + settled, + retry, + }; } /** Every page an inclusive window touches. */