- {/* The table never shrinks below the columns' summed configured sizes
+ {/* The bar is a sibling of the scrolling container, not a child: an
+ absolutely positioned child of an `overflow-x-auto` element scrolls
+ away with the content on a table wide enough to scroll. */}
+
+ {isLoading &&
}
+
+ {/* The table never shrinks below the columns' summed configured sizes
(tanstack defaults unsized columns to 150px) — on narrow screens
the wrapper scrolls horizontally instead of crushing columns until
headers stack letter-by-letter and cell contents overlap. */}
-
- {!hideHeader && (
-
- {table.getHeaderGroups().map((headerGroup) => (
-
- {headerGroup.headers.map((header) => {
- const sorted = header.column.getIsSorted();
- return (
-
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext(),
- )}
-
- );
- })}
-
- ))}
-
- )}
-
- {table.getRowModel().rows?.length ? (
- table.getRowModel().rows.map((row) => (
-
+
+ {!hideHeader && (
+
+ {table.getHeaderGroups().map((headerGroup) => (
onRowClick?.(row.original, e)}
+ key={headerGroup.id}
+ className="hover:bg-transparent"
>
- {row.getVisibleCells().map((cell) => (
-
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext(),
- )}
-
- ))}
+ {headerGroup.headers.map((header) => {
+ const sorted = header.column.getIsSorted();
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+ );
+ })}
- {renderSubComponent && row.getIsExpanded() && (
-
-
- {renderSubComponent({ row })}
-
+ ))}
+
+ )}
+ {/* Rows already on screen are the previous query's answer, so they
+ fade back while the next one is in flight rather than sitting
+ there looking current. The delay keeps a refetch that resolves
+ immediately from registering as a flicker; coming back is
+ undelayed so results land at full strength. */}
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ onRowClick?.(row.original, e)}
+ >
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
- )}
-
- ))
- ) : (
-
-
- {/* An empty body while a fetch is still out is not an empty
+ {renderSubComponent && row.getIsExpanded() && (
+
+
+ {renderSubComponent({ row })}
+
+
+ )}
+
+ ))
+ ) : (
+
+
+ {/* An empty body while a fetch is still out is not an empty
result, so it says nothing: announcing "No Data" and then
replacing it with rows a moment later is the flash this
area used to produce. The row keeps its height either
way, so the rows arrive without shifting the pagination
controls underneath. */}
-
- {!isLoading && (
-
- )}
-
-
-
- )}
-
-
+
+ {!isLoading && (
+
+ )}
+
+
+
+ )}
+
+
+
{(pagination || !manualPagination) &&
(!hidePaginationWhenSinglePage ||
@@ -445,6 +467,28 @@ export function DataTable
({
);
}
+/**
+ * An indeterminate sweep pinned to the table's top edge while a request is out.
+ *
+ * Deliberately not a spinner in place of the rows: a search refetch keeps the
+ * previous page on screen, so replacing it would collapse the table's height on
+ * every keystroke. This states that what is on screen is about to be replaced
+ * without moving any of it.
+ */
+function TableLoadingBar() {
+ return (
+
+ );
+}
+
function getColumnClassName(columnId: string) {
if (columnId === SELECT_COLUMN_ID) {
return "!p-0 text-center [&>[role=checkbox]]:translate-y-0";
diff --git a/platform/frontend/src/lib/hooks/use-is-app-loading.test.tsx b/platform/frontend/src/lib/hooks/use-is-app-loading.test.tsx
new file mode 100644
index 00000000000..37afac2322c
--- /dev/null
+++ b/platform/frontend/src/lib/hooks/use-is-app-loading.test.tsx
@@ -0,0 +1,95 @@
+import {
+ QueryClient,
+ QueryClientProvider,
+ useQuery,
+} from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it } from "vitest";
+import { useIsAppLoading, useReportSearchInFlight } from "./use-is-app-loading";
+
+/**
+ * The sidebar toggle's spinner is the app's only "I am loading" indicator, and
+ * a search now reports its own wait twice over — a lit search box above a table
+ * drawing a progress bar. These pin the rule that keeps the two apart: the
+ * spinner still covers boot and page transitions, and sits out searches.
+ */
+describe("useIsAppLoading", () => {
+ // Built per test, but stable across renders: a wrapper that constructs its
+ // client inline hands every re-render a fresh cache, so nothing is ever
+ // observed mid-fetch.
+ function makeWrapper() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return (
+ {children}
+ );
+ };
+ }
+
+ /** A query that never settles, standing in for a page still fetching. */
+ function usePendingPageLoad(enabled: boolean) {
+ useQuery({
+ queryKey: ["page-data"],
+ queryFn: () => new Promise(() => {}),
+ enabled,
+ });
+ }
+
+ it("reports loading while a page has nothing to show yet", async () => {
+ const { result } = renderHook(
+ () => {
+ usePendingPageLoad(true);
+ return useIsAppLoading();
+ },
+ { wrapper: makeWrapper() },
+ );
+
+ await waitFor(() => expect(result.current).toBe(true));
+ });
+
+ it("stays quiet while a search is waiting, then covers the app again", async () => {
+ const { result, rerender } = renderHook(
+ ({ searching }: { searching: boolean }) => {
+ usePendingPageLoad(true);
+ useReportSearchInFlight(searching);
+ return useIsAppLoading();
+ },
+ { wrapper: makeWrapper(), initialProps: { searching: true } },
+ );
+
+ // The fetch is genuinely out — it is the third indicator that is unwanted,
+ // not the loading state itself.
+ await waitFor(() => expect(result.current).toBe(false));
+
+ rerender({ searching: false });
+
+ // And the suppression lifts, rather than sticking for the session.
+ await waitFor(() => expect(result.current).toBe(true));
+ });
+
+ it("waits for every search box, not just the first one to finish", async () => {
+ const { result, rerender } = renderHook(
+ ({ first, second }: { first: boolean; second: boolean }) => {
+ usePendingPageLoad(true);
+ useReportSearchInFlight(first);
+ useReportSearchInFlight(second);
+ return useIsAppLoading();
+ },
+ { wrapper: makeWrapper(), initialProps: { first: true, second: true } },
+ );
+
+ await waitFor(() => expect(result.current).toBe(false));
+
+ // One of two overlapping searches finishing must not speak for the other.
+ await act(async () => {
+ rerender({ first: false, second: true });
+ });
+ expect(result.current).toBe(false);
+
+ rerender({ first: false, second: false });
+ await waitFor(() => expect(result.current).toBe(true));
+ });
+});
diff --git a/platform/frontend/src/lib/hooks/use-is-app-loading.ts b/platform/frontend/src/lib/hooks/use-is-app-loading.ts
index f7ef71f5241..f9a052564cd 100644
--- a/platform/frontend/src/lib/hooks/use-is-app-loading.ts
+++ b/platform/frontend/src/lib/hooks/use-is-app-loading.ts
@@ -1,6 +1,7 @@
"use client";
import { useIsFetching } from "@tanstack/react-query";
+import { useEffect, useSyncExternalStore } from "react";
/**
* Whether anything on screen is still waiting for its first bytes.
@@ -11,16 +12,95 @@ import { useIsFetching } from "@tanstack/react-query";
* and notifications continuously.
*
* This is the single signal behind the sidebar toggle's spinner, which is the
- * one place the app reports that it is loading. Pages deliberately do not draw
- * their own: a loader that appears mid-page moves the eye, and stacking
- * several of them at different heights is what made boot feel jumpy.
+ * one place the app reports that it *itself* is loading — booting, or moving
+ * between pages. A search box reporting its own wait is the documented
+ * exception; see {@link useReportSearchInFlight}.
*/
export function useIsAppLoading(): boolean {
- return (
+ const searchesInFlight = useSyncExternalStore(
+ searchActivity.subscribe,
+ searchActivity.getSnapshot,
+ searchActivity.getServerSnapshot,
+ );
+
+ const isFetching =
useIsFetching({
predicate: (query) =>
query.state.data === undefined &&
query.state.fetchStatus === "fetching",
- }) > 0
- );
+ }) > 0;
+
+ // A search already accounts for its own wait twice over — the box is lit and
+ // the table it filters is drawing a progress bar across its top edge. Adding
+ // the sidebar's spinner to that puts a third indicator on screen for one
+ // wait, in the corner furthest from where the user is looking.
+ //
+ // Note this cannot be inferred from the query alone. Changing the search term
+ // changes the query key, so the new cache entry has `data === undefined` and
+ // matches the predicate above even on the lists that keep their previous page
+ // on screen via `placeholderData` — the previous page lives on the observer,
+ // not in the entry being fetched. The search box is the only thing that knows
+ // this wait is a search.
+ return isFetching && searchesInFlight === 0;
}
+
+/**
+ * Report that a search box is waiting, for as long as it is.
+ *
+ * Called by `SearchInput` with the state driving its own spinner, so the two
+ * indicators cannot disagree about whether a search is in flight.
+ */
+export function useReportSearchInFlight(isInFlight: boolean): void {
+ useEffect(() => {
+ if (!isInFlight) return;
+ return searchActivity.begin();
+ }, [isInFlight]);
+}
+
+/**
+ * How many search boxes are waiting right now.
+ *
+ * A counter rather than a boolean because a page can hold more than one search
+ * box, and two overlapping waits must not have the first to finish speak for
+ * the second.
+ */
+class SearchActivity {
+ private count = 0;
+ private listeners = new Set<() => void>();
+
+ begin = (): (() => void) => {
+ this.count += 1;
+ this.emit();
+
+ let hasEnded = false;
+ return () => {
+ // Effect cleanups can run more than once under StrictMode's
+ // mount/unmount rehearsal; double-decrementing would strand the count
+ // below zero and suppress the spinner for the rest of the session.
+ if (hasEnded) return;
+ hasEnded = true;
+ this.count -= 1;
+ this.emit();
+ };
+ };
+
+ subscribe = (listener: () => void): (() => void) => {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ };
+
+ getSnapshot = (): number => this.count;
+
+ /** Nothing is in flight during SSR, and the count must not vary per render. */
+ getServerSnapshot = (): number => 0;
+
+ private emit(): void {
+ for (const listener of this.listeners) {
+ listener();
+ }
+ }
+}
+
+const searchActivity = new SearchActivity();