Embedding a create-react-app project into a CakePHP 4 View

Updated: 17 May 2022 Step…

Login

Blog History

Updated: 17 May 2022

Step 1 - Create the React App

I find it good to create the React project inside my CakePHP APP directory so I can keep it under the same git repository. To create the react project use create-react-app:

cd /var/www/wms/src
mkdir React
npx create-react-app edi-app

# My CakePHP project is in /var/www/wms and the react app is as follows
# /var/www/wms/src/React/edi-app

Remember to add node_modules to .gitignore, if it isn't there already so you don't bloat your repo

https://reactjs.org/docs/create-a-new-react-app.html

Step 2 - Customize scripts build

Next edit your edi-app/package.json file to create a custom build entry. The build entry does 3 things:

  1. Compiles a production build putting it in the default build directory in the react project,
  2. removes the currently deployed react directory from your CakePHP WWW_ROOT directory and
  3. copies over the latest production build react files to your CakePHP 4 webroot

package.json

{
"scripts": {
    "start": "react-scripts start",
     "build": "react-scripts build && rm -rf ../../../webroot/edi-app && cp -rv build ../../../webroot/edi-app",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
}

Each time you are happy with your react project copy it across to the CakePHP webroot using npm run build

In the above example the files in /var/www/wms/src/React/edi-app/build are copied to /var/www/wms/webroot/edi-app

This is the contents of /var/www/wms/webroot/edi-app:


./manifest.json
./static
./static/js
./static/js/main.3df63a03.js.LICENSE.txt
./static/js/main.3df63a03.js.map
./static/js/main.3df63a03.js
./static/css
./static/css/main.ccfeb934.css.map
./static/css/main.ccfeb934.css
./asset-manifest.json
./index.html
./favicon.ico

The asset-manifest.json file

When you do a production build of your react project it includes a asset-manifest.json

{
  "main.css": "/static/css/main.81a20791.chunk.css",
  "main.js": "/static/js/main.821121cc.chunk.js",
  "main.js.map": "/static/js/main.821121cc.chunk.js.map",
  "static/js/1.b3836c5f.chunk.js": "/static/js/1.b3836c5f.chunk.js",
  "static/js/1.b3836c5f.chunk.js.map": "/static/js/1.b3836c5f.chunk.js.map",
  "runtime~main.js": "/static/js/runtime~main.229c360f.js",
  "runtime~main.js.map": "/static/js/runtime~main.229c360f.js.map",
  "static/css/main.81a20791.chunk.css.map": "/static/css/main.81a20791.chunk.css.map",
  "index.html": "/index.html",
  "precache-manifest.00bfcb3f81f91832033cf3827e46d489.js": "/precache-manifest.00bfcb3f81f91832033cf3827e46d489.js",
  "service-worker.js": "/service-worker.js"
}

Newer versions of create-react-app have a different layout for the asset-manifest.json file:

{
  "files": {
    "main.css": "/static/css/main.ccfeb934.css",
    "main.js": "/static/js/main.3df63a03.js",
    "index.html": "/index.html",
    "main.ccfeb934.css.map": "/static/css/main.ccfeb934.css.map",
    "main.3df63a03.js.map": "/static/js/main.3df63a03.js.map"
  },
  "entrypoints": [
    "static/css/main.ccfeb934.css",
    "static/js/main.3df63a03.js"
  ]
}

Step 3 - Implement a ReactEmbed Component to Read asset-manifest.json

To allow the Controller Action to open and read the asset-manifest.json we can use a custom component:

ReactEmbedComponent

<?php
// src/Controller/Component/ReactEmbedComponent.php
declare(strict_types=1);

namespace App\Controller\Component;

use Cake\Collection\Collection;
use Cake\Controller\Component;
use Cake\Http\Exception\NotFoundException;


class ReactEmbedComponent extends Component
{
    protected $controller = null;

    public function initialize(array $config): void
    {
        parent::initialize($config);
    }

    /**
     * @param string $subFolder Subfolder webroot/shipment-app <= shipment-app is subfolder
     */
    public function getAssets($subFolder)
    {
        $manifest = $this->assets($subFolder);

        return $this->parseManifest($manifest, $subFolder);
    }

    /**
     * @param string $subFolder Folder that the react app is in
     * e.g. subFolder is webroot/pick-app
     *
     * @return object
     * @throws NotFoundException
     */
    public function assets($subFolder): array|null
    {
        $assetManifest = WWW_ROOT . $subFolder . DS . 'asset-manifest.json';

        $file = @file_get_contents($assetManifest);

        if ($file === false) {
            throw new NotFoundException($assetManifest . ' missing');
        }

        $manifest = json_decode($file, true);

        if (array_key_exists('entrypoints', $manifest)) {
            // just entry points
            $manifest = $manifest['entrypoints'];
        }

        return $manifest;
    }

    protected function parseManifest($manifest, string $subFolder): array
    {
        $manifest = new Collection($manifest);

        $manifest = $manifest->map(function ($asset) use ($subFolder) {
            return DS . $subFolder . DS . $asset;
        })->groupBy(function ($asset) {
            $parts = explode('.', $asset);

            // get the .css or .js part for grouping
            $extension = end($parts);

            return $extension;
        })->toArray();

        // not sure if this can happen but just in case either expected key is missing
        $manifest = $manifest + [
            'js' => [],
            'css' => []
        ];

        return $manifest;
    }
}

Step 4 - Edit Controller/ShipmentsController.php

In the Controller load the ReactEmbedComponent we will use it to read asset-manifest.json and return an assets array

// src/Controller/ShipmentsController.php

class ShipmentsController extends AppController
{
    public function initialize(): void
    {
        parent::initialize();
        $this->loadComponent('ReactEmbed');
    }

   /**
     * Edit method
     *
     * @param  string|null                                        $id Shipment id.
     * @return \Cake\Http\Response|null|void                      Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function edit($id = null)
    {
        $assets = $this->ReactEmbed->getAssets(
            'edi-app'
        );

        $baseUrl = $this->request->getAttribute('webroot');

        $productTypes = $this->Shipments->ProductTypes->find('list', ['conditions' => ['active' => 1]]);

        $this->set(compact('assets', 'baseUrl', 'productTypes'));
    }

Example of assets array generated by ReactEmbedComponent

Step 5 - Modify templates/layout/default.php

Edit your CakePHP 4 layout. Normally when using the CSS helper all the CSS links go into the default 'css' block. Putting a fetch statement in for 'react-css' block will allow you to embed the CSS after your general site CSS and make sure your React styles will be after and therefore over-ride and take precedence over the site CSS.

<head>
    <?= $this->Html->charset() ?>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title><?= h($this->fetch('title')) ?></title>
    <?= $this->fetch('meta') ?>
    <?= $this->fetch('css') ?>
    <?= $this->Html->css('main'); ?>
    <?= $this->fetch('cust-css'); ?>
    <?= $this->fetch('react-css'); ?>
    <?= $this->Html->meta(['name' => "google", 'content' => "notranslate"]); ?>
</head>

Notice the /wms/edi-app/static/css/main.ccfeb934.css file is after the other site CSS

Do the same thing for the JavaScript assets. Modify the default layout to have a custom script block. In this example I called it 'from_view'

<?php
echo $this->fetch('tb_body_start');
echo $this->fetch('tb_flash');
echo $this->fetch('content');
echo $this->fetch('tb_footer');
echo $this->fetch('script');
echo $this->fetch('postLink');
echo $this->fetch('from_view');
echo $this->fetch('tb_body_end');
?>

</html>

Step 6 - Add a root node and react configuration to templates/Shipments/edit.php

Here we have the view template where we assign the react js and css assets to their respective blocks and provide some vital configuration for the react app to function.

  • Note the csrfToken which the react app needs to send back to CakePHP 4 for CakePHP 4 CSRF Middleware to trust what react sends to it.
  • a <div id="root" data-baseurl="/wms/"></div> is where the react app will load and the data-baseurl attribute will allow the react app to know where to post back to.

<?php echo $this->Html->scriptBlock(sprintf(
    'var csrfToken = %s;',
    json_encode($this->request->getAttribute('csrfToken'))
)); ?>

<?php foreach ($assets['css'] as $style) : ?>

    <?php $this->Html->css($style, [
        'block' => 'react-css',
    ]); ?>

<?php endforeach; ?>

<?php foreach ($assets['js'] as $script) : ?>

    <?= $this->Html->script($script, ['block' => 'from_view']); ?>

<?php endforeach; ?>

<?= $this->Html->tag('div', null, [
    'data-baseurl' => $baseUrl, 
    'id' => 'root',
]); ?>

Step 7 - Implement CORS Middleware

Implement CORS Middleware in CakePHP 4 to allow calls to CakePHP 4 from your React development environment URL which is usually http://localhost:3000 also handle the pre-flight options check with the appropriate headers.

https://github.com/toggenation/cakephp-cors

Step 8 - Make the CakePHP 4 Backend API URL available to your React Dev Environment

In the CRA (Create React App) project specify the the CakePHP  URL in index.js

// src/React/edi-app/src/index.js

import React from "react";
import ReactDOM from "react-dom";
import Root from "./Components/Root";
import 'bootstrap/dist/css/bootstrap.min.css';

const root = document.getElementById("root");

if (process.env.NODE_ENV === 'development') {
    root.setAttribute('data-baseurl', 'http://10.197.3.73:6002/wms/');
}

const baseUrl = root.getAttribute("data-baseurl");

ReactDOM.render(<Root baseUrl={baseUrl} />, root);

So now when you run npm run start the dev react app will have your react devel URL available

2 Comments

  1. Brad

    Hey James, this was super useful. Thanks for sharing.
    FYI - if you're using React Router and have different/dynamic paths, to prevent Cake throwing Not Found errors you can add a wildcard to the routes and put it at the end of the scope.

    EG:
    $builder->connect('/*', ['controller' => 'React', 'action' => 'embed']);

    Maybe there's a better way to do it, but it worked for me.

    Cheers
    Brad

    Reply

Submit a Comment

Your email address will not be published. Required fields are marked *

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

The reCAPTCHA verification period has expired. Please reload the page.