From 37021044dd4382a9b214f89b7c221bf1c93f3e7d Mon Sep 17 00:00:00 2001 From: Patrick Miller Date: Thu, 4 Jan 2024 20:37:08 +0900 Subject: [PATCH] Render async SolidJS components (#6791) * Render async SolidJS components * Add renderer-specific hydration script to allow for proper SolidJS hydration * Add support for Solid.js 1.8.x * Address documentation feedback * Rebuild pnpm lock file based on main branch * Address PR feedback from ematipico --------- Co-authored-by: Johannes Spohr Co-authored-by: Florian Lefebvre --- .changeset/chilly-badgers-push.md | 31 ++++ packages/astro/e2e/shared-component-tests.js | 13 +- packages/astro/e2e/solid-component.test.js | 7 +- packages/astro/src/@types/astro.ts | 18 +++ packages/astro/src/core/render/result.ts | 1 + .../astro/src/runtime/server/render/common.ts | 10 ++ .../src/runtime/server/render/component.ts | 9 ++ .../src/runtime/server/render/instruction.ts | 16 +- .../src/components/Counter.jsx | 34 ++++ .../src/components/LazyCounter.jsx | 5 + .../src/components/async-components.jsx | 70 +++++++++ .../src/components/defer.astro | 7 + .../solid-component/src/pages/deferred.astro | 11 ++ .../solid-component/src/pages/nested.astro | 18 +++ .../src/pages/ssr-client-load-throwing.astro | 21 +++ .../src/pages/ssr-client-load.astro | 17 ++ .../src/pages/ssr-client-none-throwing.astro | 24 +++ .../src/pages/ssr-client-none.astro | 16 ++ .../src/pages/ssr-client-only.astro | 13 ++ packages/astro/test/solid-component.test.js | 147 ++++++++++++++++++ packages/integrations/solid/package.json | 2 +- packages/integrations/solid/src/client.ts | 34 ++-- packages/integrations/solid/src/context.ts | 2 +- packages/integrations/solid/src/server.ts | 121 +++++++++++--- 24 files changed, 605 insertions(+), 42 deletions(-) create mode 100644 .changeset/chilly-badgers-push.md create mode 100644 packages/astro/test/fixtures/solid-component/src/components/Counter.jsx create mode 100644 packages/astro/test/fixtures/solid-component/src/components/LazyCounter.jsx create mode 100644 packages/astro/test/fixtures/solid-component/src/components/async-components.jsx create mode 100644 packages/astro/test/fixtures/solid-component/src/components/defer.astro create mode 100644 packages/astro/test/fixtures/solid-component/src/pages/deferred.astro create mode 100644 packages/astro/test/fixtures/solid-component/src/pages/nested.astro create mode 100644 packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load-throwing.astro create mode 100644 packages/astro/test/fixtures/solid-component/src/pages/ssr-client-load.astro create mode 100644 packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none-throwing.astro create mode 100644 packages/astro/test/fixtures/solid-component/src/pages/ssr-client-none.astro create mode 100644 packages/astro/test/fixtures/solid-component/src/pages/ssr-client-only.astro diff --git a/.changeset/chilly-badgers-push.md b/.changeset/chilly-badgers-push.md new file mode 100644 index 000000000000..ffe277122bce --- /dev/null +++ b/.changeset/chilly-badgers-push.md @@ -0,0 +1,31 @@ +--- +'@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`. It also injects the actual SolidJS hydration script generated by [`generateHydrationScript`](https://www.solidjs.com/guides/server#hydration-script), so that [`Suspense`](https://www.solidjs.com/docs/latest#suspense), [`ErrorBoundary`](https://www.solidjs.com/docs/latest#errorboundary) and similar components can be hydrated correctly. + +The server render phase will now wait for Suspense boundaries to resolve instead of always rendering the Suspense fallback. + +If you use the APIs [`createResource`](https://www.solidjs.com/docs/latest#createresource) or [`lazy`](https://www.solidjs.com/docs/latest#lazy), their functionalities will now be executed on the server side, not just the client side. + +This increases the flexibility of the SolidJS integration. Server-side components can now safely fetch remote data, call async Astro server functions like `getImage()` or load other components dynamically. Even server-only components that do not hydrate in the browser will benefit. + +It is very unlikely that a server-only component would have used the Suspense feature until now, so this should not be a breaking change for server-only components. + +This could be a breaking change for components that meet the following conditions: + +- The component uses Suspense APIs like `Suspense`, `lazy` or `createResource`, and +- The component is mounted using a *hydrating* directive: + - `client:load` + - `client:idle` + - `client:visible` + - `client:media` + +These components will now first try to resolve the Suspense boundaries on the server side instead of the client side. + +If you do not want Suspense boundaries to be resolved on the server (for example, if you are using createResource to do an HTTP fetch that relies on a browser-side cookie), you may consider: + +- changing the template directive to `client:only` to skip server side rendering completely +- use APIs like [isServer](https://www.solidjs.com/docs/latest/api#isserver) or `onMount()` to detect server mode and render a server fallback without using Suspense. diff --git a/packages/astro/e2e/shared-component-tests.js b/packages/astro/e2e/shared-component-tests.js index e8ec273fd475..ccce25b0b008 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 7a195c9b1267..81e6894e80b5 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/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 9cbb0f0534b0..04ca6b1f92ae 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -2293,6 +2293,18 @@ export interface SSRLoadedRenderer extends AstroRenderer { attrs?: Record; }>; supportsAstroStaticSlot?: boolean; + /** + * If provided, Astro will call this function and inject the returned + * script in the HTML before the first component handled by this renderer. + * + * This feature is needed by some renderers (in particular, by Solid). The + * Solid official hydration script sets up a page-level data structure. + * It is mainly used to transfer data between the server side render phase + * and the browser application state. Solid Components rendered later in + * the HTML may inject tiny scripts into the HTML that call into this + * page-level data structure. + */ + renderHydrationScript?: () => string; }; } @@ -2512,6 +2524,12 @@ export interface SSRResult { */ export interface SSRMetadata { hasHydrationScript: boolean; + /** + * Names of renderers that have injected their hydration scripts + * into the current page. For example, Solid SSR needs a hydration + * script in the page HTML before the first Solid component. + */ + rendererSpecificHydrationScripts: Set; hasDirectives: Set; hasRenderedHead: boolean; headInTree: boolean; diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index 9a745fd5a95f..29b54ae85393 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -274,6 +274,7 @@ export function createResult(args: CreateResultArgs): SSRResult { response, _metadata: { hasHydrationScript: false, + rendererSpecificHydrationScripts: new Set(), hasRenderedHead: false, hasDirectives: new Set(), headInTree: false, diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts index d08bd959747c..ba0d62032f0e 100644 --- a/packages/astro/src/runtime/server/render/common.ts +++ b/packages/astro/src/runtime/server/render/common.ts @@ -89,6 +89,16 @@ function stringifyChunk( } return renderAllHeadContent(result); } + case 'renderer-hydration-script': { + const { rendererSpecificHydrationScripts } = result._metadata; + const { rendererName } = instruction; + + if (!rendererSpecificHydrationScripts.has(rendererName)) { + rendererSpecificHydrationScripts.add(rendererName); + return instruction.render(); + } + return ''; + } default: { throw new Error(`Unknown chunk type: ${(chunk as any).type}`); } diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts index 42987f011e90..3fcb6f2aa39a 100644 --- a/packages/astro/src/runtime/server/render/component.ts +++ b/packages/astro/src/runtime/server/render/component.ts @@ -375,6 +375,15 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr } } destination.write(createRenderInstruction({ type: 'directive', hydration })); + if (hydration.directive !== 'only' && renderer?.ssr.renderHydrationScript) { + destination.write( + createRenderInstruction({ + type: 'renderer-hydration-script', + rendererName: renderer.name, + render: renderer.ssr.renderHydrationScript, + }) + ); + } destination.write(markHTMLString(renderElement('astro-island', island, false))); }, }; diff --git a/packages/astro/src/runtime/server/render/instruction.ts b/packages/astro/src/runtime/server/render/instruction.ts index d8feacff96a3..5be7ffd38018 100644 --- a/packages/astro/src/runtime/server/render/instruction.ts +++ b/packages/astro/src/runtime/server/render/instruction.ts @@ -11,6 +11,16 @@ export type RenderHeadInstruction = { type: 'head'; }; +/** + * Render a renderer-specific hydration script before the first component of that + * framework + */ +export type RendererHydrationScriptInstruction = { + type: 'renderer-hydration-script'; + rendererName: string; + render: () => string; +}; + export type MaybeRenderHeadInstruction = { type: 'maybe-head'; }; @@ -18,11 +28,15 @@ export type MaybeRenderHeadInstruction = { export type RenderInstruction = | RenderDirectiveInstruction | RenderHeadInstruction - | MaybeRenderHeadInstruction; + | MaybeRenderHeadInstruction + | RendererHydrationScriptInstruction; export function createRenderInstruction( instruction: RenderDirectiveInstruction ): RenderDirectiveInstruction; +export function createRenderInstruction( + instruction: RendererHydrationScriptInstruction +): RendererHydrationScriptInstruction; export function createRenderInstruction(instruction: RenderHeadInstruction): RenderHeadInstruction; export function createRenderInstruction( instruction: MaybeRenderHeadInstruction 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 000000000000..648a6af1583f --- /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 000000000000..27ff06246549 --- /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 000000000000..1823759ea1f9 --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/components/async-components.jsx @@ -0,0 +1,70 @@ +import { createResource, createSignal, createUniqueId, ErrorBoundary, Show } 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 id = createUniqueId(); + + 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 for component id=' + id; + }); + + const [show, setShow] = createSignal(false); + + return ( +
+ {'title=' + (props.title ?? '(none)') + ' '} + {'id=' + id + ' '} + {data()}{' '} + + {/* NOTE: The props.children are intentionally hidden by default + to simulate a situation where hydration script might not + be injected in the right spot. */} + {props.children ?? 'Empty'} +
+ ); +} + +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/components/defer.astro b/packages/astro/test/fixtures/solid-component/src/components/defer.astro new file mode 100644 index 000000000000..d2004266662c --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/components/defer.astro @@ -0,0 +1,7 @@ +--- +import { AsyncComponent } from './async-components.jsx'; + +await new Promise((resolve) => setTimeout(resolve, Astro.props.delay)); +--- + + diff --git a/packages/astro/test/fixtures/solid-component/src/pages/deferred.astro b/packages/astro/test/fixtures/solid-component/src/pages/deferred.astro new file mode 100644 index 000000000000..185ae19bdea7 --- /dev/null +++ b/packages/astro/test/fixtures/solid-component/src/pages/deferred.astro @@ -0,0 +1,11 @@ +--- +import Defer from '../components/defer.astro'; +--- + + + Solid + + + + + 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 000000000000..ba5ad3082a08 --- /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 000000000000..40a5ca52c0e9 --- /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 000000000000..0b43ca972373 --- /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 000000000000..7c81c884455d --- /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 000000000000..60f0b429b541 --- /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 000000000000..f94800b2a009 --- /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 ed3af45e8883..05fb581c1acd 100644 --- a/packages/astro/test/solid-component.test.js +++ b/packages/astro/test/solid-component.test.js @@ -26,6 +26,106 @@ 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); + // AsyncComponent renders 1 button + // LazyCounter renders 4 buttons + // Total is 5 buttons + expect($('button')).to.have.lengthOf(5); + }); + + // 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); + // AsyncComponent renders 1 button, and there are 2 AsyncComponents + // LazyCounter renders 4 buttons + // Total is 6 buttons + expect($('button')).to.have.lengthOf(6); + }); + + // 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 () => { + // TODO: This tests SSG mode, where the extraHead is generally available. + // Should add a test (and solution) for SSR mode, where head is more likely to have already + // been streamed to the client. + const html = await fixture.readFile('nested/index.html'); + + const firstHydrationScriptAt = getFirstHydrationScriptLocation(html); + expect(firstHydrationScriptAt).to.be.finite.and.greaterThan(0); + + const firstHydrationEventAt = getFirstHydrationEventLocation(html); + expect(firstHydrationEventAt).to.be.finite.and.greaterThan(0); + + expect(firstHydrationScriptAt).to.be.lessThan( + firstHydrationEventAt, + 'Position of first hydration event' + ); + }); + + it('Injects hydration script before any SolidJS components in the HTML, even if render order is reversed by delay', async () => { + const html = await fixture.readFile('deferred/index.html'); + + const firstHydrationScriptAt = getFirstHydrationScriptLocation(html); + expect(firstHydrationScriptAt).to.be.finite.and.greaterThan(0); + + const firstHydrationEventAt = getFirstHydrationEventLocation(html); + expect(firstHydrationEventAt).to.be.finite.and.greaterThan(0); + + const hydrationScriptCount = countHydrationScripts(html); + expect(hydrationScriptCount).to.be.equal(1); + expect(firstHydrationScriptAt).to.be.lessThan( + firstHydrationEventAt, + 'Position of first hydration event' + ); + }); }); if (isWindows) return; @@ -64,3 +164,50 @@ describe('Solid component', () => { }); }); }); + +/** + * Get a regex that matches hydration scripts. + * + * Based on this hydration script: + * https://github.com/ryansolid/dom-expressions/blob/main/packages/dom-expressions/assets/hydrationScripts.js + * + * Which is supposed to be injected in a page with hydrating Solid components + * essentially one time. + * + * We look for the hint "_$HY=". + * + * I chose to make this a function to avoid accidentally sharing regex state + * between tests. + * + * NOTE: These scripts have ocassionally changed in the past. If the tests + * start failing after a Solid version change, we may need to find a different + * way to count the hydration scripts. + */ +const createHydrationScriptRegex = (flags) => new RegExp(/_\$HY=/, flags); + +function countHydrationScripts(/** @type {string} */ html) { + return html.match(createHydrationScriptRegex('g'))?.length ?? 0; +} + +function getFirstHydrationScriptLocation(/** @type {string} */ html) { + return html.match(createHydrationScriptRegex())?.index; +} + +/** + * Get a regex that matches hydration events. A hydration event + * is when data is emitted to help hydrate a component during SSR process. + * + * We look for the hint "_$HY.r[" + */ +const createHydrationEventRegex = (flags) => new RegExp(/_\$HY.r\[/, flags); + +function countHydrationEvents(/** @type {string} */ html) { + // Number of times a component was hydrated during rendering + // We look for the hint "_$HY.r[" + + return html.match(createHydrationEventRegex('g'))?.length ?? 0; +} + +function getFirstHydrationEventLocation(/** @type {string} */ html) { + return html.match(createHydrationEventRegex())?.index; +} diff --git a/packages/integrations/solid/package.json b/packages/integrations/solid/package.json index 0018040de6d7..d444e511cfdf 100644 --- a/packages/integrations/solid/package.json +++ b/packages/integrations/solid/package.json @@ -43,7 +43,7 @@ "solid-js": "^1.8.5" }, "peerDependencies": { - "solid-js": "^1.4.3" + "solid-js": "^1.8.5" }, "engines": { "node": ">=18.14.1" diff --git a/packages/integrations/solid/src/client.ts b/packages/integrations/solid/src/client.ts index 58f41160da14..0455bff2a823 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 e18ead749aaa..6e201e3f5534 100644 --- a/packages/integrations/solid/src/context.ts +++ b/packages/integrations/solid/src/context.ts @@ -11,7 +11,7 @@ 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(); diff --git a/packages/integrations/solid/src/server.ts b/packages/integrations/solid/src/server.ts index 445e4605e9de..f0bd138d7385 100644 --- a/packages/integrations/solid/src/server.ts +++ b/packages/integrations/solid/src/server.ts @@ -1,53 +1,125 @@ -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; if (Component.name === 'QwikComponent') return false; - const { html } = renderToStaticMarkup.call(this, Component, props, children); + + // There is nothing particularly special about Solid components. Basically they are just functions. + // In general, components from other frameworks (eg, MDX, React, etc.) tend to render as "undefined", + // so we take advantage of this trick to decide if this is a Solid component or not. + + const { html } = await renderToStaticMarkup.call(this, Component, props, children, { + // The purpose of check() is just to validate that this is a Solid component and not + // React, etc. We should render in sync mode which should skip Suspense boundaries + // or loading resources like external API calls. + renderStrategy: 'sync' as RenderStrategy, + }); + return typeof html === 'string'; } -function renderToStaticMarkup( +// AsyncRendererComponentFn +async function renderToStaticMarkup( this: RendererContext, Component: any, props: Record, { default: children, ...slotted }: any, metadata?: undefined | Record ) { - const renderId = metadata?.hydrate ? incrementId(getContext(this.result)) : ''; + const ctx = getContext(this.result); + const renderId = metadata?.hydrate ? incrementId(ctx) : ''; 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 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 end user. It is only + // used in the check() function. return createComponent(Component, newProps); - }, - { - renderId, + } 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); + }, + }); + }, + }); + } } - ); + }; + + const componentHtml = + renderStrategy === 'async' + ? await renderToStringAsync(renderFn, { + renderId, + // New setting since Solid 1.8.4 that fixes an errant hydration event appearing in + // server only components. Not available in TypeScript types yet. + // https://github.com/solidjs/solid/issues/1931 + // https://github.com/ryansolid/dom-expressions/commit/e09e255ac725fd59195aa0f3918065d4bd974e6b + ...({ noScripts: !needsHydrate } as any), + }) + : renderToString(renderFn, { renderId }); + return { attrs: { 'data-solid-render-id': renderId, }, - html, + html: componentHtml, }; } @@ -55,4 +127,5 @@ export default { check, renderToStaticMarkup, supportsAstroStaticSlot: true, + renderHydrationScript: () => generateHydrationScript(), };