Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Support for React JS server side rendering #770

Merged
merged 40 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ecd4e6f
React Renderer: Add support for server-side rendering using React.
mikehearn Mar 27, 2024
9759cf5
Simplify the code and document it.
mikehearn Mar 28, 2024
e6c2c4f
Update the Reactonaut docs.
mikehearn Apr 3, 2024
1c51664
React Renderer: remove the todo list and do a small refactor to prepa…
mikehearn Apr 3, 2024
3277ecd
React Renderer: add a basic unit test setup
mikehearn Apr 4, 2024
a5620e5
React Renderer: make the user pick the GraalJS edition.
mikehearn Apr 4, 2024
f1c8fb5
React Renderer: fix client.js for the docs
mikehearn Apr 4, 2024
2a67e81
React Renderer: sandboxing isn't yet possible
mikehearn Apr 4, 2024
4583254
React Renderer: make path to JS bundles configurable
mikehearn Apr 5, 2024
f90221c
React Renderer: support Preact
mikehearn Apr 5, 2024
2e6e06c
React Renderer: refactor engine logging out.
mikehearn Apr 5, 2024
2b944a8
React Renderer: refactor bundle path computations out.
mikehearn Apr 5, 2024
8427ae5
React Renderer: refactor more of the js out.
mikehearn Apr 5, 2024
c400cbf
React Renderer: more refactorings.
mikehearn Apr 5, 2024
f6a205c
React Renderer: parallelism
mikehearn Apr 5, 2024
a10457a
React Renderer: update todo list
mikehearn Apr 10, 2024
db2ddaf
React Renderer: optimize the reload check.
mikehearn Apr 10, 2024
03c9815
React Renderer: tweak the name of the global symbol.
mikehearn Apr 11, 2024
7b6c66b
React Renderer: make render scripts customizable.
mikehearn Apr 11, 2024
7150dfd
React Renderer: show how to use head managers.
mikehearn Apr 11, 2024
2882778
React Renderer: remove docs disclaimer
mikehearn Apr 17, 2024
4fa3ca5
React Renderer: update client.preact.js to new global symbol name.
mikehearn Apr 17, 2024
d6fa025
React Renderer: add support for sandboxing, currently disabled by def…
mikehearn Apr 24, 2024
b0e92cc
React Renderer: add license headers
mikehearn Apr 24, 2024
d90b38b
React Renderer: fix checkstyle warnings.
mikehearn Apr 24, 2024
fc73dcb
React Renderer: resolve a few review comments.
mikehearn Apr 24, 2024
042746c
React Renderer: add a discussion of why object pooling.
mikehearn Apr 24, 2024
7fab5ce
React Renderer: remove the half-baked fetch support.
mikehearn May 7, 2024
de7de8e
React Renderer: update limitations docs.
mikehearn May 7, 2024
12113fd
React Renderer: add another unit test for the sandbox.
mikehearn May 7, 2024
1fe7019
React Renderer: more sandbox tests, a bit of refactoring, and expose …
mikehearn May 8, 2024
72475a4
React Renderer: address some review comments.
mikehearn May 8, 2024
a9d6cb4
React Renderer: use the file watching support.
mikehearn May 16, 2024
62b94fc
React Renderer: fix checkstyle errors.
mikehearn May 17, 2024
8145de7
React Renderer: document how to avoid restarts.
mikehearn May 23, 2024
807112d
React Renderer: disable binary compatibility check (new module)
mikehearn May 23, 2024
206aab4
React Renderer: delete package-lock.json
mikehearn May 23, 2024
17f29f8
React Renderer: gitignore package-lock.json
mikehearn May 24, 2024
511c326
React Renderer: exclude graalvm17 from tests in CI
mikehearn May 24, 2024
33c82b7
React Renderer: instruct user to disable virtual threads.
mikehearn May 24, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ testsviewsRocker=views-rocker/src/test
testsviewsPebble=views-pebble/src/test
testsviewsJte=views-jte/src/test
testsviewsJstachio=views-jstachio/src/test
srcjsReact=views-react/src/test/js
srcjsReactRender=views-react/src/main/resources/io/micronaut/views/react

