Enable named slots in renderers (#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 <nate@astro.build>
This commit is contained in:
Nate Moore 2022-06-23 10:10:54 -05:00 committed by GitHub
parent 19cd962d0b
commit 7373d61cdc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 826 additions and 156 deletions

View file

@ -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.

View file

@ -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>
</>
)
}
```

View file

@ -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.

View file

@ -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.

View file

@ -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()],
});

View file

@ -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"
}
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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');
});
});

View file

@ -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>;

View file

@ -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');

View file

@ -208,7 +208,16 @@ Did you mean to add ${formatList(probableRendererNames.map((r) => '`' + r + '`')
throw new Error(message);
}
const children = await renderSlot(result, slots?.default);
const children: Record<string, string> = {};
if (slots) {
await Promise.all(
Object.entries(slots).map(([key, value]) =>
renderSlot(result, value as string).then((output) => {
children[key] = output;
})
)
);
}
// Call the renderers `check` hook to see if any claim this component.
let renderer: SSRLoadedRenderer | undefined;
if (metadata.hydrate !== 'only') {
@ -307,11 +316,12 @@ 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') {
const childSlots = Object.values(children).join('');
html = await renderAstroComponent(
await render`<${Component}${internalSpreadAttributes(props)}${markHTMLString(
(children == null || children == '') && voidElementNames.test(Component)
childSlots === '' && voidElementNames.test(Component)
? `/>`
: `>${children == null ? '' : children}</${Component}>`
: `>${childSlots}</${Component}>`
)}`
);
}
@ -320,7 +330,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
if (isPage) {
return html;
}
return markHTMLString(html.replace(/\<\/?astro-fragment\>/g, ''));
return markHTMLString(html.replace(/\<\/?astro-slot\>/g, ''));
}
// Include componentExport name, componentUrl, and props in hash to dedupe identical islands
@ -336,13 +346,30 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
);
result._metadata.needsHydrationStyles = true;
// Render a template if no fragment is provided.
const needsAstroTemplate = children && !/<\/?astro-fragment\>/.test(html);
const template = needsAstroTemplate ? `<template data-astro-template>${children}</template>` : '';
if (needsAstroTemplate) {
island.props.tmpl = '';
// Render template if not all astro fragments are provided.
let unrenderedSlots: string[] = [];
if (html) {
if (Object.keys(children).length > 0) {
for (const key of Object.keys(children)) {
if (!html.includes(key === 'default' ? `<astro-slot>` : `<astro-slot name="${key}">`)) {
unrenderedSlots.push(key);
}
}
}
} else {
unrenderedSlots = Object.keys(children);
}
const template =
unrenderedSlots.length > 0
? unrenderedSlots
.map(
(key) =>
`<template data-astro-template${key !== 'default' ? `="${key}"` : ''}>${
children[key]
}</template>`
)
.join('')
: '';
island.children = `${html ?? ''}${template}`;
@ -652,7 +679,7 @@ export async function renderHead(result: SSRResult): Promise<string> {
styles.push(
renderElement('style', {
props: {},
children: 'astro-island, astro-fragment { display: contents; }',
children: 'astro-island, astro-slot { display: contents; }',
})
);
}

View file

@ -29,6 +29,10 @@ export class MyElement extends LitElement {
<div id="str">${this.str}</div>
<div id="data">data: ${this.obj.data}</div>
<div id="win">${typeofwindow}</div>
<!-- Slots -->
<div id="default"><slot /></div>
<div id="named"><slot name="named" /></div>
`;
}
}

View file

@ -0,0 +1,15 @@
---
import {MyElement} from '../components/my-element.js';
---
<html>
<head>
<title>LitElement | Slot</title>
</head>
<body>
<MyElement>
<div>default</div>
<div slot="named">named</div>
</MyElement>
</body>
</html>

View file

@ -1,7 +1,7 @@
import { h, Fragment } from 'preact';
import { useState } from 'preact/hooks'
export default function Counter({ children, count: initialCount, case: id }) {
export default function Counter({ named, dashCase, children, count: initialCount, case: id }) {
const [count, setCount] = useState(initialCount);
const add = () => setCount((i) => i + 1);
const subtract = () => setCount((i) => i - 1);
@ -15,6 +15,8 @@ export default function Counter({ children, count: initialCount, case: id }) {
</div>
<div id={id} className="counter-message">
{children || <h1>Fallback</h1>}
{named}
{dashCase}
</div>
</>
);

View file

@ -8,4 +8,6 @@ import Counter from '../components/Counter.jsx'
<Counter case="false" client:visible>{false}</Counter>
<Counter case="string" client:visible>{''}</Counter>
<Counter case="content" client:visible><h1 id="slotted">Hello world!</h1></Counter>
<Counter case="named" client:visible><h1 slot="named"> / Named</h1></Counter>
<Counter case="dash-case" client:visible><h1 slot="dash-case"> / Dash Case</h1></Counter>
</main>

View file

@ -0,0 +1,9 @@
---
setup: import Counter from '../components/Counter.jsx'
---
# Slots: Preact
<Counter case="content" client:visible><h1 id="slotted">Hello world!</h1></Counter>
<Counter case="named" client:visible><h1 slot="named"> / Named</h1></Counter>
<Counter case="dash-case" client:visible><h1 slot="dash-case"> / Dash Case</h1></Counter>

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react';
export default function Counter({ children, count: initialCount, case: id }) {
export default function Counter({ named, dashCase, children, count: initialCount, case: id }) {
const [count, setCount] = useState(initialCount);
const add = () => setCount((i) => i + 1);
const subtract = () => setCount((i) => i - 1);
@ -14,6 +14,8 @@ export default function Counter({ children, count: initialCount, case: id }) {
</div>
<div id={id} className="counter-message">
{children || <h1>Fallback</h1>}
{named}
{dashCase}
</div>
</>
);

View file

@ -8,4 +8,6 @@ import Counter from '../components/Counter.jsx'
<Counter case="false" client:visible>{false}</Counter>
<Counter case="string" client:visible>{''}</Counter>
<Counter case="content" client:visible><h1 id="slotted">Hello world!</h1></Counter>
<Counter case="named" client:visible><h1 slot="named"> / Named</h1></Counter>
<Counter case="dash-case" client:visible><h1 slot="dash-case"> / Dash Case</h1></Counter>
</main>

View file

@ -0,0 +1,9 @@
---
setup: import Counter from '../components/Counter.jsx'
---
# Slots: React
<Counter case="content" client:visible><h1 id="slotted">Hello world!</h1></Counter>
<Counter case="named" client:visible><h1 slot="named"> / Named</h1></Counter>
<Counter case="dash-case" client:visible><h1 slot="dash-case"> / Dash Case</h1></Counter>

View file

@ -1,6 +1,6 @@
import { createSignal } from 'solid-js';
export default function Counter({ children, count: initialCount, case: id }) {
export default function Counter({ named, dashCase, children, count: initialCount, case: id }) {
const [count] = createSignal(0);
return (
<>
@ -9,6 +9,8 @@ export default function Counter({ children, count: initialCount, case: id }) {
</div>
<div id={id} className="counter-message">
{children || <h1>Fallback</h1>}
{named}
{dashCase}
</div>
</>
);

View file

@ -8,4 +8,6 @@ import Counter from '../components/Counter.jsx'
<Counter case="false" client:visible>{false}</Counter>
<Counter case="string" client:visible>{''}</Counter>
<Counter case="content" client:visible><h1 id="slotted">Hello world!</h1></Counter>
<Counter case="named" client:visible><h1 slot="named"> / Named</h1></Counter>
<Counter case="dash-case" client:visible><h1 slot="dash-case"> / Dash Case</h1></Counter>
</main>

View file

@ -0,0 +1,9 @@
---
setup: import Counter from '../components/Counter.jsx'
---
# Slots: Solid
<Counter case="content" client:visible><h1 id="slotted">Hello world!</h1></Counter>
<Counter case="named" client:visible><h1 slot="named"> / Named</h1></Counter>
<Counter case="dash-case" client:visible><h1 slot="dash-case"> / Dash Case</h1></Counter>

View file

@ -17,9 +17,7 @@
<button on:click={add}>+</button>
</div>
<div id={id}>
<slot>
<h1 id="fallback">Fallback</h1>
</slot>
<slot><h1 id="fallback">Fallback</h1></slot><slot name="named" /><slot name="dash-case" />
</div>
<style>

View file

@ -4,8 +4,10 @@ import Counter from '../components/Counter.svelte'
<main>
<Counter id="default-self-closing" client:visible/>
<Counter id="default-empty" client:visible></Counter>
<Counter case="zero" client:visible>{0}</Counter>
<Counter case="false" client:visible>{false}</Counter>
<Counter case="string" client:visible>{''}</Counter>
<Counter id="zero" client:visible>{0}</Counter>
<Counter id="false" client:visible>{false}</Counter>
<Counter id="string" client:visible>{''}</Counter>
<Counter id="content" client:visible><h1 id="slotted">Hello world!</h1></Counter>
<Counter id="named" client:visible><h1 slot="named"> / Named</h1></Counter>
<Counter id="dash-case" client:visible><h1 slot="dash-case"> / Dash Case</h1></Counter>
</main>

View file

@ -0,0 +1,9 @@
---
setup: import Counter from '../components/Counter.svelte'
---
# Slots: Svelte
<Counter id="content" client:visible><h1 id="slotted">Hello world!</h1></Counter>
<Counter id="named" client:visible><h1 slot="named"> / Named</h1></Counter>
<Counter id="dash-case" client:visible><h1 slot="dash-case"> / Dash Case</h1></Counter>

View file

@ -8,6 +8,8 @@
<slot>
<h1>Fallback</h1>
</slot>
<slot name="named" />
<slot name="dash-case"></slot>
</div>
</template>

View file

@ -8,4 +8,6 @@ import Counter from '../components/Counter.vue'
<Counter case="false" client:visible>{false}</Counter>
<Counter case="string" client:visible>{''}</Counter>
<Counter case="content" client:visible><h1 id="slotted">Hello world!</h1></Counter>
<Counter case="named" client:visible><h1 slot="named"> / Named</h1></Counter>
<Counter case="dash-case" client:visible><h1 slot="dash-case"> / Dash Case</h1></Counter>
</main>

View file

@ -0,0 +1,9 @@
---
setup: import Counter from '../components/Counter.vue'
---
# Slots: Vue
<Counter case="content" client:visible><h1 id="slotted">Hello world!</h1></Counter>
<Counter case="named" client:visible><h1 slot="named"> / Named</h1></Counter>
<Counter case="dash-case" client:visible><h1 slot="dash-case"> / Dash Case</h1></Counter>

View file

@ -61,4 +61,23 @@ describe('LitElement test', function () {
expect($('my-element').attr('reflected-str')).to.equal('default reflected string');
expect($('my-element').attr('reflected-str-prop')).to.equal('initialized reflected');
});
it('Correctly passes child slots', async () => {
// @lit-labs/ssr/ requires Node 13.9 or higher
if (NODE_VERSION < 13.9) {
return;
}
const html = await fixture.readFile('/slots/index.html');
const $ = cheerio.load(html);
expect($('my-element').length).to.equal(1);
const [defaultSlot, namedSlot] = $('template').siblings().toArray();
// has default slot content in lightdom
expect($(defaultSlot).text()).to.equal('default');
// has named slot content in lightdom
expect($(namedSlot).text()).to.equal('named');
});
});

View file

@ -21,4 +21,36 @@ describe('Slots: Preact', () => {
expect($('#string').text().trim()).to.equal('');
expect($('#content').text().trim()).to.equal('Hello world!');
});
it('Renders named slot', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('#named').text().trim()).to.equal('Fallback / Named');
})
it('Converts dash-case slot to camelCase', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case');
})
describe('For Markdown Pages', () => {
it('Renders default slot', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#content').text().trim()).to.equal('Hello world!');
});
it('Renders named slot', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#named').text().trim()).to.equal('Fallback / Named');
})
it('Converts dash-case slot to camelCase', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case');
})
})
});

View file

@ -21,4 +21,36 @@ describe('Slots: React', () => {
expect($('#string').text().trim()).to.equal('');
expect($('#content').text().trim()).to.equal('Hello world!');
});
it('Renders named slot', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('#named').text().trim()).to.equal('Fallback / Named');
})
it('Converts dash-case slot to camelCase', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case');
})
describe('For Markdown Pages', () => {
it('Renders default slot', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#content').text().trim()).to.equal('Hello world!');
});
it('Renders named slot', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#named').text().trim()).to.equal('Fallback / Named');
})
it('Converts dash-case slot to camelCase', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case');
})
})
});

View file

@ -21,4 +21,36 @@ describe('Slots: Solid', () => {
expect($('#string').text().trim()).to.equal('');
expect($('#content').text().trim()).to.equal('Hello world!');
});
it('Renders named slot', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('#named').text().trim()).to.equal('Fallback / Named');
})
it('Converts dash-case slot to camelCase', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case');
})
describe('For Markdown Pages', () => {
it('Renders default slot', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#content').text().trim()).to.equal('Hello world!');
});
it('Renders named slot', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#named').text().trim()).to.equal('Fallback / Named');
})
it('Converts dash-case slot to camelCase', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case');
})
})
});

View file

@ -16,9 +16,41 @@ describe('Slots: Svelte', () => {
expect($('#default-self-closing').text().trim()).to.equal('Fallback');
expect($('#default-empty').text().trim()).to.equal('Fallback');
expect($('#zero').text().trim()).to.equal('');
expect($('#zero').text().trim()).to.equal('0');
expect($('#false').text().trim()).to.equal('');
expect($('#string').text().trim()).to.equal('');
expect($('#content').text().trim()).to.equal('Hello world!');
});
it('Renders named slot', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('#named').text().trim()).to.equal('Fallback / Named');
})
it('Preserves dash-case slot', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case');
})
describe('For Markdown Pages', () => {
it('Renders default slot', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#content').text().trim()).to.equal('Hello world!');
});
it('Renders named slot', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#named').text().trim()).to.equal('Fallback / Named');
})
it('Converts dash-case slot to camelCase', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case');
})
})
});

