Skip to content
11 changes: 11 additions & 0 deletions packages/fullstack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,16 @@ export default defineConfig({
// from the `ssr.build.rollupOptions.input` entry.
// This can be disabled by setting `serverHandler: false`
// to use alternative server plugins like `@cloudflare/vite-plugin`, `nitro/vite`, etc.

// experimental: object (optional)
// Experimental features that may change in future releases
experimental: {
// deduplicateCss: boolean (default: false)
// Deduplicate CSS between server and client builds.
// When enabled, CSS that is already processed in the server build
// will be emptied in the client build to avoid duplication.
deduplicateCss: true,
}
})
],
environments: {
Expand Down Expand Up @@ -212,6 +222,7 @@ For a detailed explanation of the plugin's internal architecture and implementat

- Duplicated CSS build for each environment (e.g. client build and ssr build)
- Currently each CSS import is processed and built for each environment build, which can potentially cause inconsistency due to differing code splits, configuration, etc. This can cause duplicate CSS content loaded on client or break expected style processing.
- **Mitigation**: The experimental `deduplicateCss` option can be enabled to deduplicate CSS between server and client builds. When enabled, CSS files that are already processed in the server build will be emptied in the client build to avoid duplication. Enable this feature by setting `experimental: { deduplicateCss: true }` in the plugin options.
- `?assets=client` doesn't provide `css` during dev.
- Due to unbundled dev, the plugin doesn't eagerly traverse the client module graph and `?assets=client` provides only the `entry` field during dev. It's currently assumed that CSS files needed for SSR are the CSS files imported on the server module graph.

Expand Down
40 changes: 40 additions & 0 deletions packages/fullstack/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,46 @@ test.describe("dev", () => {
test.describe("build", () => {
const f = useFixture({ root: "examples/basic", mode: "build" });
defineTest(f);

test("css deduplication", async () => {
// Verify that CSS deduplication worked
const fs = await import("node:fs");
const path = await import("node:path");

const clientCssDir = path.join(f.root, "dist/client/assets");
const ssrCssDir = path.join(f.root, "dist/ssr/assets");

// Find CSS files
// Exclude index-*.css files which are copied from SSR build to client build
const clientCssFiles = fs
.readdirSync(clientCssDir)
.filter((file) => file.endsWith(".css") && !file.startsWith("index-"));
const ssrCssFiles = fs
.readdirSync(ssrCssDir)
.filter((file) => file.endsWith(".css"));

// Verify SSR CSS exists and has content
expect(ssrCssFiles.length).toBeGreaterThan(0);
const ssrCssFile = ssrCssFiles[0];
expect(ssrCssFile).toBeDefined();
const ssrCssContent = fs.readFileSync(
path.join(ssrCssDir, ssrCssFile!),
"utf-8",
);
expect(ssrCssContent.length).toBeGreaterThan(0);

// Verify client entry CSS is empty (deduplicated)
if (clientCssFiles.length > 0) {
const clientCssFile = clientCssFiles[0];
expect(clientCssFile).toBeDefined();
const clientCssContent = fs.readFileSync(
path.join(clientCssDir, clientCssFile!),
"utf-8",
);
// With deduplication enabled, client CSS should be empty
expect(clientCssContent).toBe("");
}
});
});

test.describe("cloudflare dev", () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/fullstack/examples/basic/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ export default defineConfig((_env) => ({
// import("vite-plugin-inspect").then((m) => m.default()),
react(),
reactHmrPreamblePlugin(),
fullstack(),
fullstack({
experimental: {
deduplicateCss: true,
},
}),
],
environments: {
client: {
Expand Down
117 changes: 117 additions & 0 deletions packages/fullstack/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ type FullstackPluginOptions = {
* @default true
*/
clientBuildFallback?: boolean;
/**
* Deduplicate CSS between server and client builds.
* When enabled, CSS that is already processed in the server build
* will be emptied in the client build to avoid duplication.
* @default false
*/
deduplicateCss?: boolean;
};
};

Expand Down Expand Up @@ -116,6 +123,10 @@ export function assetsPlugin(pluginOpts?: FullstackPluginOptions): Plugin[] {
[environment: string]: { [id: string]: ImportAssetsMeta };
} = {};
const bundleMap: { [environment: string]: Rollup.OutputBundle } = {};
// Track CSS module IDs processed in server environments for deduplication
const ssrCssModuleIds = new Set<string>();
// Map from CSS module ID to the corresponding CSS file names in SSR bundle
const ssrCssModuleToFiles = new Map<string, Set<string>>();

async function processAssetsImport(
ctx: Rollup.PluginContext,
Expand Down Expand Up @@ -410,6 +421,35 @@ export function assetsPlugin(pluginOpts?: FullstackPluginOptions): Plugin[] {
},
writeBundle(_options, bundle) {
bundleMap[this.environment.name] = bundle;

// Track CSS module IDs from server environments for deduplication
if (
pluginOpts?.experimental?.deduplicateCss &&
this.environment.name !== "client"
) {
for (const chunk of Object.values(bundle)) {
if (chunk.type === "chunk") {
// Track CSS module IDs
for (const moduleId of chunk.moduleIds) {
if (isCSSRequest(moduleId)) {
ssrCssModuleIds.add(moduleId);
}
}
// Track which CSS files are associated with each CSS module
const cssFiles = chunk.viteMetadata?.importedCss ?? [];
for (const moduleId of chunk.moduleIds) {
if (isCSSRequest(moduleId)) {
if (!ssrCssModuleToFiles.has(moduleId)) {
ssrCssModuleToFiles.set(moduleId, new Set());
}
for (const cssFile of cssFiles) {
ssrCssModuleToFiles.get(moduleId)!.add(cssFile);
}
}
}
}
}
}
},
buildStart() {
// dynamically add client entry during build
Expand Down Expand Up @@ -575,6 +615,7 @@ export default __assets_runtime.mergeAssets(${codes.join(", ")});
patchViteClientPlugin(),
patchVueScopeCssHmr(),
patchCssLinkSelfAccept(),
cssDeduplicationPlugin(pluginOpts, ssrCssModuleIds, ssrCssModuleToFiles),
];
}

Expand Down Expand Up @@ -829,3 +870,79 @@ function patchCssLinkSelfAccept(): Plugin {
},
};
}

/**
* Deduplicate CSS between server and client builds.
* For CSS files that were already processed in server build,
* return empty content in client build to avoid duplication.
*/
function cssDeduplicationPlugin(
pluginOpts: FullstackPluginOptions | undefined,
ssrCssModuleIds: Set<string>,
ssrCssModuleToFiles: Map<string, Set<string>>,
): Plugin {
return {
name: "fullstack:css-deduplication",
apply: "build",
sharedDuringBuild: true,
load: {
order: "pre",
handler(id) {
// Only apply to client environment during build
if (
!pluginOpts?.experimental?.deduplicateCss ||
this.environment.name !== "client" ||
this.environment.mode !== "build"
) {
return;
}

// Check if this CSS was already processed in SSR build
if (isCSSRequest(id) && ssrCssModuleIds.has(id)) {
// Return empty CSS to avoid duplication
return "";
}
},
},
generateBundle(_options, bundle) {
// Only apply to client environment during build
if (
!pluginOpts?.experimental?.deduplicateCss ||
this.environment.name !== "client" ||
this.environment.mode !== "build"
) {
return;
}

// Mutate client chunks to reference SSR CSS files
for (const chunk of Object.values(bundle)) {
if (chunk.type === "chunk") {
const importedCss = chunk.viteMetadata?.importedCss ?? [];
const newImportedCss = new Set<string>();

// Keep non-deduplicated CSS files
for (const cssFile of importedCss) {
newImportedCss.add(cssFile);
}

// Add SSR CSS files for deduplicated CSS modules
for (const moduleId of chunk.moduleIds) {
if (isCSSRequest(moduleId) && ssrCssModuleIds.has(moduleId)) {
const ssrCssFiles = ssrCssModuleToFiles.get(moduleId);
if (ssrCssFiles) {
for (const cssFile of ssrCssFiles) {
newImportedCss.add(cssFile);
}
}
}
}

// Update the chunk metadata
if (chunk.viteMetadata) {
chunk.viteMetadata.importedCss = [...newImportedCss];
}
}
}
},
};
}