From 529f539e7f7026019b3ebd7ac8af9bde93514bcf Mon Sep 17 00:00:00 2001 From: Gremlin Date: Tue, 22 Sep 2026 15:33:19 +0000 Subject: [PATCH] fix: immediate feedback and visible errors on table refresh --- .../table-refresh-immediate-feedback.md | 5 + Architecture/db-state.md | 5 + FEATURES.md | 5 + ui/hooks/use-active-table-query.ts | 7 + .../use-active-table-rows-collection.test.tsx | 104 ++++++-- ui/hooks/use-introspection.ts | 10 +- ui/hooks/use-table-reload.test.tsx | 251 ++++++++++++++++++ ui/hooks/use-table-reload.ts | 81 ++++++ ui/hooks/utils/get-query-preview.ts | 16 ++ ui/studio/context.test.tsx | 69 ++++- ui/studio/context.tsx | 1 + ui/studio/views/table/ActiveTableView.tsx | 35 ++- 12 files changed, 556 insertions(+), 33 deletions(-) create mode 100644 .changeset/table-refresh-immediate-feedback.md create mode 100644 ui/hooks/use-table-reload.test.tsx create mode 100644 ui/hooks/use-table-reload.ts create mode 100644 ui/hooks/utils/get-query-preview.ts diff --git a/.changeset/table-refresh-immediate-feedback.md b/.changeset/table-refresh-immediate-feedback.md new file mode 100644 index 00000000..beae84d7 --- /dev/null +++ b/.changeset/table-refresh-immediate-feedback.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": patch +--- + +Fix the "Refresh table" button giving no immediate feedback. The button now spins and disables for the whole refresh (schema re-introspection plus the row refetch), and a failed refresh keeps the last successfully loaded rows visible with an inline error notice (message, source, failed SQL preview, and retry) instead of only a generic message-less toast. Operation-error toasts now also include the underlying error message as their description. diff --git a/Architecture/db-state.md b/Architecture/db-state.md index d147a67b..fcf07641 100644 --- a/Architecture/db-state.md +++ b/Architecture/db-state.md @@ -21,6 +21,7 @@ Database state architecture is implemented by these modules: - [`ui/hooks/use-active-table-rows-collection.ts`](../ui/hooks/use-active-table-rows-collection.ts) - [`ui/hooks/use-active-table-query.ts`](../ui/hooks/use-active-table-query.ts) +- [`ui/hooks/use-table-reload.ts`](../ui/hooks/use-table-reload.ts) - [`ui/hooks/use-active-table-update.ts`](../ui/hooks/use-active-table-update.ts) - [`ui/hooks/use-active-table-delete.ts`](../ui/hooks/use-active-table-delete.ts) - [`ui/studio/context.tsx`](../ui/studio/context.tsx) @@ -120,6 +121,10 @@ Manual refetch is done with: `isFetching` for UI MUST come from collection/query state (`collection.utils.isFetching` or live-query loading fallback), not local booleans. +The busy state for the "Refresh table" action is derived the same way, never stored locally: the view binds the refresh button to `isFetching || isIntrospectionRefetching`, because a table refresh re-introspects the schema before refetching rows. + +`collection.utils.refetch({ throwOnError: true })` rejections are caught by the view (through `useTableReload`) and shown inline as a refresh-failure notice; the rows collection keeps the last successfully loaded rows, so a failed refresh never clears the grid. + ### Metadata cache `filteredRowCount` MUST be stored in `tableQueryMetaCollection` keyed by `queryScopeKey`. diff --git a/FEATURES.md b/FEATURES.md index 7d19dd32..aad96b6b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -53,6 +53,11 @@ Studio keeps the last successful schema snapshot visible when a refresh fails, d Studio re-introspects the database schema when the user returns to the window, and a dedicated "Refresh schema" toolbar button (next to "Refresh table") triggers an explicit refresh with a loading state and tooltip. All refresh paths share one React Query key and a single `refreshIntrospection` helper, so the toolbar button, the write-error self-heal path, and window-focus refetch all invalidate the same cached introspection and refetch the active observer. +## Refresh Table Feedback and Errors + +Clicking "Refresh table" gives immediate feedback: the button spins and stays disabled for the entire refresh, covering both the schema re-introspection and the row refetch it performs, so the UI always reacts the moment the action starts. +When a refresh fails, the last successfully loaded rows stay visible in the grid and an inline notice above it explains what failed — the adapter error message, its source, and a preview of the failed SQL — with a Retry action. + ## Self-Healing Editor on Type-Mismatch Write Errors When an insert or update fails with a PostgreSQL type-mismatch error (SQLSTATE `42804` datatype_mismatch or `22P02` invalid_text_representation), Studio invalidates cached introspection and refetches so the cell editor re-renders with the correct column type. diff --git a/ui/hooks/use-active-table-query.ts b/ui/hooks/use-active-table-query.ts index 60a05479..0989ee38 100644 --- a/ui/hooks/use-active-table-query.ts +++ b/ui/hooks/use-active-table-query.ts @@ -27,6 +27,12 @@ export interface ActiveTableQueryData { export interface UseActiveTableQueryResult { data: ActiveTableQueryData | undefined; isFetching: boolean; + /** + * Identity of the query scope the rows were loaded from. Consumers that + * need to reset per-scope state (for example the table reload error) can + * use it as a reset key. + */ + queryScopeKey: string; refetch: () => Promise; } @@ -72,6 +78,7 @@ export function useActiveTableQuery( } : undefined, isFetching: state.isFetching, + queryScopeKey: state.queryScopeKey, refetch: state.refetch, }; } diff --git a/ui/hooks/use-active-table-rows-collection.test.tsx b/ui/hooks/use-active-table-rows-collection.test.tsx index 8d0b2a20..65b2367d 100644 --- a/ui/hooks/use-active-table-rows-collection.test.tsx +++ b/ui/hooks/use-active-table-rows-collection.test.tsx @@ -7,15 +7,16 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { - Adapter, - AdapterIntrospectResult, - AdapterQueryDetails, - AdapterUpdateManyDetails, - Column, - FilterGroup, - SortOrderItem, - Table, +import { + type Adapter, + AdapterError, + type AdapterIntrospectResult, + type AdapterQueryDetails, + type AdapterUpdateManyDetails, + type Column, + type FilterGroup, + type SortOrderItem, + type Table, } from "../../data/adapter"; import type { TableQueryMetaState } from "../studio/context"; import { useActiveTableRowsCollection } from "./use-active-table-rows-collection"; @@ -78,14 +79,15 @@ function createActiveTable(): Table { function createAdapterMock(options?: { queryImplementation?: (details: AdapterQueryDetails) => Promise< - [ - null, - { - filteredRowCount: number; - query: { parameters: unknown[]; sql: string }; - rows: Record[]; - }, - ] + | [AdapterError] + | [ + null, + { + filteredRowCount: number; + query: { parameters: unknown[]; sql: string }; + rows: Record[]; + }, + ] >; }): Adapter { const introspection: AdapterIntrospectResult = { @@ -628,6 +630,74 @@ describe("useActiveTableRowsCollection", () => { cleanup(); }); + it("keeps the last rows and clears isFetching when refetch rejects with an AdapterError", async () => { + let queryCalls = 0; + const queryError = new AdapterError("connection refused"); + queryError.adapterSource = "postgresql"; + queryError.query = { parameters: [], sql: "select * from users" }; + + const queryImplementation: QueryImplementation = () => { + queryCalls += 1; + + if (queryCalls === 1) { + return Promise.resolve([ + null, + { + filteredRowCount: 2, + query: { parameters: [], sql: "query-initial" }, + rows: [ + { id: "u1", name: "Alice" }, + { id: "u2", name: "Bob" }, + ], + }, + ]); + } + + return Promise.resolve([queryError]); + }; + + const harness = renderHookHarness({ + queryImplementation, + }); + + await waitFor(() => (harness.getLatestState()?.rows.length ?? 0) === 2); + expect(harness.getLatestState()?.isFetching).toBe(false); + + let caught: unknown; + + await act(async () => { + try { + await harness.getLatestState()?.refetch(); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBe(queryError); + + // The failed refetch keeps the last successfully loaded rows in the + // collection instead of clearing the grid. + await waitFor(() => harness.getLatestState()?.isFetching === false); + expect(harness.getLatestState()?.rows.map((row) => row.id)).toEqual([ + "u1", + "u2", + ]); + expect( + harness.onEvent.mock.calls.some((call: unknown[]) => { + const event = call[0] as { + name: string; + payload: { operation: string }; + }; + return ( + event.name === "studio_operation_error" && + event.payload.operation === "query" + ); + }), + ).toBe(true); + + harness.cleanup(); + }); + it("preserves filtered row count while a different page is loading", async () => { let queryCalls = 0; let releaseSecondPage: (() => void) | undefined; diff --git a/ui/hooks/use-introspection.ts b/ui/hooks/use-introspection.ts index 7ed9df3d..8fdf99d4 100644 --- a/ui/hooks/use-introspection.ts +++ b/ui/hooks/use-introspection.ts @@ -8,6 +8,7 @@ import { INTROSPECTION_QUERY_KEY, refreshIntrospection, } from "./refresh-introspection"; +import { getQueryPreview } from "./utils/get-query-preview"; export interface IntrospectionErrorState { adapterSource: string; @@ -33,15 +34,6 @@ function createInitialIntrospectionResult( } satisfies AdapterIntrospectResult; } -function getQueryPreview(query: Query | undefined): string | null { - if (!query?.sql) { - return null; - } - - const preview = query.sql.slice(0, 120); - return query.sql.length > 120 ? `${preview}...` : preview; -} - export function useIntrospection() { const { adapter, hasDatabase, onEvent, queryClient } = useStudio(); const hasEmittedLaunchEventRef = useRef(false); diff --git a/ui/hooks/use-table-reload.test.tsx b/ui/hooks/use-table-reload.test.tsx new file mode 100644 index 00000000..51ba1ba9 --- /dev/null +++ b/ui/hooks/use-table-reload.test.tsx @@ -0,0 +1,251 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AdapterError } from "../../data/adapter"; +import { AbortError } from "../../data/executor"; +import { useTableReload } from "./use-table-reload"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +interface Deferred { + promise: Promise; + reject: (reason?: unknown) => void; + resolve: (value: T) => void; +} + +function createDeferred(): Deferred { + let reject!: (reason?: unknown) => void; + let resolve!: (value: T) => void; + + const promise = new Promise((promiseResolve, promiseReject) => { + reject = promiseReject; + resolve = promiseResolve; + }); + + return { promise, reject, resolve }; +} + +function createAdapterReloadError(message: string): AdapterError { + const error = new AdapterError(message); + error.adapterSource = "postgresql"; + error.query = { parameters: [], sql: "select * from users" }; + + return error; +} + +function renderHarness(args?: { + refetchActiveTable?: () => Promise; + refetchIntrospection?: () => Promise; + resetKey?: string; +}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const refetchActiveTable = + args?.refetchActiveTable ?? vi.fn(() => Promise.resolve()); + const refetchIntrospection = + args?.refetchIntrospection ?? vi.fn(() => Promise.resolve()); + let currentResetKey = args?.resetKey ?? "scope-initial"; + + let latestResult: ReturnType | undefined; + + function Harness() { + latestResult = useTableReload({ + refetchActiveTable, + refetchIntrospection, + resetKey: currentResetKey, + }); + + return null; + } + + act(() => { + root.render(); + }); + + return { + cleanup() { + act(() => { + root.unmount(); + }); + + container.remove(); + }, + getLatestResult() { + return latestResult; + }, + refetchActiveTable, + refetchIntrospection, + rerender(nextArgs: { resetKey: string }) { + currentResetKey = nextArgs.resetKey; + + act(() => { + root.render(); + }); + }, + }; +} + +afterEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +describe("useTableReload", () => { + it("awaits the introspection refetch before refetching the active table", async () => { + const introspectionDeferred = createDeferred(); + const refetchActiveTable = vi.fn(() => Promise.resolve()); + const harness = renderHarness({ + refetchActiveTable, + refetchIntrospection: () => introspectionDeferred.promise, + }); + + let reloadPromise: Promise | undefined; + + await act(async () => { + reloadPromise = harness.getLatestResult()?.reload(); + await Promise.resolve(); + }); + + expect(refetchActiveTable).not.toHaveBeenCalled(); + + await act(async () => { + introspectionDeferred.resolve(); + await reloadPromise; + }); + + expect(refetchActiveTable).toHaveBeenCalledTimes(1); + expect(harness.getLatestResult()?.reloadError).toBeNull(); + + harness.cleanup(); + }); + + it("captures an active-table refetch rejection as reloadError", async () => { + const reloadError = createAdapterReloadError("connection reset"); + const refetchActiveTable = vi.fn(() => Promise.reject(reloadError)); + const harness = renderHarness({ refetchActiveTable }); + + await act(async () => { + await harness.getLatestResult()?.reload(); + }); + + const captured = harness.getLatestResult()?.reloadError; + + expect(captured).toBe(reloadError); + expect(captured?.message).toBe("connection reset"); + expect(captured?.adapterSource).toBe("postgresql"); + expect(captured?.query).toEqual({ + parameters: [], + sql: "select * from users", + }); + + harness.cleanup(); + }); + + it("captures an introspection refetch rejection without refetching the active table", async () => { + const reloadError = createAdapterReloadError("introspection failed"); + const refetchActiveTable = vi.fn(() => Promise.resolve()); + const refetchIntrospection = vi.fn(() => Promise.reject(reloadError)); + const harness = renderHarness({ + refetchActiveTable, + refetchIntrospection, + }); + + await act(async () => { + await harness.getLatestResult()?.reload(); + }); + + expect(refetchActiveTable).not.toHaveBeenCalled(); + expect(harness.getLatestResult()?.reloadError).toBe(reloadError); + + harness.cleanup(); + }); + + it("ignores AbortError rejections from superseded queries", async () => { + const refetchIntrospection = vi.fn<() => Promise>(() => + Promise.reject(new AbortError()), + ); + const refetchActiveTable = vi.fn<() => Promise>(() => + Promise.reject(new AbortError()), + ); + const harness = renderHarness({ + refetchActiveTable, + refetchIntrospection, + }); + + await act(async () => { + await harness.getLatestResult()?.reload(); + }); + + // The introspection AbortError must also short-circuit the rows refetch. + expect(refetchActiveTable).not.toHaveBeenCalled(); + expect(harness.getLatestResult()?.reloadError).toBeNull(); + + refetchIntrospection.mockImplementation(() => Promise.resolve()); + + await act(async () => { + await harness.getLatestResult()?.reload(); + }); + + expect(refetchActiveTable).toHaveBeenCalledTimes(1); + expect(harness.getLatestResult()?.reloadError).toBeNull(); + + harness.cleanup(); + }); + + it("clears reloadError when the resetKey changes", async () => { + const refetchActiveTable = vi.fn(() => + Promise.reject(createAdapterReloadError("connection reset")), + ); + const harness = renderHarness({ + refetchActiveTable, + resetKey: "scope-a", + }); + + await act(async () => { + await harness.getLatestResult()?.reload(); + }); + + expect(harness.getLatestResult()?.reloadError?.message).toBe( + "connection reset", + ); + + harness.rerender({ resetKey: "scope-b" }); + + expect(harness.getLatestResult()?.reloadError).toBeNull(); + + harness.cleanup(); + }); + + it("clears reloadError on a subsequent successful reload", async () => { + let shouldFail = true; + const refetchActiveTable = vi.fn(() => + shouldFail + ? Promise.reject(createAdapterReloadError("connection reset")) + : Promise.resolve(), + ); + const harness = renderHarness({ refetchActiveTable }); + + await act(async () => { + await harness.getLatestResult()?.reload(); + }); + + expect(harness.getLatestResult()?.reloadError?.message).toBe( + "connection reset", + ); + + shouldFail = false; + + await act(async () => { + await harness.getLatestResult()?.reload(); + }); + + expect(harness.getLatestResult()?.reloadError).toBeNull(); + + harness.cleanup(); + }); +}); diff --git a/ui/hooks/use-table-reload.ts b/ui/hooks/use-table-reload.ts new file mode 100644 index 00000000..073b7136 --- /dev/null +++ b/ui/hooks/use-table-reload.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useState } from "react"; + +import { AdapterError } from "../../data/adapter"; + +export interface UseTableReloadArgs { + /** + * Refetches the active table's rows collection. Rejections (the collection + * refetches with `throwOnError: true`) are captured as `reloadError`. + */ + refetchActiveTable: () => Promise; + + /** + * Re-introspects the database schema. Runs before the rows refetch so a + * refresh picks up schema changes before reloading rows. + */ + refetchIntrospection: () => Promise; + + /** + * Identity of the query scope the reload applies to. When it changes (the + * user pages, sorts, filters, or switches tables), any stale reload error + * is cleared. + */ + resetKey: string | undefined; +} + +export interface UseTableReloadResult { + reload: () => Promise; + reloadError: AdapterError | null; +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + +function resolveReloadError(error: unknown): AdapterError { + if (error instanceof AdapterError) { + return error; + } + + if (error instanceof Error) { + return new AdapterError(error.message); + } + + return new AdapterError("Table refresh failed"); +} + +/** + * Orchestrates the "Refresh table" action: schema introspection first, then + * the active table's rows. Rejections are captured so a failed refresh + * surfaces as `reloadError` (rendered inline by the view) instead of being + * dropped by `void reload()` call sites. `AbortError` rejections are expected + * churn from superseded queries and are ignored. + * + * This hook keeps no local busy boolean: the busy state for a table refresh + * already exists as `isFetching || isIntrospectionRefetching` in the view (see + * `Architecture/db-state.md`). + */ +export function useTableReload(args: UseTableReloadArgs): UseTableReloadResult { + const { refetchActiveTable, refetchIntrospection, resetKey } = args; + const [reloadError, setReloadError] = useState(null); + + const reload = useCallback(async () => { + try { + await refetchIntrospection(); + await refetchActiveTable(); + setReloadError(null); + } catch (error) { + if (isAbortError(error)) { + return; + } + + setReloadError(resolveReloadError(error)); + } + }, [refetchActiveTable, refetchIntrospection]); + + useEffect(() => { + setReloadError(null); + }, [resetKey]); + + return { reload, reloadError }; +} diff --git a/ui/hooks/utils/get-query-preview.ts b/ui/hooks/utils/get-query-preview.ts new file mode 100644 index 00000000..c04141da --- /dev/null +++ b/ui/hooks/utils/get-query-preview.ts @@ -0,0 +1,16 @@ +import type { Query } from "@/data/query"; + +/** + * Build a short, human-readable preview of a failed query's SQL for error + * notices. Returns `null` when the error carries no SQL to preview. + */ +export function getQueryPreview( + query: Query | undefined, +): string | null { + if (!query?.sql) { + return null; + } + + const preview = query.sql.slice(0, 120); + return query.sql.length > 120 ? `${preview}...` : preview; +} diff --git a/ui/studio/context.test.tsx b/ui/studio/context.test.tsx index 5b1746db..b4c07815 100644 --- a/ui/studio/context.test.tsx +++ b/ui/studio/context.test.tsx @@ -6,9 +6,23 @@ import { process } from "std-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { check } from "../../checkpoint"; -import type { Adapter } from "../../data/adapter"; +import { type Adapter, AdapterError } from "../../data/adapter"; import { StudioContextProvider, useStudio } from "./context"; +const { toastErrorMock } = vi.hoisted(() => ({ + toastErrorMock: vi.fn(), +})); + +// The operation-error path under test reports through sonner toasts. Mock the +// module (including the `Toaster` component rendered by the provider) so the +// toast arguments can be asserted directly. +vi.mock("sonner", () => ({ + Toaster: () => null, + toast: { + error: toastErrorMock, + }, +})); + vi.mock("../hooks/use-navigation", () => ({ NavigationContextProvider: ({ children }: { children: ReactNode }) => ( <>{children} @@ -496,3 +510,56 @@ describe("StudioContextProvider telemetry opt-out", () => { harness.cleanup(); }); }); + +describe("StudioContextProvider operation error toasts", () => { + it("surfaces the operation error message as the toast description", () => { + const harness = renderHarness(); + + const adapterError = new AdapterError("relation does not exist"); + adapterError.adapterSource = "postgresql"; + adapterError.query = { parameters: [], sql: "select 1" }; + + act(() => { + harness.getLatestStudio()?.onEvent({ + name: "studio_operation_error", + payload: { + operation: "query", + query: adapterError.query, + error: adapterError, + }, + }); + }); + + expect(toastErrorMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).toHaveBeenCalledWith( + '"query" operation failed', + expect.objectContaining({ + description: "relation does not exist", + }), + ); + + harness.cleanup(); + }); + + it("does not toast for aborted operations", () => { + const harness = renderHarness(); + + const abortError = new AdapterError("The operation was aborted"); + abortError.name = "AbortError"; + + act(() => { + harness.getLatestStudio()?.onEvent({ + name: "studio_operation_error", + payload: { + operation: "query", + query: undefined, + error: abortError, + }, + }); + }); + + expect(toastErrorMock).not.toHaveBeenCalled(); + + harness.cleanup(); + }); +}); diff --git a/ui/studio/context.tsx b/ui/studio/context.tsx index 2e1a9363..97c61b2c 100644 --- a/ui/studio/context.tsx +++ b/ui/studio/context.tsx @@ -680,6 +680,7 @@ export function StudioContextProvider(props: StudioContextProviderProps) { window.location.hash = createUrl({ viewParam: "console" }); }, }, + description: payload.error.message, }); } diff --git a/ui/studio/views/table/ActiveTableView.tsx b/ui/studio/views/table/ActiveTableView.tsx index ecfad2de..35552d79 100644 --- a/ui/studio/views/table/ActiveTableView.tsx +++ b/ui/studio/views/table/ActiveTableView.tsx @@ -58,8 +58,10 @@ import { usePagination } from "../../../hooks/use-pagination"; import { useSelection } from "../../../hooks/use-selection"; import { useSorting } from "../../../hooks/use-sorting"; import { useStreams } from "../../../hooks/use-streams"; +import { useTableReload } from "../../../hooks/use-table-reload"; import { useTableUiState } from "../../../hooks/use-table-ui-state"; import { useUiState } from "../../../hooks/use-ui-state"; +import { getQueryPreview } from "../../../hooks/utils/get-query-preview"; import { randomUUID } from "../../../lib/random-uuid"; import { cn } from "../../../lib/utils"; import { @@ -99,6 +101,7 @@ import { type CellEditNavigationDirection, getInput, } from "../../input/get-input"; +import { IntrospectionStatusNotice } from "../../IntrospectionStatusNotice"; import { TABLE_GRID_FOCUS_REQUEST_UI_STATE_KEY, type TableGridFocusRequestUiState, @@ -249,6 +252,7 @@ export function ActiveTableView(_props: ViewProps) { const { data, isFetching, + queryScopeKey, refetch: refetchActiveTable, } = useActiveTableQuery(activeTableQueryProps); const [stableInfiniteData, setStableInfiniteData] = useState<{ @@ -887,10 +891,14 @@ export function ActiveTableView(_props: ViewProps) { }); } - const reload = useCallback(async () => { - await refetchIntrospection(); - await refetchActiveTable(); - }, [refetchActiveTable, refetchIntrospection]); + const { reload, reloadError } = useTableReload({ + refetchActiveTable, + refetchIntrospection, + resetKey: queryScopeKey, + }); + // A table refresh re-introspects the schema before refetching rows, so the + // busy state must cover both awaits — not just the rows collection fetch. + const isRefreshingTable = isFetching || isIntrospectionRefetching; const newStagedRow = useCallback(() => { // TODO: the new object should have some things set to null if they're nullable or have a default value? @@ -1632,11 +1640,11 @@ export function ActiveTableView(_props: ViewProps) { variant="outline" size="icon" onClick={() => void reload()} - disabled={isFetching} + disabled={isRefreshingTable} > @@ -1789,6 +1797,21 @@ export function ActiveTableView(_props: ViewProps) { sqlFilterLint={sqlFilterLint} table={activeTable} /> + {reloadError ? ( + void reload()} + queryPreview={getQueryPreview(reloadError.query)} + source={ + reloadError.adapterSource ?? adapter.defaultSchema ?? "unknown" + } + title="Table refresh failed" + /> + ) : null}