Render async SolidJS components

This commit is contained in:
Patrick Miller 2023-07-06 19:36:35 +09:00
parent fca6892f8d
commit f12ce6c947
18 changed files with 511 additions and 40 deletions

View file

@ -0,0 +1,21 @@
---
'@astrojs/solid-js': major
---
Render SolidJS components using [renderToStringAsync](https://www.solidjs.com/docs/latest#rendertostringasync).
This changes the renderer of SolidJS components from renderToString to renderToStringAsync.
The server render phase will now wait for Suspense boundaries to resolve instead of immediately rendering the [Suspense](https://www.solidjs.com/docs/latest#suspense) fallback.
If your SolidJS component uses APIs such as [lazy](https://www.solidjs.com/docs/latest#lazy) or [createResource](https://www.solidjs.com/docs/latest#createresource), these functions may now be called on the server side.
This increases the power of the SolidJS integration. For example, server-only SolidJS components could now call async functions directly using the `createResource` API, like loading data from another API or using the async Astro Image function `getImage()`. It is unlikely that a server only component would make use of Suspense until now, so this should not be a breaking change for server-only components.
This could be a breaking change if:
- the component has a hydrating directive like `client:load`, and
- the component uses Suspense APIs like `lazy` or `createResource`, and
- the component uses Suspense fallback with the intention for it to be a server-side fallback
In this case, instead of relying on Suspense as a server-side fallback, use APIs like [isServer](https://www.solidjs.com/docs/latest/api#isserver) or `onMount()` to detect server mode and render the server fallback without using Suspense.

View file

@ -1,7 +1,7 @@
import { expect } from '@playwright/test';
import { scrollToElement, testFactory, waitForHydrate } from './test-utils.js';
export function prepareTestFactory(opts) {
export function prepareTestFactory(opts, { canReplayClicks = false } = {}) {
const test = testFactory(opts);
let devServer;
@ -104,7 +104,16 @@ export function prepareTestFactory(opts) {
await waitForHydrate(page, counter);
await inc.click();
await expect(count, 'count incremented by 1').toHaveText('1');
if (canReplayClicks) {
// SolidJS has a hydration script that automatically captures
// and replays click and input events on Hydration:
// https://www.solidjs.com/docs/latest#hydrationscript
// so in total there are two click events.
await expect(count, 'count incremented by 2').toHaveText('2');
} else {
await expect(count, 'count incremented by 1').toHaveText('1');
}
});
test('client:only', async ({ page, astro }) => {

View file

@ -1,6 +1,11 @@
import { prepareTestFactory } from './shared-component-tests.js';
const { test, createTests } = prepareTestFactory({ root: './fixtures/solid-component/' });
const { test, createTests } = prepareTestFactory(
{ root: './fixtures/solid-component/' },
{
canReplayClicks: true,
}
);
const config = {
componentFilePath: './src/components/SolidComponent.jsx',

View file

@ -0,0 +1,34 @@
// Based on reproduction from https://github.com/withastro/astro/issues/6912
import { For, Match, Switch } from 'solid-js';
export default function Counter(props) {
return (
<For each={[1, 2, 3, 4]}>
{(page) => {
return (
<Switch>
<Match when={page % 2 === 0}>
<button
onClick={() => {
console.log(page);
}}
>
even {page}
</button>
</Match>
<Match when={page % 2 === 1}>
<button
onClick={() => {
console.log(page);
}}
>
odd {page}
</button>
</Match>
</Switch>
);
}}
</For>
);
}

View file

@ -0,0 +1,5 @@
// Based on reproduction from https://github.com/withastro/astro/issues/6912
import { lazy } from 'solid-js';
export const LazyCounter = lazy(() => import('./Counter'));

View file

@ -0,0 +1,55 @@
import { createResource, ErrorBoundary } from 'solid-js';
// It may be good to try short and long sleep times.
// But short is faster for testing.
const SLEEP_MS = 10;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export function AsyncComponent(props) {
const [data] = createResource(async () => {
// console.log("Start rendering async component " + props.title);
await sleep(props.delay ?? SLEEP_MS);
// console.log("Finish rendering async component " + props.title);
return 'async_result_from_async_component';
});
return (
<div data-name="AsyncComponent" onClick={() => actions.refetch()}>
{data()}
{/* NOTE: The props.children are intentionally commented out
to simulate a situation where hydration script might not
be injected in the right spot. */}
{/* {props.children} */}
</div>
);
}
export function AsyncErrorComponent() {
const [data] = createResource(async () => {
await sleep(SLEEP_MS);
throw new Error('Async error thrown!');
});
return <div>{data()}</div>;
}
export function AsyncErrorInErrorBoundary() {
return (
<ErrorBoundary fallback={<div>Async error boundary fallback</div>}>
<AsyncErrorComponent />
</ErrorBoundary>
);
}
export function SyncErrorComponent() {
throw new Error('Sync error thrown!');
}
export function SyncErrorInErrorBoundary() {
return (
<ErrorBoundary fallback={<div>Sync error boundary fallback</div>}>
<SyncErrorComponent />
</ErrorBoundary>
);
}

View file

@ -0,0 +1,18 @@
---
import { AsyncComponent } from '../components/async-components.jsx';
---
<html>
<head><title>Nested Test</title></head>
<body>
<div>
<AsyncComponent client:load title="level-a">
<AsyncComponent client:load title="level-a-a" ></AsyncComponent>
<AsyncComponent client:load title="level-a-b">
<AsyncComponent client:load title="level-a-b-a"></AsyncComponent>
</AsyncComponent>
<AsyncComponent client:load title="level-a-2" ></AsyncComponent>
</AsyncComponent>
</div>
</body>
</html>

View file

@ -0,0 +1,21 @@
---
import {
AsyncErrorInErrorBoundary,
SyncErrorInErrorBoundary,
} from '../components/async-components.jsx';
---
<html>
<head><title>Solid</title></head>
<body>
<div>
<!--
Error boundary in hydrating component may generate scripts script:
https://github.com/ryansolid/dom-expressions/blob/6746f048c4adf4d4797276f074dd2d487654796a/packages/dom-expressions/src/server.js#L24
So make sure that the hydration script is generated on this page.
-->
<AsyncErrorInErrorBoundary client:load />
<SyncErrorInErrorBoundary client:load />
</div>
</body>
</html>

View file

@ -0,0 +1,17 @@
---
import { LazyCounter } from '../components/LazyCounter.jsx';
import { AsyncComponent } from '../components/async-components.jsx';
---
<html>
<head><title>Solid</title></head>
<body>
<div>
<!-- client:load should generate exactly one hydration script per page -->
<AsyncComponent client:load />
<AsyncComponent client:load />
<!-- Lazy copmonents should render consistently, even on first render. -->
<LazyCounter client:load />
</div>
</body>
</html>

View file

@ -0,0 +1,24 @@
---
import {
AsyncErrorInErrorBoundary,
SyncErrorInErrorBoundary,
// AsyncErrorComponent,
// SyncErrorComponent,
} from '../components/async-components.jsx';
---
<html>
<head><title>Solid</title></head>
<body>
<div>
<!-- Async errors should be caught by ErrorBoundary -->
<AsyncErrorInErrorBoundary />
<!-- Sync errors should be caught by ErrorBoundary -->
<SyncErrorInErrorBoundary />
<!-- Error not wrapped in ErrorBoundary should bubble up to Astro renderToStaticMarkup() function. -->
<!-- <AsyncErrorComponent /> -->
<!-- <SyncErrorComponent /> -->
</div>
</body>
</html>

View file

@ -0,0 +1,16 @@
---
import { AsyncComponent } from '../components/async-components.jsx';
import { LazyCounter } from '../components/LazyCounter.jsx';
---
<html>
<head><title>Solid</title></head>
<body>
<div>
<!-- Static component should not create any hydration scripts -->
<AsyncComponent />
<!-- Lazy copmonents should render consistently, even on first render. -->
<LazyCounter />
</div>
</body>
</html>

View file

@ -0,0 +1,13 @@
---
import { AsyncComponent } from '../components/async-components.jsx';
---
<html>
<head><title>Solid</title></head>
<body>
<div>
<!-- client only component should not generate hydration -->
<AsyncComponent client:only />
</div>
</body>
</html>

View file

@ -26,6 +26,72 @@ describe('Solid component', () => {
// test 2: Support rendering proxy components
expect($('#proxy-component').text()).to.be.equal('Hello world');
});
// ssr-client-none.astro
it('Supports server only components', async () => {
const html = await fixture.readFile('ssr-client-none/index.html');
const hydrationScriptCount = countHydrationScripts(html);
expect(hydrationScriptCount).to.be.equal(0);
const hydrationEventsCount = countHydrationEvents(html);
expect(hydrationEventsCount).to.be.equal(0);
});
it('Supports lazy server only components', async () => {
const html = await fixture.readFile('ssr-client-none/index.html');
const $ = cheerio.load(html);
expect($('button')).to.have.lengthOf(4);
});
// ssr-client-none-throwing.astro
it('Supports server only components with error boundaries', async () => {
const html = await fixture.readFile('ssr-client-none-throwing/index.html');
const hydrationScriptCount = countHydrationScripts(html);
expect(hydrationScriptCount).to.be.equal(0);
expect(html).to.include('Async error boundary fallback');
expect(html).to.include('Sync error boundary fallback');
const hydrationEventsCount = countHydrationEvents(html);
expect(hydrationEventsCount).to.be.equal(0);
});
// ssr-client-load.astro
it('Supports hydrating components', async () => {
const html = await fixture.readFile('ssr-client-load/index.html');
const hydrationScriptCount = countHydrationScripts(html);
expect(hydrationScriptCount).to.be.equal(1);
});
it('Supports lazy hydrating components', async () => {
const html = await fixture.readFile('ssr-client-load/index.html');
const $ = cheerio.load(html);
expect($('button')).to.have.lengthOf(4);
});
// ssr-client-load-throwing.astro
it('Supports hydrating components with error boundaries', async () => {
const html = await fixture.readFile('ssr-client-load-throwing/index.html');
const hydrationScriptCount = countHydrationScripts(html);
expect(hydrationScriptCount).to.be.equal(1);
expect(html).to.include('Async error boundary fallback');
expect(html).to.include('Sync error boundary fallback');
const hydrationEventsCount = countHydrationEvents(html);
expect(hydrationEventsCount).to.be.greaterThanOrEqual(1);
});
// ssr-client-only.astro
it('Supports client only components', async () => {
const html = await fixture.readFile('ssr-client-only/index.html');
const hydrationScriptCount = countHydrationScripts(html);
expect(hydrationScriptCount).to.be.equal(0);
});
// nested.astro
it('Injects hydration script before any SolidJS components in the HTML, even if heavily nested', async () => {
const html = await fixture.readFile('nested/index.html');
const firstHydrationScriptAt = String(html).indexOf('_$HY=');
const firstHydrationEventAt = String(html).indexOf('_$HY.set');
expect(firstHydrationScriptAt).to.be.lessThan(firstHydrationEventAt);
});
});
if (isWindows) return;
@ -64,3 +130,18 @@ describe('Solid component', () => {
});
});
});
function countHydrationScripts(/** @type {string} */ html) {
// Based on this hydration script
// https://github.com/ryansolid/dom-expressions/blob/2ea8a70518710941da6a59b0fe9c2a8d94d1baed/packages/dom-expressions/assets/hydrationScripts.js
// we look for the hint "_$HY="
return html.match(/_\$HY=/g)?.length ?? 0;
}
function countHydrationEvents(/** @type {string} */ html) {
// number of times a component was hydrated during rendering
// we look for the hint "_$HY.set("
return html.match(/_\$HY.set\(/g)?.length ?? 0;
}

View file

@ -96,6 +96,60 @@ export default defineConfig({
});
```
## Rendering strategy
Hydrating SolidJS components are automatically wrapped in Suspense boundaries and rendered on the server using the [renderToStringAsync](https://www.solidjs.com/docs/latest/api#rendertostringasync) function. This means that lazy components, such as:
```tsx
// HelloAstro.tsx
export default function HelloAstro() {
return <div>Hello Astro</div>;
}
```
```tsx
// LazyHelloAstro.tsx
import { lazy } from 'solid-js';
export const LazyHelloAstro = lazy(() => import('./HelloAstro'));
```
```astro
// hello.astro import {LazyHelloAstro} from "./LazyHelloAstro.tsx"
<LazyHelloAstro />
```
Will be rendered into the server's HTML output as
```html
<div>Hello Astro</div>
```
Resources will also be resolved. For example:
```tsx
// CharacterName.tsx
function CharacterName() {
const [name] = createResource(() =>
fetch('https://swapi.dev/api/people/1')
.then((result) => result.json())
.then((data) => data.name)
);
return <div>Name: {name()}</div>;
}
// character.astro
<CharacterName />;
```
Will cause the server to fetch the resource and generate the following HTML output:
```html
<div>Name: Luke Skywalker</div>
```
Non-hydrating [`client:only` components](https://docs.astro.build/en/reference/directives-reference/#clientonly) are not automatically wrapped in a Suspense boundaries. You mnay wrap these components in Suspense boundaries yourself if this behavior is desired.
## Troubleshooting
For help, check out the `#support` channel on [Discord](https://astro.build/chat). Our friendly Support Squad members are here to help!

View file

@ -43,7 +43,7 @@
"solid-js": "^1.7.11"
},
"peerDependencies": {
"solid-js": "^1.4.3"
"solid-js": "^1.7.11"
},
"engines": {
"node": ">=18.14.1"

View file

@ -1,14 +1,12 @@
import { Suspense } from 'solid-js';
import { createComponent, hydrate, render } from 'solid-js/web';
export default (element: HTMLElement) =>
(Component: any, props: any, slotted: any, { client }: { client: string }) => {
// Prepare global object expected by Solid's hydration logic
if (!(window as any)._$HY) {
(window as any)._$HY = { events: [], completed: new WeakSet(), r: {} };
}
if (!element.hasAttribute('ssr')) return;
const boostrap = client === 'only' ? render : hydrate;
const isHydrate = client !== 'only';
const bootstrap = isHydrate ? hydrate : render;
let slot: HTMLElement | null;
let _slots: Record<string, any> = {};
@ -35,13 +33,25 @@ export default (element: HTMLElement) =>
const { default: children, ...slots } = _slots;
const renderId = element.dataset.solidRenderId;
const dispose = boostrap(
() =>
createComponent(Component, {
...props,
...slots,
children,
}),
const dispose = bootstrap(
() => {
const inner = () =>
createComponent(Component, {
...props,
...slots,
children,
});
if (isHydrate) {
return createComponent(Suspense, {
get children() {
return inner();
},
});
} else {
return inner();
}
},
element,
{
renderId,

View file

@ -3,6 +3,7 @@ import type { RendererContext } from './types.js';
type Context = {
id: string;
c: number;
hasHydrationScript: boolean;
};
const contexts = new WeakMap<RendererContext['result'], Context>();
@ -11,11 +12,12 @@ export function getContext(result: RendererContext['result']): Context {
if (contexts.has(result)) {
return contexts.get(result)!;
}
let ctx = {
let ctx: Context = {
c: 0,
get id() {
return 's' + this.c.toString();
},
hasHydrationScript: false,
};
contexts.set(result, ctx);
return ctx;

View file

@ -1,16 +1,38 @@
import { createComponent, renderToString, ssr } from 'solid-js/web';
import {
createComponent,
generateHydrationScript,
NoHydration,
renderToString,
renderToStringAsync,
ssr,
Suspense,
} from 'solid-js/web';
import { getContext, incrementId } from './context.js';
import type { RendererContext } from './types.js';
const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
function check(this: RendererContext, Component: any, props: Record<string, any>, children: any) {
type RenderStrategy = 'sync' | 'async';
async function check(
this: RendererContext,
Component: any,
props: Record<string, any>,
children: any
) {
if (typeof Component !== 'function') return false;
const { html } = renderToStaticMarkup.call(this, Component, props, children);
const { html } = await renderToStaticMarkup.call(this, Component, props, children, {
// The check() function appears to just be checking if it is
// a valid Solid component. This should be lightweight so prefer
// sync render strategy, which should simplify render Suspense fallbacks
// not try to load any resources.
renderStrategy: 'sync' as RenderStrategy,
});
return typeof html === 'string';
}
function renderToStaticMarkup(
// AsyncRendererComponentFn
async function renderToStaticMarkup(
this: RendererContext,
Component: any,
props: Record<string, any>,
@ -21,27 +43,91 @@ function renderToStaticMarkup(
const needsHydrate = metadata?.astroStaticSlot ? !!metadata.hydrate : true;
const tagName = needsHydrate ? 'astro-slot' : 'astro-static-slot';
const html = renderToString(
() => {
const slots: Record<string, any> = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = ssr(`<${tagName} name="${name}">${value}</${tagName}>`);
}
// 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(`<${tagName}>${children}</${tagName}>`) : children,
};
const ctx = getContext(this.result);
return createComponent(Component, newProps);
},
{
renderId,
const renderStrategy = (metadata?.renderStrategy ?? 'async') as RenderStrategy;
const renderFn = () => {
const slots: Record<string, any> = {};
for (const [key, value] of Object.entries(slotted)) {
const name = slotName(key);
slots[name] = ssr(`<${tagName} name="${name}">${value}</${tagName}>`);
}
);
// 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(`<${tagName}>${children}</${tagName}>`) : children,
};
if (renderStrategy === 'sync') {
// Sync Render:
// <Component />
// This render mode is not exposed directly to the consumer, only
// used in the check() function.
return createComponent(Component, newProps);
} else {
if (needsHydrate) {
// Hydrate + Async Render:
// <Suspense>
// <Component />
// </Suspense>
return createComponent(Suspense, {
get children() {
return createComponent(Component, newProps);
},
});
} else {
// Static + Async Render
// <NoHydration>
// <Suspense>
// <Component />
// </Suspense>
// </NoHydration>
return createComponent(NoHydration, {
get children() {
return createComponent(Suspense, {
get children() {
return createComponent(Component, newProps);
},
});
},
});
}
}
};
let html = '';
if (needsHydrate && renderStrategy === 'async') {
if (!ctx.hasHydrationScript) {
// The hydration script needs to come before to the first hydrating component of the page.
// One way to this would be to prepend the rendered output, eg:
//
// html += generateHydrationScript();
//
// However, in certain situations, nested components may be rendered depth-first, causing SolidJS
// to put the hydration script in the wrong spot.
//
// Therefore we render the hydration script to the extraHead so it can work anytime.
// NOTE: It seems that components on a page may be rendered in parallel.
// To avoid a race condition, this code block is intentionally written
// *before* the first `await` in the function, so the hydration script will
// be prefixed to the first hydratable component on the page, regardless of
// the order in which the components finish rendering.
this.result._metadata.extraHead.push(generateHydrationScript());
ctx.hasHydrationScript = true;
}
}
html +=
renderStrategy === 'async'
? await renderToStringAsync(renderFn, { renderId })
: renderToString(renderFn, { renderId });
return {
attrs: {
'data-solid-render-id': renderId,