Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions packages/static/src/plugin/clientPackages.test.ts
Original file line number Diff line number Diff line change
@@ -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/<name>/package.json` for each dependency, so `findClientPackages`
* can resolve and inspect them like a real install.
*/
function makeProject(
root: string,
rootPkg: Record<string, unknown>,
deps: Record<string, Record<string, unknown>>,
): 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([]);
});
});
88 changes: 88 additions & 0 deletions packages/static/src/plugin/clientPackages.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
try {
rootPkg = JSON.parse(
fs.readFileSync(path.join(root, "package.json"), "utf-8"),
);
} catch {
return [];
}
const declared = new Set<string>();
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<string, unknown> | 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;
}
83 changes: 83 additions & 0 deletions packages/static/src/plugin/index.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
35 changes: 28 additions & 7 deletions packages/static/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -259,7 +260,7 @@ export default function funstackStatic(
);
}
},
configEnvironment(_name, config) {
configEnvironment(name, config) {
if (!config.optimizeDeps) {
config.optimizeDeps = {};
}
Expand All @@ -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);
}
}
}
},
},
{
Expand Down