diff --git a/.changeset/chilly-badgers-push.md b/.changeset/chilly-badgers-push.md new file mode 100644 index 000000000..2bbd12f0d --- /dev/null +++ b/.changeset/chilly-badgers-push.md @@ -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. diff --git a/packages/astro/e2e/shared-component-tests.js b/packages/astro/e2e/shared-component-tests.js index e8ec273fd..ccce25b0b 100644 --- a/packages/astro/e2e/shared-component-tests.js +++ b/packages/astro/e2e/shared-component-tests.js @@ -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 }) => { diff --git a/packages/astro/e2e/solid-component.test.js b/packages/astro/e2e/solid-component.test.js index 7a195c9b1..81e6894e8 100644 --- a/packages/astro/e2e/solid-component.test.js +++ b/packages/astro/e2e/solid-component.test.js @@ -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', diff --git a/packages/astro/test/fixtures/solid-component/src/components/Counter.jsx b/packages/astro/test/fixtures/solid-component/src/components/Counter.jsx new file mode 100644 index 000000000..648a6af15 --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/components/Counter.jsx @@ -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 ( + + {(page) => { + return ( + + + + + + + + + ); + }} + + ); +} diff --git a/packages/astro/test/fixtures/solid-component/src/components/LazyCounter.jsx b/packages/astro/test/fixtures/solid-component/src/components/LazyCounter.jsx new file mode 100644 index 000000000..27ff06246 --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/components/LazyCounter.jsx @@ -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')); diff --git a/packages/astro/test/fixtures/solid-component/src/components/async-components.jsx b/packages/astro/test/fixtures/solid-component/src/components/async-components.jsx new file mode 100644 index 000000000..1b3b0db69 --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/components/async-components.jsx @@ -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 ( +
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} */} +
+ ); +} + +export function AsyncErrorComponent() { + const [data] = createResource(async () => { + await sleep(SLEEP_MS); + throw new Error('Async error thrown!'); + }); + + return
{data()}
; +} + +export function AsyncErrorInErrorBoundary() { + return ( + Async error boundary fallback}> + + + ); +} + +export function SyncErrorComponent() { + throw new Error('Sync error thrown!'); +} + +export function SyncErrorInErrorBoundary() { + return ( + Sync error boundary fallback}> + + + ); +} diff --git a/packages/astro/test/fixtures/solid-component/src/pages/nested.astro b/packages/astro/test/fixtures/solid-component/src/pages/nested.astro new file mode 100644 index 000000000..1cf733708 --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/pages/nested.astro @@ -0,0 +1,18 @@ +--- +import { AsyncComponent } from '../components/async-components.jsx'; +--- + + + Nested Test + +
+ + + + + + + +
+ + diff --git a/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load-throwing.astro b/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load-throwing.astro new file mode 100644 index 000000000..40a5ca52c --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load-throwing.astro @@ -0,0 +1,21 @@ +--- +import { + AsyncErrorInErrorBoundary, + SyncErrorInErrorBoundary, +} from '../components/async-components.jsx'; +--- + + + Solid + +
+ + + +
+ + diff --git a/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load.astro b/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load.astro new file mode 100644 index 000000000..0b43ca972 --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load.astro @@ -0,0 +1,17 @@ +--- +import { LazyCounter } from '../components/LazyCounter.jsx'; +import { AsyncComponent } from '../components/async-components.jsx'; +--- + + + Solid + +
+ + + + + +
+ + diff --git a/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none-throwing.astro b/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none-throwing.astro new file mode 100644 index 000000000..7c81c8844 --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none-throwing.astro @@ -0,0 +1,24 @@ +--- +import { + AsyncErrorInErrorBoundary, + SyncErrorInErrorBoundary, + // AsyncErrorComponent, + // SyncErrorComponent, +} from '../components/async-components.jsx'; +--- + + + Solid + +
+ + + + + + + + +
+ + diff --git a/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none.astro b/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none.astro new file mode 100644 index 000000000..60f0b429b --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none.astro @@ -0,0 +1,16 @@ +--- +import { AsyncComponent } from '../components/async-components.jsx'; +import { LazyCounter } from '../components/LazyCounter.jsx'; +--- + + + Solid + +
+ + + + +
+ + diff --git a/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-only.astro b/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-only.astro new file mode 100644 index 000000000..f94800b2a --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/pages/ssr-client-only.astro @@ -0,0 +1,13 @@ +--- +import { AsyncComponent } from '../components/async-components.jsx'; +--- + + + Solid + +
+ + +
+ + diff --git a/packages/astro/test/solid-component.test.js b/packages/astro/test/solid-component.test.js index ed3af45e8..e8028fcfc 100644 --- a/packages/astro/test/solid-component.test.js +++ b/packages/astro/test/solid-component.test.js @@ -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; +} diff --git a/packages/integrations/solid/README.md b/packages/integrations/solid/README.md index d11d5da88..83eb59a48 100644 --- a/packages/integrations/solid/README.md +++ b/packages/integrations/solid/README.md @@ -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
Hello Astro
; +} +``` + +```tsx +// LazyHelloAstro.tsx +import { lazy } from 'solid-js'; +export const LazyHelloAstro = lazy(() => import('./HelloAstro')); +``` + +```astro +// hello.astro import {LazyHelloAstro} from "./LazyHelloAstro.tsx" + +``` + +Will be rendered into the server's HTML output as + +```html +
Hello Astro
+``` + +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
Name: {name()}
; +} + +// character.astro +; +``` + +Will cause the server to fetch the resource and generate the following HTML output: + +```html +
Name: Luke Skywalker
+``` + +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! diff --git a/packages/integrations/solid/package.json b/packages/integrations/solid/package.json index 5fd37d31d..3f0f29a9a 100644 --- a/packages/integrations/solid/package.json +++ b/packages/integrations/solid/package.json @@ -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" diff --git a/packages/integrations/solid/src/client.ts b/packages/integrations/solid/src/client.ts index 58f41160d..0455bff2a 100644 --- a/packages/integrations/solid/src/client.ts +++ b/packages/integrations/solid/src/client.ts @@ -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 = {}; @@ -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, diff --git a/packages/integrations/solid/src/context.ts b/packages/integrations/solid/src/context.ts index e18ead749..3c7391c07 100644 --- a/packages/integrations/solid/src/context.ts +++ b/packages/integrations/solid/src/context.ts @@ -3,6 +3,7 @@ import type { RendererContext } from './types.js'; type Context = { id: string; c: number; + hasHydrationScript: boolean; }; const contexts = new WeakMap(); @@ -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; diff --git a/packages/integrations/solid/src/server.ts b/packages/integrations/solid/src/server.ts index 6e371da51..76182bd70 100644 --- a/packages/integrations/solid/src/server.ts +++ b/packages/integrations/solid/src/server.ts @@ -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, children: any) { +type RenderStrategy = 'sync' | 'async'; + +async function check( + this: RendererContext, + Component: any, + props: Record, + 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, @@ -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 = {}; - for (const [key, value] of Object.entries(slotted)) { - const name = slotName(key); - slots[name] = ssr(`<${tagName} name="${name}">${value}`); - } - // 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}`) : children, - }; + const ctx = getContext(this.result); - return createComponent(Component, newProps); - }, - { - renderId, + const renderStrategy = (metadata?.renderStrategy ?? 'async') as RenderStrategy; + + const renderFn = () => { + const slots: Record = {}; + for (const [key, value] of Object.entries(slotted)) { + const name = slotName(key); + slots[name] = ssr(`<${tagName} name="${name}">${value}`); } - ); + // 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}`) : children, + }; + + if (renderStrategy === 'sync') { + // Sync Render: + // + // 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: + // + // + // + return createComponent(Suspense, { + get children() { + return createComponent(Component, newProps); + }, + }); + } else { + // Static + Async Render + // + // + // + // + // + 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,