org.gradle.caching=true
org.gradle.jvmargs=-Xmx1g
Expand Down
7 changes: 7 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ managed-soy = "2023-09-13"
managed-thymeleaf = "3.1.2.RELEASE"
managed-velocity = "2.3"

graal = "24.0.0"

pebble = "3.2.2"
thymeleaf-extra-java8time = "3.0.4.RELEASE"
kotlin = "1.9.23"
Expand Down Expand Up @@ -57,6 +59,11 @@ kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", versi
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
groovy-json = { module = "org.apache.groovy:groovy-json" }

graal-polyglot = { module = "org.graalvm.polyglot:polyglot", version.ref = "graal" }
graal-js = { module = "org.graalvm.polyglot:js", version.ref = "graal" }

jetbrains-annotations = { module = "org.jetbrains:annotations", version = "24.1.0" }

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
Expand Down
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ include 'views-handlebars'
include 'views-thymeleaf'
include 'views-htmx'
include 'views-velocity'
include 'views-react'
include 'views-rocker'
include 'views-pebble'
include 'views-jte'
Expand Down
9 changes: 9 additions & 0 deletions src/main/docs/guide/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ views:
title: JStachio
jstachioInstallation: JStachio Installation
jstachioExample: JStachio Example
react:
title: React SSR
reactpreparingjs: Preparing your Javascript
reactsettingproperties: Setting serving properties
preact: Integrating with Preact
reactrenderscripts:
title: Render scripts
reactheadmanagers: Using head managers
reacttodo: Known limitations
model:
title: Working with Models
custom: Dynamically Enriching Models
Expand Down
39 changes: 39 additions & 0 deletions src/main/docs/guide/views/templates/react.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
React server-side rendering (SSR) allows you to pre-render React components to HTML before the page is sent to the user.
This improves performance by ensuring the page appears before any Javascript has loaded (albeit in a non-responsive
state) and makes it easier for search engines to index your pages.

NOTE: This module is experimental and subject to change.

Micronaut's support for React SSR has the following useful features:
mikehearn marked this conversation as resolved.
Show resolved Hide resolved

* Javascript runs using https://www.graalvm.org/[GraalJS], a high performance Javascript engine native to the JVM. Make sure to run your app on GraalVM or by compiling it to a native image to get full Javascript performance.
* Compatible out of the box with both React and https://www.preactjs.com/[Preact], an alternative lighter weight implementation of the React concept.
* Customize the Javascript used to invoke SSR to add features like head managers, or use the prepackaged default scripts to get going straight away.
* The Javascript can be sandboxed, ensuring that your server environment is protected from possible supply chain attacks.
* You can pass any `@Introspectable` Java objects to use as _props_ for your page components. This is convenient for passing in things like the user profile info.
* Logging from Javascript is sent to the Micronaut logs. `console.log` and related will go to the `INFO` level of the logger named `js`, `console.error` and Javascript exceptions will go to the `ERROR` level of the same.

To use React SSR you need to add two dependencies.

1. Add the `micronaut-views-react` dependency.
2. Add a dependency on `org.graalvm.polyglot:js` or `org.graalvm.polyglot:js-community`. The difference is to do with licensing and performance, with the `js` version being faster and free to use but not open source. https://www.graalvm.org/latest/docs/introduction/#licensing-and-support[Learn more about choosing an edition.]

dependency:micronaut-views-react[groupId="io.micronaut.views"]

The properties used can be customized by overriding the values of:

include::{includedir}configurationProperties/io.micronaut.views.react.ReactViewsRendererConfiguration.adoc[]

== How it fits together

Props can be supplied in the form of an introspectable bean or a `Map<String, Object>`. Both forms will be serialized to JSON and sent to the client for hydration, as well as used to render the root component. The URL of the current page will be taken from the request and added to the props under the `url` key, which is useful when working with libraries like https://github.com/preactjs/preact-router[`preact-router`]. If you use `Map<String, Object>` as your model type and use Micronaut Security, authenticated usernames and other security info will be added to your props automatically.

