vite-plugin-react-server
Version:
Vite plugin for React Server Components (RSC)
177 lines (156 loc) • 7.32 kB
text/typescript
import type { StreamPluginOptions } from "../../types.js";
import { configureReactServer } from "./configureReactServer.server.js";
import { resolveOptions } from "../config/resolveOptions.js";
import { CSS_EXT } from "./collectRunnerCss.js";
import { detectClientModule } from "react-server-loader/directives";
import type { Plugin, ViteDevServer } from "vite";
import { readFileSync } from "node:fs";
/**
* Dev server plugin for server environment.
* Returns two plugins: one for HMR handling (all environments) and one for server config.
*/
export const vitePluginReactDevServer = function _vitePluginReactServerDevServer(options: StreamPluginOptions): Plugin[] {
if (options == null) {
throw new Error("options is required");
}
const resolvedOptions = resolveOptions(options);
if (resolvedOptions.type === "error") {
if (resolvedOptions.error != null) {
throw resolvedOptions.error;
}
throw new Error("Failed to resolve options");
}
const userOptions = resolvedOptions.userOptions;
// Separate plugin for HMR handling (must apply to all environments)
const hmrPlugin = {
name: "vite-plugin-react-server:server-hmr",
apply: "serve" as const,
// Server-level handleHotUpdate — sends custom WS event to client
// Vite 6 Environment API: hotUpdate runs per-environment.
// Prevent server/ssr environments from triggering page reload for client components.
hotUpdate(ctx: any) {
const { file, server } = ctx;
const envName = ctx.environment?.name ?? 'unknown';
const moduleBase = userOptions.moduleBase || "src";
const projectRoot = userOptions.projectRoot || server?.config?.root || '';
const normalizedFile = file.replace(projectRoot, '').replace(/^\/+/, '');
const isSourceFile = normalizedFile.startsWith(moduleBase + '/');
if (!isSourceFile) return;
// Client environment: Vite owns client-side HMR (Fast Refresh if
// `@vitejs/plugin-react` is installed; plain reload otherwise).
if (envName === 'client') {
const isClient = (file.endsWith('.tsx') || file.endsWith('.ts') || file.endsWith('.jsx') || file.endsWith('.js')) && (() => {
try {
const source = readFileSync(file, "utf-8");
return detectClientModule({ source, moduleId: file });
} catch { return false; }
})();
if (isClient) return; // Vite's client-side HMR owns this update
const isCssFile = CSS_EXT.test(file);
// A CSS module imported transitively by a "use client" component lives
// in the CLIENT module graph (the browser fetches it directly and Vite
// injects it as a <style>), so Vite's native CSS HMR already updates it
// in place — no reload, no <link> cache-bust. Detect that case by the
// presence of client-environment modules for this file and hand the
// update back to Vite. Suppressing it here (the `return []` below) is
// what previously left client-graph CSS edits stuck until a manual
// refresh: the RSC <link> cache-bust never matches a Vite <style>.
if (isCssFile && (ctx.modules?.length ?? 0) > 0) {
return; // let Vite's native client CSS HMR apply the update
}
// CSS files that aren't imported via the client module graph (vprs's
// <Css cssFiles={...}/> pattern collects them server-side) aren't
// tracked by Vite's CSS HMR, so a content edit leaves the <link>
// tag's href unchanged. Tag the event so useRscHmr knows to refresh
// matching link tags by cache-busting their href.
const kind: 'css' | 'component' = isCssFile ? 'css' : 'component';
// Server component changed — send RSC refetch event to client
// Only do this once (from client env) to avoid duplicate events
if (userOptions.verbose) {
server.config.logger.info(`[vite-plugin-react-server] File changed (RSC refetch): ${normalizedFile}`);
}
server.ws.send({
type: 'custom',
event: 'vite-plugin-react-server:server-component-update',
data: { file: normalizedFile, path: file, kind },
});
return []; // Don't trigger client-side page reload
}
// Server/SSR environments: suppress page reload for all source files
// Server components are handled by the RSC refetch event sent above
// Invalidate the server module so next RSC request gets fresh content
if (envName === 'server') {
const mod = ctx.environment?.moduleGraph?.getModulesByFile(file);
if (mod) {
for (const m of mod) {
ctx.environment.moduleGraph.invalidateModule(m);
}
}
}
return [];
},
};
const serverPlugin = {
name: "vite-plugin-react-server:dev-server-server",
apply: "serve" as const,
applyToEnvironment(partialEnvironment: any) {
return partialEnvironment?.consumer === 'server';
},
configureServer(server: ViteDevServer) {
// Log that plugin is being configured
server.config.logger.info(`[vite-plugin-react-server] Dev server plugin configured for server environment (react-server condition)`);
// Configure the React server for server environment (direct RSC processing)
// This uses the existing configureReactServer.server.js implementation
configureReactServer({
server,
autoDiscoveredFiles: {
propsMap: new Map(),
pageMap: new Map(),
rootMap: new Map(),
htmlMap: new Map(),
routeMap: new Map(),
urlMap: new Map(),
errors: [],
workerPaths: {},
serverEntry: null,
clientEntry: {},
clientInputs: {},
staticInputs: {},
serverInputs: {},
// staticManifest removed from AutoDiscoveredFiles
serverActions: {},
},
userOptions,
serverManifest: {},
resolvedConfig: server.config,
});
},
};
// Fix CJS named imports in server environment.
// Vite's esbuild JSX transform (and @vitejs/plugin-react if present) generates
// named imports like `import { useEffect } from "react"`. In the server environment,
// react's react-server export is CJS-only, and Node's ESM interop doesn't support
// named exports from CJS. This post-transform rewrites them.
const cjsFixPlugin: Plugin = {
name: "vite-plugin-react-server:server-cjs-fix",
apply: "serve" as const,
enforce: "post" as const,
applyToEnvironment(env: any) {
return env?.name === 'server';
},
transform(code: string, id: string) {
if (id.includes('node_modules')) return;
if (!id.match(/\.[jt]sx?$/)) return;
const namedImportRe = /import\s*\{([^}]+)\}\s*from\s*["']react["']\s*;?/g;
if (!namedImportRe.test(code)) return;
namedImportRe.lastIndex = 0;
let counter = 0;
const result = code.replace(namedImportRe, (_match, imports) => {
const alias = `__react_cjs_${counter++}`;
return `import ${alias} from "react"; const {${imports}} = ${alias};`;
});
return { code: result, map: null };
},
};
return [hmrPlugin, cjsFixPlugin, serverPlugin];
};