diff --git a/.changeset/dull-carpets-breathe.md b/.changeset/dull-carpets-breathe.md new file mode 100644 index 000000000000..63aca3cefd71 --- /dev/null +++ b/.changeset/dull-carpets-breathe.md @@ -0,0 +1,24 @@ +--- +'astro': patch +--- + +Adds a new function called `addServerRenderer` to the Container API. Use this function to manually store renderers inside the instance of your container. + +This new function should be preferred when using the Container API in environments like on-demand pages: + +```ts +import type {APIRoute} from "astro"; +import { experimental_AstroContainer } from "astro/container"; +import reactRenderer from '@astrojs/react/server.js'; +import vueRenderer from '@astrojs/vue/server.js'; +import ReactComponent from "../components/button.jsx" +import VueComponent from "../components/button.vue" + +export const GET: APIRoute = async (ctx) => { + const container = await experimental_AstroContainer.create(); + container.addServerRenderer("@astrojs/react", reactRenderer); + container.addServerRenderer("@astrojs/vue", vueRenderer); + const vueComponent = await container.renderToString(VueComponent) + return await container.renderToResponse(Component); +} +``` diff --git a/examples/container-with-vitest/test/ReactWrapper.test.ts b/examples/container-with-vitest/test/ReactWrapper.test.ts index 6adbff6cfeb7..099e0c8a3a5d 100644 --- a/examples/container-with-vitest/test/ReactWrapper.test.ts +++ b/examples/container-with-vitest/test/ReactWrapper.test.ts @@ -6,7 +6,7 @@ import ReactWrapper from '../src/components/ReactWrapper.astro'; const renderers = await loadRenderers([getContainerRenderer()]); const container = await AstroContainer.create({ - renderers, + renderers }); test('ReactWrapper with react renderer', async () => { diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index d30b1b3bab1a..69c8f9368411 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -2977,27 +2977,29 @@ export interface AstroRenderer { jsxTransformOptions?: JSXTransformFn; } -export interface SSRLoadedRenderer extends AstroRenderer { - ssr: { - check: AsyncRendererComponentFn; - renderToStaticMarkup: AsyncRendererComponentFn<{ - html: string; - 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; - }; +export type SSRLoadedRendererValue = { + check: AsyncRendererComponentFn; + renderToStaticMarkup: AsyncRendererComponentFn<{ + html: string; + 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; +} + +export interface SSRLoadedRenderer extends Pick { + ssr: SSRLoadedRendererValue; } export type HookParameters< diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index 015d192721f5..47fac93a93d5 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -1,16 +1,14 @@ import { posix } from 'node:path'; import type { AstroConfig, - AstroRenderer, AstroUserConfig, ComponentInstance, ContainerImportRendererFn, - ContainerRenderer, MiddlewareHandler, Props, RouteData, RouteType, - SSRLoadedRenderer, + SSRLoadedRenderer, SSRLoadedRendererValue, SSRManifest, SSRResult, } from '../@types/astro.js'; @@ -270,6 +268,38 @@ export class experimental_AstroContainer { }); } + /** + * Use this function to manually add a renderer to the container. + * + * This function is preferred when you require to use the container with a renderer in environments such as on-demand pages. + * + * ## Example + * + * ```js + * import reactRenderer from "@astrojs/react/server.js"; + * import vueRenderer from "@astrojs/vue/server.js"; + * import { experimental_AstroContainer as AstroContainer } from "astro/container" + * + * const container = await AstroContainer.create(); + * container.addServerRenderer("@astrojs/react", reactRenderer); + * container.addServerRenderer("@astrojs/vue", vueRenderer); + * ``` + * + * @param name The name of the renderer. The name **isn't** arbitrary, and it should match the name of the package. + * @param renderer The server renderer exported by integration. + */ + public addServerRenderer(name: string, renderer: SSRLoadedRendererValue) { + if (!renderer.check || !renderer.renderToStaticMarkup) { + throw new Error("The renderer you passed isn't valid. A renderer is usually an object that exposes the `check` and `renderToStaticMarkup` functions.\n" + + "Usually, the renderer is exported by a /server.js entrypoint e.g. `import renderer from '@astrojs/react/server.js'`") + } + + this.#pipeline.manifest.renderers.push({ + name, + ssr: renderer + }) + } + // NOTE: we keep this private via TS instead via `#` so it's still available on the surface, so we can play with it. // @ematipico: I plan to use it for a possible integration that could help people private static async createFromManifest( diff --git a/packages/astro/test/container.test.js b/packages/astro/test/container.test.js index e28506988e0b..7f873fb19102 100644 --- a/packages/astro/test/container.test.js +++ b/packages/astro/test/container.test.js @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; +import { describe, it, before } from 'node:test'; import { experimental_AstroContainer } from '../dist/container/index.js'; import { Fragment, @@ -12,6 +12,8 @@ import { renderSlot, renderTemplate, } from '../dist/runtime/server/index.js'; +import {loadFixture} from "./test-utils.js"; +import testAdapter from "./test-adapter.js"; const BaseLayout = createComponent((result, _props, slots) => { return render` @@ -230,3 +232,26 @@ describe('Container', () => { assert.match(result, /Is open/); }); }); + +describe('Container with renderers', () => { + let fixture + let app; + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/container-react/', import.meta.url), + output: "server", + adapter: testAdapter() + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it("the endpoint should return the HTML of the React component", async () => { + const request = new Request("https://example.com/api"); + const response = await app.render(request) + const html = await response.text() + + assert.match(html, /I am a react button/) + }) +}); + diff --git a/packages/astro/test/fixtures/container-react/astro.config.mjs b/packages/astro/test/fixtures/container-react/astro.config.mjs new file mode 100644 index 000000000000..e7ce274c003a --- /dev/null +++ b/packages/astro/test/fixtures/container-react/astro.config.mjs @@ -0,0 +1,7 @@ +import react from '@astrojs/react'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [react()], +}); diff --git a/packages/astro/test/fixtures/container-react/package.json b/packages/astro/test/fixtures/container-react/package.json new file mode 100644 index 000000000000..43d164ce8028 --- /dev/null +++ b/packages/astro/test/fixtures/container-react/package.json @@ -0,0 +1,12 @@ +{ + "name": "@test/react-container", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@astrojs/react": "workspace:*", + "astro": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + } +} diff --git a/packages/astro/test/fixtures/container-react/src/components/button.jsx b/packages/astro/test/fixtures/container-react/src/components/button.jsx new file mode 100644 index 000000000000..2eeffc33451d --- /dev/null +++ b/packages/astro/test/fixtures/container-react/src/components/button.jsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default () => { + return ; +} diff --git a/packages/astro/test/fixtures/container-react/src/pages/api.ts b/packages/astro/test/fixtures/container-react/src/pages/api.ts new file mode 100644 index 000000000000..7fae87247caf --- /dev/null +++ b/packages/astro/test/fixtures/container-react/src/pages/api.ts @@ -0,0 +1,10 @@ +import type {APIRoute, SSRLoadedRenderer} from "astro"; +import { experimental_AstroContainer } from "astro/container"; +import server from '@astrojs/react/server.js'; +import Component from "../components/button.jsx" + +export const GET: APIRoute = async (ctx) => { + const container = await experimental_AstroContainer.create(); + container.addServerRenderer("@astrojs/react", server); + return await container.renderToResponse(Component); +} diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js index c2b2558534fb..efdd72102808 100644 --- a/packages/integrations/react/server.js +++ b/packages/integrations/react/server.js @@ -230,3 +230,4 @@ export default { renderToStaticMarkup, supportsAstroStaticSlot: true, }; + diff --git a/packages/integrations/react/src/index.ts b/packages/integrations/react/src/index.ts index 85d79eef88a6..c20146949fb6 100644 --- a/packages/integrations/react/src/index.ts +++ b/packages/integrations/react/src/index.ts @@ -1,7 +1,13 @@ import react, { type Options as ViteReactPluginOptions } from '@vitejs/plugin-react'; -import type { AstroIntegration, ContainerRenderer } from 'astro'; -import { version as ReactVersion } from 'react-dom'; +import type {AstroIntegration, ContainerRenderer} from 'astro'; import type * as vite from 'vite'; +import { + getReactMajorVersion, + isUnsupportedVersion, + versionsConfig, + type ReactVersionConfig, + type SupportedReactVersion, +} from './version.js'; export type ReactIntegrationOptions = Pick< ViteReactPluginOptions, @@ -12,39 +18,6 @@ export type ReactIntegrationOptions = Pick< const FAST_REFRESH_PREAMBLE = react.preambleCode; -const versionsConfig = { - 17: { - server: '@astrojs/react/server-v17.js', - client: '@astrojs/react/client-v17.js', - externals: ['react-dom/server.js', 'react-dom/client.js'], - }, - 18: { - server: '@astrojs/react/server.js', - client: '@astrojs/react/client.js', - externals: ['react-dom/server', 'react-dom/client'], - }, - 19: { - server: '@astrojs/react/server.js', - client: '@astrojs/react/client.js', - externals: ['react-dom/server', 'react-dom/client'], - }, -}; - -type SupportedReactVersion = keyof typeof versionsConfig; -type ReactVersionConfig = (typeof versionsConfig)[SupportedReactVersion]; - -function getReactMajorVersion(): number { - const matches = /\d+\./.exec(ReactVersion); - if (!matches) { - return NaN; - } - return Number(matches[0]); -} - -function isUnsupportedVersion(majorVersion: number) { - return majorVersion < 17 || majorVersion > 19 || Number.isNaN(majorVersion); -} - function getRenderer(reactConfig: ReactVersionConfig) { return { name: '@astrojs/react', @@ -53,19 +26,6 @@ function getRenderer(reactConfig: ReactVersionConfig) { }; } -export function getContainerRenderer(): ContainerRenderer { - const majorVersion = getReactMajorVersion(); - if (isUnsupportedVersion(majorVersion)) { - throw new Error(`Unsupported React version: ${majorVersion}.`); - } - const versionConfig = versionsConfig[majorVersion as SupportedReactVersion]; - - return { - name: '@astrojs/react', - serverEntrypoint: versionConfig.server, - }; -} - function optionsPlugin(experimentalReactChildren: boolean): vite.Plugin { const virtualModule = 'astro:react:opts'; const virtualModuleId = '\0' + virtualModule; @@ -152,3 +112,16 @@ export default function ({ }, }; } + +export function getContainerRenderer(): ContainerRenderer { + const majorVersion = getReactMajorVersion(); + if (isUnsupportedVersion(majorVersion)) { + throw new Error(`Unsupported React version: ${majorVersion}.`); + } + const versionConfig = versionsConfig[majorVersion as SupportedReactVersion]; + + return { + name: '@astrojs/react', + serverEntrypoint: versionConfig.server, + }; +} diff --git a/packages/integrations/react/src/version.ts b/packages/integrations/react/src/version.ts new file mode 100644 index 000000000000..dc3a7a85aecf --- /dev/null +++ b/packages/integrations/react/src/version.ts @@ -0,0 +1,34 @@ +import { version as ReactVersion } from 'react-dom'; + +export type SupportedReactVersion = keyof typeof versionsConfig; +export type ReactVersionConfig = (typeof versionsConfig)[SupportedReactVersion]; + +export function getReactMajorVersion(): number { + const matches = /\d+\./.exec(ReactVersion); + if (!matches) { + return NaN; + } + return Number(matches[0]); +} + +export function isUnsupportedVersion(majorVersion: number) { + return majorVersion < 17 || majorVersion > 19 || Number.isNaN(majorVersion); +} + +export const versionsConfig = { + 17: { + server: '@astrojs/react/server-v17.js', + client: '@astrojs/react/client-v17.js', + externals: ['react-dom/server.js', 'react-dom/client.js'], + }, + 18: { + server: '@astrojs/react/server.js', + client: '@astrojs/react/client.js', + externals: ['react-dom/server', 'react-dom/client'], + }, + 19: { + server: '@astrojs/react/server.js', + client: '@astrojs/react/client.js', + externals: ['react-dom/server', 'react-dom/client'], + }, +}; diff --git a/packages/integrations/react/tsconfig.json b/packages/integrations/react/tsconfig.json index 1504b4b6dfa4..3d1121296bc2 100644 --- a/packages/integrations/react/tsconfig.json +++ b/packages/integrations/react/tsconfig.json @@ -3,5 +3,5 @@ "include": ["src"], "compilerOptions": { "outDir": "./dist" - } + }, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef034d50c901..fea9345a6096 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2539,6 +2539,21 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/container-react: + dependencies: + '@astrojs/react': + specifier: workspace:* + version: link:../../../../integrations/react + astro: + specifier: workspace:* + version: link:../../.. + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + packages/astro/test/fixtures/content: dependencies: '@astrojs/mdx':