Skip to content

Commit 441e557

Browse files
authored
Enable named slots in renderers (withastro#3652)
* feat: pass all slots to renderers * refactor: pass `slots` as top-level props * test: add named slot test for frameworks * fix: nested hydration, slots that are not initially rendered * test: add nested-recursive e2e test * fix: render unmatched custom element children * chore: update lockfile * fix: unrendered slots for client:only * fix(lit): ensure lit integration uses new slots API * chore: add changeset * chore: add changesets * fix: lit slots * feat: convert dash-case or snake_case slots to camelCase for JSX * feat: remove tmpl special logic * test: add slot components-in-markdown test * refactor: prefer Object.entries.map() to for/of loop Co-authored-by: Nate Moore <[email protected]>
1 parent bac604c commit 441e557

File tree

35 files changed

+597
-36
lines changed

35 files changed

+597
-36
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineConfig } from 'astro/config';
2+
import preact from '@astrojs/preact';
3+
import react from '@astrojs/react';
4+
import svelte from '@astrojs/svelte';
5+
import vue from '@astrojs/vue';
6+
import solid from '@astrojs/solid-js';
7+
8+
// https://astro.build/config
9+
export default defineConfig({
10+
// Enable many frameworks to support all different kinds of components.
11+
integrations: [preact(), react(), svelte(), vue(), solid()],
12+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@e2e/nested-recursive",
3+
"version": "0.0.0",
4+
"private": true,
5+
"devDependencies": {
6+
"@astrojs/preact": "workspace:*",
7+
"@astrojs/react": "workspace:*",
8+
"@astrojs/solid-js": "workspace:*",
9+
"@astrojs/svelte": "workspace:*",
10+
"@astrojs/vue": "workspace:*",
11+
"astro": "workspace:*"
12+
},
13+
"dependencies": {
14+
"preact": "^10.7.3",
15+
"react": "^18.1.0",
16+
"react-dom": "^18.1.0",
17+
"solid-js": "^1.4.3",
18+
"svelte": "^3.48.0",
19+
"vue": "^3.2.36"
20+
},
21+
"scripts": {
22+
"dev": "astro dev"
23+
}
24+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useState } from 'preact/hooks';
2+
3+
/** a counter written in Preact */
4+
export default function PreactCounter({ children, id }) {
5+
const [count, setCount] = useState(0);
6+
const add = () => setCount((i) => i + 1);
7+
const subtract = () => setCount((i) => i - 1);
8+
9+
return (
10+
<div id={id} class="counter">
11+
<button class="decrement" onClick={subtract}>-</button>
12+
<pre id={`${id}-count`}>{count}</pre>
13+
<button id={`${id}-increment`} class="increment" onClick={add}>+</button>
14+
<div class="children">{children}</div>
15+
</div>
16+
);
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useState } from 'react';
2+
3+
/** a counter written in React */
4+
export default function ReactCounter({ children, id }) {
5+
const [count, setCount] = useState(0);
6+
const add = () => setCount((i) => i + 1);
7+
const subtract = () => setCount((i) => i - 1);
8+
9+
return (
10+
<div id={id} className="counter">
11+
<button className="decrement" onClick={subtract}>-</button>
12+
<pre id={`${id}-count`}>{count}</pre>
13+
<button id={`${id}-increment`} className="increment" onClick={add}>+</button>
14+
<div className="children">{children}</div>
15+
</div>
16+
);
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createSignal } from 'solid-js';
2+
3+
/** a counter written with Solid */
4+
export default function SolidCounter({ children, id }) {
5+
const [count, setCount] = createSignal(0);
6+
const add = () => setCount(count() + 1);
7+
const subtract = () => setCount(count() - 1);
8+
9+
return (
10+
<div id={id} class="counter">
11+
<button class="decrement" onClick={subtract}>-</button>
12+
<pre id={`${id}-count`}>{count()}</pre>
13+
<button id={`${id}-increment`} class="increment" onClick={add}>+</button>
14+
<div class="children">{children}</div>
15+
</div>
16+
);
17+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
2+
<script>
3+
export let id;
4+
let children;
5+
let count = 0;
6+
7+
function add() {
8+
count += 1;
9+
}
10+
11+
function subtract() {
12+
count -= 1;
13+
}
14+
</script>
15+
16+
<div {id} class="counter">
17+
<button class="decrement" on:click={subtract}>-</button>
18+
<pre id={`${id}-count`}>{ count }</pre>
19+
<button id={`${id}-increment`} class="increment" on:click={add}>+</button>
20+
<div class="children">
21+
<slot />
22+
</div>
23+
</div>
24+
25+
<style>
26+
.counter {
27+
background: white;
28+
}
29+
</style>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<template>
2+
<div :id="id" class="counter">
3+
<button class="decrement" @click="subtract()">-</button>
4+
<pre :id="`${id}-count`">{{ count }}</pre>
5+
<button :id="`${id}-increment`" class="increment" @click="add()">+</button>
6+
<div class="children">
7+
<slot />
8+
</div>
9+
</div>
10+
</template>
11+
12+
<script>
13+
import { ref } from 'vue';
14+
export default {
15+
props: {
16+
id: {
17+
type: String,
18+
required: true
19+
}
20+
},
21+
setup(props) {
22+
const count = ref(0);
23+
const add = () => (count.value = count.value + 1);
24+
const subtract = () => (count.value = count.value - 1);
25+
26+
return {
27+
id: props.id,
28+
count,
29+
add,
30+
subtract,
31+
};
32+
},
33+
};
34+
</script>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
import ReactCounter from '../components/ReactCounter.jsx';
3+
import PreactCounter from '../components/PreactCounter.tsx';
4+
import SolidCounter from '../components/SolidCounter.tsx';
5+
import VueCounter from '../components/VueCounter.vue';
6+
import SvelteCounter from '../components/SvelteCounter.svelte';
7+
---
8+
9+
<html lang="en">
10+
<head>
11+
<meta charset="utf-8" />
12+
<meta name="viewport" content="width=device-width" />
13+
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
14+
</head>
15+
<body>
16+
<main>
17+
<ReactCounter id="react-counter" client:idle>
18+
<PreactCounter id="preact-counter" client:idle>
19+
<SolidCounter id="solid-counter" client:idle>
20+
<SvelteCounter id="svelte-counter" client:idle>
21+
<VueCounter id="vue-counter" client:idle />
22+
</SvelteCounter>
23+
</SolidCounter>
24+
</PreactCounter>
25+
</ReactCounter>
26+
</main>
27+
</body>
28+
</html>

e2e/nested-recursive.test.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { test as base, expect } from '@playwright/test';
2+
import { loadFixture } from './test-utils.js';
3+
4+
const test = base.extend({
5+
astro: async ({}, use) => {
6+
const fixture = await loadFixture({ root: './fixtures/nested-recursive/' });
7+
await use(fixture);
8+
},
9+
});
10+
11+
let devServer;
12+
13+
test.beforeEach(async ({ astro }) => {
14+
devServer = await astro.startDevServer();
15+
});
16+
17+
test.afterEach(async () => {
18+
await devServer.stop();
19+
});
20+
21+
test.describe('Recursive Nested Frameworks', () => {
22+
test('React counter', async ({ astro, page }) => {
23+
await page.goto('/');
24+
25+
const counter = await page.locator('#react-counter');
26+
await expect(counter, 'component is visible').toBeVisible();
27+
28+
const count = await counter.locator('#react-counter-count');
29+
await expect(count, 'initial count is 0').toHaveText('0');
30+
31+
const increment = await counter.locator('#react-counter-increment');
32+
await increment.click();
33+
34+
await expect(count, 'count incremented by 1').toHaveText('1');
35+
});
36+
37+
test('Preact counter', async ({ astro, page }) => {
38+
await page.goto('/');
39+
40+
const counter = await page.locator('#preact-counter');
41+
await expect(counter, 'component is visible').toBeVisible();
42+
43+
const count = await counter.locator('#preact-counter-count');
44+
await expect(count, 'initial count is 0').toHaveText('0');
45+
46+
const increment = await counter.locator('#preact-counter-increment');
47+
await increment.click();
48+
49+
await expect(count, 'count incremented by 1').toHaveText('1');
50+
});
51+
52+
test('Solid counter', async ({ astro, page }) => {
53+
await page.goto('/');
54+
55+
const counter = await page.locator('#solid-counter');
56+
await expect(counter, 'component is visible').toBeVisible();
57+
58+
const count = await counter.locator('#solid-counter-count');
59+
await expect(count, 'initial count is 0').toHaveText('0');
60+
61+
const increment = await counter.locator('#solid-counter-increment');
62+
await increment.click();
63+
64+
await expect(count, 'count incremented by 1').toHaveText('1');
65+
});
66+
67+
test('Vue counter', async ({ astro, page }) => {
68+
await page.goto('/');
69+
70+
const counter = await page.locator('#vue-counter');
71+
await expect(counter, 'component is visible').toBeVisible();
72+
73+
const count = await counter.locator('#vue-counter-count');
74+
await expect(count, 'initial count is 0').toHaveText('0');
75+
76+
const increment = await counter.locator('#vue-counter-increment');
77+
await increment.click();
78+
79+
await expect(count, 'count incremented by 1').toHaveText('1');
80+
});
81+
82+
test('Svelte counter', async ({ astro, page }) => {
83+
await page.goto('/');
84+
85+
const counter = await page.locator('#svelte-counter');
86+
await expect(counter, 'component is visible').toBeVisible();
87+
88+
const count = await counter.locator('#svelte-counter-count');
89+
await expect(count, 'initial count is 0').toHaveText('0');
90+
91+
const increment = await counter.locator('#svelte-counter-increment');
92+
await increment.click();
93+
94+
await expect(count, 'count incremented by 1').toHaveText('1');
95+
});
96+
});

src/@types/astro.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,7 @@ export interface AstroConfig extends z.output<typeof AstroConfigSchema> {
737737
export type AsyncRendererComponentFn<U> = (
738738
Component: any,
739739
props: any,
740-
children: string | undefined,
740+
slots: Record<string, string>,
741741
metadata?: AstroComponentMetadata
742742
) => Promise<U>;
743743

src/runtime/server/astro-island.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,24 @@ declare const Astro: {
6464
if (!this.hydrator || this.parentElement?.closest('astro-island[ssr]')) {
6565
return;
6666
}
67-
let innerHTML: string | null = null;
68-
let fragment = this.querySelector('astro-fragment');
69-
if (fragment == null && this.hasAttribute('tmpl')) {
70-
// If there is no child fragment, check to see if there is a template.
71-
// This happens if children were passed but the client component did not render any.
72-
let template = this.querySelector('template[data-astro-template]');
73-
if (template) {
74-
innerHTML = template.innerHTML;
75-
template.remove();
76-
}
77-
} else if (fragment) {
78-
innerHTML = fragment.innerHTML;
67+
const slotted = this.querySelectorAll('astro-slot');
68+
const slots: Record<string, string> = {};
69+
// Always check to see if there are templates.
70+
// This happens if slots were passed but the client component did not render them.
71+
const templates = this.querySelectorAll('template[data-astro-template]');
72+
for (const template of templates) {
73+
if (!template.closest(this.tagName)?.isSameNode(this)) continue;
74+
slots[template.getAttribute('data-astro-template') || 'default'] = template.innerHTML;
75+
template.remove();
76+
}
77+
for (const slot of slotted) {
78+
if (!slot.closest(this.tagName)?.isSameNode(this)) continue;
79+
slots[slot.getAttribute('name') || 'default'] = slot.innerHTML;
7980
}
8081
const props = this.hasAttribute('props')
8182
? JSON.parse(this.getAttribute('props')!, reviver)
8283
: {};
83-
this.hydrator(this)(this.Component, props, innerHTML, {
84+
this.hydrator(this)(this.Component, props, slots, {
8485
client: this.getAttribute('client'),
8586
});
8687
this.removeAttribute('ssr');

0 commit comments

Comments
 (0)