By default you will need React components that return the entire page, including the `<html>` tag. You'll also need to prepare your Javascript (see below). Then just name your required page component in the `@View` annotation on a controller, for example `@View("App")` will render the `<App/>` component with your page props.

If your page components don't render the whole page or you need better control over how the framework is invoked you can use _render scripts_ (see below).

== Sandbox

By default Javascript executing server side runs with the same privilege level as the server itself. This is similar to the Node security model, but exposes you to supply chain attacks. If a third party React component you depend on turns out to be malicious or simply buggy, it could allow an attacker to run code server side instead of only inside the browser sandbox.

Normally with React SSR you can't do much about this, but with Micronaut Views React you can enable a server-side sandbox if you use GraalVM 24.1 or higher. This prevents Javascript from accessing any Java host objects that haven't been specifically marked as accessible to the sandbox. To use this set `micronaut.views.react.sandbox` to true in your `application.properties`, and then ensure that any objects you use as props have their property getters annotated with `@org.graalvm.polyglot.HostAccess.Export`. If there are properties that happen to be on your beans that should _not_ be exposed to Javascript, just don't annotate them. Any properties not annotated will simply be invisible from inside the sandbox.
38 changes: 38 additions & 0 deletions src/main/docs/guide/views/templates/react/preact.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
The https://www.preactjs.com/[Preact] library is a smaller and lighter weight implementation of React, with a few nice enhancements as well. Like React it also supports server side rendering and can be used with Micronaut React SSR. It requires some small changes to how you prepare your Javascript. Please read and understand how to prepare your JS for regular React first, as this section only covers the differences.

Your `server.js` should look like this:

[source,javascript]
.src/main/js/server.js
----
include::{srcjsReact}/server.preact.js[]
----

Notice the differences: we're re-exporting the `h` symbol from Preact (which it uses instead of `React.createComponent`) and `renderToString` from the separate `preact-render-to-string` module. Otherwise the script is the same: we have to export each page component.

Your `client.js` should look like this:

[source,javascript]
.src/main/js/client.js
----
include::{srcjsReact}/client.preact.js[]
----

Finally, you need to tell Micronaut Views React to use a different render script (see below). Set the `micronaut.views.react.render-script` application property to be `classpath:/io/micronaut/views/react/preact.js`.

That's it. If you want to use existing React components then you will also need to set up aliases in your `webpack.{client,server}.js` files like this:

[source,javascript]
----
module.exports = {
// ... existing values
resolve: {
alias: {
"react": "preact/compat",
"react-dom/test-utils": "preact/test-utils",
"react-dom": "preact/compat", // Must be below test-utils
"react/jsx-runtime": "preact/jsx-runtime"
},
}
}
----
40 changes: 40 additions & 0 deletions src/main/docs/guide/views/templates/react/reactheadmanagers.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
Head managers are libraries that let you build up the contents of your `<head>` block as your `<body>` renders. One use of custom render scripts is to integrate a head manager with your code. Here's an example of a simple render script that usees the https://github.com/nfl/react-helmet[React Helmet] library in this way. Remember to export `Helmet` from your server-side bundle.

[source,javascript]
----
export async function ssr(component, props, callback, config) {
// Create the vdom.
const element = React.createElement(component, props, null);
// Render the given component, expecting it to fill a <div id="content"></div> in the <body> tag.
const body = ReactDOMServer.renderToString(element)
// Get the data that should populate the <head> from the Helmet library.
const helmet = Helmet.renderStatic();
// Data to be passed to the browser after the main HTML has finished loading.
const boot = {
rootProps: props,
rootComponent: component.name,
};

// Assemble the HTML.
const html = `
<!doctype html>
<html ${helmet.htmlAttributes.toString()}>
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
</head>
<body ${helmet.bodyAttributes.toString()}>
<div id="content">
${body}
</div>

<script>var Micronaut = ${JSON.stringify(boot)};</script>
<script type="text/javascript" src="${config.getClientBundleURL()}" async="true"></script>
</body>
</html>`;

// Send it back.
callback.write(html);
}
----
53 changes: 53 additions & 0 deletions src/main/docs/guide/views/templates/react/reactpreparingjs.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
An app that uses SSR needs the React components to be bundled twice, once for the client and once for the server. For the server you need to make a Javascript module bundle that imports and then re-exports the (page) components you will render, along with `React` and `ReactServerDOM`. The bundle must be compatible with GraalJS, which is not NodeJS and thus doesn't support the same set of APIs. You will also need to create a client-side bundle as per usual, and change how you start up React.

