Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
3 tasks done
gregbrowndev opened this issue May 12, 2024 · 1 comment
Open
3 tasks done

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

gregbrowndev opened this issue May 12, 2024 · 1 comment

Comments

@gregbrowndev
Copy link

gregbrowndev commented May 12, 2024

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

  • I can confirm that this is a bug report, and not a feature request, RFC, question, or discussion, for which GitHub Discussions should be used
  • Read the docs.
  • Follow our Code of Conduct
@JoviDeCroock
Copy link
Collaborator

Hey,

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

  • server components can't import React hooks (can use something like rehackt I guess)
  • client-components can't await
  • a client-component can't wrap a server component

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants