urql-graphql / urql

The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
https://urql.dev/goto/docs
MIT License
8.54k stars 444 forks source link

ExecuteExchange doesn't interact well with "server-only" code #3587

Open gregbrowndev opened 1 month ago

gregbrowndev commented 1 month ago

Describe the bug

Hi,

I'm trying to use the execute exchange in a NextJS project. I've struggled with this over the last week (and posted a Q&A here with what I thought was a solution), but now I think this is a situation not fully considered by URQL.

The problem is I want to initialise the URQL execute exchange with my executable schema in a module marked "server-only" to ensure this code is not bundled into the client bundle. Note: the "server-only" directive comes from the package yarn add server-only.

The client factory is defined in src/lib/server.ts:

import 'server-only'

import {executeExchange} from "@urql/exchange-execute";
import {type Client, type SSRExchange, type Exchange} from "urql";
import {cacheExchange, createClient, ssrExchange} from "@urql/next";

// Note: executable schema imported from @repo/server
import {schema, type Context } from "@repo/server/graphql";

export async function createURQLClientForServer(): Promise<[Client, SSRExchange]> {
    const ssr = ssrExchange({
        isClient: false,
    })
    const executeExchange = await createLocalExecutor();
    const client = createClient({
        url: "undefined",
        exchanges: [cacheExchange, ssr, executeExchange],
        suspense: true,
    });
    return [client, ssr]
}

async function createLocalExecutor(): Promise<Exchange> {
    // const { schema } = await import("@repo/server/graphql");
    return executeExchange({
        schema,
        context: () => makeContext()
    })
}

async function makeContext(): Promise<Context> {
    // Load environment variables, create DB connection/session, etc.
    return {
        // context
    };
}

Note: the createURQLClientForServer may or may not need to be async, but it was from earlier attempts to solve this issue using the commented out dynamic import which returns a Promise.

There is also a corresponding factory function to create the URQL client in the browser environment, in src/lib/client.ts:

import "client-only";

import {cacheExchange, createClient, fetchExchange, ssrExchange} from "@urql/next";
import {type Client, type SSRExchange} from "urql";

export function createURQLClient(): [Client, SSRExchange] {
    const ssr = ssrExchange({
        isClient: true,
    });
    const client = createClient({
        url: "/api/graphql",
        exchanges: [cacheExchange, ssr, fetchExchange],
        suspense: true,
    });
    return [client, ssr]
}

So the problem is how to use these then to initialise the UrqlProvider in the layout.

The fact createURQLClientForServer is marked server-only and is async leads me to think I should create a server-component to wrap the provider. So in src/contexts/graphql/provider-server.ts, I have:

import React from "react";
import {UrqlProvider} from "@urql/next";
import {createURQLClientForServer} from "@/lib/server.ts";

export async function GraphqlServerProvider({ children }: React.PropsWithChildren) {
    const [client, ssr] = await createURQLClientForServer();
    return (
        <UrqlProvider client={client} ssr={ssr}>
            {children}
        </UrqlProvider>
    )
}

Using an async server component allows createURQLClientForServer to be awaited and avoid conditional rendering, which ultimately makes SSR pointless unless you use streaming SSR on all of your pages (since the SSR would just show a loading state on all of your pages).

There's an analogous provider insrc/contexts/graphql/provider-client.ts

"use client";

import React, { useMemo } from "react";
import {UrqlProvider} from "@urql/next";
import {createURQLClient} from "@/lib/client.ts";

export function GraphqlClientProvider({ children }: React.PropsWithChildren) {
    const [client, ssr] = useMemo(createURQLClient, [])
    return (
        <UrqlProvider  client={client} ssr={ssr}>
            {children}
        </UrqlProvider>
    );
}

Then to put it all together, we need to conditionally render either the server or client provider. This is done in src/contexts/graphql/provider.ts:

import dynamic from 'next/dynamic'
import React from "react";

export const GraphqlProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
    const Provider = dynamic(() => typeof window !== "undefined"
        ? import("@/contexts/graphql/provider-client").then((mod) => mod.GraphqlClientProvider)
        : import("@/contexts/graphql/provider-server").then((mod) => mod.GraphqlServerProvider)
        );

    return (
        <Provider>
            {children}
        </Provider>
    )
}

Unfortunately, this throws a compilation error:

Error: createContext only works in Client Components. Add the "use client" directive at the top of the file to use it. Read more: https://nextjs.org/docs/messages/context-in-server-component
Call stack

``` Call Stack eval ../../node_modules/urql/dist/urql.es.js (11:9) (rsc)/../../node_modules/urql/dist/urql.es.js /Users/greg/Development/todo-app/apps/web-ui/.next/server/vendor-chunks/urql.js (30:1) Next.js eval /../../node_modules/@urql/next/dist/urql-next.mjs (rsc)/../../node_modules/@urql/next/dist/urql-next.mjs /Users/greg/Development/todo-app/apps/web-ui/.next/server/vendor-chunks/@urql.js (80:1) Next.js eval /./src/contexts/graphql/provider-server.tsx (rsc)/./src/contexts/graphql/provider-server.tsx /Users/greg/Development/todo-app/apps/web-ui/.next/server/_rsc_node_modules_whatwg-node_fetch_dist_sync_recursive-_rsc_src_contexts_graphql_provider-se-50093c.js (49:1) Next.js Function.__webpack_require__ /Users/greg/Development/todo-app/apps/web-ui/.next/server/webpack-runtime.js (33:43) ```

Note: this error goes away if you mark src/contexts/graphql/provider.ts as "use client", but then you are forced to remove the "server-only" from src/lib/server.ts. With this the application functions as expected, SSR is performed with the execute exchange allowing GraphQL queries in my pages to be resolved in-memory, while CSR works via the fetchExchange. However, I don't think this should be relied upon.

I've tried several implementations of GraphqlProvider, but they all have the same problems, e.g. using a function to get the provider rather than wrapping it with a new component:

export const getGraphqlProvider = () => {
    return dynamic(() => typeof window !== "undefined"
        ? import("@/contexts/graphql/provider-client").then((mod) => mod.GraphqlClientProvider)
        : import("@/contexts/graphql/provider-server").then((mod) => mod.GraphqlServerProvider)
    );
}

So, it seems that supporting "server-only" code is very difficult to achieve for reasons in both NextJS and the execute exchange. I'm not sure if this situation has been fully considered in the NextJS integration.

Reproduction

https://github.com/gregbrowndev/todo-app

Urql version

urql 4.0.7

Validations

JoviDeCroock commented 1 month ago

Hey,

I wouldn't say this is up to us specifically, the issue here are the limitations being quite... hard to work around.

What would circumvent your issue here is not importing from an urql package that depends on React so your server-provider becomes:

import 'server-only'

import {executeExchange} from "@urql/exchange-execute";
import {type Client, type SSRExchange, type Exchange} from "@urql/core";
import {cacheExchange, createClient, ssrExchange} from "@urql/core";

typeof window !== "undefined" is also not reliable here as this could point at both a streamed as well as an RSC rendering. As you note server-only forces you into the server-component world so if your intention is to just leverage the execute-exchange for streamed rendering that is entirely possible.