Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-svelte-conditional-css-tree-shaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes CSS for conditionally rendered Svelte components being missing from production builds. When Svelte components are rendered behind `{#if}` blocks where the condition is `false` during SSR, Vite's `cssScopeTo` feature would tree-shake their CSS in the server build. The client build's CSS deduplication logic then incorrectly deleted the client CSS asset (which had the full styles) before the recovery code could use it. CSS assets are now saved before deletion and restored if they're needed by the `cssScopeTo` recovery logic.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changeset isn't correct. @matthewp can you fix it? It contains technicalities that uses don't care

8 changes: 8 additions & 0 deletions packages/astro/src/core/build/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ export interface BuildInternals {
moduleIds: string[];
prerender: boolean;
}>;

/**
* Component exports that were rendered during the SSR build.
* Used by the client build's cssScopeTo recovery to distinguish between
* CSS that was tree-shaken because the component wasn't rendered in SSR
* vs CSS that was included in SSR.
*/
ssrRenderedExports?: Map<string, Set<string>>;
}

/**
Expand Down
56 changes: 55 additions & 1 deletion packages/astro/src/core/build/plugins/plugin-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,23 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
internals.cssModuleToChunkIdMap.set(moduleId, chunk.fileName);
}
}

// Track which component exports were rendered during SSR.
// This is used by the client build to determine if cssScopeTo CSS
// was tree-shaken (component not rendered in SSR) vs included.
for (const [moduleId, moduleInfo] of Object.entries(chunk.modules || {})) {
if (moduleInfo.renderedExports.length > 0) {
const existing = internals.ssrRenderedExports?.get(moduleId);
if (existing) {
for (const exp of moduleInfo.renderedExports) {
existing.add(exp);
}
} else {
internals.ssrRenderedExports ??= new Map();
internals.ssrRenderedExports.set(moduleId, new Set(moduleInfo.renderedExports));
}
}
}
}
}

Expand All @@ -92,6 +109,16 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
// Map from component module ID to the pages that include it (via facadeModuleId)
const componentToPages = new Map<string, Set<string>>();

// Track CSS assets deleted during client-build deduplication so they can
// be restored if the cssScopeTo recovery code below determines they contain
// styles for conditionally rendered components.
const deletedCssAssets = new Map<string, (typeof bundle)[string]>();
// CSS asset IDs that the cssScopeTo recovery code added to pagesToCss.
// Only these deleted assets should be restored — the normal parent walk
// also adds deleted CSS IDs to pagesToCss, but those represent CSS that
// is already on the page from the SSR build.
const cssScopeToAddedCss = new Set<string>();

// Remove CSS files from client bundle that were already bundled with pages during SSR
if (this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.client) {
for (const [, item] of Object.entries(bundle)) {
Expand Down Expand Up @@ -126,8 +153,10 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
);

if (allCssInSSR && shouldDeleteCSSChunk(allModules, internals)) {
// Delete the CSS assets that were imported by this chunk
for (const cssId of meta.importedCss) {
if (bundle[cssId]) {
deletedCssAssets.set(cssId, bundle[cssId]);
}
delete bundle[cssId];
}
}
Expand Down Expand Up @@ -231,6 +260,19 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
}
}
}

// Only flag deleted CSS for restore when the component's
// export was NOT rendered during SSR. If it was rendered in
// SSR, the page already has these styles and the deleted
// client CSS is truly redundant.
const ssrExports = internals.ssrRenderedExports?.get(scopedToModule);
if (!ssrExports || !ssrExports.has(scopedToExport)) {
for (const cssId of meta.importedCss) {
if (deletedCssAssets.has(cssId)) {
cssScopeToAddedCss.add(cssId);
}
}
}
}
}
}
Expand Down Expand Up @@ -268,6 +310,18 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
}
}
}

// Restore deleted CSS assets that the cssScopeTo recovery code added to
// pages. Only assets explicitly flagged by cssScopeToAddedCss are restored
// — CSS added by the normal parent walk represents styles already present
// on the page from the SSR build and should stay deleted.
if (cssScopeToAddedCss.size > 0) {
for (const cssId of cssScopeToAddedCss) {
if (deletedCssAssets.has(cssId) && !bundle[cssId]) {
bundle[cssId] = deletedCssAssets.get(cssId)!;
}
}
}
},
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
<script lang="ts">
import Child from './Child.svelte';

// Use a dynamic condition that can't be statically analyzed
// The child may or may not render at runtime, so its CSS must be included
let showChild = $state(Math.random() > 0.5);
// Start with false so the child is NOT rendered during SSR/prerendering.
// This tests that CSS for conditionally rendered components is still included
// even when the condition is false during the server build.
let showChild = $state(false);

$effect(() => {
showChild = true;
});
</script>

<div class="parent">
Expand Down
Loading