Skip to content

Commit

Permalink
Fixes using React.lazy and Suspense (#3160)
Browse files Browse the repository at this point in the history
* Revert "Revert "Fixes using React.lazy and Suspense""

This reverts commit e621c2f.

* Adds a changeset

* Fix ts errors

* Remove netlify metadata folder
  • Loading branch information
matthewp authored Apr 21, 2022
1 parent 3cdc5f1 commit ae9ac5c
Show file tree
Hide file tree
Showing 21 changed files with 183 additions and 14 deletions.
7 changes: 7 additions & 0 deletions .changeset/dirty-planes-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astrojs/deno': patch
'@astrojs/netlify': patch
'@astrojs/react': patch
---

Allows using React.lazy, Suspense in SSR and with hydration
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ package-lock.json
*.env

!packages/astro/vendor/vite/dist
packages/integrations/**/.netlify/
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react';

export const LazyComponent = () => {
return (
<span id="lazy">inner content</span>
);
};

export default LazyComponent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React, { Suspense } from 'react';
const LazyComponent = React.lazy(() => import('./LazyComponent.jsx'));

export const ParentComponent = () => {
return (
<div id="outer">
<Suspense>
<LazyComponent />
</Suspense>
</div>
);
};

export default ParentComponent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
import Suspense from '../components/Suspense.jsx';
---

<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<div id="client">
<Suspense client:load />
</div>
<div id="server">
<Suspense />
</div>
</body>
</html>
19 changes: 13 additions & 6 deletions packages/astro/test/react-component.test.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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!');
Expand Down Expand Up @@ -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);
Expand All @@ -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');

Expand All @@ -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;
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/integrations/deno/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
alias['react-dom/server'] = 'react-dom/server.browser'
vite.ssr = {
noExternal: true,
};
Expand Down
3 changes: 3 additions & 0 deletions packages/integrations/deno/test/basics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion packages/integrations/deno/test/fixtures/basics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/deno": "workspace:*"
"@astrojs/deno": "workspace:*",
"@astrojs/react": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'react';

export default function() {
return (
<div id="react">testing</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
import ReactComponent from '../components/React.jsx';
---
<html>
<head>
Expand All @@ -8,5 +8,6 @@
</head>
<body>
<h1>Basic App on Deno</h1>
<ReactComponent />
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
alias['react-dom/server'] = 'react-dom/server.browser'
vite.ssr = {
noExternal: true,
};
Expand Down
1 change: 1 addition & 0 deletions packages/integrations/netlify/test/edge-functions/deps.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-nocheck
export { fromFileUrl } from 'https://deno.land/[email protected]/path/mod.ts';
export { assertEquals, assert } from 'https://deno.land/[email protected]/testing/asserts.ts';
export * from 'https://deno.land/x/deno_dom/deno-dom-wasm.ts';
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
// @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({
name: 'Edge Basics',
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();
},
});
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/react": "workspace:*",
"@astrojs/netlify": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'react';

export default function() {
return (
<div id="react">testing</div>
)
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
---
import ReactComponent from '../components/React.jsx';
---
<html>
<head><title>Testing</title></head>
<body>
Expand All @@ -6,5 +9,6 @@
<ul>
<li><a href="/two/">Two</a></li>
</ul>
<ReactComponent />
</body>
</html>
76 changes: 72 additions & 4 deletions packages/integrations/react/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -42,15 +42,21 @@ function check(Component, props, children) {
return React.createElement('div');
}

renderToStaticMarkup(Tester, props, children, {});
await renderToStaticMarkup(Tester, props, children, {});

if (error) {
throw error;
}
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,
Expand All @@ -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,
Expand Down
Loading

0 comments on commit ae9ac5c

Please sign in to comment.