View file

@ -21,4 +21,36 @@ describe('Slots: Vue', () => {
expect($('#string').text().trim()).to.equal('');
expect($('#content').text().trim()).to.equal('Hello world!');
});
it('Renders named slot', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('#named').text().trim()).to.equal('Fallback / Named');
})
it('Preserves dash-case slot', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case');
})
describe('For Markdown Pages', () => {
it('Renders default slot', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#content').text().trim()).to.equal('Hello world!');
});
it('Renders named slot', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#named').text().trim()).to.equal('Fallback / Named');
})
it('Converts dash-case slot to camelCase', async () => {
const html = await fixture.readFile('/markdown/index.html');
const $ = cheerio.load(html);
expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case');
})
})
});

View file

@ -26,7 +26,7 @@ async function check(Component, _props, _children) {
return !!(await isLitElement(Component));
}
function* render(Component, attrs, children) {
function* render(Component, attrs, slots) {
let tagName = Component;
if (typeof tagName !== 'string') {
tagName = Component[Symbol.for('tagName')];
@ -57,15 +57,23 @@ function* render(Component, attrs, children) {
yield* shadowContents;
yield '</template>';
}
yield children || ''; // dont print “undefined” as string
if (slots) {
for (const [slot, value] of Object.entries(slots)) {
if (slot === 'default') {
yield `<astro-slot>${value || ''}</astro-slot>`;
} else {
yield `<astro-slot slot="${slot}">${value || ''}</astro-slot>`;
}
}
}
yield `</${tagName}>`;
}
async function renderToStaticMarkup(Component, props, children) {
async function renderToStaticMarkup(Component, props, slots) {
let tagName = Component;
let out = '';
for (let chunk of render(tagName, props, children)) {
for (let chunk of render(tagName, props, slots)) {
out += chunk;
}

View file

@ -1,8 +1,11 @@
import { h, render } from 'preact';
import StaticHtml from './static-html.js';
export default (element) => (Component, props, children) => {
export default (element) => (Component, props, { default: children, ...slotted }) => {
if (!element.hasAttribute('ssr')) return;
for (const [key, value] of Object.entries(slotted)) {
props[key] = h(StaticHtml, { value, name: key });
}
render(
h(Component, props, children != null ? h(StaticHtml, { value: children }) : children),
element

View file

@ -2,6 +2,8 @@ import { h, Component as BaseComponent } from 'preact';
import render from 'preact-render-to-string';
import StaticHtml from './static-html.js';
const slotName = str => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
function check(Component, props, children) {
if (typeof Component !== 'function') return false;
@ -24,9 +26,16 @@ function check(Component, props, children) {
}
}
function renderToStaticMarkup(Component, props, children) {
function renderToStaticMarkup(Component, props, { default: children, ...slotted }) {
const slots = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = h(StaticHtml, { value, name });
}
// Note: create newProps to avoid mutating `props` before they are serialized
const newProps = { ...props, ...slots }
const html = render(
h(Component, props, children != null ? h(StaticHtml, { value: children }) : children)
h(Component, newProps, children != null ? h(StaticHtml, { value: children }) : children)
);
return { html };
}

View file

@ -7,9 +7,9 @@ import { h } from 'preact';
* As a bonus, we can signal to Preact that this subtree is
* entirely static and will never change via `shouldComponentUpdate`.
*/
const StaticHtml = ({ value }) => {
const StaticHtml = ({ value, name }) => {
if (!value) return null;
return h('astro-fragment', { dangerouslySetInnerHTML: { __html: value } });
return h('astro-slot', { name, dangerouslySetInnerHTML: { __html: value } });
};
/**

View file

@ -3,7 +3,10 @@ import { render, hydrate } from 'react-dom';
import StaticHtml from './static-html.js';
export default (element) =>
(Component, props, children, { client }) => {
(Component, props, { default: children, ...slotted }, { client }) => {
for (const [key, value] of Object.entries(slotted)) {
props[key] = createElement(StaticHtml, { value, name: key });
}
const componentEl = createElement(
Component,
props,

View file

@ -11,8 +11,11 @@ function isAlreadyHydrated(element) {
}
export default (element) =>
(Component, props, children, { client }) => {
(Component, props, { default: children, ...slotted }, { client }) => {
if (!element.hasAttribute('ssr')) return;
for (const [key, value] of Object.entries(slotted)) {
props[key] = createElement(StaticHtml, { value, name: key });
}
const componentEl = createElement(
Component,
props,

View file

@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/server.js';
import StaticHtml from './static-html.js';
const slotName = str => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
const reactTypeof = Symbol.for('react.element');
function errorIsComingFromPreactComponent(err) {
@ -50,12 +51,20 @@ function check(Component, props, children) {
return isReactComponent;
}
function renderToStaticMarkup(Component, props, children, metadata) {
function renderToStaticMarkup(Component, props, { default: children, ...slotted }, metadata) {
delete props['class'];
const vnode = React.createElement(Component, {
const slots = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = React.createElement(StaticHtml, { value, name });
}
// Note: create newProps to avoid mutating `props` before they are serialized
const newProps = {
...props,
...slots,
children: children != null ? React.createElement(StaticHtml, { value: children }) : undefined,
});
}
const vnode = React.createElement(Component, newProps);
let html;
if (metadata && metadata.hydrate) {
html = ReactDOM.renderToString(vnode);

View file

@ -2,6 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/server';
import StaticHtml from './static-html.js';
const slotName = str => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
const reactTypeof = Symbol.for('react.element');
function errorIsComingFromPreactComponent(err) {
@ -56,12 +57,20 @@ async function getNodeWritable() {
return Writable;
}
async function renderToStaticMarkup(Component, props, children, metadata) {
async function renderToStaticMarkup(Component, props, { default: children, ...slotted }, metadata) {
delete props['class'];
const vnode = React.createElement(Component, {
const slots = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = React.createElement(StaticHtml, { value, name });
}
// Note: create newProps to avoid mutating `props` before they are serialized
const newProps = {
...props,
...slots,
children: children != null ? React.createElement(StaticHtml, { value: children }) : undefined,
});
}
const vnode = React.createElement(Component, newProps);
let html;
if (metadata && metadata.hydrate) {
html = ReactDOM.renderToString(vnode);

View file

@ -7,9 +7,10 @@ import { createElement as h } from 'react';
* As a bonus, we can signal to React that this subtree is
* entirely static and will never change via `shouldComponentUpdate`.
*/
const StaticHtml = ({ value }) => {
const StaticHtml = ({ value, name }) => {
if (!value) return null;
return h('astro-fragment', {
return h('astro-slot', {
name,
suppressHydrationWarning: true,
dangerouslySetInnerHTML: { __html: value },
});

View file

@ -2,7 +2,7 @@ import { sharedConfig } from 'solid-js';
import { hydrate, render, createComponent } from 'solid-js/web';
export default (element) =>
(Component, props, childHTML, { client }) => {
(Component, props, slotted, { client }) => {
// Prepare global object expected by Solid's hydration logic
if (!window._$HY) {
window._$HY = { events: [], completed: new WeakSet(), r: {} };
@ -11,26 +11,30 @@ export default (element) =>
const fn = client === 'only' ? render : hydrate;
// Perform actual hydration
let children;
let _slots = {};
if (Object.keys(slotted).length > 0) {
// hydrating
if (sharedConfig.context) {
element.querySelectorAll('astro-slot').forEach((slot) => {
_slots[slot.getAttribute('name') || 'default'] = slot.cloneNode(true);
});
} else {
for (const [key, value] of Object.entries(slotted)) {
_slots[key] = document.createElement('astro-slot');
if (key !== 'default') _slots[key].setAttribute('name', key);
_slots[key].innerHTML = value;
}
}
}
const { default: children, ...slots } = _slots;
fn(
() =>
createComponent(Component, {
...props,
get children() {
if (childHTML != null) {
// hydrating
if (sharedConfig.context) {
children = element.querySelector('astro-fragment');
}
if (children == null) {
children = document.createElement('astro-fragment');
children.innerHTML = childHTML;
}
}
return children;
},
...slots,
children
}),
element
);

View file

@ -1,23 +1,28 @@
import { renderToString, ssr, createComponent } from 'solid-js/web';
const slotName = str => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
function check(Component, props, children) {
if (typeof Component !== 'function') return false;
const { html } = renderToStaticMarkup(Component, props, children);
return typeof html === 'string';
}
function renderToStaticMarkup(Component, props, children) {
const html = renderToString(() =>
createComponent(Component, {
...props,
// In Solid SSR mode, `ssr` creates the expected structure for `children`.
// In Solid client mode, `ssr` is just a stub.
children: children != null ? ssr(`<astro-fragment>${children}</astro-fragment>`) : children,
})
);
return {
html: html,
};
function renderToStaticMarkup(Component, props, { default: children, ...slotted }) {
const slots = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = ssr(`<astro-slot name="${name}">${value}</astro-slot>`);
}
// Note: create newProps to avoid mutating `props` before they are serialized
const newProps = {
...props,
...slots,
// In Solid SSR mode, `ssr` creates the expected structure for `children`.
children: children != null ? ssr(`<astro-slot>${children}</astro-slot>`) : children,
}
const html = renderToString(() => createComponent(Component, newProps));
return { html }
}
export default {

View file

@ -1,12 +0,0 @@
import { ssr } from 'solid-js/web';
/**
* Astro passes `children` as a string of HTML, so we need
* a wrapper `astro-fragment` to render that content as VNodes.
*/
const StaticHtml = ({ innerHTML }) => {
if (!innerHTML) return null;
return ssr(`<astro-fragment>${innerHTML}</astro-fragment>`);
};
export default StaticHtml;

View file

@ -1,21 +0,0 @@
<script>
/**
* Why do we need a wrapper component?
*
* Astro passes `children` as a string of HTML, so we need
* a way to render that content.
*
* Rather than passing a magical prop which needs special
* handling, using this wrapper allows Svelte users to just
* use `<slot />` like they would for any other component.
*/
const { __astro_component: Component, __astro_children, ...props } = $$props;
</script>
<svelte:component this={Component} {...props}>
{#if __astro_children != null}
<astro-fragment>
{@html __astro_children}
</astro-fragment>
{/if}
</svelte:component>

View file

@ -1,19 +0,0 @@
/* App.svelte generated by Svelte v3.38.2 */
import { create_ssr_component, missing_component, validate_component } from 'svelte/internal';
const App = create_ssr_component(($$result, $$props, $$bindings, slots) => {
const { __astro_component: Component, __astro_children, ...props } = $$props;
const children = {};
if (__astro_children != null) {
children.default = () => `<astro-fragment>${__astro_children}</astro-fragment>`;
}
return `${validate_component(Component || missing_component, 'svelte:component').$$render(
$$result,
Object.assign(props),
{},
children
)}`;
});
export default App;

View file

@ -1,15 +1,43 @@
import SvelteWrapper from './Wrapper.svelte';
const noop = () => {};
export default (target) => {
return (component, props, children, { client }) => {
return (Component, props, slotted, { client }) => {
if (!target.hasAttribute('ssr')) return;
delete props['class'];
const slots = {};
for (const [key, value] of Object.entries(slotted)) {
slots[key] = createSlotDefinition(key, value);
}
try {
new SvelteWrapper({
new Component({
target,
props: { __astro_component: component, __astro_children: children, ...props },
props: {
...props,
$$slots: slots,
$$scope: { ctx: [] }
},
hydrate: client !== 'only',
$$inline: true,
});
} catch (e) {}
};
};
function createSlotDefinition(key, children) {
return [
() => ({
// mount
m(target) {
target.insertAdjacentHTML('beforeend', `<astro-slot${key === 'default' ? '' : ` name="${key}"`}>${children}</astro-slot>`)
},
// create
c: noop,
// hydrate
l: noop,
// destroy
d: noop,
}),
noop,
noop,
]
}

View file

@ -1,15 +1,13 @@
import SvelteWrapper from './Wrapper.svelte.ssr.js';
function check(Component) {
return Component['render'] && Component['$$render'];
}
async function renderToStaticMarkup(Component, props, children) {
const { html } = SvelteWrapper.render({
__astro_component: Component,
__astro_children: children,
...props,
});
async function renderToStaticMarkup(Component, props, slotted) {
const slots = {};
for (const [key, value] of Object.entries(slotted)) {
slots[key] = () => `<astro-slot${key === 'default' ? '' : ` name="${key}"`}>${value}</astro-slot>`;
}
const { html } = Component.render(props, { $$slots: slots });
return { html };
}

View file

@ -2,15 +2,15 @@ import { h, createSSRApp, createApp } from 'vue';
import StaticHtml from './static-html.js';
export default (element) =>
(Component, props, children, { client }) => {
(Component, props, slotted, { client }) => {
delete props['class'];
if (!element.hasAttribute('ssr')) return;
// Expose name on host component for Vue devtools
const name = Component.name ? `${Component.name} Host` : undefined;
const slots = {};
if (children != null) {
slots.default = () => h(StaticHtml, { value: children });
for (const [key, value] of Object.entries(slotted)) {
slots[key] = () => h(StaticHtml, { value, name: key === 'default' ? undefined : key });
}
if (client === 'only') {
const app = createApp({ name, render: () => h(Component, props, slots) });

View file

@ -6,10 +6,10 @@ function check(Component) {
return !!Component['ssrRender'];
}
async function renderToStaticMarkup(Component, props, children) {
async function renderToStaticMarkup(Component, props, slotted) {
const slots = {};
if (children != null) {
slots.default = () => h(StaticHtml, { value: children });
for (const [key, value] of Object.entries(slotted)) {
slots[key] = () => h(StaticHtml, { value, name: key === 'default' ? undefined : key });
}
const app = createSSRApp({ render: () => h(Component, props, slots) });
const html = await renderToString(app);

View file

@ -9,10 +9,11 @@ import { h, defineComponent } from 'vue';
const StaticHtml = defineComponent({
props: {
value: String,
name: String,
},
setup({ value }) {
setup({ name, value }) {
if (!value) return () => null;
return () => h('astro-fragment', { innerHTML: value });
return () => h('astro-slot', { name, innerHTML: value });
},
});

View file

@ -1,5 +1,5 @@
export { pathToPosix } from './lib/utils';
export { AbortController, AbortSignal, alert, atob, Blob, btoa, ByteLengthQueuingStrategy, cancelAnimationFrame, cancelIdleCallback, CanvasRenderingContext2D, CharacterData, clearTimeout, Comment, CountQueuingStrategy, CSSStyleSheet, CustomElementRegistry, CustomEvent, Document, DocumentFragment, DOMException, Element, Event, EventTarget, fetch, File, FormData, Headers, HTMLBodyElement, HTMLCanvasElement, HTMLDivElement, HTMLDocument, HTMLElement, HTMLHeadElement, HTMLHtmlElement, HTMLImageElement, HTMLSpanElement, HTMLStyleElement, HTMLTemplateElement, HTMLUnknownElement, Image, ImageData, IntersectionObserver, MediaQueryList, MutationObserver, Node, NodeFilter, NodeIterator, OffscreenCanvas, ReadableByteStreamController, ReadableStream, ReadableStreamBYOBReader, ReadableStreamBYOBRequest, ReadableStreamDefaultController, ReadableStreamDefaultReader, Request, requestAnimationFrame, requestIdleCallback, ResizeObserver, Response, setTimeout, ShadowRoot, structuredClone, StyleSheet, Text, TransformStream, TreeWalker, URLPattern, Window, WritableStream, WritableStreamDefaultController, WritableStreamDefaultWriter } from './mod.js';
export { AbortController, AbortSignal, alert, atob, Blob, btoa, ByteLengthQueuingStrategy, cancelAnimationFrame, cancelIdleCallback, CanvasRenderingContext2D, CharacterData, clearTimeout, Comment, CountQueuingStrategy, CSSStyleSheet, CustomElementRegistry, CustomEvent, Document, DocumentFragment, DOMException, Element, Event, EventTarget, fetch, File, FormData, Headers, HTMLBodyElement, HTMLCanvasElement, HTMLDivElement, HTMLDocument, HTMLElement, HTMLHeadElement, HTMLHtmlElement, HTMLImageElement, HTMLSpanElement, HTMLStyleElement, HTMLTemplateElement, HTMLUnknownElement, Image, ImageData, IntersectionObserver, MediaQueryList, MutationObserver, Node, NodeFilter, NodeIterator, OffscreenCanvas, ReadableByteStreamController, ReadableStream, ReadableStreamBYOBReader, ReadableStreamBYOBRequest, ReadableStreamDefaultController, ReadableStreamDefaultReader, Request, requestAnimationFrame, requestIdleCallback, ResizeObserver, Response, setTimeout, ShadowRoot, structuredClone, StyleSheet, Text, TransformStream, TreeWalker, URLPattern, Window, WritableStream, WritableStreamDefaultController, WritableStreamDefaultWriter, } from './mod.js';
export declare const polyfill: {
(target: any, options?: PolyfillOptions): any;
internals(target: any, name: string): any;

View file

@ -888,6 +888,35 @@ importers:
'@astrojs/vue': link:../../../../integrations/vue
astro: link:../../..
packages/astro/e2e/fixtures/nested-recursive:
specifiers:
'@astrojs/preact': workspace:*
'@astrojs/react': workspace:*
'@astrojs/solid-js': workspace:*
'@astrojs/svelte': workspace:*
'@astrojs/vue': workspace:*
astro: workspace:*
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
dependencies:
preact: 10.7.3
react: 18.1.0
react-dom: 18.1.0_react@18.1.0
solid-js: 1.4.3
svelte: 3.48.0
vue: 3.2.37
devDependencies:
'@astrojs/preact': link:../../../../integrations/preact
'@astrojs/react': link:../../../../integrations/react
'@astrojs/solid-js': link:../../../../integrations/solid
'@astrojs/svelte': link:../../../../integrations/svelte
'@astrojs/vue': link:../../../../integrations/vue
astro: link:../../..
packages/astro/e2e/fixtures/nested-styles:
specifiers:
astro: workspace:*