apollo-client-nextjs icon indicating copy to clipboard operation
apollo-client-nextjs copied to clipboard

How do i re-create the client with <ApolloNextAppProvider />?

Open zfogg opened this issue 1 year ago • 50 comments

I'm using <ApolloNextProvider /> and I to update the makeClient function, but when i update the function, the component doesn't make a new client :( how can i do that? i have dependencies that are retrieved during runtime (an auth token) and i want to recreate the client when i get the token on login, but since it already rendered the token remains null. does that make sense?

zfogg avatar Sep 29 '23 01:09 zfogg

i actually solve this here: https://github.com/zfogg/apollo-client-nextjs/blob/main/package/src/ssr/ApolloNextAppProvider.tsx (here's a diff)

i added a prop called "clientIndex" which is a string. if you change the value of the string, the apollo client will be recreated because makeClient() gets called again when that happens

this is useful logic that other people probably need! should i make a pull request? should it be done differently?

zfogg avatar Sep 29 '23 03:09 zfogg

I'm very sorry to say this, but I'd prefer you didn't recreate the client at all. In a normal, long-running application, this is something you should never need to do (and doing so has all kinds of drawbacks).

What is the reason you want to recreate the client in the first place?

phryneas avatar Sep 29 '23 09:09 phryneas

in my app, makeClient runs before an auth token that the makeClient needs to authenticate with my API is available. during my login flow, i receive this token, and then i need to make sure makeClient runs with it available. but ApolloNextAppProvider is already rendered and you give no way to allow my to decide exactly when the client gets created, because you use React.useRef which ALWAYS creates it during first render, even if makeClient doesn't have what it needs yet. what if makeClient doesn't have its necessary dependencies at the time of first render? this is my situation.

my fork actually solves my problem, and makeClient gets re-run after my login auth flow. this lets me decide when makeClient runs. your component currently does not. i only need to re-create the client once (once the auth token comes in) so this works for me

zfogg avatar Sep 29 '23 15:09 zfogg

basically, i want control over when the client is created because the client might depend on something that i get from a component that is rendered inside this wrapper. this is my situation

zfogg avatar Sep 29 '23 16:09 zfogg

In that case I would recommend that you use e.g. a ref (or another non-global value that can be modified) to hold that token and move the creation of your Link into a scope where you can access that ref. That way, you can later modify the ref whenever your token changes, without having to recreate the ApolloClient or Link instance itself.

phryneas avatar Sep 29 '23 16:09 phryneas

Could be something like


function makeClientWithRef(ref) {
  return function makeClient() {
    // if you access `ref.current` from your `Link` here it will always be up to date with your React component.
  }
}

function MyWrapper(props){
  const ref = useRef()
  const authToken = useAuthToken()
  ref.current = authToken

  return <ApolloNextAppProvider makeClient={() => makeClientWithRef(ref)}>{props.children}</ApolloNextAppProvider>

}

phryneas avatar Sep 29 '23 16:09 phryneas

i'll look into this! thanks

zfogg avatar Sep 29 '23 16:09 zfogg

will close the issue if it works

zfogg avatar Sep 29 '23 16:09 zfogg

i'm a little skeptical this will work because i use the token inside of setContext though. that still won't run again if my ref changes, will it?

zfogg avatar Sep 29 '23 16:09 zfogg

If you access the ref inside of setContext, it will give you the value it has at that point in time - whenever a request is made.

But of course, changing the ref won't re-run all your requests - which usually also is not really desirable - if a user token times out and refreshes, you don't want to throw away the full cache and rerun every query after all. It will only affect future queries.

phryneas avatar Oct 02 '23 17:10 phryneas

but the value of my ref is concatenated into a string when makeClient is run with my setContext call. so unless makeClient is run again, the value of the string that my ref was concatenated into won't change

zfogg avatar Oct 03 '23 12:10 zfogg

so i need makeClient to run again

zfogg avatar Oct 03 '23 12:10 zfogg

image

see? if i use a ref.current value here, even if i update the ref the value, the string with my Bearer ${token} won't change because it will be saved in memory after the first time makeClient runs. I need makeClient to run again so this string will be concatenated again with the auth token after login. using a ref won't help me here, unless i'm mistaken

zfogg avatar Oct 03 '23 12:10 zfogg

If authLink is set up correctly, setContext should be run on every request.

Can you do something like this (sorry, it's a rough estimate, I'm not in front of my workstation right now) and append it to your clients link collection.

  const authLink = new ApolloLink((operation, forward) => {
      operation.setContext(({ headers }) => ({
          headers: {
              authorization: `Bearer ${ref.current}`, // ref from your wrapper
              ...headers
          }
      }));
      return forward(operation);
  });

 const makeClient = () => (
        new NextSSRApolloClient({      
            link: ApolloLink.from([retryLink, authLink, logLink]), // whatever steps you have in your link chain
            cache: new NextSSRInMemoryCache()
        })
    );

mvandergrift avatar Oct 03 '23 13:10 mvandergrift

Exactly that. The setContext callback function will run for every request, and can set different headers for every request. So once ref.current updates, every future request will have the new token.

phryneas avatar Oct 03 '23 13:10 phryneas

I managed to do so and make authenticated requests on both client and server components with the following:

// graphql.ts (exports the server method)
import { HttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { registerApolloClient } from "@apollo/experimental-nextjs-app-support/rsc";
import {
  NextSSRApolloClient,
  NextSSRInMemoryCache,
} from "@apollo/experimental-nextjs-app-support/ssr";

import { ACCESS_TOKEN_COOKIE_NAME } from "constants/cookies";
import { getCookie } from "utils/cookies";

// Get API URL
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;

// Create an HTTP link with the GraphQL API endpoint
const httpLink = new HttpLink({
  uri: `${apiBaseUrl}/graphql`,
  // Disable result caching
  // fetchOptions: { cache: "no-store" },
});

// Create an authentication link
const authLink = setContext(async () => {
  // Get access token stored in cookie
  const token = await getCookie(ACCESS_TOKEN_COOKIE_NAME);

  // If the token is not defined, return an empty object
  if (!token?.value) return {};

  // Return authorization headers with the token as a Bearer token
  return {
    headers: {
      authorization: `Bearer ${token.value}`,
    },
  };
});

/**
 * Apollo Client
 *
 * @see https://www.apollographql.com/blog/apollo-client/next-js/how-to-use-apollo-client-with-next-js-13/
 */
// eslint-disable-next-line import/prefer-default-export
export const { getClient } = registerApolloClient(
  () =>
    new NextSSRApolloClient({
      cache: new NextSSRInMemoryCache(),
      link: authLink.concat(httpLink),
    }),
);


And here is the client wrapper

"use client";

import { ApolloLink, HttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import {
  ApolloNextAppProvider,
  NextSSRApolloClient,
  NextSSRInMemoryCache,
  SSRMultipartLink,
} from "@apollo/experimental-nextjs-app-support/ssr";

import { ACCESS_TOKEN_COOKIE_NAME } from "constants/cookies";
import { getCookie } from "utils/cookies";

// Get API URL
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;

// Create an HTTP link with the GraphQL API endpoint
const httpLink = new HttpLink({
  uri: `${apiBaseUrl}/graphql`,
});

// Create an authentication link
const authLink = setContext(async () => {
  // Get access token stored in cookie
  const token = await getCookie(ACCESS_TOKEN_COOKIE_NAME);

  // If the token is not defined, return an empty object
  if (!token?.value) return {};

  // Return authorization headers with the token as a Bearer token
  return {
    headers: {
      authorization: `Bearer ${token.value}`,
    },
  };
});

/**
 * Create an Apollo Client instance with the specified configuration.
 */
function makeClient() {
  return new NextSSRApolloClient({
    cache: new NextSSRInMemoryCache(),
    link:
      typeof window === "undefined"
        ? ApolloLink.from([
            new SSRMultipartLink({
              stripDefer: true,
            }),
            authLink.concat(httpLink),
          ])
        : authLink.concat(httpLink),
  });
}

/**
 * Apollo Provider
 *
 * @see https://www.apollographql.com/blog/apollo-client/next-js/how-to-use-apollo-client-with-next-js-13/
 */
export default function ApolloProvider({ children }: React.PropsWithChildren) {
  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      {children}
    </ApolloNextAppProvider>
  );
}

These two work just fine, all my subsequent requests are authenticated through the bearer token. I'm still working on a cleaner version though, I'd like to extract the token getter logic so that I don't have to retrieve the token every time.

Sashkan avatar Oct 31 '23 16:10 Sashkan

@Sashkan could you share that getCookie function? Generally, the problem is that in a client component that is rendering on the server you don't have any access to cookies per default, so I wonder how you worked around that.

phryneas avatar Oct 31 '23 17:10 phryneas

Hello there!

I'm also quite stuck with the same kind of problem.

I'm on the latest version of each packages:

  • "@apollo/client": "^3.8.6",
  • "@apollo/experimental-nextjs-app-support": "^0.5.0",
  • "next": "^14.0.0",

I have 2 things to solve:

  • Updating the Accept-Language header on language change
  • Updating the bearer authorization token on sign-in

Currently, I have this implementation, and I'm not able to update these headers values without reload the entire app.

"use client";

import { setContext } from "@apollo/client/link/context";
import { ApolloLink } from "@apollo/client/link/core";
import { onError } from "@apollo/client/link/error";
import {
  ApolloNextAppProvider,
  NextSSRApolloClient,
  NextSSRInMemoryCache,
  SSRMultipartLink,
} from "@apollo/experimental-nextjs-app-support/ssr";
import { createUploadLink } from "apollo-upload-client";
import { useParams } from "next/navigation";
import { MutableRefObject, useRef } from "react";

import { i18n, Locale } from "@/app/_libs/i18n/config";
import { useAuth } from "@/app/_providers/AuthContext";

function createClient(
  localeRef: MutableRefObject<string>,
  accessTokenRef: MutableRefObject<string | null>,
  logout: ({ returnTo }: { returnTo?: boolean }) => void,
) {
  const authLink = setContext(async (_, { headers }) => {
    console.log("authLink", localeRef.current);

    return {
      headers: {
        ...headers,
        "Accept-Language": localeRef.current ?? i18n.defaultLocale,
        ...(accessTokenRef.current
          ? { authorization: `Bearer ${accessTokenRef.current}` }
          : {}),
      },
    };
  });

  const errorLink = onError(({ graphQLErrors, networkError }) => {
    if (graphQLErrors) {
      graphQLErrors.forEach(({ message, locations, path, extensions }) => {
        console.error("[GraphQL error]", {
          Message: message,
          Location: locations,
          Path: path,
          Code: extensions?.code,
          Status: extensions?.status,
        });

        if (extensions?.status === "unauthorized") {
          logout({ returnTo: true });
        }
      });
    }
    if (networkError) console.error(`[Network error]: ${networkError}`);
  });

  const uploadLink = createUploadLink({
    uri: `${process.env.NEXT_PUBLIC_BASE_URL_API}/graphql`,
    fetchOptions: { cache: "no-store" },
  });

  const linkArray = [authLink, errorLink, uploadLink] as ApolloLink[];

  return new NextSSRApolloClient({
    cache: new NextSSRInMemoryCache({
      typePolicies: {
        TeamPlaybook: {
          keyFields: ["id", "teamId"],
        },
        TeamChapter: {
          keyFields: ["id", "teamId"],
        },
        TeamTheme: {
          keyFields: ["id", "teamId"],
        },
        TeamPractice: {
          keyFields: ["id", "teamId"],
        },
      },
    }),
    link:
      typeof window === "undefined"
        ? ApolloLink.from([
            new SSRMultipartLink({
              stripDefer: true,
            }),
            ...linkArray,
          ])
        : ApolloLink.from(linkArray),
    connectToDevTools: true,
    defaultOptions: {
      query: {
        errorPolicy: "all",
      },
      mutate: {
        errorPolicy: "all",
      },
    },
  });
}

export function ApolloWrapper({ children }: { children: React.ReactNode }) {
  const params = useParams();
  const lang = params?.lang as Locale;

  const localeRef = useRef<string>(lang || i18n.defaultLocale);
  localeRef.current = lang || i18n.defaultLocale;

  const accessTokenRef = useRef<string | null>(null);
  const { accessToken } = useAuth();
  accessTokenRef.current = accessToken;

  const { logout } = useAuth();

  console.log("ApolloWrapper", localeRef.current);

  return (
    <ApolloNextAppProvider
      makeClient={() => createClient(localeRef, accessTokenRef, logout)}
    >
      {children}
    </ApolloNextAppProvider>
  );
}

When I update the lang param in the url, console.log("ApolloWrapper", localeRef.current); display the new language, but console.log("authLink", localeRef.current); is still giving the previous one and when navigating, all the new queries have also the old language value in the headers. I have the exact same problem with my access token.

Bonus question: Since useAuth is taking the token from a cookie, and there is no access to the cookies in a client component during the server rendering, I get 401 unauthorized response for all my queries that need authentication. To bypass that I check for each query if the accessToken exist, and if the hook is used in a layout, I must store the data in a state with an effect to avoid an hydration error, it's really painful:

export function useTeam(teamId?: string) {
  const { accessToken } = useAuth();

  const { data } = useSuspenseQuery<{ team: Team }, { teamId: string }>(
    GET_TEAM,
    accessToken && teamId
      ? { variables: { teamId }, fetchPolicy: "cache-and-network" }
      : skipToken,
  );

  const team = data?.team;

  return {
    team,
  };
}
export default function NavigationTeamPopover() {
  const params = useParams();
  const teamId = params?.teamId as string;

  const [teamInfo, setTeamInfo] = useState<Team | null>(null);

  const { team } = useTeam(teamId);

  useEffect(() => {
    setTeamInfo(team || null);
  }, [team]);

  return (
    <CustomPopover
      label={
        <>
          <CustomAvatar
            type="team"
            size="xs"
            imageUrl={teamInfo?.imageUrl}
            label={teamInfo?.name}
          />
          {teamInfo?.name ? (
            <div
              className="w-full truncate font-medium"
              data-testid="navigation-team-popover-label"
            >
              {teamInfo.name}
            </div>
          ) : (
            <TextLoadingState className="w-full bg-gray-700" />
          )}
          <ExpandIcon className="h-3.5 w-3.5 flex-none" />
        </>
      }
      buttonClassName="min-h-[2.25rem]"
      placement="bottom-start"
      theme="dark"
      testId="navigation-team-popover"
    >
      <div className="flex max-w-[20rem] flex-col gap-2">
        <NavigationTeamSwitcher />
      </div>
    </CustomPopover>
  );
}

Am I doing something wrong? Did a better pattern exist to handle that?

giovannetti-eric avatar Nov 01 '23 17:11 giovannetti-eric

Hey there! We just shipped @apollo/client version 3.9.0-alpha.3, which contains an API that will make this a lot easier:

client.defaultContext

The idea here is that you can modify the defaultContext token of your ApolloClient instance at any time, and that will be available in your link chain.

So here you could do something like this:

// you can optionally enhance the `DefaultContext` like this to add some type safety to it.
declare module '@apollo/client' {
  export interface DefaultContext {
    token?: string
  }
}

function makeClient() {
  const authLink = setContext(async (_, { headers, token }) => {
    return {
      headers: {
        ...headers,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
    };
  });

  return new NextSSRApolloClient({
    cache: new NextSSRInMemoryCache(),
    link: authLink.concat(new HttpLink({ uri: 'https://example.com/graphl' }))
  });
}

export function ApolloWrapper({ children }: { children: React.ReactNode }) {
  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      <UpdateAuth>
        {children}
      </UpdateAuth>
    </ApolloNextAppProvider>
  );
}

function UpdateAuth({ children }: { children: React.ReactNode }) {
  const token = useAuthToken();
  const apolloClient = useApolloClient()
  // just synchronously update the `apolloClient.defaultContext` before any child component can be rendered
  // so the value is available for any query started in a child
  apolloClient.defaultContext.token = token
  return <>{children}</>;
}

This would of course also work for more things than just the token, e.g. for that language selection that @giovannetti-eric is trying to implement here.

It does not solve the problems of accessing cookies in a SSRed client component though - that's something that Next.js has to solve on their side, unfortunately.

phryneas avatar Nov 02 '23 15:11 phryneas

thanks SO much for getting into this problem after i pointed out my issue! i really appreciate the responsiveness. the new solution looks awesome but your previous solution actually works fine for me. i'm glad to know there's a more elegant way built into the app now. you can close this issue if you deem it solved by this! my issue i solved and i'm no longer using my fork 😄

zfogg avatar Nov 03 '23 15:11 zfogg

Let's leave this open for visibility for now - it seems quite a lot of people are landing here :)

phryneas avatar Nov 03 '23 17:11 phryneas

@phryneas Thanks a lot 🙏 Can I use it to update both clients ? The one returned by the useApolloClient hook and the one returned by the experimental registerApolloClient method ? Since I'm using both client and server components in my app, I want to make sure that the token is properly updated in both use cases 🤔

Sashkan avatar Nov 06 '23 12:11 Sashkan

@Sashkan you theoretically could, but in your Server Components (and also Server-rendered Client Components), you'll have a new Apollo Client instance for every request the user makes, so I wouldn't expect any token changes to happen there.

phryneas avatar Nov 06 '23 13:11 phryneas

unless you authenticate and then make another request within the same server-side code... that's possible. then they'd go from unauthed to authed and the apollo client would need to update with the token before making the second request and returning to the client

zfogg avatar Nov 07 '23 20:11 zfogg

Hey there! We just shipped @apollo/client version 3.9.0-alpha.3, which contains an API that will make this a lot easier:

client.defaultContext

The idea here is that you can modify the defaultContext token of your ApolloClient instance at any time, and that will be available in your link chain.

So here you could do something like this:

// you can optionally enhance the `DefaultContext` like this to add some type safety to it.
declare module '@apollo/client' {
  export interface DefaultContext {
    token?: string
  }
}

function makeClient() {
  const authLink = setContext(async (_, { headers, token }) => {
    return {
      headers: {
        ...headers,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
    };
  });

  return new NextSSRApolloClient({
    cache: new NextSSRInMemoryCache(),
    link: authLink.concat(new HttpLink({ uri: 'https://example.com/graphl' }))
  });
}

export function ApolloWrapper({ children }: { children: React.ReactNode }) {
  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      <UpdateAuth>
        {children}
      </UpdateAuth>
    </ApolloNextAppProvider>
  );
}

function UpdateAuth({ children }: { children: React.ReactNode }) {
  const token = useAuthToken();
  const apolloClient = useApolloClient()
  // just synchronously update the `apolloClient.defaultContext` before any child component can be rendered
  // so the value is available for any query started in a child
  apolloClient.defaultContext.token = token
  return children;
}

This would of course also work for more things than just the token, e.g. for that language selection that @giovannetti-eric is trying to implement here.

It does not solve the problems of accessing cookies in a SSRed client component though - that's something that Next.js has to solve on their side, unfortunately.

This is working wonderfully, but only for queries? I set up a simple lazy query that runs in an effect on the client, and can see our token getting passed to the query, but when we run a mutation, the token is missing.

Relevant code:

// authLink.ts
import { setContext } from '@apollo/client/link/context'

declare module '@apollo/client' {
  export interface DefaultContext {
    token?: string | null
  }
}

export const authLink = setContext(async (_graphqlRequest, context) => {
  return {
    headers: {
      ...context.headers,
      ...(context.token ? { authorization: context.token } : {}),
    },
  }
})
// ApolloContextUpdater.tsx
'use client'

import { useApolloClient } from '@apollo/client'

interface ApolloContextUpdaterProps {
  token?: string | null | undefined
}

const ApolloContextUpdater: React.FC<ApolloContextUpdaterProps> = (props) => {
  const client = useApolloClient()
  client.defaultContext.token = props.token

  return null
}

export default ApolloContextUpdater
// makeClient.ts
function makeClient() {
  const httpLink = new HttpLink({
    uri: process.env.NEXT_PUBLIC_GRAPHQL_URL,
    fetchOptions: { cache: 'no-store' },
  })

  return new NextSSRApolloClient({
    cache: new NextSSRInMemoryCache(),
    link:
      typeof window === 'undefined'
        ? ApolloLink.from([
            new SSRMultipartLink({
              stripDefer: true,
            }),
            authLink,
            httpLink,
          ])
        : ApolloLink.from([authLink, httpLink]),
  })
}

mikew avatar Nov 22 '23 17:11 mikew

@mikew thank you for the report - that will be fixed over in https://github.com/apollographql/apollo-client/pull/11385

phryneas avatar Nov 27 '23 11:11 phryneas

the mutation issue makes this alpha unusable for me. i'm using my fork still.

zfogg avatar Nov 28 '23 21:11 zfogg

Hey there! We just shipped @apollo/client version 3.9.0-alpha.3, which contains an API that will make this a lot easier:

client.defaultContext

The idea here is that you can modify the defaultContext token of your ApolloClient instance at any time, and that will be available in your link chain.

So here you could do something like this:

// you can optionally enhance the `DefaultContext` like this to add some type safety to it.
declare module '@apollo/client' {
  export interface DefaultContext {
    token?: string
  }
}

function makeClient() {
  const authLink = setContext(async (_, { headers, token }) => {
    return {
      headers: {
        ...headers,
        ...(token ? { authorization: `Bearer ${token}` } : {}),
      },
    };
  });

  return new NextSSRApolloClient({
    cache: new NextSSRInMemoryCache(),
    link: authLink.concat(new HttpLink({ uri: 'https://example.com/graphl' }))
  });
}

export function ApolloWrapper({ children }: { children: React.ReactNode }) {
  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      <UpdateAuth>
        {children}
      </UpdateAuth>
    </ApolloNextAppProvider>
  );
}

function UpdateAuth({ children }: { children: React.ReactNode }) {
  const token = useAuthToken();
  const apolloClient = useApolloClient()
  // just synchronously update the `apolloClient.defaultContext` before any child component can be rendered
  // so the value is available for any query started in a child
  apolloClient.defaultContext.token = token
  return children;
}

This would of course also work for more things than just the token, e.g. for that language selection that @giovannetti-eric is trying to implement here.

It does not solve the problems of accessing cookies in a SSRed client component though - that's something that Next.js has to solve on their side, unfortunately.

Just tried this approach but i'm getting this runtime error: TypeError: Cannot set properties of undefined (setting 'token').

My code for reference:

export const ApolloProvider = ({ children }: IApolloProvider) => {
  return (
    <ApolloNextAppProvider makeClient={makeApolloClient}>
      <UpdateAuth>{children}</UpdateAuth>
    </ApolloNextAppProvider>
  )
}

const UpdateAuth = ({ children }: { children: React.ReactNode }) => {
  const session = getSession()
  const token = session?.token
  const apolloClient = useApolloClient()
  apolloClient.defaultContext.token = token
  return children
}

This is with package versions:

  • @apollo/client v3.9.0-alpha.5
  • @apollo/experimental-nextjs-app-support v0.5.2.

wcwung avatar Dec 07 '23 11:12 wcwung

@wcwung that sounds to me like you might still have an old version of Apollo Client installed, maybe as a dependency of a depenceny. You can do npm ls @apollo/client or yarn why @apollo/client to find out which versions you have installed.

phryneas avatar Dec 07 '23 13:12 phryneas

Thanks! Was able to fix it by setting a resolution:

 "resolutions": {
    "@apollo/client": "^3.9.0-alpha.5"
  },

But I'm now I'm not running into this error: Error: async/await is not yet supported in Client Components, only Server Components. This error is often caused by accidentally adding 'use client' to a module that was originally written for the server.

I presume it's the way I'm i'm using async/await to fetch the session and the subsequent token:

export const UpdateAuth = ({ children }: { children: React.ReactNode }) => {
 const apolloClient = useApolloClient()

 const getToken = async () => {
   const session = await getServerSession(authOptions)
   return session?.token
 }

 return getToken().then((token) => {
   apolloClient.defaultContext.token = token
   return children
 })
}

Is there another suggested approach here when it comes to fetching the token before setting it?

wcwung avatar Dec 08 '23 02:12 wcwung