diff --git a/.changeset/brown-bikes-divide.md b/.changeset/brown-bikes-divide.md new file mode 100644 index 00000000000..8701c21eae9 --- /dev/null +++ b/.changeset/brown-bikes-divide.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +switch `useRenderGuard` to an approach not accessing React's internals diff --git a/.size-limits.json b/.size-limits.json index 4176d307cbb..5c28672b1f7 100644 --- a/.size-limits.json +++ b/.size-limits.json @@ -1,4 +1,4 @@ { - "dist/apollo-client.min.cjs": 39620, + "dist/apollo-client.min.cjs": 39561, "import { ApolloClient, InMemoryCache, HttpLink } from \"dist/index.js\" (production)": 32821 } diff --git a/src/react/hooks/internal/__tests__/useRenderGuard.test.tsx b/src/react/hooks/internal/__tests__/useRenderGuard.test.tsx index ff27fb82a3c..0f60cb58892 100644 --- a/src/react/hooks/internal/__tests__/useRenderGuard.test.tsx +++ b/src/react/hooks/internal/__tests__/useRenderGuard.test.tsx @@ -2,7 +2,6 @@ import React, { useEffect } from "rehackt"; import { useRenderGuard } from "../useRenderGuard"; import { render, waitFor } from "@testing-library/react"; -import { withCleanup } from "../../../../testing/internal"; const UNDEF = {}; const IS_REACT_19 = React.version.startsWith("19"); @@ -35,45 +34,3 @@ it("returns a function that returns `false` if called after render", async () => }); expect(result).toBe(false); }); - -function breakReactInternalsTemporarily() { - const R = React as unknown as { - __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: any; - }; - const orig = R.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED; - - R.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = {}; - return withCleanup({}, () => { - R.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = orig; - }); -} - -it("results in false negatives if React internals change", () => { - let result: boolean | typeof UNDEF = UNDEF; - function TestComponent() { - using _ = breakReactInternalsTemporarily(); - const calledDuringRender = useRenderGuard(); - result = calledDuringRender(); - return <>Test; - } - render(); - expect(result).toBe(false); -}); - -it("does not result in false positives if React internals change", async () => { - let result: boolean | typeof UNDEF = UNDEF; - function TestComponent() { - using _ = breakReactInternalsTemporarily(); - const calledDuringRender = useRenderGuard(); - useEffect(() => { - using _ = breakReactInternalsTemporarily(); - result = calledDuringRender(); - }); - return <>Test; - } - render(); - await waitFor(() => { - expect(result).not.toBe(UNDEF); - }); - expect(result).toBe(false); -}); diff --git a/src/react/hooks/internal/useRenderGuard.ts b/src/react/hooks/internal/useRenderGuard.ts index 2d5a798fc3c..ba101f109b7 100644 --- a/src/react/hooks/internal/useRenderGuard.ts +++ b/src/react/hooks/internal/useRenderGuard.ts @@ -1,23 +1,45 @@ import * as React from "rehackt"; -function getRenderDispatcher() { - return (React as any).__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED - ?.ReactCurrentDispatcher?.current; -} - -let RenderDispatcher: unknown = null; +let Ctx: React.Context; -/* -Relay does this too, so we hope this is safe. -https://github.com/facebook/relay/blob/8651fbca19adbfbb79af7a3bc40834d105fd7747/packages/react-relay/relay-hooks/loadQuery.js#L90-L98 -*/ +function noop() {} export function useRenderGuard() { - // eslint-disable-next-line react-compiler/react-compiler - RenderDispatcher = getRenderDispatcher(); + if (!Ctx) { + // we want the intialization to be lazy because `createContext` would error on import in a RSC + Ctx = React.createContext(null); + } + + return React.useCallback( + /** + * @returns true if the hook was called during render + */ () => { + const orig = console.error; + try { + console.error = noop; - return React.useCallback(() => { - return ( - RenderDispatcher != null && RenderDispatcher === getRenderDispatcher() - ); - }, []); + /** + * `useContext` can be called conditionally during render, so this is safe. + * (Also, during render we would want to throw as a reaction to this anyways, so it + * wouldn't even matter if we got the order of hooks mixed up...) + * + * They cannot however be called outside of Render, and that's what we're testing here. + * + * Different versions of React have different behaviour on an invalid hook call: + * + * React 16.8 - 17: throws an error + * https://github.com/facebook/react/blob/2b93d686e359c7afa299e2ec5cf63160a32a1155/packages/react/src/ReactHooks.js#L18-L26 + * + * React 18 & 19: `console.error` in development, then `resolveDispatcher` returns `null` and a member access on `null` throws. + * https://github.com/facebook/react/blob/58e8304483ebfadd02a295339b5e9a989ac98c6e/packages/react/src/ReactHooks.js#L28-L35 + */ + React["useContext" /* hide this from the linter */](Ctx); + return true; + } catch (e) { + return false; + } finally { + console.error = orig; + } + }, + [] + ); }