diff options
60 files changed, 827 insertions, 157 deletions
diff --git a/.changeset/clever-pumpkins-begin.md b/.changeset/clever-pumpkins-begin.md new file mode 100644 index 000000000..26b9e5f18 --- /dev/null +++ b/.changeset/clever-pumpkins-begin.md @@ -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. diff --git a/.changeset/lovely-bulldogs-admire.md b/.changeset/lovely-bulldogs-admire.md new file mode 100644 index 000000000..74888cb27 --- /dev/null +++ b/.changeset/lovely-bulldogs-admire.md @@ -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> + </> + ) +} +``` diff --git a/.changeset/mean-ears-mate.md b/.changeset/mean-ears-mate.md new file mode 100644 index 000000000..6bb538e4e --- /dev/null +++ b/.changeset/mean-ears-mate.md @@ -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. diff --git a/.changeset/tough-ants-rest.md b/.changeset/tough-ants-rest.md new file mode 100644 index 000000000..f95ae30c2 --- /dev/null +++ b/.changeset/tough-ants-rest.md @@ -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. diff --git a/packages/astro/e2e/fixtures/nested-recursive/astro.config.mjs b/packages/astro/e2e/fixtures/nested-recursive/astro.config.mjs new file mode 100644 index 000000000..4b50887cd --- /dev/null +++ b/packages/astro/e2e/fixtures/nested-recursive/astro.config.mjs @@ -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()], +}); diff --git a/packages/astro/e2e/fixtures/nested-recursive/package.json b/packages/astro/e2e/fixtures/nested-recursive/package.json new file mode 100644 index 000000000..3376ef596 --- /dev/null +++ b/packages/astro/e2e/fixtures/nested-recursive/package.json @@ -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" + } +} diff --git a/packages/astro/e2e/fixtures/nested-recursive/src/components/PreactCounter.tsx b/packages/astro/e2e/fixtures/nested-recursive/src/components/PreactCounter.tsx new file mode 100644 index 000000000..32200f41f --- /dev/null +++ b/packages/astro/e2e/fixtures/nested-recursive/src/components/PreactCounter.tsx @@ -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> + ); +} diff --git a/packages/astro/e2e/fixtures/nested-recursive/src/components/ReactCounter.jsx b/packages/astro/e2e/fixtures/nested-recursive/src/components/ReactCounter.jsx new file mode 100644 index 000000000..6b3a1de5f --- /dev/null +++ b/packages/astro/e2e/fixtures/nested-recursive/src/components/ReactCounter.jsx @@ -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> + ); +} diff --git a/packages/astro/e2e/fixtures/nested-recursive/src/components/SolidCounter.tsx b/packages/astro/e2e/fixtures/nested-recursive/src/components/SolidCounter.tsx new file mode 100644 index 000000000..afabe43b9 --- /dev/null +++ b/packages/astro/e2e/fixtures/nested-recursive/src/components/SolidCounter.tsx @@ -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> + ); +} diff --git a/packages/astro/e2e/fixtures/nested-recursive/src/components/SvelteCounter.svelte b/packages/astro/e2e/fixtures/nested-recursive/src/components/SvelteCounter.svelte new file mode 100644 index 000000000..733f58076 --- /dev/null +++ b/packages/astro/e2e/fixtures/nested-recursive/src/components/SvelteCounter.svelte @@ -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> diff --git a/packages/astro/e2e/fixtures/nested-recursive/src/components/VueCounter.vue b/packages/astro/e2e/fixtures/nested-recursive/src/components/VueCounter.vue new file mode 100644 index 000000000..d404cc965 --- /dev/null +++ b/packages/astro/e2e/fixtures/nested-recursive/src/components/VueCounter.vue @@ -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> diff --git a/packages/astro/e2e/fixtures/nested-recursive/src/pages/index.astro b/packages/astro/e2e/fixtures/nested-recursive/src/pages/index.astro new file mode 100644 index 000000000..685c7fb5e --- /dev/null +++ b/packages/astro/e2e/fixtures/nested-recursive/src/pages/index.astro @@ -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> diff --git a/packages/astro/e2e/nested-recursive.test.js b/packages/astro/e2e/nested-recursive.test.js new file mode 100644 index 000000000..ae981189a --- /dev/null +++ b/packages/astro/e2e/nested-recursive.test.js @@ -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'); + }); +}); diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index dc2af7db3..d2ef92365 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -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>; diff --git a/packages/astro/src/runtime/server/astro-island.ts b/packages/astro/src/runtime/server/astro-island.ts index a067c9a57..10c69aa8e 100644 --- a/packages/astro/src/runtime/server/astro-island.ts +++ b/packages/astro/src/runtime/server/astro-island.ts @@ -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'); diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index 6c7b12699..1b78d7171 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -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; }', }) ); } diff --git a/packages/astro/test/fixtures/lit-element/src/components/my-element.js b/packages/astro/test/fixtures/lit-element/src/components/my-element.js index b2cf72dea..e946924cf 100644 --- a/packages/astro/test/fixtures/lit-element/src/components/my-element.js +++ b/packages/astro/test/fixtures/lit-element/src/components/my-element.js @@ -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> `; } } diff --git a/packages/astro/test/fixtures/lit-element/src/pages/slots.astro b/packages/astro/test/fixtures/lit-element/src/pages/slots.astro new file mode 100644 index 000000000..b8fc4963c --- /dev/null +++ b/packages/astro/test/fixtures/lit-element/src/pages/slots.astro @@ -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> diff --git a/packages/astro/test/fixtures/slots-preact/src/components/Counter.jsx b/packages/astro/test/fixtures/slots-preact/src/components/Counter.jsx index cc11b9ee3..16d2a95b9 100644 --- a/packages/astro/test/fixtures/slots-preact/src/components/Counter.jsx +++ b/packages/astro/test/fixtures/slots-preact/src/components/Counter.jsx @@ -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> </> ); diff --git a/packages/astro/test/fixtures/slots-preact/src/pages/index.astro b/packages/astro/test/fixtures/slots-preact/src/pages/index.astro index f8f101e73..b2b039566 100644 --- a/packages/astro/test/fixtures/slots-preact/src/pages/index.astro +++ b/packages/astro/test/fixtures/slots-preact/src/pages/index.astro @@ -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> diff --git a/packages/astro/test/fixtures/slots-preact/src/pages/markdown.md b/packages/astro/test/fixtures/slots-preact/src/pages/markdown.md new file mode 100644 index 000000000..f86720fea --- /dev/null +++ b/packages/astro/test/fixtures/slots-preact/src/pages/markdown.md @@ -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> diff --git a/packages/astro/test/fixtures/slots-react/src/components/Counter.jsx b/packages/astro/test/fixtures/slots-react/src/components/Counter.jsx index 93f267ca4..733cc47cc 100644 --- a/packages/astro/test/fixtures/slots-react/src/components/Counter.jsx +++ b/packages/astro/test/fixtures/slots-react/src/components/Counter.jsx @@ -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> </> ); diff --git a/packages/astro/test/fixtures/slots-react/src/pages/index.astro b/packages/astro/test/fixtures/slots-react/src/pages/index.astro index f8f101e73..b2b039566 100644 --- a/packages/astro/test/fixtures/slots-react/src/pages/index.astro +++ b/packages/astro/test/fixtures/slots-react/src/pages/index.astro @@ -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> diff --git a/packages/astro/test/fixtures/slots-react/src/pages/markdown.md b/packages/astro/test/fixtures/slots-react/src/pages/markdown.md new file mode 100644 index 000000000..308450506 --- /dev/null +++ b/packages/astro/test/fixtures/slots-react/src/pages/markdown.md @@ -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> diff --git a/packages/astro/test/fixtures/slots-solid/src/components/Counter.jsx b/packages/astro/test/fixtures/slots-solid/src/components/Counter.jsx index 6a585b8e3..4b9c63c66 100644 --- a/packages/astro/test/fixtures/slots-solid/src/components/Counter.jsx +++ b/packages/astro/test/fixtures/slots-solid/src/components/Counter.jsx @@ -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> </> ); diff --git a/packages/astro/test/fixtures/slots-solid/src/pages/index.astro b/packages/astro/test/fixtures/slots-solid/src/pages/index.astro index f8f101e73..b2b039566 100644 --- a/packages/astro/test/fixtures/slots-solid/src/pages/index.astro +++ b/packages/astro/test/fixtures/slots-solid/src/pages/index.astro @@ -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> diff --git a/packages/astro/test/fixtures/slots-solid/src/pages/markdown.md b/packages/astro/test/fixtures/slots-solid/src/pages/markdown.md new file mode 100644 index 000000000..d9bc2dabd --- /dev/null +++ b/packages/astro/test/fixtures/slots-solid/src/pages/markdown.md @@ -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> diff --git a/packages/astro/test/fixtures/slots-svelte/src/components/Counter.svelte b/packages/astro/test/fixtures/slots-svelte/src/components/Counter.svelte index 11901049e..24f4e734e 100644 --- a/packages/astro/test/fixtures/slots-svelte/src/components/Counter.svelte +++ b/packages/astro/test/fixtures/slots-svelte/src/components/Counter.svelte @@ -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> diff --git a/packages/astro/test/fixtures/slots-svelte/src/pages/index.astro b/packages/astro/test/fixtures/slots-svelte/src/pages/index.astro index bc25c17c7..72b0bc107 100644 --- a/packages/astro/test/fixtures/slots-svelte/src/pages/index.astro +++ b/packages/astro/test/fixtures/slots-svelte/src/pages/index.astro @@ -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> diff --git a/packages/astro/test/fixtures/slots-svelte/src/pages/markdown.md b/packages/astro/test/fixtures/slots-svelte/src/pages/markdown.md new file mode 100644 index 000000000..e5e415921 --- /dev/null +++ b/packages/astro/test/fixtures/slots-svelte/src/pages/markdown.md @@ -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> diff --git a/packages/astro/test/fixtures/slots-vue/src/components/Counter.vue b/packages/astro/test/fixtures/slots-vue/src/components/Counter.vue index 304511560..cd7ce75fe 100644 --- a/packages/astro/test/fixtures/slots-vue/src/components/Counter.vue +++ b/packages/astro/test/fixtures/slots-vue/src/components/Counter.vue @@ -8,6 +8,8 @@ <slot> <h1>Fallback</h1> </slot> + <slot name="named" /> + <slot name="dash-case"></slot> </div> </template> diff --git a/packages/astro/test/fixtures/slots-vue/src/pages/index.astro b/packages/astro/test/fixtures/slots-vue/src/pages/index.astro index 65ca26726..ffc272267 100644 --- a/packages/astro/test/fixtures/slots-vue/src/pages/index.astro +++ b/packages/astro/test/fixtures/slots-vue/src/pages/index.astro @@ -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> diff --git a/packages/astro/test/fixtures/slots-vue/src/pages/markdown.md b/packages/astro/test/fixtures/slots-vue/src/pages/markdown.md new file mode 100644 index 000000000..6a08515f2 --- /dev/null +++ b/packages/astro/test/fixtures/slots-vue/src/pages/markdown.md @@ -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> diff --git a/packages/astro/test/lit-element.test.js b/packages/astro/test/lit-element.test.js index b9d5f53c2..350248ab6 100644 --- a/packages/astro/test/lit-element.test.js +++ b/packages/astro/test/lit-element.test.js @@ -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'); + }); }); diff --git a/packages/astro/test/slots-preact.test.js b/packages/astro/test/slots-preact.test.js index c86a25fb7..b655638b2 100644 --- a/packages/astro/test/slots-preact.test.js +++ b/packages/astro/test/slots-preact.test.js @@ -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'); + }) + }) }); diff --git a/packages/astro/test/slots-react.test.js b/packages/astro/test/slots-react.test.js index da6953142..e84b02519 100644 --- a/packages/astro/test/slots-react.test.js +++ b/packages/astro/test/slots-react.test.js @@ -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'); + }) + }) }); diff --git a/packages/astro/test/slots-solid.test.js b/packages/astro/test/slots-solid.test.js index d7659f033..faff85efc 100644 --- a/packages/astro/test/slots-solid.test.js +++ b/packages/astro/test/slots-solid.test.js @@ -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'); + }) + }) }); diff --git a/packages/astro/test/slots-svelte.test.js b/packages/astro/test/slots-svelte.test.js index 0bbbae25a..9beab3281 100644 --- a/packages/astro/test/slots-svelte.test.js +++ b/packages/astro/test/slots-svelte.test.js @@ -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'); + }) + }) }); diff --git a/packages/astro/test/slots-vue.test.js b/packages/astro/test/slots-vue.test.js index 2b0dbc743..dd2b612fe 100644 --- a/packages/astro/test/slots-vue.test.js +++ b/packages/astro/test/slots-vue.test.js @@ -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'); + }) + }) }); diff --git a/packages/integrations/lit/server.js b/packages/integrations/lit/server.js index bc8995061..2f9076672 100644 --- a/packages/integrations/lit/server.js +++ b/packages/integrations/lit/server.js @@ -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 || ''; // don’t 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; } diff --git a/packages/integrations/preact/client.js b/packages/integrations/preact/client.js index 12c5666df..e2f4ca803 100644 --- a/packages/integrations/preact/client.js +++ b/packages/integrations/preact/client.js @@ -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 diff --git a/packages/integrations/preact/server.js b/packages/integrations/preact/server.js index 0729f42e9..31f980aa3 100644 --- a/packages/integrations/preact/server.js +++ b/packages/integrations/preact/server.js @@ -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 }; } diff --git a/packages/integrations/preact/static-html.js b/packages/integrations/preact/static-html.js index 9af8002a7..7e964ef06 100644 --- a/packages/integrations/preact/static-html.js +++ b/packages/integrations/preact/static-html.js @@ -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 } }); }; /** diff --git a/packages/integrations/react/client-v17.js b/packages/integrations/react/client-v17.js index 1dce34709..443109603 100644 --- a/packages/integrations/react/client-v17.js +++ b/packages/integrations/react/client-v17.js @@ -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, diff --git a/packages/integrations/react/client.js b/packages/integrations/react/client.js index 2828d2cbe..b41d7845a 100644 --- a/packages/integrations/react/client.js +++ b/packages/integrations/react/client.js @@ -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, diff --git a/packages/integrations/react/server-v17.js b/packages/integrations/react/server-v17.js index b48d7b6f4..5d747a832 100644 --- a/packages/integrations/react/server-v17.js +++ b/packages/integrations/react/server-v17.js @@ -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); diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js index 776901563..cda839a1c 100644 --- a/packages/integrations/react/server.js +++ b/packages/integrations/react/server.js @@ -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); diff --git a/packages/integrations/react/static-html.js b/packages/integrations/react/static-html.js index ecd76ae9b..9589aaed8 100644 --- a/packages/integrations/react/static-html.js +++ b/packages/integrations/react/static-html.js @@ -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 }, }); diff --git a/packages/integrations/solid/client.js b/packages/integrations/solid/client.js index 867d951c6..ceb7546d2 100644 --- a/packages/integrations/solid/client.js +++ b/packages/integrations/solid/client.js @@ -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 ); diff --git a/packages/integrations/solid/server.js b/packages/integrations/solid/server.js index dc4f88227..92b614012 100644 --- a/packages/integrations/solid/server.js +++ b/packages/integrations/solid/server.js @@ -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 { diff --git a/packages/integrations/solid/static-html.js b/packages/integrations/solid/static-html.js deleted file mode 100644 index 9f969eac9..000000000 --- a/packages/integrations/solid/static-html.js +++ /dev/null @@ -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; diff --git a/packages/integrations/svelte/Wrapper.svelte b/packages/integrations/svelte/Wrapper.svelte deleted file mode 100644 index c1ee77d91..000000000 --- a/packages/integrations/svelte/Wrapper.svelte +++ /dev/null @@ -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> diff --git a/packages/integrations/svelte/Wrapper.svelte.ssr.js b/packages/integrations/svelte/Wrapper.svelte.ssr.js deleted file mode 100644 index e6a4781a7..000000000 --- a/packages/integrations/svelte/Wrapper.svelte.ssr.js +++ /dev/null @@ -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; diff --git a/packages/integrations/svelte/client.js b/packages/integrations/svelte/client.js index 3f401b544..36a8e8de1 100644 --- a/packages/integrations/svelte/client.js +++ b/packages/integrations/svelte/client.js @@ -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, + ] +} diff --git a/packages/integrations/svelte/server.js b/packages/integrations/svelte/server.js index 3c989cd5a..7a5610b4a 100644 --- a/packages/integrations/svelte/server.js +++ b/packages/integrations/svelte/server.js @@ -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 }; } diff --git a/packages/integrations/vue/client.js b/packages/integrations/vue/client.js index c6206fe51..648a69658 100644 --- a/packages/integrations/vue/client.js +++ b/packages/integrations/vue/client.js @@ -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) }); diff --git a/packages/integrations/vue/server.js b/packages/integrations/vue/server.js index 1ae2b757b..883aa4de0 100644 --- a/packages/integrations/vue/server.js +++ b/packages/integrations/vue/server.js @@ -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); diff --git a/packages/integrations/vue/static-html.js b/packages/integrations/vue/static-html.js index ff1459b6f..a7f09eace 100644 --- a/packages/integrations/vue/static-html.js +++ b/packages/integrations/vue/static-html.js @@ -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 }); }, }); diff --git a/packages/webapi/mod.d.ts b/packages/webapi/mod.d.ts index a3c49dc5c..b385e82a5 100644 --- a/packages/webapi/mod.d.ts +++ b/packages/webapi/mod.d.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 520b204a4..65317d88a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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:* |