Skip to content

Commit

Permalink
ApolloContext: handling in React Server Components (#10872)
Browse files Browse the repository at this point in the history
* add failing tests

* ApolloProvider: ensure context value stability

fixes #7626

* adjust bundlesize

* ApolloContext: handling in React Server Components

* feedback from code review
  • Loading branch information
phryneas authored May 16, 2023
1 parent ba1d061 commit 96b4f88
Show file tree
Hide file tree
Showing 10 changed files with 59 additions and 40 deletions.
5 changes: 5 additions & 0 deletions .changeset/twelve-files-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@apollo/client': patch
---

The "per-React-Version-Singleton" ApolloContext is now stored on `globalThis`, not `React.createContext`, and throws an error message when accessed from React Server Components.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ src/react/*
!src/react/context/
src/react/context/*
!src/react/context/ApolloProvider.tsx
!src/react/context/ApolloContext.ts
!src/react/context/__tests__/
src/react/context/__tests__/*
!src/react/context/__tests__/ApolloProvider.test.tsx
Expand Down
54 changes: 40 additions & 14 deletions src/react/context/ApolloContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,59 @@ import type { ApolloClient } from '../../core';
import { canUseSymbol } from '../../utilities';
import type { SuspenseCache } from '../cache';
import type { RenderPromises } from '../ssr';
import { global, invariant } from '../../utilities/globals';

export interface ApolloContextValue {
client?: ApolloClient<object>;
renderPromises?: RenderPromises;
suspenseCache?: SuspenseCache;
}

// To make sure Apollo Client doesn't create more than one React context
// (which can lead to problems like having an Apollo Client instance added
// in one context, then attempting to retrieve it from another different
// context), a single Apollo context is created and tracked in global state.
const contextKey = canUseSymbol
declare global {
interface Window {
[contextKey]: Map<typeof React, React.Context<ApolloContextValue>>;
}
}

// To make sure that Apollo Client does not create more than one React context
// per React version, we store that Context in a global Map, keyed by the
// React version. This way, if there are multiple versions of React loaded,
// (e.g. in a Microfrontend environment), each React version will get its own
// Apollo context.
// If there are multiple versions of Apollo Client though, which can happen by
// accident, this can avoid bugs where those multiple Apollo Client versions
// would be unable to "see each other", even if an ApolloProvider was present.
const contextKey: unique symbol = canUseSymbol
? Symbol.for('__APOLLO_CONTEXT__')
: '__APOLLO_CONTEXT__';
: ('__APOLLO_CONTEXT__' as any);

export function getApolloContext(): React.Context<ApolloContextValue> {
let context = (React.createContext as any)[contextKey] as React.Context<ApolloContextValue>;
invariant(
'createContext' in React,
'Invoking `getApolloContext` in an environment where `React.createContext` is not available.\n' +
'The Apollo Client functionality you are trying to use is only available in React Client Components.\n' +
'Please make sure to add "use client" at the top of your file.\n' +
// TODO: change to React documentation once React documentation contains information about Client Components
'For more information, see https://nextjs.org/docs/getting-started/react-essentials#client-components'
);

let contextStorage = global[contextKey];
if (!contextStorage) {
contextStorage = global[contextKey] = new Map();
}

let context = contextStorage.get(React);
if (!context) {
Object.defineProperty(React.createContext, contextKey, {
value: context = React.createContext<ApolloContextValue>({}),
enumerable: false,
writable: false,
configurable: true,
});
context = React.createContext<ApolloContextValue>({});
context.displayName = 'ApolloContext';
contextStorage.set(React, context);
}
return context;
}

export { getApolloContext as resetApolloContext }
/**
* @deprecated This function has no "resetting" effect since Apollo Client 3.4.12,
* and will be removed in the next major version of Apollo Client.
* If you want to get the Apollo Context, use `getApolloContext` instead.
*/
export const resetApolloContext = getApolloContext;
2 changes: 1 addition & 1 deletion src/react/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ export { ApolloConsumer, ApolloConsumerProps } from './ApolloConsumer';
export {
ApolloContextValue,
getApolloContext,
getApolloContext as resetApolloContext
resetApolloContext
} from './ApolloContext';
export { ApolloProvider, ApolloProviderProps } from './ApolloProvider';
6 changes: 1 addition & 5 deletions src/react/hooks/__tests__/useApolloClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,11 @@ import { InvariantError } from 'ts-invariant';

import { ApolloClient } from '../../../core';
import { ApolloLink } from '../../../link/core';
import { ApolloProvider, resetApolloContext } from '../../context';
import { ApolloProvider } from '../../context';
import { InMemoryCache } from '../../../cache';
import { useApolloClient } from '../useApolloClient';

describe('useApolloClient Hook', () => {
afterEach(() => {
resetApolloContext();
});

it('should return a client instance from the context if available', () => {
const client = new ApolloClient({
cache: new InMemoryCache(),
Expand Down
5 changes: 1 addition & 4 deletions src/react/hooks/__tests__/useLazyQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
TypedDocumentNode
} from '../../../core';
import { Observable } from '../../../utilities';
import { ApolloProvider, resetApolloContext } from '../../../react';
import { ApolloProvider } from '../../../react';
import {
MockedProvider,
mockSingleLink,
Expand All @@ -26,9 +26,6 @@ import { QueryResult } from '../../types/types';
const IS_REACT_18 = React.version.startsWith("18");

describe('useLazyQuery Hook', () => {
afterEach(() => {
resetApolloContext();
});
const helloQuery: TypedDocumentNode<{
hello: string;
}> = gql`query { hello }`;
Expand Down
5 changes: 1 addition & 4 deletions src/react/hooks/__tests__/useMutation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,13 @@ import fetchMock from "fetch-mock";
import { ApolloClient, ApolloLink, ApolloQueryResult, Cache, NetworkStatus, Observable, ObservableQuery, TypedDocumentNode } from '../../../core';
import { InMemoryCache } from '../../../cache';
import { itAsync, MockedProvider, MockSubscriptionLink, mockSingleLink, subscribeAndCount } from '../../../testing';
import { ApolloProvider, resetApolloContext } from '../../context';
import { ApolloProvider } from '../../context';
import { useQuery } from '../useQuery';
import { useMutation } from '../useMutation';
import { BatchHttpLink } from '../../../link/batch-http';
import { FetchResult } from '../../../link/core';

describe('useMutation Hook', () => {
afterEach(() => {
resetApolloContext();
});
interface Todo {
id: number;
description: string;
Expand Down
5 changes: 1 addition & 4 deletions src/react/hooks/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
WatchQueryFetchPolicy,
} from '../../../core';
import { InMemoryCache } from '../../../cache';
import { ApolloProvider, resetApolloContext } from '../../context';
import { ApolloProvider } from '../../context';
import { Observable, Reference, concatPagination } from '../../../utilities';
import { ApolloLink } from '../../../link/core';
import {
Expand All @@ -28,9 +28,6 @@ import { useQuery } from '../useQuery';
import { useMutation } from '../useMutation';

describe('useQuery Hook', () => {
afterEach(() => {
resetApolloContext();
});
describe('General use', () => {
it('should handle a simple query', async () => {
const query = gql`{ hello }`;
Expand Down
6 changes: 1 addition & 5 deletions src/react/hooks/__tests__/useSubscription.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,11 @@ import {
} from '../../../core';
import { PROTOCOL_ERRORS_SYMBOL } from '../../../errors';
import { InMemoryCache as Cache } from '../../../cache';
import { ApolloProvider, resetApolloContext } from '../../context';
import { ApolloProvider } from '../../context';
import { MockSubscriptionLink } from '../../../testing';
import { useSubscription } from '../useSubscription';

describe('useSubscription Hook', () => {
afterEach(() => {
resetApolloContext();
});

it('should handle a simple subscription properly', async () => {
const subscription = gql`
subscription {
Expand Down
10 changes: 7 additions & 3 deletions src/utilities/globals/global.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { maybe } from "./maybe";

declare global {
interface Window {
__DEV__?: boolean;
}
}

export default (
maybe(() => globalThis) ||
maybe(() => window) ||
Expand All @@ -12,6 +18,4 @@ export default (
// improve your static analysis to detect this obfuscation, think again. This
// is an arms race you cannot win, at least not in JavaScript.
maybe(function() { return maybe.constructor("return this")() })
) as typeof globalThis & {
__DEV__?: boolean;
};
) as typeof globalThis & Window;

0 comments on commit 96b4f88

Please sign in to comment.