Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Prevent removal of nested slots within islands #7093

Merged
merged 3 commits into from
May 17, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/unlucky-lamps-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@astrojs/preact': minor
'@astrojs/svelte': minor
'@astrojs/react': minor
'@astrojs/solid-js': minor
'@astrojs/vue': minor
'astro': minor
---

Prevent removal of nested slots within islands

This change introduces a new flag that renderers can add called `supportsAstroStaticSlot`. What this does is let Astro know that the render is sending `<astro-static-slot>` as placeholder values for static (non-hydrated) slots which Astro will then remove.

This change is completely backwards compatible, but fixes bugs caused by combining ssr-only and client-side framework components like so:

```astro
<Component>
<div>
<Component client:load>
<span>Nested</span>
</Component>
</div>
</Component>
```
2 changes: 2 additions & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export interface AstroComponentMetadata {
hydrateArgs?: any;
componentUrl?: string;
componentExport?: { value: string; namespace?: boolean };
astroStaticSlot: true;
}

/** The flags supported by the Astro CLI */
Expand Down Expand Up @@ -1632,6 +1633,7 @@ export interface SSRLoadedRenderer extends AstroRenderer {
html: string;
attrs?: Record<string, string>;
}>;
supportsAstroStaticSlot?: boolean;
};
}

Expand Down
20 changes: 17 additions & 3 deletions packages/astro/src/runtime/server/render/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ function isHTMLComponent(Component: unknown) {
return Component && typeof Component === 'object' && (Component as any)['astro:html'];
}

const ASTRO_SLOT_EXP = /\<\/?astro-slot\b[^>]*>/g;
const ASTRO_STATIC_SLOT_EXP = /\<\/?astro-static-slot\b[^>]*>/g;
function removeStaticAstroSlot(html: string, supportsAstroStaticSlot: boolean) {
const exp = supportsAstroStaticSlot ? ASTRO_STATIC_SLOT_EXP : ASTRO_SLOT_EXP;
return html.replace(exp, '');
}

