You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The previous discussion involved an older version of the spec but still has relevant info
We have implemented a proof of concept for running React Server Components on Vite. I'm starting this thread to discuss the challenges and possible solutions in implementing it.
General Info on RSC
If we ignore SSR for now, RSC is built on serializing a component tree rendered on the server and then deserializing it on the client to be rendered to the DOM. The serialization process distinguishes between two types of components: Server components and client components. There's also a third type called "shared components" which simply means components that can act like either.
The root of the tree always starts with a server component. The server/client boundary is marked by a "use client" directive at the top of a module.
React fully renders server components into HTML tags (divs, spans, etc.) until it encounters a client component boundary. This "fully fleshed-out" serialization stops at the boundary and the client component at the boundary is serialized into a "client reference" which consists of a component ID and props passed to that component in serialized form. There's one level of indirection here: The runtime consults a "bundler config map" which maps the component ID into whatever form the bundler uses internally. Currently, we're piggybacking on the Webpack implementation so the internal format consists of a name, a module ID, and a list of chunks required to load that module. The client, when deserializing a component tree, locates and loads the client components from this reference information.
The react-server-dom-webpack/server package exports a renderToReadableStream (edge version) or a renderToPipeableStream (Node version) function that performs this serialization. The bundler config is the second argument to these functions. The client runtime (react-server-dom-webpack/client) has a series of functions (createFromFetch, createFromReadableStream, createFromXHR) that perform the deserialization, using global Webpack specific hooks __webpack_chunk_load__ and __webpack_require__ (which we shimmed to work with Vite).
SSR works more or less the same way, except that "client" components are loaded and rendered on the server.
Challenges
Three module graphs
When coupled with SSR, RSC requires three module graphs: Two on the server (RSC and SSR), one on the client. Sharing a single module graph between SSR and RSC is challenging because they require separate resolution and transformation rules: For RSC, resolution must respect the react-server export condition and client boundary modules ("use client") have to be transformed into client references.
Currently, we're solving this by using a query parameter (?rsc) for files that are inside the Vite root, and we do the resolution of external modules ourselves. It works fine for simple cases. But after the SSR external boundary, Vite has no control over resolution anymore. So, if an external server component S imports a client component C, Vite will have no say in resolution and transformation, because S will be simply imported without transformation. Marking S as ssr.noExternal fails if S is a CommonJS module. I think this can be remedied by ssr.optimizeDeps but it's not possible to selectively optimize a dep for RSC and not optimize for SSR. A single optimized bundle won't work because we need separate resolution and transformation rules. It's also not possible to mark a module as ssr.noExternal for RSC and ssr.external for SSR.
I have experimented with an ESM loader to take over resolution and transformation after Vite hands over control to Node on the ssr.external boundary and it seems to work. I haven't yet tried the same for CommonJS though.
This challenge mostly involves vite serve: We can simply run three separate builds with separate rules for production.
Deep imports
Suppose the scenario where a module (included in the project) imports a server component S from an external package xyz. And S, in turn, imports a client component C. Also suppose that C isn't directly exported from package xyz but lives in a file node_modules/xyz/dist/chunk-1234.js. Now, if we resolve C ourselves and try to import it from the client (from /node_modules/xyz/dist/chunk-1234.js), it seems to confuse Vite and resolution of further modules (e.g. react itself) fails to consider preoptimized dependencies.
Possible solutions
The first challenge could be solved if Vite had a third module graph for RSC and if it would:
allow resolution and transformation rules that are separate from SSR and client graphs,
function mostly like the client graph in that it would fully resolve all dependencies without externalizing anything,
And the second challenge would be solved if:
the client deps optimizer respected "use client" directives to create separate entry points for each client component,
we had a way to map full paths (node_modules/xyz/dist/chunk-1234.js) to the optimized entry point.
Alternatives
We could always consider supporting a restricted form of RSC where server/client boundaries can only happen within the project root. This would solve the "deep imports" challenge. Even Next users have to follow this rule until the ecosystem fully adapts since most packages don't have "use client" directives yet.
For the "three graphs" challenge, we can try to replicate the work done by the deps optimizer inside a plugin.
Running a separate Vite server in a separate Node process (spawned with --condition react-server) is also worth exploring.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
-
We have implemented a proof of concept for running React Server Components on Vite. I'm starting this thread to discuss the challenges and possible solutions in implementing it.
General Info on RSC
If we ignore SSR for now, RSC is built on serializing a component tree rendered on the server and then deserializing it on the client to be rendered to the DOM. The serialization process distinguishes between two types of components: Server components and client components. There's also a third type called "shared components" which simply means components that can act like either.
The root of the tree always starts with a server component. The server/client boundary is marked by a
"use client"
directive at the top of a module.React fully renders server components into HTML tags (
div
s,span
s, etc.) until it encounters a client component boundary. This "fully fleshed-out" serialization stops at the boundary and the client component at the boundary is serialized into a "client reference" which consists of a component ID and props passed to that component in serialized form. There's one level of indirection here: The runtime consults a "bundler config map" which maps the component ID into whatever form the bundler uses internally. Currently, we're piggybacking on the Webpack implementation so the internal format consists of a name, a module ID, and a list of chunks required to load that module. The client, when deserializing a component tree, locates and loads the client components from this reference information.The
react-server-dom-webpack/server
package exports arenderToReadableStream
(edge version) or arenderToPipeableStream
(Node version) function that performs this serialization. The bundler config is the second argument to these functions. The client runtime (react-server-dom-webpack/client
) has a series of functions (createFromFetch
,createFromReadableStream
,createFromXHR
) that perform the deserialization, using global Webpack specific hooks__webpack_chunk_load__
and__webpack_require__
(which we shimmed to work with Vite).SSR works more or less the same way, except that "client" components are loaded and rendered on the server.
Challenges
Three module graphs
When coupled with SSR, RSC requires three module graphs: Two on the server (RSC and SSR), one on the client. Sharing a single module graph between SSR and RSC is challenging because they require separate resolution and transformation rules: For RSC, resolution must respect the
react-server
export condition and client boundary modules ("use client"
) have to be transformed into client references.Currently, we're solving this by using a query parameter (
?rsc
) for files that are inside the Vite root, and we do the resolution of external modules ourselves. It works fine for simple cases. But after the SSR external boundary, Vite has no control over resolution anymore. So, if an external server component S imports a client component C, Vite will have no say in resolution and transformation, because S will be simply imported without transformation. Marking S asssr.noExternal
fails if S is a CommonJS module. I think this can be remedied byssr.optimizeDeps
but it's not possible to selectively optimize a dep for RSC and not optimize for SSR. A single optimized bundle won't work because we need separate resolution and transformation rules. It's also not possible to mark a module asssr.noExternal
for RSC andssr.external
for SSR.I have experimented with an ESM loader to take over resolution and transformation after Vite hands over control to Node on the
ssr.external
boundary and it seems to work. I haven't yet tried the same for CommonJS though.This challenge mostly involves
vite serve
: We can simply run three separate builds with separate rules for production.Deep imports
Suppose the scenario where a module (included in the project) imports a server component S from an external package
xyz
. And S, in turn, imports a client component C. Also suppose that C isn't directly exported from packagexyz
but lives in a filenode_modules/xyz/dist/chunk-1234.js
. Now, if we resolve C ourselves and try to import it from the client (from/node_modules/xyz/dist/chunk-1234.js
), it seems to confuse Vite and resolution of further modules (e.g.react
itself) fails to consider preoptimized dependencies.Possible solutions
The first challenge could be solved if Vite had a third module graph for RSC and if it would:
And the second challenge would be solved if:
"use client"
directives to create separate entry points for each client component,node_modules/xyz/dist/chunk-1234.js
) to the optimized entry point.Alternatives
We could always consider supporting a restricted form of RSC where server/client boundaries can only happen within the project root. This would solve the "deep imports" challenge. Even Next users have to follow this rule until the ecosystem fully adapts since most packages don't have
"use client"
directives yet.For the "three graphs" challenge, we can try to replicate the work done by the deps optimizer inside a plugin.
Running a separate Vite server in a separate Node process (spawned with
--condition react-server
) is also worth exploring.Beta Was this translation helpful? Give feedback.
All reactions