From 6ca5ef4981085caf326bcfc984757078648c709e Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 11 Jun 2024 11:17:56 +0200 Subject: [PATCH] start testing with React 19 (#11883) * start testing with React 19 --------- Co-authored-by: phryneas --- .circleci/config.yml | 16 ++++++--- config/jest.config.js | 28 ++++++++++++++- package-lock.json | 31 +++++++++++++++++ package.json | 4 ++- .../hooks/__tests__/useFragment.test.tsx | 2 +- .../hooks/__tests__/useLoadableQuery.test.tsx | 6 ++++ .../hooks/__tests__/useMutation.test.tsx | 2 +- src/react/hooks/__tests__/useQuery.test.tsx | 34 +++++++++++++++++-- .../hooks/__tests__/useReactiveVar.test.tsx | 3 +- .../hooks/__tests__/useSuspenseQuery.test.tsx | 13 ++++--- .../__tests__/useRenderGuard.test.tsx | 3 ++ src/testing/internal/profile/profile.tsx | 11 ++++-- .../__tests__/mockSubscriptionLink.test.tsx | 3 +- 13 files changed, 137 insertions(+), 19 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index fdb5be1a7ca..ff7ac58f270 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -33,16 +33,20 @@ jobs: Tests: docker: - image: cimg/node:22.2.0 + parameters: + project: + type: string steps: - checkout - run: npm run ci:precheck - run: npm version - run: npm ci + - run: if test "<< parameters.project >>" = "Core Tests"; then npm run test:memory; fi - run: name: Jest suite with coverage - command: npm run test:ci + command: npm run test:ci -- --selectProjects "<< parameters.project >>" environment: - JEST_JUNIT_OUTPUT_FILE: "reports/junit/js-test-results.xml" + JEST_JUNIT_OUTPUT_FILE: "reports/junit/js-test-results-<< parameters.project >>.xml" - store_test_results: path: reports/junit - store_artifacts: @@ -124,7 +128,11 @@ workflows: Build and Test: jobs: # - Filesize - - Tests + - Tests: + matrix: + parameters: + project: + ["Core Tests", "ReactDOM 17", "ReactDOM 18", "ReactDOM 19"] - Formatting - Lint - BuildTarball @@ -165,7 +173,7 @@ workflows: - "@types/react@16.8 @types/react-dom@16.8" - "@types/react@17 @types/react-dom@17" - "@types/react@18 @types/react-dom@18" - - "@types/react@npm:types-react@19.0.0-alpha.3 @types/react-dom@npm:types-react-dom@19.0.0-alpha.3" + - "@types/react@npm:types-react@19.0.0-rc.0 @types/react-dom@npm:types-react-dom@19.0.0-rc.0" - "typescript@next" security-scans: jobs: diff --git a/config/jest.config.js b/config/jest.config.js index 646185e63da..33e7aba59df 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -29,6 +29,16 @@ const defaults = { const ignoreTSFiles = ".ts$"; const ignoreTSXFiles = ".tsx$"; +const react19TestFileIgnoreList = [ + ignoreTSFiles, + // The HOCs and Render Prop Components have been deprecated since March 2020, + // and to test them we would need to rewrite a lot of our test suites. + // We will not support them any more for React 19. + // They will probably work, but we make no more guarantees. + "src/react/hoc/.*", + "src/react/components/.*", +]; + const react17TestFileIgnoreList = [ ignoreTSFiles, // We only support Suspense with React 18, so don't test suspense hooks with @@ -49,6 +59,17 @@ const tsStandardConfig = { // For both React (Jest) "projects", ignore core tests (.ts files) as they // do not import React, to avoid running them twice. +const standardReact19Config = { + ...defaults, + displayName: "ReactDOM 19", + testPathIgnorePatterns: react19TestFileIgnoreList, + moduleNameMapper: { + "^react$": "react-19", + "^react-dom$": "react-dom-19", + "^react-dom/(.*)$": "react-dom-19/$1", + }, +}; + const standardReact18Config = { ...defaults, displayName: "ReactDOM 18", @@ -69,5 +90,10 @@ const standardReact17Config = { }; module.exports = { - projects: [tsStandardConfig, standardReact17Config, standardReact18Config], + projects: [ + tsStandardConfig, + standardReact17Config, + standardReact18Config, + standardReact19Config, + ], }; diff --git a/package-lock.json b/package-lock.json index df5d9cf9714..9e67a4ec4fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,8 +81,10 @@ "prettier": "3.1.1", "react": "18.3.1", "react-17": "npm:react@^17", + "react-19": "npm:react@19.0.0-rc-cc1ec60d0d-20240607", "react-dom": "18.3.1", "react-dom-17": "npm:react-dom@^17", + "react-dom-19": "npm:react-dom@19.0.0-rc-cc1ec60d0d-20240607", "react-error-boundary": "4.0.13", "recast": "0.23.6", "resolve": "1.22.8", @@ -10301,6 +10303,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-19": { + "name": "react", + "version": "19.0.0-rc-cc1ec60d0d-20240607", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0-rc-cc1ec60d0d-20240607.tgz", + "integrity": "sha512-q8A0/IdJ2wdHsjDNO1igFcSSFIMqSKmO7oJZtAjxIA9g0klK45Lxt15NQJ7z7cBvgD1r3xRTtQ/MAqnmwYHs1Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -10339,6 +10351,25 @@ "object-assign": "^4.1.1" } }, + "node_modules/react-dom-19": { + "name": "react-dom", + "version": "19.0.0-rc-cc1ec60d0d-20240607", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0-rc-cc1ec60d0d-20240607.tgz", + "integrity": "sha512-paspD9kAfKKuURVwKWJ0/g3qYw1DGi9h1k9xQV2iQN9cSVZ4JAOD727yjVLyp1zdzsoygjFfLMtSBdZ+oERYvA==", + "dev": true, + "dependencies": { + "scheduler": "0.25.0-rc-cc1ec60d0d-20240607" + }, + "peerDependencies": { + "react": "19.0.0-rc-cc1ec60d0d-20240607" + } + }, + "node_modules/react-dom-19/node_modules/scheduler": { + "version": "0.25.0-rc-cc1ec60d0d-20240607", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-cc1ec60d0d-20240607.tgz", + "integrity": "sha512-yFVKy6SDJkN2bOJSeH6gNo4+1MTygTZXnLRY5IHvEB6P9+O6WYRWz9PkELLjnl64lQwRgiigwzWQRSMNEboOGQ==", + "dev": true + }, "node_modules/react-error-boundary": { "version": "4.0.13", "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.13.tgz", diff --git a/package.json b/package.json index d8620f4b238..cb6168914be 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "inline-inherit-doc": "ts-node-script config/inlineInheritDoc.ts", "test": "node --expose-gc ./node_modules/jest/bin/jest.js --config ./config/jest.config.js", "test:debug": "node --inspect-brk node_modules/.bin/jest --config ./config/jest.config.js --runInBand --testTimeout 99999 --logHeapUsage", - "test:ci": "TEST_ENV=ci npm run test:coverage -- --logHeapUsage && npm run test:memory", + "test:ci": "TEST_ENV=ci npm run test:coverage -- --logHeapUsage", "test:watch": "jest --config ./config/jest.config.js --watch", "test:memory": "npm i && npm run build && cd scripts/memory && npm i && npm test", "test:coverage": "npm run coverage -- --ci --runInBand --reporters=default --reporters=jest-junit", @@ -162,8 +162,10 @@ "prettier": "3.1.1", "react": "18.3.1", "react-17": "npm:react@^17", + "react-19": "npm:react@19.0.0-rc-cc1ec60d0d-20240607", "react-dom": "18.3.1", "react-dom-17": "npm:react-dom@^17", + "react-dom-19": "npm:react-dom@19.0.0-rc-cc1ec60d0d-20240607", "react-error-boundary": "4.0.13", "recast": "0.23.6", "resolve": "1.22.8", diff --git a/src/react/hooks/__tests__/useFragment.test.tsx b/src/react/hooks/__tests__/useFragment.test.tsx index 0af4a3a1d37..f58ef9aaa6d 100644 --- a/src/react/hooks/__tests__/useFragment.test.tsx +++ b/src/react/hooks/__tests__/useFragment.test.tsx @@ -7,7 +7,7 @@ import { within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { act } from "react-dom/test-utils"; +import { act } from "@testing-library/react"; import { UseFragmentOptions, useFragment } from "../useFragment"; import { MockedProvider } from "../../../testing"; diff --git a/src/react/hooks/__tests__/useLoadableQuery.test.tsx b/src/react/hooks/__tests__/useLoadableQuery.test.tsx index 520ec1edf98..a5e97ca52e8 100644 --- a/src/react/hooks/__tests__/useLoadableQuery.test.tsx +++ b/src/react/hooks/__tests__/useLoadableQuery.test.tsx @@ -55,6 +55,8 @@ import { useTrackRenders, } from "../../../testing/internal"; +const IS_REACT_19 = React.version.startsWith("19"); + afterEach(() => { jest.useRealTimers(); }); @@ -4594,6 +4596,8 @@ it('does not suspend deferred queries with partial data in the cache and using a }); it("throws when calling loadQuery on first render", async () => { + // We don't provide this functionality with React 19 anymore since it requires internals access + if (IS_REACT_19) return; using _consoleSpy = spyOnConsole("error"); const { query, mocks } = useSimpleQueryCase(); @@ -4613,6 +4617,8 @@ it("throws when calling loadQuery on first render", async () => { }); it("throws when calling loadQuery on subsequent render", async () => { + // We don't provide this functionality with React 19 anymore since it requires internals access + if (React.version.startsWith("19")) return; using _consoleSpy = spyOnConsole("error"); const { query, mocks } = useSimpleQueryCase(); diff --git a/src/react/hooks/__tests__/useMutation.test.tsx b/src/react/hooks/__tests__/useMutation.test.tsx index cbabe673071..452b1ad77de 100644 --- a/src/react/hooks/__tests__/useMutation.test.tsx +++ b/src/react/hooks/__tests__/useMutation.test.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from "react"; import { GraphQLError } from "graphql"; import gql from "graphql-tag"; -import { act } from "react-dom/test-utils"; +import { act } from "@testing-library/react"; import { render, waitFor, screen, renderHook } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import fetchMock from "fetch-mock"; diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 457a6fb6fed..f900f61bbda 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -1,7 +1,7 @@ import React, { Fragment, ReactNode, useEffect, useRef, useState } from "react"; import { DocumentNode, GraphQLError } from "graphql"; import gql from "graphql-tag"; -import { act } from "react-dom/test-utils"; +import { act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { render, screen, waitFor, renderHook } from "@testing-library/react"; import { @@ -35,6 +35,8 @@ import { import { useApolloClient } from "../useApolloClient"; import { useLazyQuery } from "../useLazyQuery"; +const IS_REACT_19 = React.version.startsWith("19"); + describe("useQuery Hook", () => { describe("General use", () => { it("should handle a simple query", async () => { @@ -1557,7 +1559,33 @@ describe("useQuery Hook", () => { function checkObservableQueries(expectedLinkCount: number) { const obsQueries = client.getObservableQueries("all"); - expect(obsQueries.size).toBe(2); + /* +This is due to a timing change in React 19 + +In React 18, you observe this pattern: + + 1. render + 2. useState initializer + 3. component continues to render with first state + 4. strictMode: render again + 5. strictMode: call useState initializer again + 6. component continues to render with second state + +now, in React 19 it looks like this: + + 1. render + 2. useState initializer + 3. strictMode: call useState initializer again + 4. component continues to render with one of these two states + 5. strictMode: render again + 6. component continues to render with the same state as during the first render + +Since useQuery breaks the rules of React and mutably creates an ObservableQuery on the state during render if none is present, React 18 did create two, while React 19 only creates one. + +This is pure coincidence though, and the useQuery rewrite that doesn't break the rules of hooks as much and creates the ObservableQuery as part of the state initializer will end up with behaviour closer to the old React 18 behaviour again. + +*/ + expect(obsQueries.size).toBe(IS_REACT_19 ? 1 : 2); const activeSet = new Set(); const inactiveSet = new Set(); @@ -1578,7 +1606,7 @@ describe("useQuery Hook", () => { } }); expect(activeSet.size).toBe(1); - expect(inactiveSet.size).toBe(1); + expect(inactiveSet.size).toBe(obsQueries.size - activeSet.size); } checkObservableQueries(1); diff --git a/src/react/hooks/__tests__/useReactiveVar.test.tsx b/src/react/hooks/__tests__/useReactiveVar.test.tsx index 3c5e8afdbf3..8979f9bbd32 100644 --- a/src/react/hooks/__tests__/useReactiveVar.test.tsx +++ b/src/react/hooks/__tests__/useReactiveVar.test.tsx @@ -6,6 +6,7 @@ import { makeVar } from "../../../core"; import { useReactiveVar } from "../useReactiveVar"; const IS_REACT_18 = React.version.startsWith("18"); +const IS_REACT_19 = React.version.startsWith("19"); describe("useReactiveVar Hook", () => { it("works with one component", async () => { @@ -277,7 +278,7 @@ describe("useReactiveVar Hook", () => { ); await waitFor(() => { - if (IS_REACT_18) { + if (IS_REACT_18 || IS_REACT_19) { expect(mock).toHaveBeenCalledTimes(3); expect(mock).toHaveBeenNthCalledWith(1, 0); expect(mock).toHaveBeenNthCalledWith(2, 0); diff --git a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx index 121ae749493..31855bf83a4 100644 --- a/src/react/hooks/__tests__/useSuspenseQuery.test.tsx +++ b/src/react/hooks/__tests__/useSuspenseQuery.test.tsx @@ -9591,9 +9591,14 @@ describe("useSuspenseQuery", () => { await act(() => user.type(input, "ab")); - await waitFor(() => { - expect(screen.getByTestId("result")).toHaveTextContent("ab"); - }); + await waitFor( + () => { + expect(screen.getByTestId("result")).toHaveTextContent("ab"); + }, + { + timeout: 10000, + } + ); await act(() => user.type(input, "c")); @@ -9612,7 +9617,7 @@ describe("useSuspenseQuery", () => { await waitFor(() => { expect(screen.getByTestId("result")).toHaveTextContent("abc"); }); - }); + }, 10000); it("works with startTransition to change variables", async () => { type Variables = { diff --git a/src/react/hooks/internal/__tests__/useRenderGuard.test.tsx b/src/react/hooks/internal/__tests__/useRenderGuard.test.tsx index 0bf53ed8ab5..ff27fb82a3c 100644 --- a/src/react/hooks/internal/__tests__/useRenderGuard.test.tsx +++ b/src/react/hooks/internal/__tests__/useRenderGuard.test.tsx @@ -5,8 +5,11 @@ import { render, waitFor } from "@testing-library/react"; import { withCleanup } from "../../../../testing/internal"; const UNDEF = {}; +const IS_REACT_19 = React.version.startsWith("19"); it("returns a function that returns `true` if called during render", () => { + // We don't provide this functionality with React 19 anymore since it requires internals access + if (IS_REACT_19) return; let result: boolean | typeof UNDEF = UNDEF; function TestComponent() { const calledDuringRender = useRenderGuard(); diff --git a/src/testing/internal/profile/profile.tsx b/src/testing/internal/profile/profile.tsx index b9e82619534..d1c7731ec2a 100644 --- a/src/testing/internal/profile/profile.tsx +++ b/src/testing/internal/profile/profile.tsx @@ -434,13 +434,20 @@ export function profileHook( ); } -function resolveHookOwner(): React.ComponentType | undefined { +function resolveR18HookOwner(): React.ComponentType | undefined { return (React as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED ?.ReactCurrentOwner?.current?.elementType; } +function resolveR19HookOwner(): React.ComponentType | undefined { + return ( + React as any + ).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE?.A?.getOwner() + .elementType; +} + export function useTrackRenders({ name }: { name?: string } = {}) { - const component = name || resolveHookOwner(); + const component = name || resolveR18HookOwner() || resolveR19HookOwner(); if (!component) { throw new Error( diff --git a/src/testing/react/__tests__/mockSubscriptionLink.test.tsx b/src/testing/react/__tests__/mockSubscriptionLink.test.tsx index 0515e45cb0a..8b26aea0dd3 100644 --- a/src/testing/react/__tests__/mockSubscriptionLink.test.tsx +++ b/src/testing/react/__tests__/mockSubscriptionLink.test.tsx @@ -9,6 +9,7 @@ import { ApolloProvider } from "../../../react/context"; import { useSubscription } from "../../../react/hooks"; const IS_REACT_18 = React.version.startsWith("18"); +const IS_REACT_19 = React.version.startsWith("19"); describe("mockSubscriptionLink", () => { it("should work with multiple subscribers to the same mock websocket", async () => { @@ -64,7 +65,7 @@ describe("mockSubscriptionLink", () => { ); - const numRenders = IS_REACT_18 ? 2 : results.length + 1; + const numRenders = IS_REACT_18 || IS_REACT_19 ? 2 : results.length + 1; // automatic batching in React 18 means we only see 2 renders vs. 5 in v17 await waitFor(