From 54fa6a3960ff95cef0fef1ba18f676f1c4c6f22a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 04:21:29 +0000 Subject: [PATCH] fix: pre-bundle client packages to avoid duplicate React on cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a server component imports a "use client" package — one that declares `react` as a peer dependency, such as @funstack/router or a third-party component library — @vitejs/plugin-rsc reaches it on the browser through a `client-package-proxy` virtual module generated at request time. The dependency optimizer's initial scan cannot follow that virtual module, so on a cold-started dev server the package (and the copy of React it pulls in) is only discovered once the page requests it. That late discovery triggers a re-optimization pass, which leaves a second copy of React loaded on the very first render before Vite reloads the page. The mismatched React instances broke hooks with an "Invalid hook call" error. Detect the project's directly-declared client packages (the same `react`-peer-dependency heuristic @vitejs/plugin-rsc uses to decide which packages to proxy) and add them to the browser environment's `optimizeDeps.include`, so they are pre-bundled during the initial pass and React stays deduplicated to a single optimized chunk from the first request. Fixes #128 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DADrMq65BFa1Qv9Dw1YG4c --- .../static/src/plugin/clientPackages.test.ts | 107 ++++++++++++++++++ packages/static/src/plugin/clientPackages.ts | 89 +++++++++++++++ packages/static/src/plugin/index.test.ts | 78 +++++++++++++ packages/static/src/plugin/index.ts | 15 ++- 4 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 packages/static/src/plugin/clientPackages.test.ts create mode 100644 packages/static/src/plugin/clientPackages.ts create mode 100644 packages/static/src/plugin/index.test.ts diff --git a/packages/static/src/plugin/clientPackages.test.ts b/packages/static/src/plugin/clientPackages.test.ts new file mode 100644 index 0000000..79f8684 --- /dev/null +++ b/packages/static/src/plugin/clientPackages.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { findClientPackages } from "./clientPackages"; + +/** + * Build a throwaway project on disk: a root `package.json` plus a + * `node_modules//package.json` for each dependency, so `findClientPackages` + * can resolve and inspect them like a real install. + */ +function makeProject( + root: string, + rootPkg: Record, + deps: Record>, +): void { + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "test-project", ...rootPkg }), + ); + for (const [name, pkgJson] of Object.entries(deps)) { + const dir = path.join(root, "node_modules", name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ name, version: "1.0.0", ...pkgJson }), + ); + } +} + +const reactPeer = { peerDependencies: { react: "^19.0.0" } }; + +describe("findClientPackages", () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "funstack-clientpkgs-")); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("detects dependencies that declare react as a peer dependency", () => { + makeProject( + root, + { dependencies: { "ui-kit": "^1.0.0", lodash: "^4.0.0" } }, + { "ui-kit": reactPeer, lodash: {} }, + ); + expect(findClientPackages(root)).toEqual(["ui-kit"]); + }); + + it("considers devDependencies and optionalDependencies", () => { + makeProject( + root, + { + devDependencies: { "dev-widget": "^1.0.0" }, + optionalDependencies: { "opt-widget": "^1.0.0" }, + }, + { "dev-widget": reactPeer, "opt-widget": reactPeer }, + ); + expect(findClientPackages(root).sort()).toEqual([ + "dev-widget", + "opt-widget", + ]); + }); + + it("skips @funstack/static and react-dom even with a react peer dependency", () => { + makeProject( + root, + { + dependencies: { + "@funstack/static": "^1.0.0", + "react-dom": "^19.0.0", + "@funstack/router": "^1.0.0", + }, + }, + { + "@funstack/static": reactPeer, + "react-dom": reactPeer, + "@funstack/router": reactPeer, + }, + ); + expect(findClientPackages(root)).toEqual(["@funstack/router"]); + }); + + it("ignores transitive dependencies (only direct ones are scanned)", () => { + // `ui-kit` is a direct dep; `nested-widget` is only present in + // node_modules (a transitive dep) and must not be returned. + makeProject( + root, + { dependencies: { "ui-kit": "^1.0.0" } }, + { "ui-kit": reactPeer, "nested-widget": reactPeer }, + ); + expect(findClientPackages(root)).toEqual(["ui-kit"]); + }); + + it("skips declared dependencies that are not installed", () => { + makeProject(root, { dependencies: { "missing-pkg": "^1.0.0" } }, {}); + expect(findClientPackages(root)).toEqual([]); + }); + + it("returns an empty list when there is no package.json", () => { + expect(findClientPackages(path.join(root, "does-not-exist"))).toEqual([]); + }); +}); diff --git a/packages/static/src/plugin/clientPackages.ts b/packages/static/src/plugin/clientPackages.ts new file mode 100644 index 0000000..307d91d --- /dev/null +++ b/packages/static/src/plugin/clientPackages.ts @@ -0,0 +1,89 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +/** + * Discover the project's "client packages": directly-declared dependencies that + * list `react` as a peer dependency. + * + * `@vitejs/plugin-rsc` treats exactly these (a `react` peer dependency) as + * framework packages. When a server component imports one, the package is + * reached on the browser through a runtime-generated `client-package-proxy` + * virtual module that the dependency optimizer's initial scan cannot follow, so + * the package — and the copy of React it pulls in — is otherwise only + * discovered at request time. That late discovery triggers a re-optimization + * pass which leaves a second copy of React loaded on the first cold-start + * render, breaking hooks with an "Invalid hook call" error before Vite reloads + * the page (#128). Pre-bundling these packages in the browser environment keeps + * React deduplicated to a single optimized chunk from the first request. + * + * Only directly-declared dependencies are considered: those are the bare + * specifiers a server component can import (and that Vite can resolve from the + * project root), whereas a framework package nested inside another dependency + * is pre-bundled together with its parent. `@funstack/static` and `react-dom` + * are skipped — `@funstack/static` is excluded from optimizeDeps (it imports + * virtual modules), and `react-dom` is handled separately. + */ +export function findClientPackages(root: string): string[] { + let rootPkg: Record; + try { + rootPkg = JSON.parse( + fs.readFileSync(path.join(root, "package.json"), "utf-8"), + ); + } catch { + return []; + } + const declared = new Set(); + for (const field of [ + "dependencies", + "devDependencies", + "optionalDependencies", + ]) { + const deps = rootPkg[field]; + if (deps && typeof deps === "object") { + for (const name of Object.keys(deps)) { + declared.add(name); + } + } + } + const require = createRequire(path.join(root, "package.json")); + const clientPackages: string[] = []; + for (const name of declared) { + if (name === "@funstack/static" || name === "react-dom") { + continue; + } + const pkgJson = readDependencyPackageJson(require, root, name); + const peerDependencies = pkgJson?.["peerDependencies"]; + if ( + peerDependencies && + typeof peerDependencies === "object" && + "react" in peerDependencies + ) { + clientPackages.push(name); + } + } + return clientPackages; +} + +/** + * Read a dependency's `package.json`. Prefers Node resolution (handles pnpm and + * other layouts), falling back to a direct `node_modules` read for packages + * that do not expose `./package.json` via their `exports` map. + */ +function readDependencyPackageJson( + require: NodeJS.Require, + root: string, + name: string, +): Record | undefined { + for (const candidate of [ + () => require.resolve(`${name}/package.json`), + () => path.join(root, "node_modules", name, "package.json"), + ]) { + try { + return JSON.parse(fs.readFileSync(candidate(), "utf-8")); + } catch { + // Try the next resolution strategy. + } + } + return undefined; +} diff --git a/packages/static/src/plugin/index.test.ts b/packages/static/src/plugin/index.test.ts new file mode 100644 index 0000000..39c1faa --- /dev/null +++ b/packages/static/src/plugin/index.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import type { Plugin } from "vite"; +import funstackStatic, { type FunstackStaticOptions } from "./index"; + +/** + * Extract the `@funstack/static:config` plugin, which owns the + * `configEnvironment` hook that tweaks `optimizeDeps`. + */ +function getConfigPlugin(options: FunstackStaticOptions): Plugin { + const plugin = funstackStatic(options) + .flat() + .find((p): p is Plugin => p.name === "@funstack/static:config"); + if (!plugin) { + throw new Error("config plugin not found"); + } + return plugin; +} + +/** + * Run the `configEnvironment` hook for the given environment and return the + * mutated `optimizeDeps` config. + */ +function runConfigEnvironment( + options: FunstackStaticOptions, + name: string, +): { include: string[]; exclude: string[] } { + const plugin = getConfigPlugin(options); + const hook = plugin.configEnvironment; + const handler = typeof hook === "function" ? hook : hook?.handler; + if (!handler) { + throw new Error("configEnvironment hook not found"); + } + const config: { optimizeDeps?: { include?: string[]; exclude?: string[] } } = + {}; + // The hook only reads `name` and mutates `config.optimizeDeps`; the third + // `ConfigEnv` argument is unused, so an empty object is sufficient. + handler.call({} as never, name, config as never, {} as never); + return { + include: config.optimizeDeps?.include ?? [], + exclude: config.optimizeDeps?.exclude ?? [], + }; +} + +const fsRoutesOptions: FunstackStaticOptions = { + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + adapter: "@funstack/static/fs-routes/next-adapter", + }, +}; + +describe("configEnvironment optimizeDeps", () => { + it("always bundles React through @funstack/static and excludes the package", () => { + const { include, exclude } = runConfigEnvironment( + fsRoutesOptions, + "client", + ); + expect(include).toContain("@funstack/static > react"); + expect(include).toContain("@funstack/static > react-dom"); + expect(exclude).toContain("@funstack/static"); + }); + + it("pre-bundles client packages (react peer dependency) in the client environment", () => { + // The detection runs against this package, whose own devDependencies + // include @funstack/router (a `react` peer-dependency client package). + // Pre-bundling such packages avoids a cold-start re-optimization that would + // load a second copy of React and break hooks (#128). + const { include } = runConfigEnvironment(fsRoutesOptions, "client"); + expect(include).toContain("@funstack/router"); + }); + + it("only pre-bundles client packages in the client environment", () => { + for (const name of ["rsc", "ssr"]) { + const { include } = runConfigEnvironment(fsRoutesOptions, name); + expect(include).not.toContain("@funstack/router"); + } + }); +}); diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index 8168e2d..4ea89a4 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -3,6 +3,7 @@ import { normalizePath, type Plugin } from "vite"; import rsc from "@vitejs/plugin-rsc"; import { buildApp } from "../build/buildApp"; import { serverPlugin } from "./server"; +import { findClientPackages } from "./clientPackages"; import { defaultRscPayloadDir } from "../rsc/rscModule"; interface FunstackStaticBaseOptions { @@ -259,7 +260,7 @@ export default function funstackStatic( ); } }, - configEnvironment(_name, config) { + configEnvironment(name, config) { if (!config.optimizeDeps) { config.optimizeDeps = {}; } @@ -284,6 +285,18 @@ export default function funstackStatic( "@funstack/static > react", "@funstack/static > react-dom", ); + // Pre-bundle the project's client packages in the browser environment + // so React stays deduplicated on a cold-started dev server. See + // findClientPackages for the full explanation (#128). + if (name === "client") { + // process.cwd() matches the root @vitejs/plugin-rsc uses to detect + // these same framework packages, so the two stay consistent. + for (const pkg of findClientPackages(process.cwd())) { + if (!config.optimizeDeps.include.includes(pkg)) { + config.optimizeDeps.include.push(pkg); + } + } + } }, }, {