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
5 changes: 5 additions & 0 deletions .changeset/fix-svelte-conditional-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Fixes styles not being included for conditionally rendered Svelte 5 components in production builds
26 changes: 26 additions & 0 deletions packages/astro/src/core/build/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export interface BuildInternals {
*/
pagesByScriptId: Map<string, Set<PageBuildData>>;

/**
* A map for page-specific information by a hydrated component
*/
pagesByHydratedComponent: Map<string, Set<PageBuildData>>;

/**
* A map of hydrated components to export names that are discovered during the SSR build.
* These will be used as the top-level entrypoints for the client build.
Expand Down Expand Up @@ -113,6 +118,7 @@ export function createBuildInternals(): BuildInternals {
pagesByViteID: new Map(),
pagesByClientOnly: new Map(),
pagesByScriptId: new Map(),
pagesByHydratedComponent: new Map(),

propagatedStylesMap: new Map(),

Expand Down Expand Up @@ -182,6 +188,26 @@ export function trackScriptPageDatas(
}
}

/**
* Tracks hydrated components to the pages they are associated with.
*/
export function trackHydratedComponentPageDatas(
internals: BuildInternals,
pageData: PageBuildData,
hydratedComponents: string[],
) {
for (const hydratedComponent of hydratedComponents) {
let pageDataSet: Set<PageBuildData>;
if (internals.pagesByHydratedComponent.has(hydratedComponent)) {
pageDataSet = internals.pagesByHydratedComponent.get(hydratedComponent)!;
} else {
pageDataSet = new Set<PageBuildData>();
internals.pagesByHydratedComponent.set(hydratedComponent, pageDataSet);
}
pageDataSet.add(pageData);
}
}

export function* getPageDatasByClientOnlyID(
internals: BuildInternals,
viteid: ViteID,
Expand Down
28 changes: 21 additions & 7 deletions packages/astro/src/core/build/plugins/plugin-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { BuildInternals } from '../internal.js';
import {
getPageDataByViteID,
trackClientOnlyPageDatas,
trackHydratedComponentPageDatas,
trackScriptPageDatas,
} from '../internal.js';
import type { AstroBuildPlugin } from '../plugin.js';
Expand All @@ -21,13 +22,26 @@ function vitePluginAnalyzer(internals: BuildInternals): VitePlugin {

const astro = info.meta.astro as AstroPluginMetadata['astro'];

for (const c of astro.hydratedComponents) {
const rid = c.resolvedPath ? decodeURI(c.resolvedPath) : c.specifier;
if (internals.discoveredHydratedComponents.has(rid)) {
const exportNames = internals.discoveredHydratedComponents.get(rid);
exportNames?.push(c.exportName);
} else {
internals.discoveredHydratedComponents.set(rid, [c.exportName]);
if (astro.hydratedComponents.length) {
const hydratedComponents: string[] = [];

for (const c of astro.hydratedComponents) {
const rid = c.resolvedPath ? decodeURI(c.resolvedPath) : c.specifier;
if (internals.discoveredHydratedComponents.has(rid)) {
const exportNames = internals.discoveredHydratedComponents.get(rid);
exportNames?.push(c.exportName);
} else {
internals.discoveredHydratedComponents.set(rid, [c.exportName]);
}
hydratedComponents.push(rid);
}

// Track which pages use each hydrated component
for (const pageInfo of getTopLevelPageModuleInfos(id, this)) {
const newPageData = getPageDataByViteID(internals, pageInfo.id);
if (!newPageData) continue;

trackHydratedComponentPageDatas(internals, newPageData, hydratedComponents);
}
}

Expand Down
93 changes: 93 additions & 0 deletions packages/astro/src/core/build/plugins/plugin-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { BuildInternals } from '../internal.js';
import { getPageDataByViteID, getPageDatasByClientOnlyID } from '../internal.js';
import type { AstroBuildPlugin, BuildTarget } from '../plugin.js';
import type { PageBuildData, StaticBuildOptions, StylesheetAsset } from '../types.js';
import { normalizeEntryId } from './plugin-component-entry.js';
import { extendManualChunks, shouldInlineAsset } from './util.js';

interface PluginOptions {
Expand Down Expand Up @@ -106,6 +107,33 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
},

async generateBundle(_outputOptions, bundle) {
// In the client build, collect which component modules have their exports rendered
// and which pages/entries contain them. This is used to handle CSS with cssScopeTo
// metadata for conditionally rendered components.
const renderedComponentExports = new Map<string, string[]>();
// Map from component module ID to the pages that include it (via facadeModuleId)
const componentToPages = new Map<string, Set<string>>();
if (options.target === 'client') {
for (const [, asset] of Object.entries(bundle)) {
if (asset.type === 'chunk') {
for (const [moduleId, moduleRenderedInfo] of Object.entries(asset.modules)) {
if (moduleRenderedInfo.renderedExports.length > 0) {
renderedComponentExports.set(moduleId, moduleRenderedInfo.renderedExports);
// Track which entry/page this component belongs to
if (asset.facadeModuleId) {
let pages = componentToPages.get(moduleId);
if (!pages) {
pages = new Set();
componentToPages.set(moduleId, pages);
}
pages.add(asset.facadeModuleId);
}
}
}
}
}
}

for (const [, chunk] of Object.entries(bundle)) {
if (chunk.type !== 'chunk') continue;
if ('viteMetadata' in chunk === false) continue;
Expand All @@ -126,6 +154,71 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
}
}
}

// Handle CSS with cssScopeTo metadata for conditionally rendered components.
// These components may not be in the server build (due to conditional rendering)
// but are in the client build. We need to ensure their CSS is included.
for (const id of Object.keys(chunk.modules)) {
const moduleInfo = this.getModuleInfo(id);
const cssScopeTo = moduleInfo?.meta?.vite?.cssScopeTo as [string, string] | undefined;
if (cssScopeTo) {
const [scopedToModule, scopedToExport] = cssScopeTo;
const renderedExports = renderedComponentExports.get(scopedToModule);
// If the component's export is rendered in the client build,
// ensure its CSS is associated with the pages that use it
if (renderedExports?.includes(scopedToExport)) {
// Walk up from the scoped-to module to find pages or scripts
const parentModuleInfos = getParentExtendedModuleInfos(
scopedToModule,
this,
hasAssetPropagationFlag,
);
for (const { info: pageInfo, depth, order } of parentModuleInfos) {
if (moduleIsTopLevelPage(pageInfo)) {
const pageData = getPageDataByViteID(internals, pageInfo.id);
if (pageData) {
appendCSSToPage(pageData, meta, pagesToCss, depth, order);
}
}
// For hydrated components, check if this parent is a script/component entry
// that's tracked in pagesByScriptId
const pageDatas = internals.pagesByScriptId.get(pageInfo.id);
if (pageDatas) {
for (const pageData of pageDatas) {
appendCSSToPage(pageData, meta, pagesToCss, -1, order);
}
}
}

// If we couldn't find a page through normal traversal,
// check if any parent in the chain is a hydrated component and
// use the pagesByHydratedComponent mapping from the server build.
let addedToAnyPage = false;
for (const importedCssImport of meta.importedCss) {
for (const pageData of internals.pagesByKeys.values()) {
const cssToInfoRecord = pagesToCss[pageData.moduleSpecifier];
if (cssToInfoRecord && importedCssImport in cssToInfoRecord) {
addedToAnyPage = true;
break;
}
}
}
if (!addedToAnyPage) {
// Walk up the parent chain and check if any parent is a hydrated component
for (const { info: parentInfo } of parentModuleInfos) {
const normalizedParent = normalizeEntryId(parentInfo.id);
// Check if this parent is tracked as a hydrated component
const pages = internals.pagesByHydratedComponent.get(normalizedParent);
if (pages) {
for (const pageData of pages) {
appendCSSToPage(pageData, meta, pagesToCss, -1, -1);
}
}
}
}
}
}
}
}

// For this CSS chunk, walk parents until you find a page. Add the CSS to that page.
Expand Down
83 changes: 83 additions & 0 deletions packages/integrations/svelte/test/conditional-rendering.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { load as cheerioLoad } from 'cheerio';
import { loadFixture } from '../../../astro/test/test-utils.js';

/**
* @see https://github.com/withastro/astro/issues/14252
*
* Svelte components that are conditionally rendered (inside {#if} blocks)
* should have their styles included in the production build, even when
* the condition is initially false during SSR.
*/

let fixture;

describe('Conditional rendering styles', () => {
before(async () => {
fixture = await loadFixture({
root: new URL('./fixtures/conditional-rendering/', import.meta.url),
});
});

describe('build', () => {
before(async () => {
await fixture.build();
});

it('includes styles for conditionally rendered Svelte components', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerioLoad(html);

// Get all CSS - either inline styles or linked stylesheets
let allCss = '';

// Check inline styles
$('style').each((_, el) => {
allCss += $(el).text();
});

// Check linked stylesheets
const cssLinks = $('link[rel="stylesheet"]');
for (const link of cssLinks.toArray()) {
const href = $(link).attr('href');
if (href) {
const cssContent = await fixture.readFile(href);
allCss += cssContent;
}
}

// Verify that styles from the Child component are included
// The Child has: background-color: red
// Even though the child is not rendered during SSR (showChild starts as false),
// its styles should still be included in the build output
const hasChildStyles = allCss.includes('red');
assert.ok(
hasChildStyles,
`Child component styles (background-color: red) should be included in build output even when conditionally rendered. CSS found: ${allCss.substring(0, 500)}`,
);
});
});

describe('dev', () => {
let devServer;

before(async () => {
devServer = await fixture.startDevServer();
});

after(async () => {
await devServer.stop();
});

it('includes styles for conditionally rendered Svelte components', async () => {
const html = await fixture.fetch('/').then((res) => res.text());

// In dev mode, styles are typically injected via JS
// The component should be present and work correctly
const hasParentComponent = html.includes('parent');

assert.ok(hasParentComponent, 'Parent component should be present in dev mode');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// @ts-check
import { defineConfig } from 'astro/config';

import svelte from '@astrojs/svelte';

// https://astro.build/config
export default defineConfig({
integrations: [svelte()]
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "conditional-rendering",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/svelte": "workspace:*",
"astro": "workspace:*",
"svelte": "5.46.1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div class="child">
This is the child svelte component. It has CSS to give it a RED background color.
</div>

<style>
.child {
background-color: red;
padding: 1rem;
color: white;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script lang="ts">
import Child from './Child.svelte';

let showChild = $state(false);
</script>

<div class="parent">
<p>This is the parent svelte component.</p>

<label>
Show child component
<input type="checkbox" bind:checked={showChild} />
</label>

{#if showChild}
<Child />
{/if}
</div>

<style>
.parent {
border: 1px solid black;
padding: 16px;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
import Parent from '../components/Parent.svelte';
---

<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Conditional Rendering Test</title>
</head>
<body>
<h1>Conditional Rendering Test</h1>
<Parent client:load />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading