Skip to content

Commit c5fe295

Browse files
Only attach SVG component in SSR (#14623)
* Only attach SVG component in SSR * Add test that checks React bundle doesn’t include server code * Properly describe tests * Rename test suite * Add dev server test * Fix lockfile * Make test error messages consistent * Clean up build fixture * Fix fixture name in tests * Fix tests * Add changeset --------- Co-authored-by: Florian Lefebvre <[email protected]>
1 parent dacebaf commit c5fe295

File tree

10 files changed

+152
-6
lines changed

10 files changed

+152
-6
lines changed

.changeset/silver-radios-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes a leak of server runtime code when importing SVGs in client-side code. Previously, when importing an SVG file in client code, Astro could end up adding code for rendering SVGs on the server to the client bundle.

packages/astro/src/assets/vite-plugin-assets.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -241,16 +241,17 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl
241241
});
242242
}
243243

244-
if (id.endsWith('.svg')) {
245-
const contents = await fs.promises.readFile(imageMetadata.fsPath, { encoding: 'utf8' });
246-
// We know that the contents are present, as we only emit this property for SVG files
247-
return { code: makeSvgComponent(imageMetadata, contents) };
248-
}
249-
250244
// We can only reliably determine if an image is used on the server, as we need to track its usage throughout the entire build.
251245
// Since you cannot use image optimization on the client anyway, it's safe to assume that if the user imported
252246
// an image on the client, it should be present in the final build.
253247
if (options?.ssr) {
248+
if (id.endsWith('.svg')) {
249+
const contents = await fs.promises.readFile(imageMetadata.fsPath, {
250+
encoding: 'utf8',
251+
});
252+
// We know that the contents are present, as we only emit this property for SVG files
253+
return { code: makeSvgComponent(imageMetadata, contents) };
254+
}
254255
return {
255256
code: `export default ${getProxyCode(
256257
imageMetadata,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import assert from 'node:assert/strict';
2+
import { after, before, describe, it } from 'node:test';
3+
import * as cheerio from 'cheerio';
4+
import { loadFixture } from './test-utils.js';
5+
6+
describe('astro:assets - SVG Components in Astro Islands', async () => {
7+
/** @type {import('./test-utils.js').Fixture} */
8+
let fixture;
9+
10+
before(async () => {
11+
fixture = await loadFixture({
12+
root: './fixtures/core-image-svg-in-island/',
13+
});
14+
});
15+
16+
describe('dev', () => {
17+
/** @type {import('./test-utils.js').DevServer} */
18+
let devServer;
19+
20+
before(async () => {
21+
devServer = await fixture.startDevServer();
22+
});
23+
24+
after(() => devServer.stop());
25+
26+
it('SVG metadata imported in React island is small', async () => {
27+
const html = await fixture.fetch('/').then((res) => res.text());
28+
const $ = cheerio.load(html, { xml: true });
29+
const island = $('astro-island');
30+
const componentUrl = island.attr('component-url');
31+
assert.ok(componentUrl, 'Expected component-url attribute to be present on astro-island.');
32+
const componentModule = await fixture.fetch(componentUrl).then((res) => res.text());
33+
const imports = componentModule
34+
.split('\n')
35+
.map((line) => line.trim())
36+
.filter((line) => line.startsWith('import '));
37+
const svgImportStatement = imports.find((imp) => imp.includes('src/components/astro.svg'));
38+
assert.ok(svgImportStatement, 'Expected SVG to be imported in the component.');
39+
const importPath = svgImportStatement.split('from')[1].trim().replace(/['";]/g, '');
40+
const mod = await fixture.fetch(importPath).then((res) => res.text());
41+
assert.ok(
42+
mod.length < 1_500,
43+
`Expected SVG module to be smaller than 1500 bytes, got ${mod.length} bytes. If this test fails, it is likely that server code has been imported while importing an SVG.`,
44+
);
45+
assert.ok(
46+
!mod.includes('import'),
47+
'Expected client-side SVG not to include import statements.',
48+
);
49+
});
50+
});
51+
52+
describe('build', () => {
53+
before(() => fixture.build({}));
54+
55+
after(() => fixture.clean());
56+
57+
it('React bundle size is small when importing an SVG', async () => {
58+
const files = await fixture.readdir('_astro');
59+
const bundledReactComponentFilename = files.find(
60+
(f) => f.startsWith('ReactTest.') && f.endsWith('.js'),
61+
);
62+
assert.ok(bundledReactComponentFilename, 'Expected to find React component in build output.');
63+
const bundledReactComponent = await fixture.readFile(
64+
`_astro/${bundledReactComponentFilename}`,
65+
);
66+
assert.ok(bundledReactComponent, 'Expected React component bundle not to be empty');
67+
assert.ok(
68+
bundledReactComponent.length < 1_000,
69+
`Expected React component bundle to be smaller than 1000 bytes, got ${bundledReactComponent.length} bytes. If this test fails, it is likely that server code has been imported while importing an SVG.`,
70+
);
71+
});
72+
});
73+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import react from '@astrojs/react';
2+
import { defineConfig } from 'astro/config';
3+
export default defineConfig({
4+
integrations: [react()],
5+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "@test/core-image-svg-in-client",
3+
"version": "0.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"@astrojs/react": "workspace:*",
7+
"astro": "workspace:*",
8+
"react": "^19.1.1",
9+
"react-dom": "^19.1.1"
10+
},
11+
"scripts": {
12+
"dev": "astro dev",
13+
"build": "astro build"
14+
}
15+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import AstroIcon from './astro.svg';
2+
3+
export const Test = () => {
4+
return (
5+
<img src={AstroIcon.src} alt=""></img>
6+
);
7+
};
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
import { Test } from '../components/ReactTest.tsx';
3+
---
4+
5+
<html>
6+
<head>
7+
<title>SVG in Client Components Test</title>
8+
</head>
9+
<body>
10+
<div id="default">
11+
<Test client:load />
12+
</div>
13+
</body>
14+
</html>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "astro/tsconfigs/strict",
3+
"compilerOptions": {
4+
"jsx": "react-jsx",
5+
"jsxImportSource": "react"
6+
}
7+
}

pnpm-lock.yaml

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)