Skip to content

feat: add react-server-dom-vite #33152

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

hi-ogawa
Copy link

@hi-ogawa hi-ogawa commented May 8, 2025

Summary

As a continuation from the discussion on Jacob's PR #31768, this is a new PR to add the react-server-dom-vite package and fixtures/flight-vite. To begin with, thank you to the React team for the patience with multiple PR iterations and for providing reviews. I also want to thank the Vite ecosystem for demonstrating Vite RSC integrations. I've learned fundamental concepts from existing Vite RSC frameworks and they are essential to reach this PR.

Firstly, let me clarify a bundler-level characteristic that affects how RSC integration is approached for Vite. (This has been raised in past PRs as well, but I want to provide fuller context.)

  • Vite/Rollup/Rolldown employ ESM as the unit of modules/chunks, so module loading is inherently asynchronous. Also, there's no module factory wrapper, so chunk loading is side-effectful. This means eagerly importing dependency chunks can break the execution order designed by a bundler. Thus, what Vite does on the browser is to use modulepreload. On the server, there's no alternative other than importing the chunk itself. (These are bundler-level and ESM runtime-level design concerns, and we assume Vite users are aware of them as a trade-off. It might still be interesting to find ways to circumvent and optimize this in userland, potentially suggest them as general bundler-level features, or employ new ESM specifications, but that's not the current focus for Vite RSC adoption.)
  • Vite has dependency chunk preloading in the browser build out-of-the-box: https://vitejs.dev/guide/features.html#async-chunk-loading-optimization. This allows client reference loading to automatically trigger modulepreload injection of dependency chunks at runtime in the browser. Therefore, encoding chunks into the RSC payload seems unnecessary, also with the fact that there's no way to preload ESM chunks on the server. (However, relying on Vite's preload feature might be considered an "anti-RSC" concept, since it essentially bakes the entire client manifest into the main browser bundle instead of sending chunk metadata as needed from the server. My current take (and thus this PR) is that since most Vite apps (both SPA and SSR) assume this feature, we can just use it, but challenging Vite's default mechanism might be intersting. Further discussion is very welcome.)
  • Both development and build should be runtime-agnostic. fixtures/flight-vite assumes that both SSR and RSC run on the same Node.js runtime as the main CLI process during development. However, this doesn't have to be the case in actual frameworks (for example, Jacob and RedwoodJS uses Cloudflare: https://github.com/redwoodjs/sdk). Therefore, a certain degree of generalization is required to make react-server-dom-vite portable for such usages (though this is mostly a concern for the plugin API, not the runtime API, and I haven't yet put any plugins at the react-server-dom-vite level).

Given this background, notable differences of react-server-dom-vite compared to other react-server-dom integrations are:

  • Expose a setPreloadModule(loadModule: (id: string) => Promise<unknown>) API.
  • Add moduleLoading.prepareDestinationManifest to pass browser chunk mapping to SSR directly for "prepare destination". (EDIT: removed since setPreloadModule(...) alone can handle the same logic on user land)
  • The "bundler config" is removed since reference ID remapping can be handled earlier or inside a custom setPreloadModule. Also, chunks do not need to be encoded in the RSC payload.
  • Add a global async module cache only in production builds to address the concern raised in add react-server-dom-vite impl and fixture #26926 (comment). (We could technically do the same in development since Vite requires a module invalidation timestamp ?t= for proper client reference HMR, but clearing the cache requires more logic, so I haven't attempted this at the moment.)

Although this is the API I've currently implemented for the fixtures/flight-vite demo, I'm happy to iterate on the exact shape through discussions with the React team and Vite framework maintainers.

