August 2023 update: OXIDE now includes a template for an HTML bridge React component using TypeScript:
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: