diff --git a/.changeset/bright-dragons-introspect-once.md b/.changeset/bright-dragons-introspect-once.md new file mode 100644 index 00000000..efa97620 --- /dev/null +++ b/.changeset/bright-dragons-introspect-once.md @@ -0,0 +1,7 @@ +--- +"@prisma/studio-core": minor +--- + +# Fix duplicate startup introspection requests + +Avoid cancelling and repeating introspection requests when Studio initially mounts. diff --git a/Architecture/introspection.md b/Architecture/introspection.md index 36decc02..e7504294 100644 --- a/Architecture/introspection.md +++ b/Architecture/introspection.md @@ -87,6 +87,7 @@ Failure diagnostics MUST include: Changes to this subsystem MUST include tests for: +- a single initial introspection without mount-time cancellation or refetch - failed initial introspection without automatic retry - stale-data preservation after a failed refetch - startup recovery UI rendering diff --git a/FEATURES.md b/FEATURES.md index b617da84..2acaa70e 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -9,6 +9,7 @@ Each adapter handles introspection, querying, inserts, updates, and deletes whil Studio introspects connected databases to build schemas, tables, columns, relationships, filter operators, and timezone metadata. This gives users an accurate live model of the database and keeps table navigation grounded in current structure. +A fresh Studio mount performs this discovery once, while actual adapter or database-availability changes invalidate cached metadata and load it again. ## Deployable Prisma Postgres Demo diff --git a/ui/studio/context.test.tsx b/ui/studio/context.test.tsx index 90049f48..8f4ec91c 100644 --- a/ui/studio/context.test.tsx +++ b/ui/studio/context.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { act } from "react"; import { createRoot } from "react-dom/client"; @@ -50,10 +51,20 @@ function createAdapter(): Adapter { } as unknown as Adapter; } -function renderHarness(props?: { streamsUrl?: string }) { +type RenderHarnessProps = { + adapter?: Adapter; + hasDatabase?: boolean; + streamsUrl?: string; +}; + +function renderHarness(props?: RenderHarnessProps) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); + let currentProps = { + ...props, + adapter: props?.adapter ?? createAdapter(), + }; let latestStudio: ReturnType | undefined; @@ -62,15 +73,20 @@ function renderHarness(props?: { streamsUrl?: string }) { return null; } - act(() => { + function render() { root.render( , ); + } + + act(() => { + render(); }); return { @@ -83,6 +99,16 @@ function renderHarness(props?: { streamsUrl?: string }) { getLatestStudio() { return latestStudio; }, + rerender(nextProps: RenderHarnessProps) { + currentProps = { + ...currentProps, + ...nextProps, + }; + + act(() => { + render(); + }); + }, }; } @@ -170,6 +196,33 @@ afterEach(() => { .VERSION_INJECTED_AT_BUILD_TIME; }); +describe("StudioContextProvider database cache lifecycle", () => { + it("resets cached queries only after the database configuration changes", () => { + const resetQueriesSpy = vi + .spyOn(QueryClient.prototype, "resetQueries") + .mockResolvedValue(); + const initialAdapter = createAdapter(); + const harness = renderHarness({ adapter: initialAdapter }); + + try { + expect(resetQueriesSpy).not.toHaveBeenCalled(); + + const nextAdapter = createAdapter(); + harness.rerender({ adapter: nextAdapter }); + expect(resetQueriesSpy).toHaveBeenCalledTimes(1); + + harness.rerender({ adapter: nextAdapter }); + expect(resetQueriesSpy).toHaveBeenCalledTimes(1); + + harness.rerender({ adapter: nextAdapter, hasDatabase: false }); + expect(resetQueriesSpy).toHaveBeenCalledTimes(2); + } finally { + harness.cleanup(); + resetQueriesSpy.mockRestore(); + } + }); +}); + describe("StudioContextProvider pagination preferences", () => { it("persists shared page-size and infinite-scroll preferences across remounts", () => { const firstHarness = renderHarness(); diff --git a/ui/studio/context.tsx b/ui/studio/context.tsx index a688e24d..f074dd0b 100644 --- a/ui/studio/context.tsx +++ b/ui/studio/context.tsx @@ -317,6 +317,7 @@ export function StudioContextProvider(props: StudioContextProviderProps) { } = props; const queryClientRef = useRef(new QueryClient()); + const previousDatabaseConfigRef = useRef({ adapter, hasDatabase }); const signatureRef = useRef(shortUUID.generate()); const rowsCollectionCacheRef = useRef(new Map()); const tableQueryExecutionStateCacheRef = useRef( @@ -526,7 +527,17 @@ export function StudioContextProvider(props: StudioContextProviderProps) { }, [studioUiCollection]); useEffect(() => { - // if the adapter has been changed, then we need to reload + const previousDatabaseConfig = previousDatabaseConfigRef.current; + previousDatabaseConfigRef.current = { adapter, hasDatabase }; + + if ( + previousDatabaseConfig.adapter === adapter && + previousDatabaseConfig.hasDatabase === hasDatabase + ) { + return; + } + + // If the database configuration changed, then we need to reload. for (const state of tableQueryExecutionStateCacheRef.current.values()) { state.activeController?.abort(); }