TIP: This tutorial doesn't take you through how to create a ReactJS project from scratch - please refer to the React documentation for that.

To start we will need a `server.js` file. It should be a part of your frontend project and can be named and placed wherever you like, as the server will only need the final compiled bundle. Your `server.js` should look like this:

[source,javascript]
.src/main/js/server.js
----
include::{srcjsReact}/server.js[]
----

Add your page components as imports, and then also to the export line. We will now set up Webpack to turn this file into a bundle.

1. Run `npm i webpack node-polyfill-webpack-plugin text-encoding` to install some extra packages that are needed.
2. Create a config file called e.g. `webpack.server.js` like the following:

[source,javascript]
.src/main/js/webpack.server.js
----
include::{srcjsReact}/webpack.server.js[]
----

This Webpack config does several things:

* It polyfills APIs that lack a native implementation in the GraalJS engine.
* It ensures the output is a native Javascript module.
* It names the result `ssr-components.mjs` which is the only name Micronaut React SSR accepts. All components must be in one server side bundle currently.
* It makes the `SERVER` variable be statically true when the Javascript is being bundled for server-side rendering. This allows you to include/exclude code blocks at bundle optimization time.

You can use such a config by running `npx webpack --mode production --config webpack.server.js`. Add the `--watch` flag if you want the bundle to be recreated whenever an input file changes. Micronaut React SSR will notice if the bundle file has changed on disk and reload it (see <<react-dev-mode,Development>>).

Now create `client.js`. This will contain the Javascript that runs once the page is loaded, and which will "hydrate" the React app (reconnect the event handlers to the pre-existing DOM). It should look like this:

[source,javascript]
.src/main/js/client.js
----
include::{srcjsReact}/client.js[]
----

Depending on how you configure minification, you may also need to import your page components here. This small snippet of code reads the `Micronaut` object which is generated by the Micronaut React SSR renderer just before your `client.js` code is loaded. It contains the component named in your `@View("MyPageComponent")` annotation, which is then loaded assuming it is in a Javascript module of the same name. The props that will be passed to that page component as generated from the object you return from your controller method. If you wish you can wrap `<PageComponent/>` here with any contexts you need.

And now for the `webpack.client.js` config:

[source,javascript]
.src/main/js/webpack.client.js
----
include::{srcjsReact}/webpack.client.js[]
----

It tells Webpack to generate a series of JS files that are then placed in the `src/main/resources/static` directory.

Run Webpack to generate the needed Javascript files for both client and server.
40 changes: 40 additions & 0 deletions src/main/docs/guide/views/templates/react/reactrenderscripts.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
The code that kicks off the SSR process using your React libraries API is called a render script. Micronaut Views React ships with two pre-packaged render scripts, one for ReactJS and one for Preact, but you are also able to supply your own. This lets you take complete control over the server-side Javascript. To use a custom script, place it somewhere on your classpath or file system and then set the `micronaut.views.react.render-script` property to its path, prefixed with either `classpath:` or `file:` depending on where it should be found.

A render script should be an ESM module that exports a single function called `ssr` that takes four arguments:

1. A function object for the page component to render.
2. An object containing the root props.
3. A callback object that contains APIs used to communicate with Micronaut.
4. A string that receives the URL of the bundle that the browser should load. This is specified by the `micronaut.views.react.clientBundleURL` application property.

The default render script looks like this:

[source,javascript]
.classpath:/io/micronaut/views/react/react.js
----
include::{srcjsReactRender}/react.js[]
----