In terms of "RSC spec" compliance, fixtures/flight-vite might look like it's achieving proper semantics. However, for transparency, I'm mentioning again here that supporting "use client" inside node_modules can be difficult in some cases during dev. Here is an example repository to demonstrate such behaviors: https://github.com/hi-ogawa/rsc-tests. Supporting it case by case based on framework-side heuristics or additional user-side configuration is likely possible (for example, moving a client boundary from node_modules to "user code" by re-exporting locally is an obvious workaround), but handling all cases uniformly behind the scenes as per the "RSC spec" is a challenge with the current unbundled dev. Eventually, this issue should disappear when we build RSC on top of a fully bundled development server with Vite/Rolldown. So, at the moment, I'd like each framework (and myself) to continue exploring approaches to support each case as best as possible.

In parallel with addressing any feedback here, I'm going to test react-server-dom-vite in existing Vite RSC frameworks. For example, I have PRs on Waku (wakujs/waku#1393), RedwoodJS (redwoodjs/sdk#360), and my own plugin (hi-ogawa/vite-plugins#768) to preliminarily test with a local build of the package. My local build is pushed to my repository, so it can be installed via "react-server-dom-vite": "https://github.com/hi-ogawa/vite-plugins/raw/refs/heads/04-24-refactor_rsc_use_react-server-dom-vite/react-server-dom-vite-19.1.0.tgz" in package.json. Anyone interested is welcome to test the package. It's normally hard to implement "prepare destination" using react-server-dom-webpack, so it might be interesting to see how switching to react-server-dom-vite helps.

My further plan for react-server-dom-vite is to publish a polished version of fixtures/flight-vite/basic to provide an out-of-the-box, "framework-less" RSC experience on Vite (something similar to Parcel). This is intended to be "framework-less", but it's likely not directly reusable for more opinionated frameworks (for example, a framework might assume a custom environment like RedwoodJS and Cloudflare, or employ its own file system conventions and transforms such as "use cache"). However, certain core aspects of the current fixtures/flight-vite/basic should be reusable (such as vite-utils.ts, findSourceMapURL, and a bunch of virtual modules). I'll try to find a way to extract the common parts and provide a helper package for Vite RSC frameworks (or eventually move them into react-server-dom-vite).

How did you test this change?

Integration tests are included in fixtures/flight-vite. Please take a look fixtures/flight-vite/README.md for the detail.

wip: copy parcel to vite

wip: add __vite_rsc_preload__ and __vite_rsc_require__

wip: more rename

wip: dev only

wip: rename

wip: remove parcelRequire

wip: build prod

wip: move findSourceMapURL to client option

chore: comment

chore: use __vite_rsc_preload__ only

wip: fix findSourceMapURL

wip: add fixtures/flight-vite

wip: build

fix: ssr client reference modulepreload

wip: add setPreloadModule API

refactor: abstract runtime

wip: rename createClientReference to registerClientReference like webpack

wip: tweak createServerReference

refactor: remove unused

wip: remove setServerCallback and align with webpack

chore: comment

chore: rename parcel to vite

chore: prettier

chore: lint

chore: fix flow

fix: global async reference cache

wip: add ClientManifest type

chore: lint

chore: add css example

wip: remove caching

chore: add suspense example

chore: rename mini to basic

chore: fix streaming in vite preview

feat: support prepare destination

chore: cleanup

test: tweak

feat: add findSourceMapURL

test: tweak fixture

chore: cleanup

test: normalize reference key

chore: fix actions imported from client + preserve `createServerReference` position

chore: temporary references

chore: use `@hiogawa/transforms` in fixture

chore: test temporary reference + early hydration

chore: move code

chore: test nonce in fixtures

chore: fix deps

chore: comment

fix: allow unsafe-eval during dev for `createFakeFunction`

chore: add inline action

chore: copy transform utils to fixture

chore: comment and readme
@hi-ogawa
Copy link
Author

hi-ogawa commented May 10, 2025

I initially didn't have css import handling, but now I just pushed one approach I've been thinking. This requires "prepare destination" to also handle ReactDOM.preinit(..., { as: "style" }), so I updated the API setPreloadModule to allow more customization. (EDIT: actually API is the same but I moved "prepare destination" logic entirely on user land.) Again I'm happy to polish API surface better, but first I'd like to confirm with React team whether handling css for client reference in this way gives proper semantics.

Copy link
Contributor

@himself65 himself65 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks amazing, I assume it will works well in wakujs.

/cc @dai-shi

@sebmarkbage
Copy link
Collaborator

sebmarkbage commented May 15, 2025

First of all, excellent PR description. The fact that you've clearly thought of many cases and reviewed the previous PR discussions and pointed out potential gaps makes it so much easier to iterate because it reads like a possible todo for follow ups.

Expose a setPreloadModule(loadModule: (id: string) => Promise) API.

I'm not quite sure how this follows. Can you explain a bit for why this injection is needed as opposed to just hard coding the various branches into the Config? Other than the environments, which should be statically branched, what kind of customization do you expect at this level? We try really hard to avoid dynamic dependency injection and specially module level ones. React doesn't have any other APIs like this.

fixtures/flight-vite assumes that both SSR and RSC run on the same Node.js runtime as the main CLI process during development.

The important semantic is that you can branch the module graph between the react-server condition being on or off. E.g. if I import "client-only" or import "server-only" package it should work in one graph but not the other. Similarly I can also have two different implementations of the same module for the two packages. This is a key part of the "rsc spec". It doesn't have to be implemented as multiple processes though. E.g. Next.js implements this by compiling one of them into its own module graph owned by Webpack and leave the other module graph owned by Node.js. (Although Next.js does this in the reverse of what I think is ideal by putting RSC in Webpack. It should be the other way around.)

For example, it seems like ReactFlightClientConfigBundlerVite could pick between importing basic/rsc.tsx or basic/ssr.tsx that way instead of it being done with dynamic injection using setPreloadModule.

supporting "use client" inside node_modules can be difficult in some cases during dev

A simple approach is just compiling node_modules during dev ofc, or at least some subset of the compilation graph. However, it's not the only way to implement this. Another approach is to instead rely on Node.js level module instrumentation to detect these. Either with an ESM loader or CommonJS registered loader. In this approach, Vite isn't required to compile the "use client" entry point to a proxy. That's something that the Node.js loader does.

However, what Vite can expose is a way to register that entry point with the client to be reachable by the client to load. Basically you need some way to on-the-fly detect a new "use client" file and register that file to be "compiled" by the Vite client while developing. However, that detection can happen on-demand when the server component renders it so that by the time the browser requests it, it's already known to the Vite runtime. Similar to HMR adding a new file.

@hi-ogawa
Copy link
Author

Hi, thanks for the review!

Expose a setPreloadModule(loadModule: (id: string) => Promise) API.

I'm not quite sure how this follows. Can you explain a bit for why this injection is needed as opposed to just hard coding the various branches into the Config? Other than the environments, which should be statically branched, what kind of customization do you expect at this level? We try really hard to avoid dynamic dependency injection and specially module level ones. React doesn't have any other APIs like this.

[...]

For example, it seems like ReactFlightClientConfigBundlerVite could pick between importing basic/rsc.tsx or basic/ssr.tsx that way instead of it being done with dynamic injection using setPreloadModule.

I think this was partly for my convenience to quickly iterate on async module loding logic and at this point, I think I'm satisfied and there shouldn't be customization required (unless other framework authors or I discover something lacks with approach later), but there are some technical concerns, which makes moving basic/{browser,ssr,rsc}.tsx logic into react-server-dom-vite not straight forward.

For example, one question I have is that I think the logic like wrapResourceProxy (preinit/preload wrapper) can be technically move to the module transform itself (which will be closer to what Parcel does for css?), so I'm wondering what would be the preference, whether wrapResourceProxy can be in the plugin/transform side or somewhat more baked into runtime side in ReactFlightClientConfigBundlerVite, which means we need to pass a fixed format of asssets manifest to ssr as serverConsumerManifest?

