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
68 changes: 68 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,8 @@ export function assetsPlugin(pluginOpts?: FullstackPluginOptions): Plugin[] {
[environment: string]: { [id: string]: ImportAssetsMeta };
} = {};
const bundleMap: { [environment: string]: Rollup.OutputBundle } = {};
// Map from CSS module ID to the corresponding CSS file names in server bundle
const serverCssIdToFiles: Record<string, string[]> = {};

async function processAssetsImport(
ctx: Rollup.PluginContext,
Expand Down Expand Up @@ -572,6 +581,65 @@ export default __assets_runtime.mergeAssets(${codes.join(", ")});
}
},
},
{
name: "fullstack:css-deduplication",
apply: "build",
sharedDuringBuild: true,
// Empty client css if it's already included in server builds.
// TODO: how to avoid empty files being generated in the end?
load: {
order: "pre",
handler(id) {
if (
pluginOpts?.experimental?.deduplicateCss &&
this.environment.name === "client" &&
serverCssIdToFiles[id]
) {
return ``;
}
},
},
// Mutate client build `viteMetadata` to reference server CSS files
generateBundle: {
order: "pre",
handler(_options, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type === "chunk") {
const serverCssFiles: string[] = [];
for (const moduleId of chunk.moduleIds) {
if (serverCssIdToFiles[moduleId]) {
serverCssFiles.push(...serverCssIdToFiles[moduleId]);
}
}
if (chunk.viteMetadata) {
chunk.viteMetadata.importedCss = new Set([
...chunk.viteMetadata.importedCss,
...serverCssFiles,
]);
}
}
}
},
},
// Track CSS module IDs from server builds for deduplication
writeBundle(_options, bundle) {
if (
pluginOpts?.experimental?.deduplicateCss &&
this.environment.name !== "client"
) {
for (const chunk of Object.values(bundle)) {
if (chunk.type === "chunk") {
const cssFiles = [...(chunk.viteMetadata?.importedCss ?? [])];
for (const moduleId of chunk.moduleIds) {
if (isCSSRequest(moduleId)) {
serverCssIdToFiles[moduleId] = cssFiles;
}
}
}
}
}
},
},
patchViteClientPlugin(),
patchVueScopeCssHmr(),
patchCssLinkSelfAccept(),
Expand Down
Loading