diff --git a/.api-reports/api-report-testing_experimental.md b/.api-reports/api-report-testing_experimental.md index cb1c775d5bf..caeebb6e726 100644 --- a/.api-reports/api-report-testing_experimental.md +++ b/.api-reports/api-report-testing_experimental.md @@ -12,8 +12,12 @@ import type { GraphQLSchema } from 'graphql'; // @alpha export const createSchemaFetch: (schema: GraphQLSchema, mockFetchOpts?: { - validate: boolean; -}) => ((uri: any, options: any) => Promise) & { + validate?: boolean; + delay?: { + min: number; + max: number; + }; +}) => ((uri?: any, options?: any) => Promise) & { mockGlobal: () => { restore: () => void; } & Disposable; diff --git a/.changeset/strong-paws-kneel.md b/.changeset/strong-paws-kneel.md new file mode 100644 index 00000000000..85262ce36f4 --- /dev/null +++ b/.changeset/strong-paws-kneel.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": minor +--- + +Add ability to set min and max delay in `createSchemaFetch` diff --git a/config/jest.config.js b/config/jest.config.js index 4832d1355c7..646185e63da 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -33,7 +33,7 @@ const react17TestFileIgnoreList = [ ignoreTSFiles, // We only support Suspense with React 18, so don't test suspense hooks with // React 17 - "src/testing/core/__tests__/createTestSchema.test.tsx", + "src/testing/experimental/__tests__/createTestSchema.test.tsx", "src/react/hooks/__tests__/useSuspenseQuery.test.tsx", "src/react/hooks/__tests__/useBackgroundQuery.test.tsx", "src/react/hooks/__tests__/useLoadableQuery.test.tsx", diff --git a/src/testing/experimental/__tests__/createTestSchema.test.tsx b/src/testing/experimental/__tests__/createTestSchema.test.tsx index 6d0163e0962..3d3eab1597b 100644 --- a/src/testing/experimental/__tests__/createTestSchema.test.tsx +++ b/src/testing/experimental/__tests__/createTestSchema.test.tsx @@ -24,6 +24,7 @@ import { FallbackProps, ErrorBoundary as ReactErrorBoundary, } from "react-error-boundary"; +import { InvariantError } from "ts-invariant"; const typeDefs = /* GraphQL */ ` type User { @@ -396,7 +397,7 @@ describe("schema proxy", () => { return
Hello
; }; - const { unmount } = renderWithClient(, { + renderWithClient(, { client, wrapper: Profiler, }); @@ -422,8 +423,6 @@ describe("schema proxy", () => { }, }); } - - unmount(); }); it("allows you to call .fork without providing resolvers", async () => { @@ -491,7 +490,7 @@ describe("schema proxy", () => { return
Hello
; }; - const { unmount } = renderWithClient(, { + renderWithClient(, { client, wrapper: Profiler, }); @@ -520,8 +519,6 @@ describe("schema proxy", () => { }, }); } - - unmount(); }); it("handles mutations", async () => { @@ -615,7 +612,7 @@ describe("schema proxy", () => { const user = userEvent.setup(); - const { unmount } = renderWithClient(, { + renderWithClient(, { client, wrapper: Profiler, }); @@ -666,8 +663,6 @@ describe("schema proxy", () => { }, }); } - - unmount(); }); it("returns GraphQL errors", async () => { @@ -743,7 +738,7 @@ describe("schema proxy", () => { return
Hello
; }; - const { unmount } = renderWithClient(, { + renderWithClient(, { client, wrapper: Profiler, }); @@ -760,8 +755,6 @@ describe("schema proxy", () => { }) ); } - - unmount(); }); it("validates schema by default and returns validation errors", async () => { @@ -823,7 +816,7 @@ describe("schema proxy", () => { return
Hello
; }; - const { unmount } = renderWithClient(, { + renderWithClient(, { client, wrapper: Profiler, }); @@ -842,8 +835,6 @@ describe("schema proxy", () => { }) ); } - - unmount(); }); it("preserves resolvers from previous calls to .add on subsequent calls to .fork", async () => { @@ -983,7 +974,7 @@ describe("schema proxy", () => { const user = userEvent.setup(); - const { unmount } = renderWithClient(, { + renderWithClient(, { client, wrapper: Profiler, }); @@ -1033,7 +1024,109 @@ describe("schema proxy", () => { }, }); } + }); - unmount(); + it("createSchemaFetch respects min and max delay", async () => { + const Profiler = createDefaultProfiler(); + + const minDelay = 1500; + const maxDelay = 2000; + + using _fetch = createSchemaFetch(schema, { + delay: { min: minDelay, max: maxDelay }, + }).mockGlobal(); + + const client = new ApolloClient({ + cache: new InMemoryCache(), + uri, + }); + + const query: TypedDocumentNode = gql` + query { + viewer { + id + name + age + book { + id + title + publishedAt + } + } + } + `; + + const Fallback = () => { + useTrackRenders(); + return
Loading...
; + }; + + const App = () => { + return ( + }> + + + ); + }; + + const Child = () => { + const result = useSuspenseQuery(query); + + useTrackRenders(); + + Profiler.mergeSnapshot({ + result, + } as Partial<{}>); + + return
Hello
; + }; + + renderWithClient(, { + client, + wrapper: Profiler, + }); + + // initial suspended render + await Profiler.takeRender(); + + await expect(Profiler).not.toRerender({ timeout: minDelay - 100 }); + + { + const { snapshot } = await Profiler.takeRender({ + // This timeout doesn't start until after our `minDelay - 100` + // timeout above, so we don't have to wait the full `maxDelay` + // here. + // Instead we can just wait for the difference between `maxDelay` + // and `minDelay`, plus a bit to prevent flakiness. + timeout: maxDelay - minDelay + 110, + }); + + expect(snapshot.result?.data).toEqual({ + viewer: { + __typename: "User", + age: 42, + id: "1", + name: "Jane Doe", + book: { + __typename: "TextBook", + id: "1", + publishedAt: "2024-01-01", + title: "The Book", + }, + }, + }); + } + }); + + it("should call invariant.error if min delay is greater than max delay", async () => { + await expect(async () => { + createSchemaFetch(schema, { + delay: { min: 3000, max: 1000 }, + }); + }).rejects.toThrow( + new InvariantError( + "Please configure a minimum delay that is less than the maximum delay. The default minimum delay is 3ms." + ) + ); }); }); diff --git a/src/testing/experimental/createSchemaFetch.ts b/src/testing/experimental/createSchemaFetch.ts index fd6347033e2..5c03ea0da0d 100644 --- a/src/testing/experimental/createSchemaFetch.ts +++ b/src/testing/experimental/createSchemaFetch.ts @@ -2,6 +2,7 @@ import { execute, validate } from "graphql"; import type { GraphQLError, GraphQLSchema } from "graphql"; import { ApolloError, gql } from "../../core/index.js"; import { withCleanup } from "../internal/index.js"; +import { wait } from "../core/wait.js"; /** * A function that accepts a static `schema` and a `mockFetchOpts` object and @@ -32,47 +33,59 @@ import { withCleanup } from "../internal/index.js"; */ const createSchemaFetch = ( schema: GraphQLSchema, - mockFetchOpts: { validate: boolean } = { validate: true } + mockFetchOpts: { + validate?: boolean; + delay?: { min: number; max: number }; + } = { validate: true } ) => { const prevFetch = window.fetch; + const delayMin = mockFetchOpts.delay?.min ?? 3; + const delayMax = mockFetchOpts.delay?.max ?? delayMin + 2; - const mockFetch: (uri: any, options: any) => Promise = ( + if (delayMin > delayMax) { + throw new Error( + "Please configure a minimum delay that is less than the maximum delay. The default minimum delay is 3ms." + ); + } + + const mockFetch: (uri?: any, options?: any) => Promise = async ( _uri, options ) => { - return new Promise(async (resolve) => { - const body = JSON.parse(options.body); - const document = gql(body.query); + if (delayMin > 0) { + const randomDelay = Math.random() * (delayMax - delayMin) + delayMin; + await wait(randomDelay); + } - if (mockFetchOpts.validate) { - let validationErrors: readonly Error[] = []; + const body = JSON.parse(options.body); + const document = gql(body.query); - try { - validationErrors = validate(schema, document); - } catch (e) { - validationErrors = [ - new ApolloError({ graphQLErrors: [e as GraphQLError] }), - ]; - } + if (mockFetchOpts.validate) { + let validationErrors: readonly Error[] = []; - if (validationErrors?.length > 0) { - return resolve( - new Response(JSON.stringify({ errors: validationErrors })) - ); - } + try { + validationErrors = validate(schema, document); + } catch (e) { + validationErrors = [ + new ApolloError({ graphQLErrors: [e as GraphQLError] }), + ]; } - const result = await execute({ - schema, - document, - variableValues: body.variables, - operationName: body.operationName, - }); - - const stringifiedResult = JSON.stringify(result); + if (validationErrors?.length > 0) { + return new Response(JSON.stringify({ errors: validationErrors })); + } + } - resolve(new Response(stringifiedResult)); + const result = await execute({ + schema, + document, + variableValues: body.variables, + operationName: body.operationName, }); + + const stringifiedResult = JSON.stringify(result); + + return new Response(stringifiedResult); }; function mockGlobal() { diff --git a/src/testing/internal/profile/profile.tsx b/src/testing/internal/profile/profile.tsx index 12e681ad7d2..b9e82619534 100644 --- a/src/testing/internal/profile/profile.tsx +++ b/src/testing/internal/profile/profile.tsx @@ -151,6 +151,9 @@ export function createProfiler({ let nextRender: Promise> | undefined; let resolveNextRender: ((render: Render) => void) | undefined; let rejectNextRender: ((error: unknown) => void) | undefined; + function resetNextRender() { + nextRender = resolveNextRender = rejectNextRender = undefined; + } const snapshotRef = { current: initialSnapshot }; const replaceSnapshot: ReplaceSnapshot = (snap) => { if (typeof snap === "function") { @@ -241,7 +244,7 @@ export function createProfiler({ }); rejectNextRender?.(error); } finally { - nextRender = resolveNextRender = rejectNextRender = undefined; + resetNextRender(); } }; @@ -340,13 +343,12 @@ export function createProfiler({ rejectNextRender = reject; }), new Promise>((_, reject) => - setTimeout( - () => - reject( - applyStackTrace(new WaitForRenderTimeoutError(), stackTrace) - ), - timeout - ) + setTimeout(() => { + reject( + applyStackTrace(new WaitForRenderTimeoutError(), stackTrace) + ); + resetNextRender(); + }, timeout) ), ]); }