Creating a React HTML Bridge Package

I recently tried implementing an HTML bridge TypeScript Package and thought I should share some tips that might be useful to others.

There already is an excellent guide on how to create HTML bridge components. This current guide contains a few tips and tricks for creating a React based HTML bridge component that is included in TypeScript Apps via the Packages system.

A nice feature of creating React apps or components is that it’s easily possible using the create-react-app command. This functionality bundles all the features required to build a web app into one easy command. Many tools such as Webpack are configured by default. Scripts for developing, building and live viewing the results for development are also included.

Note that the app packages functionality is only available for TypeScript Journey apps. It is also currently required to have git functionality on the app in order to create the package locally.

With the git repository cloned to a local folder. Go to the root of the repository and create a packages folder if not present. Change directory to this folder.

Creating a React App

cd packages

Make sure you have NodeJS (Node >= 14.0.0 and npm >= 5.6) and yarn installed on your system. Run the create-react-app command, specifying the [package_name] of your choice. This command might take a few seconds.

npx create-react-app [package_name] --template typescript

Change directory to the [package_name] folder

cd [package_name]

The packages documentation states that packages should have a name which starts with @local/. The react tool does not follow this convention, so we must open the package.json file, in the [package_name] folder, and add the name field.

[package_name]/package.json

{
  "name": "@local/[package_name]",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
...

Test the newly created project by running the development server. Note, any changes to the code will automatically reflect in the live preview.

npm run start

You should see a spinning react logo open in your webbrowser at localhost:3000 (or a similar port)

Setup

Some additional steps are required to make the React project work as a Journey HTML bridge component.

Webpack configuration

The default behaviour of a React app is to be a web application that is served from a static web server using technologies such as Express. The web server would generally serve the index.html file to the browser while the contents of the html file would make additional requests to the server for resources such as style sheets or images etc.

The Journey runtime does not allow for the additional relative requests for static resources. Everything required by the component needs to be bundled into the single index.html file.

We can achieve this without the need for ejecting the project (which would open up all the complex internals of the project). A tool called Craco achieves this nicely. Add it to the project by executing

yarn add @craco/craco -D

Create a new file, craco.config.js, in the [package_name] directory in order to configure Craco to output a single HTML file.

[package_name]/craco.config.js

const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');

module.exports = {
  webpack: {
    configure: (webpackConfig, { env, paths }) => {

      webpackConfig.plugins.forEach(plugin => {
        if (plugin instanceof InlineChunkHtmlPlugin) {
          plugin.tests =  [ /.+[.]js/ ]
        }
      })

      const oneOfRuleIdx = webpackConfig.module.rules.findIndex(rule => !!rule.oneOf);
      webpackConfig.module.rules[oneOfRuleIdx].oneOf.forEach(loader => {
        if (loader.test && loader.test.test && (loader.test.test("test.module.css") || loader.test.test("test.module.scss"))) {
          loader.use.forEach(use => {
            if (use.loader && use.loader.includes('mini-css-extract-plugin')) {
              use.loader = require.resolve('style-loader');
            }
          })
        }
      })

      return webpackConfig
    }
  },
}

The start, build and test scripts need to be updated to use Craco. Edit the scripts section of the package.json file to be as below
[package_name]/package.json

...
"scripts": {
    "start": "craco start",
    "build": "craco build",
    "test": "craco test",
    "eject": "react-scripts eject"
  },
...

Running the build script should now produce an index.html file in the [package_name]/build/ directory. This single HTML file now contains all the resources of the project.

We don’t want to include these build files in the Git repo. So add a gitignore entry in the repo gitignore file.

[git_root]/.gitignore

**/*/[package_name]/build/
node_modules/

The version of Webpack used for the React App and for the JourneyApp might not be the same. So we need to skip some checks. Add a .env file in the [package_name] folder containing the following

[package_name]/.env

SKIP_PREFLIGHT_CHECK=true

From build to Journey App source

The end goal is to be able to use the compiled HTML bridge component in a view with the following format

some_view.view.xml

<html id="some_id" src="html/[package_name]/index.html" />

The trick is that the build command specified in the [package_name]/package.json file will be executed on each deploy of the application. Running the build script currently outputs the index.html file to the build directory, but we want to copy the output file to the Journey source code (the html assets folder) to be included in the Journey Application final build process. We can modify the build script to achieve this.

First, create a new Javascript script file to perform this copy operation on build. We will put it in a nice scripts folder. Create a scripts folder in the package folder and then create a build_copy.js file in there.

[package_name]/scripts/build_copy.js

const fs = require('fs');
const path = require('path');

console.log(`Warning!!! Do not run this script locally via git. It will copy a compiled HTML to the html assets directory.`);

const packageName = path.basename(process.cwd());
const appDirectory = path.join(process.cwd(), "..", "..");
const htmlDirectory = path.join(appDirectory, "mobile", "html", packageName);

if (!fs.existsSync(htmlDirectory)) {
    fs.mkdirSync(htmlDirectory, {recursive: true});
}

const sourceFile = path.join(process.cwd(), 'build/index.html');
const targetFile = path.join(htmlDirectory, `/index.html`);

fs.copyFileSync(sourceFile, targetFile);

Now edit the package.json file to include the copy step in the build script.

[package_name]/package.json

"scripts": {
    "start": "craco start",
    "build": "craco build && node ./scripts/build_copy.js",
    "build_local": "craco build",
    "test": "craco test",
    "eject": "react-scripts eject"
  },
...

When testing the build process locally, use the build_local script instead. As running, npm run build will then copy the compiled HTML into the mobile/assets/[package_name]/index.html path. We don’t need to include an output in the source.

At this point, the code is ready for Oxide. You can stage, commit and push the changes.

All the following steps can either be done in Oxide or locally.

React development

Let’s take the React library and use all its power for a simple example: Displaying a centred div.

We would like to show variable text in our magnificent centred div. So we need the Journey IFrame Client. This can be added in Oxide by using the Packages pane and right-clicking on the package name and selecting to add a NodeJS package.

Add the journey-iframe-client package

Locally

yarn add journey-iframe-client

Then, in Oxide or locally, open the src/app.tsx file and replace the contents with.

import React from 'react';
import './App.css';

import JourneyIFrameClient from 'journey-iframe-client';

//@ts-ignore
const html_client = new JourneyIFrameClient();

function App() {
  // State declarations
  const [message, setMessage] = React.useState('');

  //on component mounted, send the initialized signal over html bridge
  React.useEffect(() => {
    //inform the JourneyApp that HTML is ready and get an optional init message
    html_client.post('client_initialized', { value: true }).then((message?: string) => {
      if (message) {
        setMessage(message);
      }
    });

    html_client.on('update_message', (message: string) => {
      setMessage(message);
    });
  }, []);

  return (
    <div style={{
      width: '100%',
      display: 'flex',
      justifyContent: 'center',
      alignContent: 'center'
    }}>
      <div>
        {message}
      </div>
    </div>

  );
}

export default App;

View Development

Add the following code to a view

a_view.view.xml

<?xml version="1.0" encoding="UTF-8"?>
<view title="steven-typescript-test">
    <var name="message" type="text" />

    <text-input bind="message" required="false" on-change="$: updateMessage()" />

    <html id="some_id" src="html/[package_name]/index.html" show-fullscreen-button="true"/>

</view>

a_view.ts

// This function is called when the app navigates to this view (using a link)
let html_ready_promise: Promise<null>;

async function init() {
    // initialize any data here that should be available when the view is shown
    html_ready_promise = new Promise(resolve => {
        component.html({id: 'some_id'}).on('client_initialized', () => {
            resolve(null); //the HTML is ready
            //set an initial message
            return 'Hello Center World';
        });
    });
}

function updateMessage() {
    html_ready_promise = html_ready_promise.then(async () => {
        await component.html({id: 'some_id'}).post('update_message', view.message);
        return null;
    });
}

// This function is called when the user returns to this view from another view
function resume(from) {
    // from.back       (true/false) if true, the user pressed the "Back" button to return to this view
    // from.dismissed  (true/false) if true, the app dismissed to return to this view
    // from.path       contains the path of the view that the user returned from
    // if any data needs to be refreshed when the user returns to this view, you can do that here:
}

The Result of all the efforts should be a centered div:

3 Likes

Thanks for the extremely thorough post!

In some cases, the Journey runtime might be timing sensitive and not receive post messages sent via the useEffect hook.

Web containers in particular appear to be sensitive to timing. In order to be safe, the following queue system is recommended when posting messages. It is assumed the Runtime listens to post events after the iFrame window load event is fired. So we can halt posting messages until after the event is fired.

Replace the outer scoped code of app.tsx with the following:

import React from 'react';
import './App.css';

import JourneyIFrameClient from 'journey-iframe-client';

//@ts-ignore
const html_client = new JourneyIFrameClient();

/**
 * Resolves once the Window has been loaded
 */
const queue_promise: Promise<void> = new Promise((resolve) => {
    window.addEventListener('load', () => {
        console.log('Window loaded');
        //start the queue
        resolve();
    });
});

/**
 * Posts over HTML bridge only after window has been loaded
 */
const journeyPost = async (event_name: string, param?: any) => {
    await queue_promise;

    return html_client.post(event_name, param);
};

Call the journeyPost wrapper instead of html_client.post in the application.