The default render script for Preact looks like this:

[source,javascript]
.classpath:/io/micronaut/views/react/preact.js
----
include::{srcjsReactRender}/preact.js[]
----

A more sophisticated render script might support the use of head managers (see below), do multiple renders, expose other APIs and so on.

A render script is evaluated _after_ your server side bundle, and has access to any symbols your server script exported. If you wish to access a JS module you should therefore include it in your `server.js` that gets fed to Webpack or similar bundler, and then re-export it like this:

[source,javascript]
----
import * as mymod from 'mymod';
export { mymod };
----

The callback object has a few different APIs you can use:

1. `write(string)`: Writes the given string to the network response.
2. `write(bytes)`: Writes the given array of bytes to the network response.
3. `url()`: Returns either null or a string containing the URL of the page being served. Useful for sending to page routers.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
React SSR needs some Micronaut application properties to be set.

[configuration]
----
micronaut:
# Point to client and server JS
views:
folder: classes/views
router:
static-resources:
js:
mapping: "/static/**"
paths: "classpath:static"

# A temporary workaround for a GraalJS limitation.
executors:
blocking:
virtual: false
----

This sets up static file serving so your client JS will be served by your Micronaut app. This isn't mandatory: you can serve your client JS from anywhere, but you would need to set `micronaut.views.react.client-bundle-url` in that case to where the client root bundle can be found.

IMPORTANT: Watch out for the last property that disables virtual threads. If you skip this you will get an error the first time a view is rendered. Future releases of GraalJS will remove the need to disable virtual threads in Micronaut.

[[react-dev-mode]]
== Development

During development you want the fastest iteration speed possible. Firstly turn off response caching so hot reload works with `npx webpack --watch`. Micronaut Views React will automatically notice the file changed on disk and reload it.

[configuration]
----
micronaut:
# For development purposes only.
server:
netty:
responses:
file:
cache-seconds: 0
----

If using Maven turn off Micronaut's automatic restart features so that changes to the compiled bundle JS don't cause the whole server to reboot:

[xml]
----
<plugin>
<groupId>io.micronaut.maven</groupId>
<artifactId>micronaut-maven-plugin</artifactId>
<version>...</version>
<configuration>
<watches>
<watch>
<directory>src/main/resources</directory>
<excludes>
<exclude>**/*.js</exclude>
<exclude>**/*.mjs</exclude>
</excludes>
</watch>
</watches>
</configuration>
</plugin>
----

6 changes: 6 additions & 0 deletions src/main/docs/guide/views/templates/react/reacttodo.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Micronaut React SSR has the following known issues and limitations:

- There is currently no way to extend the Javascript execution environment with custom Java-side objects.
- There is no built-in support for server side fetching.
- The rendering isn't streamed to the user.
- `<Suspense>` is not supported.
5 changes: 5 additions & 0 deletions views-react/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
src/test/js/node_modules
src/test/resources/views/ssr-components.mjs
src/test/resources/views/ssr-components.preact.mjs
src/test/resources/views/static/
/src/test/js/package-lock.json
17 changes: 17 additions & 0 deletions views-react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# React SSR support for Micronaut

## TODO

1. Eliminate all TODOs from the docs.
2. Make HTTP prefetches run in parallel.
3. Reduce the need for config:
1. Work out what `micronaut.views.folder` is supposed to be when run from Maven. Get rid of the need to specify this.
2. Make it configurable and allow the path to the static assets to be configured so it doesn't have to be served from MN itself.
3. Get rid of the blocking of the event loop when prefetching. Pending answer from MN team about why IO pool switch isn't implemented.
4. Write unit tests.
5. Document what you can and cannot do in GraalJS.
6. Find a way to use `renderToPipeableStream`?
7. Replace `__micronaut_prefetch` with Sam's implementation of fetch() for Micronaut?
8. Document how to do debugging?
9. Implement / get implemented TextEncoder/TextDecoder
10. Update the micronaut-spa-app sample.
Loading
Loading