async function renderFrameworkComponent(
result: SSRResult,
displayName: string,
Expand All @@ -68,7 +75,10 @@ async function renderFrameworkComponent(
}

const { renderers } = result._metadata;
const metadata: AstroComponentMetadata = { displayName };
const metadata: AstroComponentMetadata = {
astroStaticSlot: true,
displayName
};

const { hydration, isPage, props } = extractDirectives(displayName, _props);
let html = '';
Expand Down Expand Up @@ -263,7 +273,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
if (isPage || renderer?.name === 'astro:jsx') {
yield html;
} else if (html && html.length > 0) {
yield markHTMLString(html.replace(/\<\/?astro-slot\b[^>]*>/g, ''));
yield markHTMLString(removeStaticAstroSlot(html, renderer?.ssr?.supportsAstroStaticSlot ?? false));
} else {
yield '';
}
Expand All @@ -288,7 +298,11 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
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}">`)) {
let tagName = renderer?.ssr?.supportsAstroStaticSlot ?
!!metadata.hydrate ? 'astro-slot' : 'astro-static-slot'
: 'astro-slot';
let expectedHTML = key === 'default' ? `<${tagName}>` : `<${tagName} name="${key}">`;
if (!html.includes(expectedHTML)) {
unrenderedSlots.push(key);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/vite-plugin-jsx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export default function jsx({ settings, logging }: AstroPluginJSXOptions): Plugi
Unable to resolve a renderer that handles this file! With more than one renderer enabled, you should include an import or use a pragma comment.
Add ${colors.cyan(
IMPORT_STATEMENTS[defaultRendererName] || `import '${defaultRendererName}';`
)} or ${colors.cyan(`/* jsxImportSource: ${defaultRendererName} */`)} to this file.
)} or ${colors.cyan(`/** @jsxImportSource: ${defaultRendererName} */`)} to this file.
`
);
return null;
Expand Down
35 changes: 35 additions & 0 deletions packages/astro/test/astro-slots-nested.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';

describe('Nested Slots', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;

before(async () => {
Expand All @@ -23,4 +24,38 @@ describe('Nested Slots', () => {
const $ = cheerio.load(html);
expect($('script')).to.have.a.lengthOf(1, 'script rendered');
});

describe('Client components nested inside server-only framework components', () => {
/** @type {cheerio.CheerioAPI} */
let $;
before(async () => {
const html = await fixture.readFile('/server-component-nested/index.html');
$ = cheerio.load(html);
});

it('react', () => {
expect($('#react astro-slot')).to.have.a.lengthOf(1);
expect($('#react astro-static-slot')).to.have.a.lengthOf(0);
});

it('vue', () => {
expect($('#vue astro-slot')).to.have.a.lengthOf(1);
expect($('#vue astro-static-slot')).to.have.a.lengthOf(0);
});

it('preact', () => {
expect($('#preact astro-slot')).to.have.a.lengthOf(1);
expect($('#preact astro-static-slot')).to.have.a.lengthOf(0);
});

it('solid', () => {
expect($('#solid astro-slot')).to.have.a.lengthOf(1);
expect($('#solid astro-static-slot')).to.have.a.lengthOf(0);
});

it('svelte', () => {
expect($('#svelte astro-slot')).to.have.a.lengthOf(1);
expect($('#svelte astro-static-slot')).to.have.a.lengthOf(0);
});
});
});
12 changes: 11 additions & 1 deletion packages/astro/test/fixtures/astro-slots-nested/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import preact from '@astrojs/preact';
import solid from '@astrojs/solid-js';
import svelte from '@astrojs/svelte';
import vue from '@astrojs/vue';

export default defineConfig({
integrations: [react()]
integrations: [
react(),
preact(),
solid(),
svelte(),
vue()
]
});
10 changes: 9 additions & 1 deletion packages/astro/test/fixtures/astro-slots-nested/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/preact": "workspace:*",
"@astrojs/react": "workspace:*",
"@astrojs/vue": "workspace:*",
"@astrojs/solid-js": "workspace:*",
"@astrojs/svelte": "workspace:*",
"astro": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"solid-js": "^1.7.4",
"svelte": "^3.58.0",
"vue": "^3.2.47",
"preact": "^10.13.2"
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import React from 'react';

export default function Inner() {
return <span>Inner</span>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React, { Fragment } from 'react';

export default function PassesChildren({ children }) {
return <Fragment>{ children }</Fragment>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { h, Fragment } from 'preact';

export default function PassesChildren({ children }) {
return <Fragment>{ children }</Fragment>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** @jsxImportSource solid-js */

export default function PassesChildren({ children }) {
return <>{ children }</>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="svelte-children">
<slot></slot>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template>
<div class="vue-wrapper">
<slot></slot>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
import PassesChildren from '../components/PassesChildren.jsx';
import PassesChildrenP from '../components/PassesChildrenP.jsx';
import PassesChildrenS from '../components/PassesChildrenS.jsx';
import PassesChildrenSv from '../components/PassesChildrenSv.svelte';
import PassesChildrenV from '../components/PassesChildrenV.vue';
---

<html lang="en">
<head>
<title>Testing</title>
</head>
<body>
<main>
<div id="react">
<PassesChildren>
<div>
<PassesChildren client:load>
<span>Inner children</span>
</PassesChildren>
</div>
</PassesChildren>
</div>
<div id="preact">
<PassesChildrenP>
<div>
<PassesChildrenP client:load>
<span>Inner children</span>
</PassesChildrenP>
</div>
</PassesChildrenP>
</div>
<div id="solid">
<PassesChildrenS>
<div>
<PassesChildrenS client:load>
<span>Inner children</span>
</PassesChildrenS>
</div>
</PassesChildrenS>
</div>
<div id="svelte">
<PassesChildrenSv>
<div>
<PassesChildrenSv client:load>
<span>Inner children</span>
</PassesChildrenSv>
</div>
</PassesChildrenSv>
</div>
<div id="vue">
<PassesChildrenV>
<div>
<PassesChildrenV client:load>
<span>Inner children</span>
</PassesChildrenV>
</div>
</PassesChildrenV>
</div>
</main>
</body>
</html>
24 changes: 19 additions & 5 deletions packages/integrations/preact/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AstroComponentMetadata } from 'astro';
import { Component as BaseComponent, h } from 'preact';
import render from 'preact-render-to-string';
import { getContext } from './context.js';
Expand All @@ -10,7 +11,7 @@ const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w
let originalConsoleError: typeof console.error;
let consoleFilterRefs = 0;

function check(this: RendererContext, Component: any, props: Record<string, any>, children: any) {
function check(this: RendererContext, Component: any, props: Record<string, any>, children: any, ) {
if (typeof Component !== 'function') return false;

if (Component.prototype != null && typeof Component.prototype.render === 'function') {
Expand All @@ -21,7 +22,7 @@ function check(this: RendererContext, Component: any, props: Record<string, any>

try {
try {
const { html } = renderToStaticMarkup.call(this, Component, props, children);
const { html } = renderToStaticMarkup.call(this, Component, props, children, undefined);
if (typeof html !== 'string') {
return false;
}
Expand All @@ -38,18 +39,28 @@ function check(this: RendererContext, Component: any, props: Record<string, any>
}
}

function shouldHydrate(metadata: AstroComponentMetadata | undefined) {
// Adjust how this is hydrated only when the version of Astro supports `astroStaticSlot`
return metadata?.astroStaticSlot ? !!metadata.hydrate : true;
}

function renderToStaticMarkup(
this: RendererContext,
Component: any,
props: Record<string, any>,
{ default: children, ...slotted }: Record<string, any>
{ default: children, ...slotted }: Record<string, any>,
metadata: AstroComponentMetadata | undefined,
) {
const ctx = getContext(this.result);

const slots: Record<string, ReturnType<typeof h>> = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = h(StaticHtml, { value, name });
slots[name] = h(StaticHtml, {
hydrate: shouldHydrate(metadata),
value,
name
});
}

// Restore signals back onto props so that they will be passed as-is to components
Expand All @@ -61,7 +72,9 @@ function renderToStaticMarkup(
serializeSignals(ctx, props, attrs, propsMap);

const html = render(
h(Component, newProps, children != null ? h(StaticHtml, { value: children }) : children)
h(Component, newProps, children != null ? h(StaticHtml, {
hydrate: shouldHydrate(metadata),
value: children}) : children)
);
return {
attrs,
Expand Down Expand Up @@ -127,4 +140,5 @@ function filteredConsoleError(msg: string, ...rest: any[]) {
export default {
check,
renderToStaticMarkup,
supportsAstroStaticSlot: true,
};
11 changes: 9 additions & 2 deletions packages/integrations/preact/src/static-html.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { h } from 'preact';

type Props = {
value: string;
name?: string;
hydrate?: boolean;
}

/**
* Astro passes `children` as a string of HTML, so we need
* a wrapper `div` to render that content as VNodes.
*
* As a bonus, we can signal to Preact that this subtree is
* entirely static and will never change via `shouldComponentUpdate`.
*/
const StaticHtml = ({ value, name }: { value: string; name?: string }) => {
const StaticHtml = ({ value, name, hydrate }: Props) => {
if (!value) return null;
return h('astro-slot', { name, dangerouslySetInnerHTML: { __html: value } });
const tagName = hydrate === false ? 'astro-static-slot' : 'astro-slot';
return h(tagName, { name, dangerouslySetInnerHTML: { __html: value } });
};

/**
Expand Down
7 changes: 6 additions & 1 deletion packages/integrations/react/server-v17.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ function renderToStaticMarkup(Component, props, { default: children, ...slotted
};
const newChildren = children ?? props.children;
if (newChildren != null) {
newProps.children = React.createElement(StaticHtml, { value: newChildren });
newProps.children = React.createElement(StaticHtml, {
// Adjust how this is hydrated only when the version of Astro supports `astroStaticSlot`
hydrate: metadata.astroStaticSlot ? !!metadata.hydrate : true,
value: newChildren
});
}
const vnode = React.createElement(Component, newProps);
let html;
Expand All @@ -80,4 +84,5 @@ function renderToStaticMarkup(Component, props, { default: children, ...slotted
export default {
check,
renderToStaticMarkup,
supportsAstroStaticSlot: true,
};
Loading
Loading