Also if we have import "virtual:vite-rsc/..." inside react-server-dom-vite, there's an issue with virtual module in cjs and server externals, so react-server-dom-vite should be completely esm at least to make consumption of the package easy (otherwise people will need to vendor and/or patch it internally). The alternative might be passing more data through bundler config instead of virtual module import, but I'm not sure if there's any reason to prefer one over another.

Another minor thing is that I didn't put custom public path support in fixtures/flight-vite/basic. If loadModule is on user land, it's rather simple to prefix it to browser import and ssr preload/preinit, but if the logic is moved to react-server-dom-vite, then I think I need to pass prefix like ModuleLoading too.

That said, I'm inclined to move more code to react-server-dom-vite and stabilize the API there, so I'll see what I can do. But I'm wonder how much it would be a blocker to have basic/{browser,ssr,rsc}.ts outside.

supporting "use client" inside node_modules can be difficult in some cases during dev

A simple approach is just compiling node_modules during dev ofc, or at least some subset of the compilation graph. [...]

This might not be an interest to React team, but let me clarify on this point in case it helps other readers as well. Vite frameworks already processes/compiles all the code (or at least the dependencies which has react in the dependency for example) on react-server condition-ed module graph, so we do detect "use client" inside node_modules and this indeed happens lazily as server environment module is evaluated during dev.

