Render async SolidJS components
This commit is contained in:
parent
fca6892f8d
commit
f12ce6c947
18 changed files with 511 additions and 40 deletions
21
.changeset/chilly-badgers-push.md
Normal file
21
.changeset/chilly-badgers-push.md
Normal 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.
|
|
@ -1,7 +1,7 @@
|
||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
import { scrollToElement, testFactory, waitForHydrate } from './test-utils.js';
|
import { scrollToElement, testFactory, waitForHydrate } from './test-utils.js';
|
||||||
|
|
||||||
export function prepareTestFactory(opts) {
|
export function prepareTestFactory(opts, { canReplayClicks = false } = {}) {
|
||||||
const test = testFactory(opts);
|
const test = testFactory(opts);
|
||||||
|
|
||||||
let devServer;
|
let devServer;
|
||||||
|
@ -104,7 +104,16 @@ export function prepareTestFactory(opts) {
|
||||||
await waitForHydrate(page, counter);
|
await waitForHydrate(page, counter);
|
||||||
|
|
||||||
await inc.click();
|
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 }) => {
|
test('client:only', async ({ page, astro }) => {
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import { prepareTestFactory } from './shared-component-tests.js';
|
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 = {
|
const config = {
|
||||||
componentFilePath: './src/components/SolidComponent.jsx',
|
componentFilePath: './src/components/SolidComponent.jsx',
|
||||||
|
|
34
packages/astro/test/fixtures/solid-component/src/components/Counter.jsx
vendored
Normal file
34
packages/astro/test/fixtures/solid-component/src/components/Counter.jsx
vendored
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
5
packages/astro/test/fixtures/solid-component/src/components/LazyCounter.jsx
vendored
Normal file
5
packages/astro/test/fixtures/solid-component/src/components/LazyCounter.jsx
vendored
Normal 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'));
|
55
packages/astro/test/fixtures/solid-component/src/components/async-components.jsx
vendored
Normal file
55
packages/astro/test/fixtures/solid-component/src/components/async-components.jsx
vendored
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
18
packages/astro/test/fixtures/solid-component/src/pages/nested.astro
vendored
Normal file
18
packages/astro/test/fixtures/solid-component/src/pages/nested.astro
vendored
Normal 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>
|
21
packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load-throwing.astro
vendored
Normal file
21
packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load-throwing.astro
vendored
Normal 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>
|
17
packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load.astro
vendored
Normal file
17
packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load.astro
vendored
Normal 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>
|
24
packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none-throwing.astro
vendored
Normal file
24
packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none-throwing.astro
vendored
Normal 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>
|
16
packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none.astro
vendored
Normal file
16
packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none.astro
vendored
Normal 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>
|
13
packages/astro/test/fixtures/solid-component/src/pages/ssr-client-only.astro
vendored
Normal file
13
packages/astro/test/fixtures/solid-component/src/pages/ssr-client-only.astro
vendored
Normal 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>
|
|
@ -26,6 +26,72 @@ describe('Solid component', () => {
|
||||||
// test 2: Support rendering proxy components
|
// test 2: Support rendering proxy components
|
||||||
expect($('#proxy-component').text()).to.be.equal('Hello world');
|
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;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
## Troubleshooting
|
||||||
|
|
||||||
For help, check out the `#support` channel on [Discord](https://astro.build/chat). Our friendly Support Squad members are here to help!
|
For help, check out the `#support` channel on [Discord](https://astro.build/chat). Our friendly Support Squad members are here to help!
|
||||||
|
|
|
@ -43,7 +43,7 @@
|
||||||
"solid-js": "^1.7.11"
|
"solid-js": "^1.7.11"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"solid-js": "^1.4.3"
|
"solid-js": "^1.7.11"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
|
import { Suspense } from 'solid-js';
|
||||||
import { createComponent, hydrate, render } from 'solid-js/web';
|
import { createComponent, hydrate, render } from 'solid-js/web';
|
||||||
|
|
||||||
export default (element: HTMLElement) =>
|
export default (element: HTMLElement) =>
|
||||||
(Component: any, props: any, slotted: any, { client }: { client: string }) => {
|
(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;
|
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 slot: HTMLElement | null;
|
||||||
let _slots: Record<string, any> = {};
|
let _slots: Record<string, any> = {};
|
||||||
|
@ -35,13 +33,25 @@ export default (element: HTMLElement) =>
|
||||||
const { default: children, ...slots } = _slots;
|
const { default: children, ...slots } = _slots;
|
||||||
const renderId = element.dataset.solidRenderId;
|
const renderId = element.dataset.solidRenderId;
|
||||||
|
|
||||||
const dispose = boostrap(
|
const dispose = bootstrap(
|
||||||
() =>
|
() => {
|
||||||
createComponent(Component, {
|
const inner = () =>
|
||||||
...props,
|
createComponent(Component, {
|
||||||
...slots,
|
...props,
|
||||||
children,
|
...slots,
|
||||||
}),
|
children,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isHydrate) {
|
||||||
|
return createComponent(Suspense, {
|
||||||
|
get children() {
|
||||||
|
return inner();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return inner();
|
||||||
|
}
|
||||||
|
},
|
||||||
element,
|
element,
|
||||||
{
|
{
|
||||||
renderId,
|
renderId,
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type { RendererContext } from './types.js';
|
||||||
type Context = {
|
type Context = {
|
||||||
id: string;
|
id: string;
|
||||||
c: number;
|
c: number;
|
||||||
|
hasHydrationScript: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const contexts = new WeakMap<RendererContext['result'], Context>();
|
const contexts = new WeakMap<RendererContext['result'], Context>();
|
||||||
|
@ -11,11 +12,12 @@ export function getContext(result: RendererContext['result']): Context {
|
||||||
if (contexts.has(result)) {
|
if (contexts.has(result)) {
|
||||||
return contexts.get(result)!;
|
return contexts.get(result)!;
|
||||||
}
|
}
|
||||||
let ctx = {
|
let ctx: Context = {
|
||||||
c: 0,
|
c: 0,
|
||||||
get id() {
|
get id() {
|
||||||
return 's' + this.c.toString();
|
return 's' + this.c.toString();
|
||||||
},
|
},
|
||||||
|
hasHydrationScript: false,
|
||||||
};
|
};
|
||||||
contexts.set(result, ctx);
|
contexts.set(result, ctx);
|
||||||
return ctx;
|
return ctx;
|
||||||
|
|
|
@ -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 { getContext, incrementId } from './context.js';
|
||||||
import type { RendererContext } from './types.js';
|
import type { RendererContext } from './types.js';
|
||||||
|
|
||||||
const slotName = (str: string) => str.trim().replace(/[-_]([a-z])/g, (_, w) => w.toUpperCase());
|
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;
|
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';
|
return typeof html === 'string';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderToStaticMarkup(
|
// AsyncRendererComponentFn
|
||||||
|
async function renderToStaticMarkup(
|
||||||
this: RendererContext,
|
this: RendererContext,
|
||||||
Component: any,
|
Component: any,
|
||||||
props: Record<string, any>,
|
props: Record<string, any>,
|
||||||
|
@ -21,27 +43,91 @@ function renderToStaticMarkup(
|
||||||
const needsHydrate = metadata?.astroStaticSlot ? !!metadata.hydrate : true;
|
const needsHydrate = metadata?.astroStaticSlot ? !!metadata.hydrate : true;
|
||||||
const tagName = needsHydrate ? 'astro-slot' : 'astro-static-slot';
|
const tagName = needsHydrate ? 'astro-slot' : 'astro-static-slot';
|
||||||
|
|
||||||
const html = renderToString(
|
const ctx = getContext(this.result);
|
||||||
() => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
return createComponent(Component, newProps);
|
const renderStrategy = (metadata?.renderStrategy ?? 'async') as RenderStrategy;
|
||||||
},
|
|
||||||
{
|
const renderFn = () => {
|
||||||
renderId,
|
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 {
|
return {
|
||||||
attrs: {
|
attrs: {
|
||||||
'data-solid-render-id': renderId,
|
'data-solid-render-id': renderId,
|
||||||
|
|
Loading…
Reference in a new issue