Skip to content

Commit

Permalink
Enable named slots in renderers (#3652)
Browse files Browse the repository at this point in the history
* 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 <nate@astro.build>
  • Loading branch information
natemoo-re and natemoo-re authored Jun 23, 2022
1 parent 19cd962 commit 7373d61
Show file tree
Hide file tree
Showing 60 changed files with 827 additions and 157 deletions.
7 changes: 7 additions & 0 deletions .changeset/clever-pumpkins-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astrojs/lit': minor
---

Adds support for passing named slots from `.astro` => Lit components.

All slots are treated as Light DOM content.
29 changes: 29 additions & 0 deletions .changeset/lovely-bulldogs-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@astrojs/preact': minor
'@astrojs/react': minor
'@astrojs/solid-js': minor
---

Add support for passing named slots from `.astro` => framework components.

Each `slot` is be passed as a top-level prop. For example:

```jsx
// From .astro
<Component>
<h2 slot="title">Hello world!</h2>
<h2 slot="slot-with-dash">Dash</h2>
<div>Default</div>
</Component>

// For .jsx
export default function Component({ title, slotWithDash, children }) {
return (
<>
<div id="title">{title}</div>
<div id="slot-with-dash">{slotWithDash}</div>
<div id="main">{children}</div>
</>
)
}
```
7 changes: 7 additions & 0 deletions .changeset/mean-ears-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'astro': patch
---

Add renderer support for passing named slots to framework components.

**BREAKING**: integrations using the `addRenderer()` API are now passed all named slots via `Record<string, string>` rather than `string`. Previously only the default slot was passed.
8 changes: 8 additions & 0 deletions .changeset/tough-ants-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@astrojs/svelte': minor
'@astrojs/vue': minor
---

Adds support for passing named slots from `.astro` => framework components.

Inside your components, use the built-in `slot` API as you normally would.
12 changes: 12 additions & 0 deletions packages/astro/e2e/fixtures/nested-recursive/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from 'astro/config';
import preact from '@astrojs/preact';
import react from '@astrojs/react';
import svelte from '@astrojs/svelte';
import vue from '@astrojs/vue';
import solid from '@astrojs/solid-js';

// https://astro.build/config
export default defineConfig({
// Enable many frameworks to support all different kinds of components.
integrations: [preact(), react(), svelte(), vue(), solid()],
});
24 changes: 24 additions & 0 deletions packages/astro/e2e/fixtures/nested-recursive/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@e2e/nested-recursive",
"version": "0.0.0",
"private": true,
"devDependencies": {
"@astrojs/preact": "workspace:*",
"@astrojs/react": "workspace:*",
"@astrojs/solid-js": "workspace:*",
"@astrojs/svelte": "workspace:*",
"@astrojs/vue": "workspace:*",
"astro": "workspace:*"
},
"dependencies": {
"preact": "^10.7.3",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"solid-js": "^1.4.3",
"svelte": "^3.48.0",
"vue": "^3.2.36"
},
"scripts": {
"dev": "astro dev"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useState } from 'preact/hooks';

/** a counter written in Preact */
export default function PreactCounter({ children, id }) {
const [count, setCount] = useState(0);
const add = () => setCount((i) => i + 1);
const subtract = () => setCount((i) => i - 1);

return (
<div id={id} class="counter">
<button class="decrement" onClick={subtract}>-</button>
<pre id={`${id}-count`}>{count}</pre>
<button id={`${id}-increment`} class="increment" onClick={add}>+</button>
<div class="children">{children}</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useState } from 'react';

/** a counter written in React */
export default function ReactCounter({ children, id }) {
const [count, setCount] = useState(0);
const add = () => setCount((i) => i + 1);
const subtract = () => setCount((i) => i - 1);

return (
<div id={id} className="counter">
<button className="decrement" onClick={subtract}>-</button>
<pre id={`${id}-count`}>{count}</pre>
<button id={`${id}-increment`} className="increment" onClick={add}>+</button>
<div className="children">{children}</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createSignal } from 'solid-js';

/** a counter written with Solid */
export default function SolidCounter({ children, id }) {
const [count, setCount] = createSignal(0);
const add = () => setCount(count() + 1);
const subtract = () => setCount(count() - 1);

return (
<div id={id} class="counter">
<button class="decrement" onClick={subtract}>-</button>
<pre id={`${id}-count`}>{count()}</pre>
<button id={`${id}-increment`} class="increment" onClick={add}>+</button>
<div class="children">{children}</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@

<script>
export let id;
let children;
let count = 0;
function add() {
count += 1;
}
function subtract() {
count -= 1;
}
</script>

<div {id} class="counter">
<button class="decrement" on:click={subtract}>-</button>
<pre id={`${id}-count`}>{ count }</pre>
<button id={`${id}-increment`} class="increment" on:click={add}>+</button>
<div class="children">
<slot />
</div>
</div>

<style>
.counter {
background: white;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<template>
<div :id="id" class="counter">
<button class="decrement" @click="subtract()">-</button>
<pre :id="`${id}-count`">{{ count }}</pre>
<button :id="`${id}-increment`" class="increment" @click="add()">+</button>
<div class="children">
<slot />
</div>
</div>
</template>

<script>
import { ref } from 'vue';
export default {
props: {
id: {
type: String,
required: true
}
},
setup(props) {
const count = ref(0);
const add = () => (count.value = count.value + 1);
const subtract = () => (count.value = count.value - 1);
return {
id: props.id,
count,
add,
subtract,
};
},
};
</script>
28 changes: 28 additions & 0 deletions packages/astro/e2e/fixtures/nested-recursive/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
import ReactCounter from '../components/ReactCounter.jsx';
import PreactCounter from '../components/PreactCounter.tsx';
import SolidCounter from '../components/SolidCounter.tsx';
import VueCounter from '../components/VueCounter.vue';
import SvelteCounter from '../components/SvelteCounter.svelte';
---

<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
</head>
<body>
<main>
<ReactCounter id="react-counter" client:idle>
<PreactCounter id="preact-counter" client:idle>
<SolidCounter id="solid-counter" client:idle>
<SvelteCounter id="svelte-counter" client:idle>
<VueCounter id="vue-counter" client:idle />
</SvelteCounter>
</SolidCounter>
</PreactCounter>
</ReactCounter>
</main>
</body>
</html>
96 changes: 96 additions & 0 deletions packages/astro/e2e/nested-recursive.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { test as base, expect } from '@playwright/test';
import { loadFixture } from './test-utils.js';

const test = base.extend({
astro: async ({}, use) => {
const fixture = await loadFixture({ root: './fixtures/nested-recursive/' });
await use(fixture);
},
});

let devServer;

test.beforeEach(async ({ astro }) => {
devServer = await astro.startDevServer();
});

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

test.describe('Recursive Nested Frameworks', () => {
test('React counter', async ({ astro, page }) => {
await page.goto('/');

const counter = await page.locator('#react-counter');
await expect(counter, 'component is visible').toBeVisible();

const count = await counter.locator('#react-counter-count');
await expect(count, 'initial count is 0').toHaveText('0');

const increment = await counter.locator('#react-counter-increment');
await increment.click();

await expect(count, 'count incremented by 1').toHaveText('1');
});

test('Preact counter', async ({ astro, page }) => {
await page.goto('/');

const counter = await page.locator('#preact-counter');
await expect(counter, 'component is visible').toBeVisible();

const count = await counter.locator('#preact-counter-count');
await expect(count, 'initial count is 0').toHaveText('0');

const increment = await counter.locator('#preact-counter-increment');
await increment.click();

await expect(count, 'count incremented by 1').toHaveText('1');
});

test('Solid counter', async ({ astro, page }) => {
await page.goto('/');

const counter = await page.locator('#solid-counter');
await expect(counter, 'component is visible').toBeVisible();

const count = await counter.locator('#solid-counter-count');
await expect(count, 'initial count is 0').toHaveText('0');

const increment = await counter.locator('#solid-counter-increment');
await increment.click();

await expect(count, 'count incremented by 1').toHaveText('1');
});

test('Vue counter', async ({ astro, page }) => {
await page.goto('/');

const counter = await page.locator('#vue-counter');
await expect(counter, 'component is visible').toBeVisible();

const count = await counter.locator('#vue-counter-count');
await expect(count, 'initial count is 0').toHaveText('0');

const increment = await counter.locator('#vue-counter-increment');
await increment.click();

await expect(count, 'count incremented by 1').toHaveText('1');
});

test('Svelte counter', async ({ astro, page }) => {
await page.goto('/');

const counter = await page.locator('#svelte-counter');
await expect(counter, 'component is visible').toBeVisible();

const count = await counter.locator('#svelte-counter-count');
await expect(count, 'initial count is 0').toHaveText('0');

const increment = await counter.locator('#svelte-counter-increment');
await increment.click();

await expect(count, 'count incremented by 1').toHaveText('1');
});
});
2 changes: 1 addition & 1 deletion packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@ export interface AstroConfig extends z.output<typeof AstroConfigSchema> {
export type AsyncRendererComponentFn<U> = (
Component: any,
props: any,
children: string | undefined,
slots: Record<string, string>,
metadata?: AstroComponentMetadata
) => Promise<U>;

Expand Down
27 changes: 14 additions & 13 deletions packages/astro/src/runtime/server/astro-island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,23 +64,24 @@ declare const Astro: {
if (!this.hydrator || this.parentElement?.closest('astro-island[ssr]')) {
return;
}
let innerHTML: string | null = null;
let fragment = this.querySelector('astro-fragment');
if (fragment == null && this.hasAttribute('tmpl')) {
// If there is no child fragment, check to see if there is a template.
// This happens if children were passed but the client component did not render any.
let template = this.querySelector('template[data-astro-template]');
if (template) {
innerHTML = template.innerHTML;
template.remove();
}
} else if (fragment) {
innerHTML = fragment.innerHTML;
const slotted = this.querySelectorAll('astro-slot');
const slots: Record<string, string> = {};
// Always check to see if there are templates.
// This happens if slots were passed but the client component did not render them.
const templates = this.querySelectorAll('template[data-astro-template]');
for (const template of templates) {
if (!template.closest(this.tagName)?.isSameNode(this)) continue;
slots[template.getAttribute('data-astro-template') || 'default'] = template.innerHTML;
template.remove();
}
for (const slot of slotted) {
if (!slot.closest(this.tagName)?.isSameNode(this)) continue;
slots[slot.getAttribute('name') || 'default'] = slot.innerHTML;
}
const props = this.hasAttribute('props')
? JSON.parse(this.getAttribute('props')!, reviver)
: {};
this.hydrator(this)(this.Component, props, innerHTML, {
this.hydrator(this)(this.Component, props, slots, {
client: this.getAttribute('client'),
});
this.removeAttribute('ssr');
Expand Down
Loading

0 comments on commit 7373d61

Please sign in to comment.