Skip to content

plugin-rsc: a directive that pins a module to the ssr environment ("use ssr"), built on import.meta.viteRsc.import #1465

Description

@ramonmalcolm10

Proposal: a directive that pins a module to another environment ("use ssr"), built on import.meta.viteRsc.import

Follow-up to #1263, which was closed pointing at import.meta.viteRsc.import(..., { environment: 'ssr' }). That helper is the right mechanism, and this proposes the ergonomic layer on top of it, offered as a PR if the shape is welcome.

The pain, restated

Any library that renders React to HTML (@react-email/render, renderToString, PDF and Markdown renderers that use React) fails when called from a server function or an RSC route handler:

Error: react-dom/server is not supported in React Server Components.

As designed by React, and the cure is to run that code in the ssr environment. Today that means every framework on plugin-rsc (TanStack Start, vinext, ours) documents the same paragraph telling users to split a file and write:

const { renderEmail } = await import.meta.viteRsc.import<typeof import('./email-renderer')>(
  './email-renderer',
  { environment: 'ssr' },
)

It works, but it is the framework's plumbing leaking into an app: an env-specific global, a generic parameter that repeats the specifier, and an await at the call site because the import is now a promise.

Proposal

A module can declare which environment it belongs to, the way "use client" does:

// src/lib/email/render.tsx
"use ssr";
import { render } from '@react-email/render'
import { OtpEmail } from './otp-email'

export async function renderOtpEmail(code: string) {
  return render(<OtpEmail code={code} />)
}

Everything else imports it normally. In the rsc environment the plugin replaces the module with proxies of its exports that call across:

const mod = import.meta.viteRsc.import('./render.tsx', { environment: 'ssr' })
export const renderOtpEmail = async (...args) => (await mod).renderOtpEmail(...args)

Dev and build both already work through the existing rsc:import-environment handling; the transform only needs to run ahead of it (enforce: 'pre', applyToEnvironment: rsc), so the module's own imports (react-dom/server among them) are never resolved in rsc at all.

Rules, enforced at transform time with the export named:

  • exports are async functions (the call crosses environments; the answer is a promise)
  • a value export, a sync function, a class, export { } / export * is an error
  • pass data across, not elements: an element created in rsc carries that side's react

Shape questions for the maintainers

  1. Name and generality. "use ssr" reads well but hardcodes the conventional environment name. A generic form, e.g. "use environment: ssr" or a plugin option directives: { 'use ssr': 'ssr' }, keeps it framework-agnostic. Happy with either.
  2. React's directive space. "use *" directives are React's namespace; this one is bundler-only and never reaches React. Worth a note in the README rather than a blocker, I think.
  3. A friendlier failure for the direct import. Independently of the directive: resolving react-dom/server* in rsc to a module that throws a message naming the app file that imported it and the fix. Cheap, and it turns the most-asked question into a message.

Prior art

Implemented and shipping in rsc-kit (packages/core/src/useSsr.ts, ~100 lines plus tests, including a fixture that renders a component with useState through renderToStaticMarkup from a server action): rsc-kit/rsc-kit#96. I will adapt it to whatever shape you prefer and open the PR.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions