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..f343640 --- /dev/null +++ b/packages/static/src/plugin/clientPackages.ts @@ -0,0 +1,88 @@ +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 is otherwise only discovered at request time. That late discovery + * triggers a re-optimization pass which — mid-flight — corrupts CJS-interop + * module references (duplicate React on cold start, #128; broken dev-JSX, #124). + * Pre-bundling these packages in the browser environment keeps the optimizer to + * a single pass, so no re-optimization happens on the first render. + * + * 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..8772dc5 --- /dev/null +++ b/packages/static/src/plugin/index.test.ts @@ -0,0 +1,83 @@ +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("includes React and ReactDOM as a single (bare) optimized chunk", () => { + const { include, exclude } = runConfigEnvironment( + fsRoutesOptions, + "client", + ); + // Bare specifiers (not the nested `@funstack/static > react` form) so React + // merges with the copy the optimizer scanner already discovers — one chunk, + // no duplicate-React flip on a cold-started dev server (#128). + expect(include).toContain("react"); + expect(include).toContain("react-dom"); + expect(include).not.toContain("@funstack/static > react"); + expect(include).not.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 + // corrupt CJS-interop module references (#124, #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..2b82057 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 = {}; } @@ -278,12 +279,32 @@ export default function funstackStatic( // Since code includes imports to virtual modules, we need to exclude // us from Optimize Deps. config.optimizeDeps.exclude.push("@funstack/static"); - // However, since Vite prohibits excluding a CommonJS package, - // we need to include React and ReactDOM so they are bundled properly. - config.optimizeDeps.include.push( - "@funstack/static > react", - "@funstack/static > react-dom", - ); + // Vite prohibits excluding a CommonJS package, so include React and + // ReactDOM as plain entries. Using the bare specifiers (rather than the + // nested `@funstack/static > react` form) merges them with the copy the + // optimizer's scanner already discovers, so React is bundled into a + // SINGLE optimized chunk. The nested form produced a second, redundant + // React chunk from the same source file; whenever a re-optimization + // changed which chunk was canonical, two live React instances ended up + // loaded on a cold start (#128). One chunk makes that impossible. + config.optimizeDeps.include.push("react", "react-dom"); + // Pre-bundle the project's client packages in the browser environment + // so they are discovered in the optimizer's INITIAL pass. Otherwise + // @vitejs/plugin-rsc reaches them at request time through a + // `client-package-proxy` virtual module that the scanner cannot follow, + // forcing a re-optimization on the first render. That mid-flight + // re-optimization corrupts CJS-interop module references — duplicate + // React (#128) and broken dev-JSX (`react/jsx-runtime` "export named + // 't'", #124). See findClientPackages for the full explanation. + 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); + } + } + } }, }, {