├── .gitignore
├── LICENSE
├── README.md
├── docs
├── api-js.md
├── api-php.md
├── limitations.md
└── quick-start.md
├── error-overlay.css
├── index.js
├── namespace.php
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | bundle.js
2 | node_modules/
3 | yarn.lock
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2018 Human Made
2 |
3 | Permission to use, copy, modify, and/or distribute this software for any
4 | purpose with or without fee is hereby granted, provided that the above
5 | copyright notice and this permission notice appear in all copies.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | react-wp-ssr
5 | Server-side rendering for React-based WordPress plugins and themes.
6 | |
7 |
8 |
9 | |
10 |
11 |
12 |
13 | A Human Made project. Maintained by @rmccue.
14 | |
15 |
16 |
17 |
18 | react-wp-ssr provides easy server-side rendering for React-based WordPress plugins and themes, using [v8js](https://github.com/phpv8/v8js).
19 |
20 | This repository is a hybrid PHP/JS repository, and both pieces are required in your project.
21 |
22 |
23 | ## Requirements
24 |
25 | * PHP 7.0+
26 | * [v8js Extension for PHP](https://github.com/phpv8/v8js)
27 |
28 | react-wp-ssr works best when used with [react-wp-scripts](https://github.com/humanmade/react-wp-scripts/) to handle the various build processes.
29 |
30 | For local development, we recommend the [v8js extension for Chassis](https://github.com/Chassis/v8js).
31 |
32 |
33 | ## Documentation
34 |
35 | * [Quick Start](docs/quick-start.md)
36 | * [JavaScript API](docs/api-js.md)
37 | * [PHP API](docs/api-php.md)
38 | * [Limitations](docs/limitations.md)
39 |
40 |
41 | ## Credits
42 |
43 | Created by Human Made for complex React-powered sites. We power our [Engineering Handbook](https://engineering.hmn.md/) with react-wp-ssr.
44 |
45 | Written and maintained by Ryan McCue. Licensed under the [ISC license](LICENSE). Thanks to all our contributors.
46 |
47 | Interested in working on projects using react-wp-ssr? [We're hiring!](https://humanmade.com/hiring/)
48 |
--------------------------------------------------------------------------------
/docs/api-js.md:
--------------------------------------------------------------------------------
1 | # JavaScript API
2 |
3 | The `react-wp-ssr` package provides one default export (`render`), and a bunch of utility functions and constants.
4 |
5 | ## Constants
6 |
7 | `import { ENV_BROWSER, ENV_SERVER } from 'react-wp-ssr`
8 |
9 | These constants are available if you want to use typehinting instead of checking arbitrary strings.
10 |
11 | * `ENV_BROWSER = 'browser'`
12 | * `ENV_SERVER = 'server'`
13 |
14 |
15 | ## `render( getComponent: environment => React.Element, onClientRender: () => void ): void`
16 |
17 | `import render from 'react-wp-ssr'`
18 |
19 | Render a React component on the frontend and backend.
20 |
21 | `getComponent` receives the current environment as a parameter (one of `ENV_SERVER` or `ENV_BROWSER`) and should return a React element to be rendered or mounted to the DOM.
22 |
23 | `onClientRender` is an optional callback. This is only called on the frontend after `ReactDOM.hydrate` or `ReactDOM.render` has completed.
24 |
25 |
26 | ## `getEnvironment(): string`
27 |
28 | `import { getEnvironment } from 'react-wp-ssr'`
29 |
30 | Get the current environment being rendered. One of `ENV_SERVER` or `ENV_BROWSER`.
31 |
32 |
33 | ## `onFrontend( () => void ): void`
34 |
35 | `import { onFrontend } from 'react-wp-ssr'`
36 |
37 | Run a callback only on the frontend (browser).
38 |
39 |
40 | ## `onBackend( () => void ): void`
41 |
42 | `import { onBackend } from 'react-wp-ssr'`
43 |
44 | Run a callback only on the backend (server).
--------------------------------------------------------------------------------
/docs/api-php.md:
--------------------------------------------------------------------------------
1 | # PHP API
2 |
3 | ## `render( string $directory, array $options = [] ): void`
4 |
5 | Render a React app into static HTML.
6 |
7 | `$directory` specifies the root directory to load scripts from. This directory must contain your `build` directory.
8 |
9 | You can specify the following options:
10 |
11 | * `$handle` (`string`): Directory to load scripts from. This will default to the last part of the directory.
12 | * `$container` (`string`, default `root`): ID for the container div. "root" by default.
13 | * `$async` (`boolean`, default `true`): Should we load the script asynchronously on the frontend?
14 |
15 |
16 | ## `apply_filters( 'reactwpssr.functions', array $functions, string $directory, array $options )`
17 |
18 | Filter functions available to the server-side rendering.
19 |
20 | If you want to expose additional functions to your React app when rendering on the server-side, you can add them to this array.
21 |
22 | `$functions` is a map of function name => callback. These functions will be exposed on `global.PHP`.
23 |
24 | `$directory` and `$options` are the original parameters passed to `render()`.
25 |
26 |
--------------------------------------------------------------------------------
/docs/limitations.md:
--------------------------------------------------------------------------------
1 | # Limitations
2 |
3 | Server-side rendering is not a cure-all, so it's important to be mindful of the limitations to this approach. There are alternative approaches that might be better suited, so be sure to investigate all the options first.
4 |
5 |
6 | ## No Asynchronous Rendering
7 |
8 | While some asynchronous operations (including promises) will work, React only does a single render pass when rending on the server. This means that any asynchronous rendering won't work; e.g. showing a loading screen before loading content.
9 |
10 | Only the [componentWillMount](https://reactjs.org/docs/react-component.html#componentwillmount) lifecycle hook is called by React on the server. Ensure that any data manipulation you need to do is handled here.
11 |
12 |
13 | ## No External Requests
14 |
15 | react-wp-ssr does not expose an way for your app to trigger external requests. These are usually handled asynchronously in JavaScript, so they aren't a good match for the synchronous fetches in PHP.
16 |
17 | In most cases, you should supply the data statically via the usual `wp_localize_script` API. This ensures the data is also available for the initial React render as well.
18 |
19 | If you do want to allow this, you can [expose a function to JS](api-php.md) to allow it to be used, but take care to sync carefully between the server and frontend.
20 |
21 |
22 | ## Limited Environment
23 |
24 | react-wp-ssr uses [the v8js PHP extension](https://github.com/phpv8/v8js) to execute your JavaScript. While this is the full V8 (Chrome) JavaScript engine, it only contains a minimal environment (i.e. global variables). react-wp-ssr attempts to reimplement some of this functionality, but not all of it.
25 |
26 | While most React apps will simply work straight out of the box (since they need to run in the browser anyway), it is important to keep this in mind if doing advanced operations. If you are used to working in a Node environment, you may find yourself severely limited.
27 |
--------------------------------------------------------------------------------
/docs/quick-start.md:
--------------------------------------------------------------------------------
1 | # Quick Start
2 |
3 | You should already have a React-based WP project ready to adapt, using [react-wp-scripts](https://github.com/humanmade/react-wp-scripts/).
4 |
5 | ## Step 1: Add backend library
6 |
7 | Add this repository to your project, either with git submodules or Composer. You'll then need to load it into your project:
8 |
9 | ```php
10 | require_once __DIR__ . '/vendor/react-wp-ssr/namespace.php';
11 | ```
12 |
13 |
14 | ## Step 2: Add backend render call
15 |
16 | In PHP, call [`ReactWPSSR\render()`](api-php.md) wherever you want to render your app. (Do not include the container yourself.)
17 |
18 | For themes, the best practice is to create a minimal `index.php`:
19 |
20 | ```php
21 | );
51 | ```
52 |
53 | ## Developing with react-wp-ssr
54 |
55 | By default, react-wp-ssr does not render on the server during development (i.e. with `WP_DEBUG` set to true), as it uses your built script; during development, your built script will tend to be behind your live development script, and this will cause hydration errors.
56 |
57 | When you do want to test, there are two constants you can use to control react-wp-ssr:
58 |
59 | ```php
60 | // Define as `true` to render on the server, even during development.
61 | define( 'SSR_DEBUG_ENABLE', false );
62 |
63 | // Define as `true` to only render on the server and skip loading the script.
64 | // Useful to check the server is correctly rendering.
65 | define( 'SSR_DEBUG_SERVER_ONLY', false );
66 | ```
67 |
68 |
69 | ## Detecting the Environment
70 |
71 | The callback you pass to `render` will receive the [current environment](api-js.md#constants) as a parameter, allowing you to change what you render when you need to:
72 |
73 | ```js
74 | import { BrowserRouter, StaticRouter } from 'react-router-dom';
75 |
76 | render( environment => {
77 | const Router = environment === 'server' ? StaticRouter : BrowserRouter;
78 | const routerProps = environment === 'server' ? { location: window.location } : {};
79 |
80 | return
81 |
82 | ;
83 | } );
84 | ```
85 |
86 | (Note that this should be used sparingly, as [React's hydration](https://reactjs.org/docs/react-dom.html#hydrate) will complain if the HTML does not match what it expects.)
87 |
88 | You can also use the [`onFrontend` and `onBackend` functions](api-js.md) to run callbacks only in a single environment if you need.
89 |
--------------------------------------------------------------------------------
/error-overlay.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) 2015-present, Facebook, Inc.
3 | *
4 | * This source code is licensed under the MIT license found in the
5 | * LICENSE file in the root directory of this source tree.
6 | */
7 |
8 | /* @flow */
9 |
10 | html, body {
11 | overflow: hidden;
12 | }
13 |
14 | .error-overlay {
15 | position: fixed;
16 | top: 0;
17 | left: 0;
18 | width: 100%;
19 | height: 100%;
20 | border: none;
21 | z-index: 1000;
22 |
23 | --black: #293238;
24 | --dark-gray: #878e91;
25 | --red: #ce1126;
26 | --red-transparent: rgba(206, 17, 38, 0.05);
27 | --light-red: #fccfcf;
28 | --yellow: #fbf5b4;
29 | --yellow-transparent: rgba(251, 245, 180, 0.3);
30 | --white: #ffffff;
31 | }
32 |
33 | .error-overlay .wrapper {
34 | width: 100%;
35 | height: 100%;
36 | box-sizing: border-box;
37 | text-align: center;
38 | background-color: var( --white );
39 | }
40 |
41 | .primaryErrorStyle {
42 | background-color: var( --light-red );
43 | };
44 |
45 | .secondaryErrorStyle {
46 | background-color: var( --yellow );
47 | }
48 |
49 | .error-overlay .wrapper {
50 | /*display: flex;*/
51 | /*flex-direction: column;*/
52 | }
53 |
54 | .error-overlay .overlay {
55 | position: relative;
56 | display: inline-flex;
57 | flex-direction: column;
58 | height: 100%;
59 | width: 1024px;
60 | max-width: 100%;
61 | overflow-x: hidden;
62 | overflow-y: auto;
63 | padding: 0.5rem;
64 | box-sizing: border-box;
65 | text-align: left;
66 | font-family: Consolas, Menlo, monospace;
67 | font-size: 11px;
68 | line-height: 1.5;
69 | color: var( --black );
70 | }
71 |
72 | .header {
73 | font-size: 2em;
74 | font-family: sans-serif;
75 | color: var( --red );
76 | white-space: pre-wrap;
77 | margin: 0 2rem 0.75rem 0;
78 | flex: 0 0 auto;
79 | max-height: 50%;
80 | overflow: auto;
81 | }
82 |
83 | .preStyle {
84 | display: block;
85 | padding: 0.5em;
86 | margin-top: 0.5em;
87 | margin-bottom: 0.5em;
88 | overflow-x: auto;
89 | white-space: pre-wrap;
90 | border-radius: 0.25rem;
91 | background-color: var( --red-transparent );
92 | font-size: inherit;
93 | }
94 |
95 | .codeStyle {
96 | font-family: Consolas, Menlo, monospace;
97 | font-size: inherit;
98 | }
99 |
100 | .tab {
101 | color: rgba( 255, 255, 255, 0.1 );
102 | }
103 |
104 | .footer {
105 | font-family: sans-serif;
106 | color: var( --dark-gray );
107 | margin-top: 0.5rem;
108 | flex: 0 0 auto;
109 | }
110 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import ReactDOM from 'react-dom';
2 | import ReactDOMServer from 'react-dom/server';
3 |
4 | export const ENV_BROWSER = 'browser';
5 | export const ENV_SERVER = 'server';
6 |
7 | // Store current script reference in case we need it later, as currentScript
8 | // is only available on the first (synchronous) run.
9 | let renderedScript = false;
10 | try {
11 | renderedScript = document && document.currentScript;
12 | } catch ( err ) {
13 | // No-op; not defined in Node.
14 | }
15 |
16 | export function getEnvironment() {
17 | return typeof global.isSSR === 'undefined' ? ENV_BROWSER : ENV_SERVER;
18 | }
19 |
20 | export const onFrontend = callback => getEnvironment() === ENV_BROWSER && callback();
21 | export const onBackend = callback => getEnvironment() === ENV_SERVER && callback();
22 |
23 | export default function render( getComponent, onClientRender ) {
24 | const environment = getEnvironment();
25 | const component = getComponent( environment );
26 |
27 | switch ( environment ) {
28 | case ENV_SERVER:
29 | global.print( ReactDOMServer.renderToString( component ) );
30 | break;
31 |
32 | case ENV_BROWSER: {
33 | const container = document.getElementById( renderedScript.dataset.container );
34 | const didRender = 'rendered' in container.dataset;
35 |
36 | if ( didRender ) {
37 | ReactDOM.hydrate(
38 | component,
39 | container,
40 | onClientRender
41 | );
42 | } else {
43 | ReactDOM.render(
44 | component,
45 | container,
46 | onClientRender
47 | );
48 | }
49 | break;
50 | }
51 |
52 | default:
53 | throw new Error( `Unknown environment "${ environment }"` );
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/namespace.php:
--------------------------------------------------------------------------------
1 | get_data( $handle, 'data' );
17 | return $data;
18 | }
19 |
20 | /**
21 | * Get data to load into the `window` object in JS.
22 | *
23 | * @return object `window`-compatible object.
24 | */
25 | function get_window_object() {
26 | list( $path ) = explode( '?', $_SERVER['REQUEST_URI'] );
27 | $port = $_SERVER['SERVER_PORT'];
28 | $port = $port !== '80' && $port !== '443' ? (int) $port : '';
29 | $query = $_SERVER['QUERY_STRING'];
30 | return [
31 | 'location' => [
32 | 'hash' => '',
33 | 'host' => $port ? $_SERVER['HTTP_HOST'] . ':' . $port : $_SERVER['HTTP_HOST'],
34 | 'hostname' => $_SERVER['HTTP_HOST'],
35 | 'pathname' => $path,
36 | 'port' => $port,
37 | 'protocol' => is_ssl() ? 'https:' : 'http:',
38 | 'search' => $query ? '?' . $query : '',
39 | ],
40 | ];
41 | }
42 |
43 | /**
44 | * Render a JS bundle into a container.
45 | *
46 | * @param string $directory Root directory to load from.
47 | * @param array $options {
48 | * Additional options and overrides.
49 | *
50 | * @type string $handle Script handle. Defaults to basename of the directory.
51 | * @type string $container ID for the container div. "root" by default.
52 | * @type boolean $async Should we load the script asynchronously on the frontend?
53 | * }
54 | */
55 | function render( $directory, $options = [] ) {
56 | $options = wp_parse_args( $options, [
57 | 'handle' => basename( $directory ),
58 | 'container' => 'root',
59 | 'async' => true,
60 | ] );
61 | $handle = $options['handle'];
62 |
63 | // Ensure the live script also receives the container.
64 | add_filter( 'script_loader_tag', function ( $tag, $script_handle ) use ( $handle, $options ) {
65 | if ( $script_handle !== $handle ) {
66 | return $tag;
67 | }
68 |
69 | // Allow disabling frontend rendering for debugging.
70 | if ( defined( 'SSR_DEBUG_SERVER_ONLY' ) && SSR_DEBUG_SERVER_ONLY ) {
71 | return '';
72 | }
73 |
74 | $new_tag = sprintf( '