Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(rendering): prevent removal of necessary <astro-slot> elements #10317

Merged
merged 7 commits into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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/thick-geckos-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Fixes an issue where elements slotted within interactive framework components disappeared after hydration.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
const Tag = 'div';
---
<Tag>
<slot />
</Tag>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<script>
export let id;
</script>
<div {id}>Slot goes here:<slot /></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
import Poly from '../components/Poly.astro';
import Stuff from '../components/Stuff.svelte';
---

<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Astro</title>
</head>
<body>
<h1>Astro</h1>

<Poly>
<Stuff client:load id="hydratable">poo</Stuff>
</Poly>

<Poly>
<Stuff id="ssr-only">bar</Stuff>
</Poly>
</body>
</html>
11 changes: 11 additions & 0 deletions packages/astro/e2e/svelte-component.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect } from '@playwright/test';
import { prepareTestFactory } from './shared-component-tests.js';
import { waitForHydrate } from './test-utils.js';

const { test, createTests } = prepareTestFactory({ root: './fixtures/svelte-component/' });

Expand Down Expand Up @@ -35,3 +36,13 @@ test.describe('Svelte components lifecycle', () => {
expect((await toggle.textContent()).trim()).toBe('open');
});
});


test.describe('Slotting content into svelte components', () => {
test('should stay after hydration', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/with-slots'));
const hydratableElement = page.locator('#hydratable');
await waitForHydrate(page, hydratableElement);
await expect(hydratableElement).toHaveText("Slot goes here:poo");
});
});
4 changes: 3 additions & 1 deletion packages/astro/src/runtime/server/render/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { renderAllHeadContent } from './head.js';
import { isRenderInstruction } from './instruction.js';
import { type SlotString, isSlotString } from './slot.js';
import { restoreHydratableAstroSlot } from './component.js';

/**
* Possible chunk types to be written to the destination, and it'll
Expand Down Expand Up @@ -137,7 +138,8 @@ export function chunkToByteArray(
} else {
// `stringifyChunk` might return a HTMLString, call `.toString()` to really ensure it's a string
const stringified = stringifyChunk(result, chunk);
return encoder.encode(stringified.toString());
const restoredMarkup = restoreHydratableAstroSlot(stringified)
return encoder.encode(restoredMarkup.toString());
}
}

Expand Down
26 changes: 25 additions & 1 deletion packages/astro/src/runtime/server/render/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,34 @@ function isHTMLComponent(Component: unknown) {
}

const ASTRO_SLOT_EXP = /<\/?astro-slot\b[^>]*>/g;
// same as ASTRO_SLOT_EXP, but only includes the tag name in the match while requiring that it still be surrounded by "<" and ">"
const ASTRO_SLOT_TAGNAME_EXP = /(?<=<\/?)astro-slot(?=\b[^>]*>)/g;
// used to match temporary tags that will be replaced back with astro-slot
const ASTRO_PRESERVED_SLOT_TAGNAME_EXP = /(?<=<\/?)astro-preserved-slot(?=\b[^>]*>)/g;

const ASTRO_STATIC_SLOT_EXP = /<\/?astro-static-slot\b[^>]*>/g;
// same as ASTRO_STATIC_SLOT_EXP, but only includes the tag name in the match while requiring that it still be surrounded by "<" and ">"
const ASTRO_STATIC_SLOT_TAGNAME_EXP = /(?<=<\/?)astro-static-slot(?=\b[^>]*>)/g;
// used to match temporary tags that will be replaced back with astro-static-slot
const ASTRO_PRESERVED_STATIC_SLOT_TAGNAME_EXP = /(?<=<\/?)astro-preserved-static-slot(?=\b[^>]*>)/g;

function removeStaticAstroSlot(html: string, supportsAstroStaticSlot: boolean) {
const exp = supportsAstroStaticSlot ? ASTRO_STATIC_SLOT_EXP : ASTRO_SLOT_EXP;
return html.replace(exp, '');
}

// An HTML string may be processed by the parent of a parent, and if it isn't to be hydrated, astro-slot tags will be incorrectly removed.
Copy link
Member

Choose a reason for hiding this comment

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

"parent of a parent" -> "grandparent"?

// We rename them here so that the regex doesn't match.
function preserveHydratableAstroSlot(html: string) {
return html.replaceAll(ASTRO_STATIC_SLOT_TAGNAME_EXP, 'astro-preserved-static-slot')
.replaceAll(ASTRO_SLOT_TAGNAME_EXP, 'astro-preserved-slot');
}

export function restoreHydratableAstroSlot(html: string) {
return html.replaceAll(ASTRO_PRESERVED_STATIC_SLOT_TAGNAME_EXP, 'astro-static-slot')
.replaceAll(ASTRO_PRESERVED_SLOT_TAGNAME_EXP, 'astro-slot');
}

async function renderFrameworkComponent(
result: SSRResult,
displayName: string,
Expand Down Expand Up @@ -391,7 +413,9 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
})
);
}
destination.write(markHTMLString(renderElement('astro-island', island, false)));
const renderedElement = renderElement('astro-island', island, false);
const hydratableMarkup = preserveHydratableAstroSlot(renderedElement);
destination.write(markHTMLString(hydratableMarkup));
},
};
}
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/runtime/server/render/page.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { RouteData, SSRResult } from '../../../@types/astro.js';
import { type NonAstroPageComponent, renderComponentToString } from './component.js';
import { type NonAstroPageComponent, renderComponentToString, restoreHydratableAstroSlot } from './component.js';
import type { AstroComponentFactory } from './index.js';

import { isAstroComponentFactory } from './astro/index.js';
Expand Down Expand Up @@ -31,7 +31,7 @@ export async function renderPage(
route
);

const bytes = encoder.encode(str);
const bytes = encoder.encode(restoreHydratableAstroSlot(str));

return new Response(bytes, {
headers: new Headers([
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/test/astro-slot-with-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ describe('Slots with client: directives', () => {
assert.equal($('script').length, 1);
});

it('Astro slot tags are cleaned', async () => {
it('Astro slot tags are kept', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
assert.equal($('astro-slot').length, 0);
assert.equal($('astro-slot').length, 1);
});
Comment on lines -21 to 25
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test was checking for the conditions that cause the bug. We need <astro-slot> elements to exist to allow slots to render after hydration.

});
Loading