diff --git a/.changeset/dirty-planes-dance.md b/.changeset/dirty-planes-dance.md new file mode 100644 index 000000000..ec8689668 --- /dev/null +++ b/.changeset/dirty-planes-dance.md @@ -0,0 +1,7 @@ +--- +'@astrojs/deno': patch +'@astrojs/netlify': patch +'@astrojs/react': patch +--- + +Allows using React.lazy, Suspense in SSR and with hydration diff --git a/.gitignore b/.gitignore index 6a837da1d..30c24d1e9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ package-lock.json *.env !packages/astro/vendor/vite/dist +packages/integrations/**/.netlify/ diff --git a/packages/astro/test/fixtures/react-component/src/components/LazyComponent.jsx b/packages/astro/test/fixtures/react-component/src/components/LazyComponent.jsx new file mode 100644 index 000000000..b43aa36be --- /dev/null +++ b/packages/astro/test/fixtures/react-component/src/components/LazyComponent.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export const LazyComponent = () => { + return ( + inner content + ); +}; + +export default LazyComponent; diff --git a/packages/astro/test/fixtures/react-component/src/components/Suspense.jsx b/packages/astro/test/fixtures/react-component/src/components/Suspense.jsx new file mode 100644 index 000000000..87dc82625 --- /dev/null +++ b/packages/astro/test/fixtures/react-component/src/components/Suspense.jsx @@ -0,0 +1,14 @@ +import React, { Suspense } from 'react'; +const LazyComponent = React.lazy(() => import('./LazyComponent.jsx')); + +export const ParentComponent = () => { + return ( +
+ + + +
+ ); +}; + +export default ParentComponent; diff --git a/packages/astro/test/fixtures/react-component/src/pages/suspense.astro b/packages/astro/test/fixtures/react-component/src/pages/suspense.astro new file mode 100644 index 000000000..5a9d15644 --- /dev/null +++ b/packages/astro/test/fixtures/react-component/src/pages/suspense.astro @@ -0,0 +1,17 @@ +--- +import Suspense from '../components/Suspense.jsx'; +--- + + + + + + +
+ +
+
+ +
+ + diff --git a/packages/astro/test/react-component.test.js b/packages/astro/test/react-component.test.js index bb67f3df2..749fc0c16 100644 --- a/packages/astro/test/react-component.test.js +++ b/packages/astro/test/react-component.test.js @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import cheerio from 'cheerio'; +import { load as cheerioLoad } from 'cheerio'; import { isWindows, loadFixture } from './test-utils.js'; let fixture; @@ -18,7 +18,7 @@ describe('React Components', () => { it('Can load React', async () => { const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); + const $ = cheerioLoad(html); // test 1: basic component renders expect($('#react-static').text()).to.equal('Hello static!'); @@ -51,13 +51,13 @@ describe('React Components', () => { it('Can load Vue', async () => { const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); + const $ = cheerioLoad(html); expect($('#vue-h2').text()).to.equal('Hasta la vista, baby'); }); it('Can use a pragma comment', async () => { const html = await fixture.readFile('/pragma-comment/index.html'); - const $ = cheerio.load(html); + const $ = cheerioLoad(html); // test 1: rendered the PragmaComment component expect($('.pragma-comment')).to.have.lengthOf(2); @@ -66,7 +66,7 @@ describe('React Components', () => { // TODO: is this still a relevant test? it.skip('Includes reactroot on hydrating components', async () => { const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); + const $ = cheerioLoad(html); const div = $('#research'); @@ -76,6 +76,13 @@ describe('React Components', () => { // test 2: renders correctly expect(div.html()).to.equal('foo bar 1'); }); + + it('Can load Suspense-using components', async () => { + const html = await fixture.readFile('/suspense/index.html'); + const $ = cheerioLoad(html); + expect($('#client #lazy')).to.have.lengthOf(1); + expect($('#server #lazy')).to.have.lengthOf(1); + }); }); if (isWindows) return; @@ -93,7 +100,7 @@ describe('React Components', () => { it('scripts proxy correctly', async () => { const html = await fixture.fetch('/').then((res) => res.text()); - const $ = cheerio.load(html); + const $ = cheerioLoad(html); for (const script of $('script').toArray()) { const { src } = script.attribs; diff --git a/packages/integrations/deno/src/index.ts b/packages/integrations/deno/src/index.ts index d1b3b14ba..f0658f2c8 100644 --- a/packages/integrations/deno/src/index.ts +++ b/packages/integrations/deno/src/index.ts @@ -23,6 +23,10 @@ export default function createIntegration(args?: Options): AstroIntegration { }, 'astro:build:setup': ({ vite, target }) => { if (target === 'server') { + vite.resolve = vite.resolve || {}; + vite.resolve.alias = vite.resolve.alias || {}; + const alias = vite.resolve.alias as Record; + alias['react-dom/server'] = 'react-dom/server.browser' vite.ssr = { noExternal: true, }; diff --git a/packages/integrations/deno/test/basics.test.js b/packages/integrations/deno/test/basics.test.js index 115883466..42efc87d9 100644 --- a/packages/integrations/deno/test/basics.test.js +++ b/packages/integrations/deno/test/basics.test.js @@ -13,6 +13,9 @@ Deno.test({ assertEquals(resp.status, 200); const html = await resp.text(); assert(html); + const doc = new DOMParser().parseFromString(html, `text/html`); + const div = doc.querySelector("#react"); + assert(div, 'div exists'); }); }, }); diff --git a/packages/integrations/deno/test/fixtures/basics/astro.config.mjs b/packages/integrations/deno/test/fixtures/basics/astro.config.mjs index e56fe2e98..af5f1aa6b 100644 --- a/packages/integrations/deno/test/fixtures/basics/astro.config.mjs +++ b/packages/integrations/deno/test/fixtures/basics/astro.config.mjs @@ -1,8 +1,10 @@ import { defineConfig } from 'astro/config'; import deno from '@astrojs/deno'; +import react from '@astrojs/react'; export default defineConfig({ adapter: deno(), + integrations: [react()], experimental: { ssr: true } diff --git a/packages/integrations/deno/test/fixtures/basics/package.json b/packages/integrations/deno/test/fixtures/basics/package.json index a623de766..7800873f7 100644 --- a/packages/integrations/deno/test/fixtures/basics/package.json +++ b/packages/integrations/deno/test/fixtures/basics/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "astro": "workspace:*", - "@astrojs/deno": "workspace:*" + "@astrojs/deno": "workspace:*", + "@astrojs/react": "workspace:*" } } diff --git a/packages/integrations/deno/test/fixtures/basics/src/components/React.jsx b/packages/integrations/deno/test/fixtures/basics/src/components/React.jsx new file mode 100644 index 000000000..42c74a7aa --- /dev/null +++ b/packages/integrations/deno/test/fixtures/basics/src/components/React.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export default function() { + return ( +
testing
+ ); +} diff --git a/packages/integrations/deno/test/fixtures/basics/src/pages/index.astro b/packages/integrations/deno/test/fixtures/basics/src/pages/index.astro index 9a37d684b..4eb15f2f0 100644 --- a/packages/integrations/deno/test/fixtures/basics/src/pages/index.astro +++ b/packages/integrations/deno/test/fixtures/basics/src/pages/index.astro @@ -1,5 +1,5 @@ --- - +import ReactComponent from '../components/React.jsx'; --- @@ -8,5 +8,6 @@

Basic App on Deno

+ diff --git a/packages/integrations/netlify/src/integration-edge-functions.ts b/packages/integrations/netlify/src/integration-edge-functions.ts index 0165c7831..b76617f1e 100644 --- a/packages/integrations/netlify/src/integration-edge-functions.ts +++ b/packages/integrations/netlify/src/integration-edge-functions.ts @@ -86,6 +86,10 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {}) }, 'astro:build:setup': ({ vite, target }) => { if (target === 'server') { + vite.resolve = vite.resolve || {}; + vite.resolve.alias = vite.resolve.alias || {}; + const alias = vite.resolve.alias as Record; + alias['react-dom/server'] = 'react-dom/server.browser' vite.ssr = { noExternal: true, }; diff --git a/packages/integrations/netlify/test/edge-functions/deps.ts b/packages/integrations/netlify/test/edge-functions/deps.ts index f3e46181a..1b23a94f7 100644 --- a/packages/integrations/netlify/test/edge-functions/deps.ts +++ b/packages/integrations/netlify/test/edge-functions/deps.ts @@ -1,3 +1,4 @@ // @ts-nocheck export { fromFileUrl } from 'https://deno.land/std@0.110.0/path/mod.ts'; export { assertEquals, assert } from 'https://deno.land/std@0.132.0/testing/asserts.ts'; +export * from 'https://deno.land/x/deno_dom/deno-dom-wasm.ts'; diff --git a/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts b/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts index 7fc8877bd..0b29fc1a9 100644 --- a/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts +++ b/packages/integrations/netlify/test/edge-functions/edge-basic.test.ts @@ -1,7 +1,7 @@ // @ts-ignore import { runBuild } from './test-utils.ts'; // @ts-ignore -import { assertEquals, assert } from './deps.ts'; +import { assertEquals, assert, DOMParser } from './deps.ts'; // @ts-ignore Deno.test({ @@ -9,12 +9,17 @@ Deno.test({ async fn() { let close = await runBuild('./fixtures/edge-basic/'); const { default: handler } = await import( - './fixtures/edge-basic/dist/edge-functions/entry.mjs' + './fixtures/edge-basic/dist/edge-functions/entry.js' ); const response = await handler(new Request('http://example.com/')); assertEquals(response.status, 200); const html = await response.text(); assert(html, 'got some html'); + + const doc = new DOMParser().parseFromString(html, `text/html`)!; + const div = doc.querySelector('#react'); + assert(div, 'div exists'); + await close(); }, }); diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/astro.config.mjs b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/astro.config.mjs index c55135e43..d7c899264 100644 --- a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/astro.config.mjs +++ b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/astro.config.mjs @@ -1,10 +1,12 @@ import { defineConfig } from 'astro/config'; import { netlifyEdgeFunctions } from '@astrojs/netlify'; +import react from "@astrojs/react"; export default defineConfig({ adapter: netlifyEdgeFunctions({ dist: new URL('./dist/', import.meta.url), }), + integrations: [react()], experimental: { ssr: true } diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/package.json b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/package.json index bbda2476b..16ff30088 100644 --- a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/package.json +++ b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "astro": "workspace:*", + "@astrojs/react": "workspace:*", "@astrojs/netlify": "workspace:*" } } diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/components/React.jsx b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/components/React.jsx new file mode 100644 index 000000000..c6cf39aa5 --- /dev/null +++ b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/components/React.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export default function() { + return ( +
testing
+ ) +} diff --git a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/index.astro b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/index.astro index a87de65db..80d2eed75 100644 --- a/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/index.astro +++ b/packages/integrations/netlify/test/edge-functions/fixtures/edge-basic/src/pages/index.astro @@ -1,3 +1,6 @@ +--- +import ReactComponent from '../components/React.jsx'; +--- Testing @@ -6,5 +9,6 @@ + diff --git a/packages/integrations/react/server.js b/packages/integrations/react/server.js index 6ae49e7bc..b41492a7a 100644 --- a/packages/integrations/react/server.js +++ b/packages/integrations/react/server.js @@ -12,7 +12,7 @@ function errorIsComingFromPreactComponent(err) { ); } -function check(Component, props, children) { +async function check(Component, props, children) { // Note: there are packages that do some unholy things to create "components". // Checking the $$typeof property catches most of these patterns. if (typeof Component === 'object') { @@ -42,7 +42,7 @@ function check(Component, props, children) { return React.createElement('div'); } - renderToStaticMarkup(Tester, props, children, {}); + await renderToStaticMarkup(Tester, props, children, {}); if (error) { throw error; @@ -50,7 +50,13 @@ function check(Component, props, children) { return isReactComponent; } -function renderToStaticMarkup(Component, props, children, metadata) { +async function getNodeWritable() { + let nodeStreamBuiltinModuleName = 'stream'; + let { Writable } = await import(nodeStreamBuiltinModuleName); + return Writable; +} + +async function renderToStaticMarkup(Component, props, children, metadata) { delete props['class']; const vnode = React.createElement(Component, { ...props, @@ -59,12 +65,74 @@ function renderToStaticMarkup(Component, props, children, metadata) { let html; if (metadata && metadata.hydrate) { html = ReactDOM.renderToString(vnode); + if('renderToReadableStream' in ReactDOM) { + html = await renderToReadableStreamAsync(vnode); + } else { + html = await renderToPipeableStreamAsync(vnode); + } } else { - html = ReactDOM.renderToStaticMarkup(vnode); + if('renderToReadableStream' in ReactDOM) { + html = await renderToReadableStreamAsync(vnode); + } else { + html = await renderToStaticNodeStreamAsync(vnode); + } + } return { html }; } +async function renderToPipeableStreamAsync(vnode) { + const Writable = await getNodeWritable(); + let html = ''; + return new Promise((resolve, reject) => { + let error = undefined; + let stream = ReactDOM.renderToPipeableStream(vnode, { + onError(err) { + error = err; + reject(error); + }, + onAllReady() { + stream.pipe(new Writable({ + write(chunk, _encoding, callback) { + html += chunk.toString('utf-8'); + callback(); + }, + destroy() { + resolve(html); + } + })); + } + }); + }); +} + +async function renderToStaticNodeStreamAsync(vnode) { + const Writable = await getNodeWritable(); + let html = ''; + return new Promise((resolve) => { + let stream = ReactDOM.renderToStaticNodeStream(vnode); + stream.pipe(new Writable({ + write(chunk, _encoding, callback) { + html += chunk.toString('utf-8'); + callback(); + }, + destroy() { + resolve(html); + } + })); + }); +} + +async function renderToReadableStreamAsync(vnode) { + const decoder = new TextDecoder(); + const stream = await ReactDOM.renderToReadableStream(vnode); + let html = ''; + for await(const chunk of stream) { + html += decoder.decode(chunk); + } + return html; +} + export default { check, renderToStaticMarkup, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0274700ac..8fad7c180 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1238,9 +1238,11 @@ importers: packages/integrations/deno/test/fixtures/basics: specifiers: '@astrojs/deno': workspace:* + '@astrojs/react': workspace:* astro: workspace:* dependencies: '@astrojs/deno': link:../../.. + '@astrojs/react': link:../../../../react astro: link:../../../../../astro packages/integrations/lit: @@ -1270,9 +1272,11 @@ importers: packages/integrations/netlify/test/edge-functions/fixtures/edge-basic: specifiers: '@astrojs/netlify': workspace:* + '@astrojs/react': workspace:* astro: workspace:* dependencies: '@astrojs/netlify': link:../../../.. + '@astrojs/react': link:../../../../../react astro: link:../../../../../../astro packages/integrations/node: