From 37f67a5759226721803b2fa73a5039d3c055d4bd Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 24 Apr 2023 13:14:08 +0200 Subject: [PATCH 01/11] add RFC --- RFC.md | 639 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 RFC.md diff --git a/RFC.md b/RFC.md new file mode 100644 index 00000000..4fb91c94 --- /dev/null +++ b/RFC.md @@ -0,0 +1,639 @@ +# The 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". + +Server components are marked with a `"use server"` pragma at the top of the file. Client components are marked with a `"use client"` pragma. +These pragmas are only necessary for components that designate a "boundary". Children of server components will be rendered as server components, and children of client components will be rendered as client components, even if they are imported from other files that don't contain a pragma. + +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. 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: this is hackable - as presented [here by Dai-Shi](https://twitter.com/dai_shi/status/1631989295866347520) CC can contain RSC, but these hacks should be left reserved for routing libraries, not for everyday usage. + +### An example with interweaving client and server components + +Let's assume we have this tree. + +```jsx +// Layout.js +"use server" + +// inside the component + + + {children} // Page will be inserted here by the router + + + +// Page.js +"use server" + +// inside the component +
+

Hello World

+ + lalala + +
+ +// ClientComponent.js +"use client" + +// inside the component +
+ Foo {foo} + {children} +
+``` +(Let's assume that `SomeServerComponent` just renders a `div` here...) + +This will lead to two React trees being rendered on the server: + +```jsx +// RSC render + + +
+

Hello World

+ lalala + } /> +
+ + + +// Client Component render (this will also happen once on the server, but with "client rules"!) +
+ Foo {"test"} + +
+``` + +Both trees can "see" boundaries of the other tree, but treat them as a black box. + +It is important to note that we have a unidirectional dataflow here: (serializable) props can flow from the RSC layer, but nothing can flow from client components back into RSC. + + +Those two trees will then be stitched back into a single tree and be sent to the client. +Note that for the sake of simplicity, the example did not contain suspense boundaries, which add another layer of complication. + +This is the final HTML that will be sent to the browser: +```jsx + + +
+

Hello World

+
+ Foo test + lalala +
+
+ + +``` + +This will be streamed to the client, rendered out, then hydrated: + +Once hydrated, client components are fully executable and interactive, while server components remain static HTML. It is possible to request a rerender of RSC from the client. To do so, the client sends a request to the server, which can then either rerender the full RSC tree, or in the future, selectively rerender parts of it. + +### 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 +"use server" + + + + + {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 `"use server"` enabled component is not possible, as `client` is non-serializable and cannot pass the RSC-SSR boundary (let alone the SSR-Browser boundary). The Apollo Client will need to be created in a "client-only" file, as it cannot pass the Server-Client-Boundary. + + +### 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" aren't strictly run in the browser. These are also executed in an SSR pass on the server, albeit with different behavior. Effects are not executed and there is no ability to rerender. + +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". +Per default, Next.js will render RSC and the SSR pass statically at build time. If you use "dynamic" features like `cache`, 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. + +### Possible stages and capabilities + +This leaves us with these five different stages, all which have different capabilities: + +| Feature | static RSC | static SSR | dynamic RSC | dynamic SSR | browser | +| --------------- | ---------- | ---------- | ----------- | ----------- | ------- | +| "use client" | ❌ | ✅ | ❌ | ✅ | ✅ | +| "use server" | ✅ | ❌ | ✅ | ❌ | ❌ | +| Context | ❌ | ✅ | ❌ | ✅ | ✅ | +| Hooks | ❌ | ✅ | ❌ | ✅ | ✅ | +| Cookies/headers | ❌ | ❌ | ✅ | ❌ | ❌ | +| Executes effects| ❌ | ❌ | ❌ | ❌ | ✅ | +| Can rerender | ❌ | ❌ | ❌ | ❌ | ✅ | + +## 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. +There is no support for anything like this in React, however Next.js internally uses `AsyncLocalStorage` to pass the `headers` down scoped per request. +We currently [use this](https://github.com/apollographql/apollo-client-nextjs/blob/c622586533e0f2ac96b692a5106642373c3c45c6/package/src/rsc/registerApolloClient.tsx#L12) to achieve this goal. +This however is a Next.js internal and might not be stable. + +### 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, there is no isolated RSC run. RSC renders are always part of a larger SSR pass. The second half of that SSR pass renders client components, where users expect normal browser features to be available. In fact, most users will likely be unaware that their client components render on the server. + +That means that we need 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 is no longer optimal and 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 result + SSRCache -) Stream: serialized data + deactivate SSRCache + Stream -) BCache: restore + 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 result + SSRCache -) Stream: serialized data + deactivate SSRCache + Stream -) BCache: restore + 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 result + SSRCache -) Stream: serialized data + deactivate SSRCache + Stream -) BCache: restore + 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 result + SSRCache -) Stream: serialized data + deactivate SSRCache + Stream -) BCache: restore + 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 `