The difficultly arises due to the specific dev-only strategy Vite employs when handling dependency package import on browser (which is called "deps optimization" or "pre-bundling"). Though I haven't fully explored the solution yet, current Vite inner working expects that browser module graph to process the exact import like import "some-client-package/or-deeper" or import "some-client-package/deep". However, normally we need to resolve import "some-client-package" to import "/(resolved-path-to)/node_modules/some-client-package/..." in react-server module graph, so there's a some trick required for "deps optimization" to work by restoring the original import in some way (or, in the other way around, disable "deps optimization" for such packages, but this has a downside that, for example, cjs support needs to be given up or implemented by the framework's own transform).

@sebmarkbage
Copy link
Collaborator

However, relying on Vite's preload feature might be considered an "anti-RSC" concept, since it essentially bakes the entire client manifest into the main browser bundle instead of sending chunk metadata as needed from the server. My current take (and thus this PR) is that since most Vite apps (both SPA and SSR) assume this feature, we can just use it, but challenging Vite's default mechanism might be intersting.

I do feel it's a bit against the grain of RSC since it didn't allow infinite scaling. There are also other implications like that you cannot rely on not sending a file as a security mechanism. This is helpful for example to allow prereleases of a/b test feature to not leak to hackers. It also works as a secondary protection for Server Actions that if you can't find them then you can't invoke them (if something went wrong with authentication earlier).

However, I have to be careful about how much we put my opinions into the "spec". The important part of the spec is to preserve interoperability - not that it's performance or scalable or has extra security features. There's a fine line because there might be implications for what kind of RSC library you can publish in a cross-framework compatible way. I don't think this reaches the level of interop incompatible. The important part of the implementation is that we can maintain it.

I don't think this is a blocker. This is just something for the Vite community to evaluate within itself.

Technically it's not even a blocker for interoperability that you can preload the chunks during SSR if you're ok living with bad performance. The important thing there is that there are not other alternative solutions to solve the performance issues that end up incompatible. Loading CSS at the right timing is important for semantics though.

But I'm wonder how much it would be a blocker to have basic/{browser,ssr,rsc}.ts outside.

It's not a blocker to land per se but I think we'd land as a private package to start and moving it to a stable release would require stabilizing the outer API that integrators are supposed to interact with.

The alternative might be passing more data through bundler config instead of virtual module import, but I'm not sure if there's any reason to prefer one over another.

It makes the timing of resolution more resilient to moving module initialization around. However, the goal should be to minimize the configurability so there's less surface area to maintain and consider for changes.

@sebmarkbage
Copy link
Collaborator

For example, one question I have is that I think the logic like wrapResourceProxy (preinit/preload wrapper) can be technically move to the module transform itself (which will be closer to what Parcel does for css?)

I believe that this is not done with a module transform. This is the prepareDestinationForModule hook. It gets called unconditionally in the context of where the RSC payload is parsed. Even if the module is cached. This is what the other bundler configs use to trigger the preloading during SSR.

The way this is wired up is a little confusing because there are several indirections but the end result is that the builds for SSR includes an implementation for this and the builds for clients make this a noop so it gets compiled out.

A couple downsides of the wrapResourceProxy approach. One is that it's no longer semantically equivalent since the same module loaded from two different places don't have reference equality. That itself can then lead to breakages like React updates blowing out state because if the identity is not the same it'll treat it as a different component that doesn't reconcile. Another issue is that if you have code like this, then it doesn't get dereferenced on the server so it doesn't get the preload and then leads to a waterfall during hydration.

function Component({ clientModule }) {
  useEffect(() => clientModule.hello());
}

prepareDestinationForModule exists so that this can happen eagerly when parsing an RSC payload. It's important that the RSC payload gets parsed inside the AsyncLocalStorage of the SSR render though instead of outside it. E.g. createFromReadableStream has to be called inside a client component inside the SSR render. However, that's also important for forwarding user space ReactDOM.preload() that happens inside the Server Component.

@dilane3
Copy link

dilane3 commented May 18, 2025

That's an incredible PR @hi-ogawa 👏🏽 Good job to you.

Supporting RSC into Vite is an excellent idea, as many Frameworks are still using it as bundler.

I had like to test that plugin into my framework rasengan.js and see how it behave and plan to support RSC in the future.

@hi-ogawa
Copy link
Author

But I'm wonder how much it would be a blocker to have basic/{browser,ssr,rsc}.ts outside.

It's not a blocker to land per se but I think we'd land as a private package to start and moving it to a stable release would require stabilizing the outer API that integrators are supposed to interact with.

The alternative might be passing more data through bundler config instead of virtual module import, but I'm not sure if there's any reason to prefer one over another.

It makes the timing of resolution more resilient to moving module initialization around. However, the goal should be to minimize the configurability so there's less surface area to maintain and consider for changes.

Thanks for clarifying it. Yes, my goal is to have a enough discussion here for stable API so it's maintain-able and you are comfortable publishing it on npm. I'm going to rework API this week.

A couple downsides of the wrapResourceProxy approach. One is that it's no longer semantically equivalent since the same module loaded from two different places don't have reference equality. ...

Can you elaborate regarding this issue? From what I understand, client references in rsc payload are always deserialized through preloadModule/requireModule pair, which guarantees module getter access at the client boundary. I'm not sure if it's even possible for flight client to access the module object itself other than importing it directly inside the client boundary, in which case, they don't get wrapped with proxy, so module identity is not broken. For example, I can see client2.tsx chunk being modulepreload-ed as expected with the following code:

  • root.tsx
import { Client1 } from './client1';
import * as client2 from "./client2"

export function Root() {
  // passing module itself is not supported since esm is [Module: null prototype]
  //   > Error: Only plain objects, and a few built-ins,
  //   > can be passed to Client Components from Server Components.
  //   > Classes or null prototypes are not supported.
  // return <Client1 clientModule={client2} />

  // thus destructure it for example
  return <Client1 clientModules={{ ...client2 }}>
}
  • client1.tsx
"use client"

import React from "react"

export function Client1({ clientModule }: { clientModule: { hello: () => void } }) {
  React.useEffect(() => clientModule.hello());
  return null;
}
  • client2.tsx
"use client"

export function hello() {
  console.log("hello")
}

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

Successfully merging this pull request may close these issues.

5 participants