Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/features/dataGrid/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,19 @@ export function DataGrid<TRow extends object, TGroup, TKey extends string | numb
// per render: only the callback handed to glide closes the loop, over time.
const { range, onRectChanged } = useWindowRange(pageSize, rangeLoadedRef);

const { rowAt, isPending, isRangeLoaded, total, span, status, retry, isStale, overlay, store } =
useGridData(instance, range, spec, collapsedGroups, repaintRows);
const {
rowAt,
isPending,
isRangeLoaded,
total,
span,
status,
failedInView,
retry,
isStale,
overlay,
store,
} = useGridData(instance, range, spec, collapsedGroups, repaintRows);

// Published during render, like the model and the column count below it. The
// predicate closes over the store the grid is reading NOW, and a window that
Expand Down Expand Up @@ -252,6 +263,7 @@ export function DataGrid<TRow extends object, TGroup, TKey extends string | numb
<div style={{ display: "flex", flexDirection: "column", height: "100%", width: "100%" }}>
<GridHeaderBar
status={status}
partialFailure={failedInView}
error={lastError?.message ?? null}
rowCount={model.rowCount}
stale={isStale}
Expand Down
34 changes: 34 additions & 0 deletions src/features/dataGrid/GridHeaderBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,40 @@ describe("GridHeaderBar", () => {
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
Expand Down
20 changes: 19 additions & 1 deletion src/features/dataGrid/GridHeaderBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -82,14 +88,15 @@ function Wrong({ children }: { children: ReactNode }) {
*/
function StatusZone({
status,
partialFailure,
error,
rowCount,
stale,
onRetry,
onDismissError,
}: Pick<
GridHeaderBarProps,
"status" | "error" | "rowCount" | "stale" | "onRetry" | "onDismissError"
"status" | "partialFailure" | "error" | "rowCount" | "stale" | "onRetry" | "onDismissError"
>) {
if (status === "error") {
return (
Expand All @@ -102,6 +109,17 @@ function StatusZone({
);
}

if (partialFailure === true) {
return (
<Wrong>
Some rows could not load.
<Button size="1" variant="soft" color="red" onClick={onRetry}>
Retry
</Button>
</Wrong>
);
}

if (stale === true) {
return <Working>Loading…</Working>;
}
Expand Down
11 changes: 10 additions & 1 deletion src/features/dataGrid/hooks/useGridData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ export interface UseGridDataResult<TRow extends object, TGroup, TKey extends str
*/
status: "loading" | "ready" | "error";

/**
* Whether a page of the visible window failed. Unlike `status`, this stays
* true while OTHER pages of that window draw fine, which is the case the bar
* has to speak for: rows that are blank because a request failed look exactly
* like rows that are blank because they are still coming.
*/
failedInView: boolean;

/** Loads the failed pages of the current window again. The error banner's button. */
retry: () => void;

Expand Down Expand Up @@ -296,7 +304,7 @@ export function useGridData<TRow extends object, TGroup, TKey extends string | n
// hold an extra page fetched only for `precedingGroupKey`; that page is off
// screen, so whether it loads or fails must never change what `status`
// reports about the page(s) the user can actually see.
const { status, settled, retry } = useRowPages({
const { status, failedInView, settled, retry } = useRowPages({
descriptor,
store: loadingView.store,
range: loadRange,
Expand Down Expand Up @@ -478,6 +486,7 @@ export function useGridData<TRow extends object, TGroup, TKey extends string | n
total: displayed.total,
span,
status,
failedInView,
retry,
isStale: pending !== null,
store: displayed.store,
Expand Down
39 changes: 38 additions & 1 deletion src/features/dataGrid/hooks/useRowPages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ function makeHarness(total: number) {
let openFetch: () => void = () => {};
let fetchGate: Promise<void> = Promise.resolve();
let failNext = false;
let deadOffset: number | null = null;

const fetchRows = vi.fn(async (p: Parameters<typeof server.api.fetchRows>[0]) => {
await fetchGate;

if (failNext) {
if (failNext || p.offset === deadOffset) {
failNext = false;
throw new Error("boom");
}
Expand Down Expand Up @@ -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 }));
},
Expand Down Expand Up @@ -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<Row, number>(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<Row, number>(PAGE, (r) => r.id);
Expand All @@ -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 () => {
Expand Down
12 changes: 11 additions & 1 deletion src/features/dataGrid/hooks/useRowPages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export interface UseRowPagesArgs<TRow extends object, TGroup, TKey extends strin
export interface UseRowPagesResult {
status: "loading" | "ready" | "error";

/**
* Whether a page the window SHOWS is in the failed state.
*/
failedInView: boolean;

/**
* Counts page settlements. It names no page and carries no row, so nothing
* about a row's VALUE reaches React through it.
Expand Down Expand Up @@ -237,7 +242,12 @@ export function useRowPages<TRow extends object, TGroup, TKey extends string | n
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagesKey, stateKey, store, descriptor, queryClient]);

return { status: statusOf(store, statusPages), settled, retry };
return {
status: statusOf(store, statusPages),
failedInView: statusPages.some((page) => store.pageState(page) === "failed"),
settled,
retry,
};
}

/** Every page an inclusive window touches. */
Expand Down
Loading