diff --git a/.changeset/rare-jars-dance.md b/.changeset/rare-jars-dance.md
new file mode 100644
index 000000000..5c4ae440a
--- /dev/null
+++ b/.changeset/rare-jars-dance.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Fixes an issue where void elements are rendered with opening and closing tags.
diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts
index 37a7dc2e2..07acb0ba0 100644
--- a/packages/astro/src/runtime/server/index.ts
+++ b/packages/astro/src/runtime/server/index.ts
@@ -7,6 +7,8 @@ import { serializeListValue } from './util.js';
export { createMetadata } from './metadata.js';
export type { Metadata } from './metadata';
+const voidElementNames = /^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i;
+
// INVESTIGATE:
// 2. Less anys when possible and make it well known when they are needed.
@@ -223,7 +225,9 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
// This is a custom element without a renderer. Because of that, render it
// as a string and the user is responsible for adding a script tag for the component definition.
if (!html && typeof Component === 'string') {
- html = await renderAstroComponent(await render`<${Component}${spreadAttributes(props)}>${children}${Component}>`);
+ html = await renderAstroComponent(
+ await render`<${Component}${spreadAttributes(props)}${(children == null || children == '') && voidElementNames.test(Component) ? `/>` : `>${children}${Component}>`}`
+ );
}
// This is used to add polyfill scripts to the page, if the renderer needs them.
diff --git a/packages/astro/test/astro-basic.test.js b/packages/astro/test/astro-basic.test.js
index e1991634b..72668820b 100644
--- a/packages/astro/test/astro-basic.test.js
+++ b/packages/astro/test/astro-basic.test.js
@@ -83,6 +83,26 @@ describe('Astro basics', () => {
});
});
+ it('Supports void elements whose name is a string (#2062)', async () => {
+ const html = await fixture.readFile('/input/index.html');
+ const $ = cheerio.load(html);
+
+ //
+ expect($('body > :nth-child(1)').prop('outerHTML')).to.equal('');
+
+ //
+ expect($('body > :nth-child(2)').prop('outerHTML')).to.equal('');
+
+ //
+ expect($('body > :nth-child(3)').prop('outerHTML')).to.equal('');
+
+ //
+ expect($('body > :nth-child(4)').prop('outerHTML')).to.equal('');
+
+ // textarea
+ expect($('body > :nth-child(5)').prop('outerHTML')).to.equal('');
+ });
+
describe('preview', () => {
it('returns 200 for valid URLs', async () => {
const result = await fixture.fetch('/');
diff --git a/packages/astro/test/fixtures/astro-basic/src/components/Input.astro b/packages/astro/test/fixtures/astro-basic/src/components/Input.astro
new file mode 100644
index 000000000..459d2ce3a
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-basic/src/components/Input.astro
@@ -0,0 +1,18 @@
+---
+const {
+ type: initialType,
+ ...props
+} = {
+ ...Astro.props
+} as {
+ [K: string]: any;
+};
+
+const isSelect = /^select$/i.test(initialType);
+const isTextarea = /^textarea$/i.test(initialType);
+
+const Control = isSelect ? 'select' : isTextarea ? 'textarea' : 'input';
+
+if (Control === 'input' && initialType) props.type = initialType;
+---
+