diff --git a/RFC.md b/RFC.md new file mode 100644 index 00000000..30fdf752 --- /dev/null +++ b/RFC.md @@ -0,0 +1,633 @@ +# The Next.js "App Router", React Server Component & "SSR with Suspense" story + +> This RFC accompanies the release of a `@apollo/experimental-nextjs-app-support` package, which is created to supplement the `@apollo/client` package and add primitives to support React Server Components and SSR in the Next.js `app` directory. +Please also see the full package README at the [repository](https://github.com/apollographql/apollo-client-nextjs). + +## Introduction: What are React Server Components? + +React Server Components (RSC) are a new technology that is currently landing in frameworks like [Next.js](https://nextjs.org/docs/advanced-features/react-18/server-components) and [Gatsby](https://www.gatsbyjs.com/docs/how-to/performance/partial-hydration/). Remix is currently experimenting with it, Dai-Shi Kato is currently building the experimental Framework [`wakuwork`](https://github.com/dai-shi/wakuwork) with it. (This one could be the best way to read into internals.) + +In this RFC, I'll be referring to RSC in the context of Next.js, as that is the most mature implementation of RSC we are seeing so far and the one likely to be adopted by users first, given the messaging around the Next.js `app` directory. + +In an RSC environment, the root component is a "server component". + +Imports from this root Server Component are treated as a Server Components too unless the imported file has a "use client" pragma. Components imported from such a file will be handled as Client Components - and all further components imported directly or indirectly from a "use client" file will also be handled as Client Components. +These pragmas are only necessary for files that designate a "boundary". Generally, files will inherit their "Serverness" or "Clientness" from the file that imports them. + +A server component can render a mix of server components and client components as children, allowing server components to pass props to client components. It's important to note that any "over the boundary" props passed from server to client components **must** be serializable. However, there is one exception to this rule. Server components can pass JSX elements as props to client components, either as `children` or other props that accept JSX elements (i.e. "named slots"). + +It is important to note that most hooks do not work in server components: `useContext`, `useState`, `useEffect` and the likes will throw. +On the other hand, server components can be `async` functions that can `await` promises. This is the preferred way to fetch data in server components. + +In contrast, client components cannot directly import and render server components, or pass props to them. The reason for this is quite simple: All Server Components are evaluated before any Client Components are evaluated. +Instead, they can use props to accept "server component JSX" from a parent server component (e.g. the `children` prop). This allows some level of "interweaving" between server and client components since server components can inject other server components as children to client components. +This makes it possible to have a client component high up in the component tree that makes use of a client-only feature, like a context provider, while allowing for multiple levels of server components before additional client components are rendered. + +> Notable exception: as presented [here by Dai-Shi](https://twitter.com/dai_shi/status/1631989295866347520), CC can start a new RSC render tree, but this practice should be left reserved for routing libraries, not for everyday usage. + +### An example interweaving client and server components + +Note that for the sake of simplicity, the example did not contain suspense boundaries, which add another layer of complication. + +Let's assume we have this tree. + +```js +// Layout.js +export default function Layout() { + return ( + + + {/* Page will be inserted here by the router */} + {children} + + + ); +} + +// Page.js + +// this is imported from a Client file, so it will be handled as a Client Component +import { Collapsible } from "./Collapsible"; + +export default function Page() { + return ( +
+ {/* a client component */} + + {/* // a server component */} + + +
+ ); +} + +// Collapsible.js +"use client"; + +export function Collapsible({ title, children }) { + const [collapsed, setCollapsed] = useState(false); + return ( +
+

{title}

+ {collapsed ? null : children} + +
+ ); +} + +// AuthorProfile.js +export async function AuthorProfile() { + const { data } = await fetch("/api/author"); + return ( +
+

{data.name}

+

{data.bio}

+
+ ); +} + +``` + +First, the React Server Component layer will render all React Server Components and leave the Client components unevaluated, returning this JSX: + +```jsx + + +
+ {/* the client component is not evaluated yet */} + +
+

Some name

+

Lorem Ipsum

+
+
+
+ + +``` + +This JSX can be handled in multiple ways: +* It could be kept around for later usage (e.g., if this was part of a build step), and one of the other options will later be used with it. +* It could be evaluated for an SSR pass on the server (e.g., on first page load) and be streamed to the browser as HTML, where the components will get hydrated. +* It could be transformed into a React-specific JSON format and sent to the browser, where it will be evaluated by the client component layer. + +It is important to note that we have a unidirectional dataflow here: (serializable) props can flow from the RSC layer to Client Components, but nothing can flow from Client Components back into RSC. (We do not yet cover Server Actions, as these were released a few days ago as of writing this.) + + + +Once hydrated, client components are fully executable and interactive, and will re-render in response to state updates. The output of server components does not change in response to state updates, but it is possible to request a re-render of RSC from the client imperatively (usually, by clicking a link or explicitly asking the RSC router to refetch the server output). To do so, the client sends a request to the server, which re-renders the RSC tree. An RSC router may also allow to re-render a part of the tree associated with a subroute. When server components re-render, their new output is merged into the existing client tree, so client state does not get lost. + +### Interweaving client and server components when using React Context + +We expect one of the most common usages of interweaving will be to use React's Context in a client component high up in the React tree. + +While Apollo Client usage in RSCs does not use Context, we still need a Context Provider for our hooks that will be used in Client components. +The most convenient way to provide that is to create a Client Component that "weaves" itself into the Server Component tree inside the Layout: +```jsx +// Apollo.js +"use client" + +export function Apollo(props) { + const clientRef = useRef() + if (!clientRef) clientRef.current = buildApolloClient() + + return ( + + {props.children} + + ); +} + +// Layout.js +export default function Layout(){ + return ( + + + + {children} // Page will be inserted here by the router + + + + ) +} +``` + +This way, `{children}` can still be a server component, but all client components further down the tree are able to access the context. + +Note that using `` in a React Server Component is impossible, as `client` is non-serializable and cannot be passed from one environment into the next. As a result, the Apollo Client must be created in a Client file. + +### The terms "client" and "server". + +We should note that the terms "Client" and "Server" are a bit of a misnomer. +* "Server components" aren't strictly rendered on a running server. While this is one case, these components can also be rendered during a build (such as static generation), in a local dev environment, or in CI. +* "Client components" are the React components we have known for years. They aren't strictly run in the browser. These are also executed in an SSR pass on the server, with a subset of the capabilities they would have in the browser: Effects are not executed and there is no ability to rerender. Because of the different environments, the `window` object is not available. + +In an effort to provide some clarity, I'll use the following terms: + +* RSC - React Server components run on the server (either on a running server, or during a build) +* SSR pass - The render phase of client components on the server before the result is sent and hydrated in the browser +* Browser rendering - The rendering of client components in the browser + +### Next.js specifics: "static" and "dynamic" renders. + +RSC within Next.js can happen in different stages: either "statically" or "dynamically". +By default, Next.js will try to render both RSC and the SSR pass statically at build time. If you use "dynamic" features like `cookies`, they will be skipped during the build and instead be rendered on the running server. + +At this point, we see either "static" or "dynamic" RSC/SSR passes, but I assume that in the future, there is a possibility to have a "static" RSC outer layout, while a child page is rendered as "dynamic" RSC. + +## Apollo Client and RSC + +The best way to use Apollo Client in RSC is to create an Apollo Client instance and call `await client.query` inside of an `async` React Server Components. + +We want to make sure that the Apollo Client does not have to be recreated for every query, but we also don't necessarily want to share it between different requests, as the client could be accessing cookies in a dynamic render, so potentially sensitive data could be shared between multiple users on accident. + +### Sharing the client between components + +As React Server Components cannot access Context, we need another way of creating this "scoped shared client" and passing it around. +For this purpose, we use the `React.cache` API, which essentially behaves like a [per-request globally-scoped `useMemo` call](https://twitter.com/dan_abramov/status/1655216626608791555). + +### Getting data from RSC into the SSR pass + +I've discussed the possibility of using the RSC cache to rehydrate the SSR cache with [@gaearon](https://github.com/gaearon) and we agree that it mostly doesn't make sense. +The base assumption was that something in the RSC cache would be valuable for the SSR pass. If this were the case, the same data could be rendered by RSC and SSR/Browser components. But while the latter could update dynamically on cache updates, the former could not update without the client manually triggering a server-rerender. + +**Instead, we should document and encourage that RSC and SSR/Browser components (i.e. client components) should not use overlapping data. If data is expected to change often during an application lifetime, it makes more sense for it to live solely in client components.** + +### Library design regarding React Server Components + +We will export a `registerApolloClient` function to be called in a global scope. This function will return a `getClient` function that can be used to get the correct client instance for the current request, or create a new one. + +In the future, this could be configurable, so the client would be created per-request in dynamic rendering, but shared between pages in static rendering. + +
Toggle example usage: + +```js +import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; +import { registerApolloClient } from "@apollo/experimental-nextjs-app-support/rsc"; + +export const { getClient } = registerApolloClient(() => { + return new ApolloClient({ + cache: new InMemoryCache(), + link: new HttpLink({ + // this needs to be an absolute url, as relative urls cannot be used in SSR + uri: "http://example.com/api/graphql", + // you can disable result caching here if you want to + // (this does not work if you are rendering your page with `export const dynamic = "force-static"`) + // fetchOptions: { cache: "no-store" }, + }), + }); +}); +``` + +You can then use that getClient function in your server components: +```jsx +const { data } = await getClient().query({ query: userQuery }); +``` +
+ +## Usage scenario: Streamed SSR & Suspense + +As mentioned Client Components in Next.js will have a render pass on the server on the first page load. +That is not really noticeable without using "fetch-in-render" suspense features, as the component render on the server was usually side-effect free. +But with the `useSuspense` hook, the component will now fire a request during render, and suspend until it has data. This does not only happen in the Browser but also on the server. +Where the SSR run was mostly inconsequential for the user (assuming they were not using other SSR-specific APIs) before, they now see different behavior, and we need additional functionality to support that. +We now need a way to transparently move data from the server to the client while the server-side Apollo Client receives query responses during the SSR pass - and to inject that data into the browser-side Apollo Client before the rehydration happens. + +With prior React versions that used synchronous rendering, the "data transport" problem has typically been solved using a "single-pass hydration" technique. In the case of [Apollo](https://www.apollographql.com/docs/react/performance/server-side-rendering), we would render the full React tree one or more times until all queries had successfully been fetched. Once fetched, we would extract all cache data then output that data with the final HTML. This would allow the browser to prime the client-side cache with the server data during hydration. + +This technique does not work with RSC. React 18 enables [Steaming SSR](https://beta.nextjs.org/docs/data-fetching/streaming-and-suspense) with Suspense, which allows parts of your app to be streamed to the browser while React works on rendering other parts of your app on the server. Content up to the next suspense boundary will be rendered, then moved over to the client and rehydrated. +Once a suspense boundary resolves, the next chunk will me moved over and rehydrated, and so on. + +In this model, we can no longer wait for all queries to finish execution before we extract data from the Apollo Cache. Instead, we need to stream data to the client as soon as it is available. + +To illustrate this, here is a diagram that represents two components with their own suspense boundaries that do not have overlapping timing - usually indicated by a waterfall situation. + +JSX for that could be: + +```jsx +// page.js + + + + +// ComponentA.js + + + +``` + + +```mermaid +sequenceDiagram + participant GQL as Graphql Server + participant SSRCache as SSR Cache + box gray SuspenseBoundary 1 + participant SSRA as SSR Component A + end + box gray Suspense Boundary 2 + participant SSRB as SSR Component B + end + participant Stream + participant BCache as Browser Cache + box gray Suspense Boundary 1 + participant BA as Browser Component A + end + box gray Suspense Boundary 2 + participant BB as Browser Component B + end + + SSRA ->> SSRA: render + activate SSRA + SSRA -) SSRCache: query + activate SSRCache + Note over SSRA: render started network request, suspend + SSRCache -) GQL: query A + GQL -) SSRCache: query A result + SSRCache -) SSRA: query A result + SSRCache -) Stream: serialized query A result + deactivate SSRCache + Stream -) BCache: add query A result to cache + SSRA ->> SSRA: render + Note over SSRA: render successful, suspense finished + SSRA -) Stream: transport + deactivate SSRA + Stream -) BA: restore DOM + BA ->> BA: rehydration render + + SSRB ->> SSRB: render + activate SSRB + SSRB -) SSRCache: query + activate SSRCache + Note over SSRB: render started network request, suspend + SSRCache -) GQL: query B + GQL -) SSRCache: query B result + SSRCache -) SSRB: query B result + SSRCache -) Stream: serialized query B result + deactivate SSRCache + Stream -) BCache: add query B result to cache + SSRB ->> SSRB: render + Note over SSRB: render successful, suspense finished + SSRB -) Stream: transport + deactivate SSRB + Stream -) BB: restore DOM + BB ->> BB: rehydration render +``` + +Here is the same diagram with overlapping (this is still a pretty optimal case!): + +The JSX for that could be: + +```jsx +// page.js + + + + + + + +``` + +```mermaid +sequenceDiagram + participant GQL as Graphql Server + participant SSRCache as SSR Cache + box gray SuspenseBoundary 1 + participant SSRA as SSR Component A + end + box gray Suspense Boundary 2 + participant SSRB as SSR Component B + end + participant Stream + participant BCache as Browser Cache + box gray Suspense Boundary 1 + participant BA as Browser Component A + end + box gray Suspense Boundary 2 + participant BB as Browser Component B + end + + SSRA ->> SSRA: render + activate SSRA + SSRA -) SSRCache: query + activate SSRCache + SSRCache -) GQL: query A + Note over SSRA: render started network request, suspend + + SSRB ->> SSRB: render + activate SSRB + SSRB -) SSRCache: query + activate SSRCache + SSRCache -) GQL: query B + Note over SSRB: render started network request, suspend + + GQL -) SSRCache: query A result + SSRCache -) SSRA: query A result + SSRCache -) Stream: serialized query A result + deactivate SSRCache + Stream -) BCache: add query A result to cache + SSRA ->> SSRA: render + Note over SSRA: render successful, suspense finished + SSRA -) Stream: transport + deactivate SSRA + Stream -) BA: restore DOM + BA ->> BA: rehydration render + + + + GQL -) SSRCache: query B result + SSRCache -) SSRB: query B result + SSRCache -) Stream: serialized query B result + deactivate SSRCache + Stream -) BCache: add query B result to cache + SSRB ->> SSRB: render + Note over SSRB: render successful, suspense finished + SSRB -) Stream: transport + deactivate SSRB + Stream -) BB: restore DOM + BB ->> BB: rehydration render +``` + +In both of these scenarios, about the only control we have during the SSR pass is when to stream data from the SSR Apollo Client instance to the browser. +Ideally we'd have the choice to either move data to the client immediately as we receive it, or as the suspense boundary resolves. +Unfortunately we can only do the latter, as React does not a mechanism of injecting data into the stream at arbitrary points in time. + +### Approaches for transferring data from the server to the client + +#### Option: Use Next.js `useServerInsertedHTML` hook (meant for CSS) + +Next.js has a [`useServerInsertedHTML`](https://beta.nextjs.org/docs/styling/css-in-js#configuring-css-in-js-in-app) hook that allows components to dump arbitrary HTML into the stream, which will then be inserted into the DOM by `getServerInsertedHTML`. That code will be dumped out right before React starts rehydrating a suspense boundary. + +This is the [mechanism we're using](https://github.com/apollographql/apollo-client-nextjs/blob/c622586533e0f2ac96b692a5106642373c3c45c6/package/src/ssr/RehydrationContext.tsx#L52), though we use the inner `ServerInsertedHTMLContext` directly as it gives us more control over how we inject data. + +#### Option: Pipe directly into the stream + +This was [@gaearon](https://github.com/gaearon)'s initial suggestion. + +If we would get access to the `ReadableStream/PipeableStream` instance that is used to transfer data to the server, we could use that to inject data into the stream directly. + +The big questions here are "how to get the stream from within React components", and "when to inject code to inject *between* React's renders" and both have no easy answer. + +There is a [relevant RFC](https://github.com/reactjs/rfcs/pull/219) for a `useStream` hook that would allow us to inject into the stream at arbitrary points in time - but it is not sure if that will ever make it into React. + +Manual approaches seem, very hacky: + +We could try to use `localAsyncStorage` to make a `injectIntoStream` function available to the React render, but that would require us to patch every framework to make that work. + +Also, once we have the stream we have the problem of identifying the "right moment" to inject things, so we don't collide with React while it streams things over. + +Prior art to that is aparently somewhere in [unstubbable/mfng](https://github.com/unstubbable/mfng), but given the option of NextJS `ServerInsertedHTMLContext`, I didn't investigate this further. In the end, we want so offer our users a solution that doesn't need them to patch their framework. + +#### Option: Wrap the `Suspense` component. + +Using this approach, we'd export a custom wrapped `` component and have users to use that one. Data transported over the wire would then be rendered out into a `