A React widget (UMD library) for Airport animations with flight departures/arrivals.
1. About
2. How It Works
2-1. UMD Library
2-2. APIPlugin - Using Webpack Hash
2-3. Using Pixi Legacy
2-4. App Structure
(a) Basic Entry
(b) react-pixi-fiber
(c) Calling from Other React Apps
3. What I Did
3-1. Installed NPM Packages All
3-2. Babel
3-3. Webpack
3-4. Loaders
3-5. Other Build Tools
3-6. Emotion
3-7. Other Dependencies
4. Dev + Build
5. Notes
5-1. Issues: webpack-dev-server
5-2. Issues: Tailwind
5-3. Using Preact - Minimize App Size
6. LICENSE
View Demo
(may not work in some browsers: e.g. Facebook browsers)
ALTERNATIVE:
If you would like a SIMPLER implementation,
check out react-widget-setup-2021.
This is an attempt to show how you can bundle your React app into a widget (UMD library).
Instead of being "installed", this app is to be "embedded" in other apps.
(or, you can totally call it from another React apps.
See Example)
It exposes the widget globally (in our case Airport
).
So, this is how embedding is done:
<script crossorigin src="https://unpkg.com/react@17/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js"></script>
<script type="text/javascript" src="./airport.app.js"></script>
<script type="text/javascript">
Airport.app.init();
</script>
Notice this app depends on external React
and ReactDOM
.
Also, while it is not necessary,
it is better that we define peerDependencies
in package.json
:
package.json
"peerDependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2"
}
It also demonstrates implementing a canvas animation using
reac-pixi-fiber.
Compared to izzimach/react-pixi,
it is a bit tricky to implement, and I hope it helps someone as well.
Note #1: Another option is to use
inlet/react-pixi,
but I had never tried.
See the problem they have.
Note #2: Note that reac-pixi-fiber
does not work with preact
(See: Why or 5-5. Using Preact).
As you can see, it outputs 2 bundle files (you can output 1).
For this app, one of the files is for
SharedWorker,
and it allows the caller of the widget to send messages to the widget.
Here is how a caller can send messages to its widget:
const worker = new SharedWorker('./airport.worker.js');
worker.port.postMessage({
action: 'resize',
payload: {
width: window.innerWidth,
height: window.innerHeight,
},
});
Yeah. I have some issues. We all fail, right?
webpack-dev-server
fails (see "5-2. Issues: webpack-dev-server")twin.macro
(Tailwind macro) fails at runtime (see notes).- Externalizing
pixi
,react-pixi
, orreact-pixi-fiber
fails. - Currently,
react-pixi-fiber
works ONLY whenprocess.env.NODE_ENV === 'production'
. Looks like other packages which utilizesreact-reconciler
are also not working (see issue. EmitsTypeError: Cannot set property 'getCurrentStack' of undefined
.
Building an UMD library is relatively easy.
It's just that we often bump into problems when working with babel
...
webpack.base.js
entry: {
app: './src/index.jsx',
worker: './src/worker.js',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'airport.[name].js?[hash]',
library: ['Airport', '[name]'],
libraryTarget: 'umd',
},
I have 2 entries in the above, but you can totally have only 1.
I have 2 because one of them is for SharedWorker
,
and it has to be an independent file.
To output only 1, you would do:
entry: './src/index.jsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'airport.js?[hash]',
library: 'Airport',
libraryTarget: 'umd',
},
[hash]
isn't needed either.
I am adding [hash]
so that I don't have to hard reload browsers when making changes.
Now, back to UMD library.
The entry for the library look like this:
src/index.jsx
export const init = config => {
ReactDOM.render(
<Widget config={config} />,
document.getElementById('airport')
);
};
As you can see, it exports init
.
If you want to use export default
,
then you need a special setup for babel.
The module is now exposed globally as Airport
.
When people want to use the widget,
they would download files from dist
directory,
and embed them in their HTML pages:
- airport.app.js (722 KB)
- airport.worker.js (15 KB)
For this project, I use html-webpack-plugin
for a static page
so that I can test the widget.
As far as creating a widget, you don't need this,
but for this time, this is for a testing purpose.
src/index.html
<!DOCTYPE html>
<html>
<body>
<div id="airport"></div>
<script
crossorigin
src="https://unpkg.com/react@17/umd/react.production.min.js"
></script>
<script
crossorigin
src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js"
></script>
<script
type="text/javascript"
src="<%= htmlWebpackPlugin.files.js[0] %>"
></script>
<script type="text/javascript">
Airport.app.init({
WHATEVER_PARAMS_YOU_WANT,
});
</script>
</body>
</html>
Notice in the above that
<%= htmlWebpackPlugin.files.js[0] %>
is replaced with:
/airport.app.js?[WHATEVER_THE_HASH_GENERATED]
Once again, having HTML is only for testing reason.
Also, I didn't have to use html-webpack-plugin
to generate the HTML page
but I could simply serve the HTML page statically.
I use html-webpack-plugin
only because
I wanted to append a "hash" to the resources
so that I don't have to worry about browser cache when developing.
Alright. This is something that has nothing to do with UMD library,
but it is about sharing the "hash" generated between two files.
I told you in the previous that I use "hash".
For the same "hash" which is appended to airport.app.js
,
I want the same appended for airport.worker.js
as well.
Instead of having this:
const worker = new SharedWorker('./my_worker.js');
we want something like this:
const worker = new SharedWorker('./my_worker.js?4e066ad15f78a871e174');
This is where APIPlugin
of Webpack's comes in.
APIPlugin
exposes the hash generated by Webpack
as a special global variable __webpack_hash__
,
and you can use the hash at runtime in your application.
webpack.base.js
const APIPlugin = require('webpack/lib/APIPlugin');
module.exports = {
...
...
plugins: [
new APIPlugin(),
],
};
and it allows you to use the exposed hash like this:
const worker = new SharedWorker(`./my_worker.js?{__webpack_hash__}`);
Some browsers do not support WebGL the way Pixi v5 wants,
and must fallback to canvas rendering.
There, we need pixi.js-legacy
instead.
There are several ways to handle this,
but I found
a neat solution,
and this is what I do in this project.
The idea is to export both pixi.js
and pixi.js-legacy
in the codebase,
and use aliases to internally handle names.
Whenever looking up pixi.js
, it refers to src/lib/pixi.js
:
# webpack.base.js
resolve: {
extensions: ['.js', '.jsx'],
alias: {
'pixi.js': path.resolve(__dirname, 'src/lib/pixi.js'),
'pixi.js-stable': path.resolve(__dirname, 'node_modules/pixi.js'),
'react-pixi$': 'react-pixi-fiber/react-pixi-alias',
'@': path.join(__dirname, 'src'),
},
},
# src/lib/pixi.js
export * from 'pixi.js-stable';
export * from 'pixi.js-legacy';
where pixi.js-stable
is a newly defined alias to the original pixi.js
.
It is probably worth describing how the app work.
If you are only interested in UMD library, you may stop reading.
So, the app starts when it renders React app into a designated DOM:
src/index.html
<div id="airport"></div>
<script crossorigin src="https://unpkg.com/react@17/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js"></script>
<script type="text/javascript" src="./airport.app.js"></script>
src/index.jsx
import { Widget } from './widget';
export const init = config => {
ReactDOM.render(
<Widget config={config} />,
document.getElementById('airport')
);
};
Here, the prop config
is static, and it is given from whoever passes.
By saying static, it means, React will not pick up the changes
even when the starter change the content of the prop.
Now, it is the Widget
component which renders the actual content:
src/widget/index.jsx
import { AirportContent as Content } from './content';
...
...
...
export const Widget = ({ config: given }) => {
const [worker, setWorker] = useState();
const [stageOptions, setStageOptions] = useState(DEFAULT_STAGE_OPTIONS);
const [airportOptions, setAirportOptions] = useState(DEFAULT_AIRPORT_OPTIONS);
useEffect(() => {
if (!worker) {
setWorker(
new SharedWorker(given.worker_file_path || DEFAULT_WORKER_FILE_PATH)
);
}
setAirportOptions(makeAirportOptions(given));
setStageOptions({
width: window.innerWidth * 0.65,
height: window.innerHeight * 0.65,
});
}, []);
useEffect(() => {
if (worker && worker.port) {
worker.port.onmessage = (event = {}) => {
const { data = {} } = event;
const { action, payload } = data;
if (action && action === 'resize' && payload) {
const { width, height } = payload;
if (width && height) {
setStageOptions({
width,
height,
});
}
}
};
}
}, [worker]);
// Just showing you can use 'emotion' for styles.
return (
<Stage
id="airport-stage"
options={stageOptions}
css={css`
background-color: #f00;
`}
>
<Content
id="airport-content"
cw={stageOptions.width}
ch={stageOptions.height}
options={airportOptions}
/>
</Stage>
);
};
in the above, <Stage>
is a component provided by react-pixi-fiber
which does NOT render something that we are familiar with,
but it actually renders a canvas
element.
All the components within <Stage>
are graphical elements of HTML5 Canvas.
In Widget
component, the app uses useState
to set the followings:
stageOptions
airportOptions
stageOptions
is passed to <Stage>
so that reac-pixi-fiber
can decide
the size for the canvas element.
airportOptions
is for the airport animations only.
When we init
the widget from the HTML, we statically pass config
.
In config
prop, we have a bunch fo parameters which define the app behavior.
Also, Widget
refers to airport.worker.js
.
setWorker(new SharedWorker('./airport.worker.js'));
You can now use this worker to update the size of the widget from the page you embed the widget (and I already described how).
So, instead of embedding the widget in HTML pages,
you want to call it from other React apps?
Here is an example from one of my working apps:
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import tw, { css } from 'twin.macro';
import { useDeviceSize } from '@/hooks/device';
import { useDebounce } from '@/hooks/debounce';
import { Layout } from '@/components/layout';
const MIN_WIDTH = 580;
const DEBOUNCE_MSEC = 1000;
const WORKER_FILE_PATH = '/assets/airport.worker.js';
const layoutStyles = {
header: tw`bg-black text-white`,
content: tw`bg-black text-white`,
};
const contentStyle = css`
min-height: 30vh;
${tw`p-4 flex flex-col justify-start items-start`}
`;
export const AirportDemo = () => {
const { width: dw, height: dh } = useDeviceSize(null);
const { t } = useTranslation();
const [worker, setWorker] = useState();
const dwDelay = useDebounce(dw, DEBOUNCE_MSEC);
const dhDelay = useDebounce(dh, DEBOUNCE_MSEC);
const resize = () => {
let w = dw * 0.75;
if (w < MIN_WIDTH) {
w = MIN_WIDTH;
}
if (worker) {
worker.port.postMessage({
action: 'resize',
payload: {
width: w,
height: w * 0.85,
},
});
}
};
useEffect(() => {
Airport.app.init({ worker_file_path: WORKER_FILE_PATH });
if (!worker) {
// Set it only when don't have the worker to prevent from
// another port being created when it is already mounted.
setWorker(new SharedWorker(WORKER_FILE_PATH));
}
}, []);
useEffect(() => {
if (worker && worker.port) {
resize();
}
}, [dwDelay, dhDelay, worker]);
return (
<Layout styles={layoutStyles}>
<div id="content" css={contentStyle}>
<div id="airport"></div>
</div>
</Layout>
);
};
yarn add @emotion/react pixi.js pixi.js-legacy react-pixi-fiber@1.0.0-beta.4 ramda
yarn add --dev @babel/core @babel/preset-env @babel/preset-react @babel/cli core-js@3 @babel/runtime-corejs3 babel-plugin-macros babel-loader file-loader style-loader css-loader postcss-loader webpack webpack-cli webpack-merge clean-webpack-plugin html-webpack-plugin license-webpack-plugin @emotion/babel-plugin-jsx-pragmatic autoprefixer prettier http-server
For @babel/polyfill
has been deprecated, we use core-js
.
- @babel/core
- @babel/preset-env
- @babel/cli
- core-js@3
- @babel/runtime-corejs3
- @babel/preset-react
yarn add --dev @babel/core @babel/preset-env @babel/cli core-js@3 @babel/runtime-corejs3 @babel/preset-react
- webpack
- webpack-cli
yarn add --dev webpack webpack-cli
- babel-loader
- file-loader
- style-loader
- css-loader
- postcss-loader
yarn add --dev babel-loader file-loader style-loader css-loader postcss-loader
- webpack-merge
- clean-webpack-plugin
- html-webpack-plugin (only for testing)
- license-webpack-plugin
- autoprefixer
- prettier
yarn add --dev webpack-merge clean-webpack-plugin html-webpack-plugin license-webpack-plugin autoprefixer prettier
See issues with "webpack-dev-server".
- babel-plugin-macros
- @emotion/babel-plugin-jsx-pragmatic
- @emotion/react (for
dependencies
)
yarn add --dev babel-plugin-macros @emotion/babel-plugin-jsx-pragmatic
yarn add @emotion/react
- ramda
- pixi.js
- pixi.js-legacy
- react-pixi-fiber@1.0.0-beta.5
- See issue
^1.0.0-beta.5
works, but seems like it has a positioning bug...^1.0.0-beta.6
are totally not working at all...
- http-server
yarn add ramda pixi.js pixi.js-legacy react-pixi-fiber@1.0.0-beta.4
yarn add --dev http-server
Check out a neat trick when using pixi.js-legacy
(see 2-3. Using Pixi Legacy)
Note: chrome://inspect/#workers
to inspect running workers.
yarn start
yarn build
yarn serve
As mentioned, webpack-dev-server
does not work,
and it is due to Webpack v5 release on 10/10/2020.
I had mainly 2 issues.
The first issue was that the bundled library exporting an empty object when using webpack-dev-server
.
For this project, specifically, Airport.app
became {}
.
It was a bug, and a
solution
was to use webpack-dev-server@4.0.0-beta.0
.
The second issue is associated with SharedWorker, and window
becomes undefined.
For this, I still have no solutions.
Attempt to use twin.macro
(Tailwind macro, or Twin) fails.
There are 2 reasons:
(1) twin.macro
uses CommonJS style libraries internally,
and Webpack 5 does not like that.
(2) Runtime error for __cssprop
For (1) is not an issue with Webpack 4, and I will talk about it later.
For (2), it has to do with the recent release of Twin v2 which supports:
- tailwind@2 (released on Nov. 19, 2020)
- emotion@11 (released on Nov. 12, 2020)
They give a bit of migration tips in the release note, but it seems to fail for UMD libraries. It builds fine, but I get the following runtime error:
index.jsx:13 Uncaught ReferenceError: __cssprop is not defined
Let's talk about (1).
So, with Webpack 5, I get the error at build time:
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
This is no longer the case. Verify if you need this module and configure a polyfill for it.
This is because Webpack 5 no longer supports automatic polyfill for Node.js modules, and you have to manually resolve the modules in use (one by one).
Here is how you polyfill by yourself, but remember, it still fails at runtime...
If anyone figured out a solution for using twin.macro
in UMD library,
please, let me know!
yarn add --dev util path-browserify url os-browserify process imports-loader
webpack.base.js
resolve: {
extensions: ['.js', '.jsx'],
alias: {
'@': path.join(__dirname, 'src'),
},
fallback: {
util: require.resolve('util/'),
path: require.resolve('path-browserify'),
url: require.resolve('url/'),
os: require.resolve('os-browserify/browser'),
fs: false,
module: false,
},
},
...
...
module: {
rules: [
...
...
{
test: /node_modules\/resolve\/lib\/core\.js$/,
use: [{
loader: 'imports-loader',
options: {
type: 'commonjs',
imports: ['single process/browser process'],
},
}],
},
This issue describes the problem in depth, and here is another issue.
In case you solved the issue, remember that you also need
to configure babel-plugin-macros.config.js
to use Tailwind:
babel-plugin-macros.config.js
module.exports = {
twin: {
styled: {
import: 'default',
from: '@emotion/styled',
},
css: {
import: 'css',
from: '@emotion/react',
},
global: {
import: 'Global',
from: '@emotion/react',
},
config: './src/tailwind.config.js', // <-- HERE
dataTwProp: true, // <-- HERE
debugPlugins: false,
debug: false,
},
};
If you want to use preact, here are the steps.
# Step (1): Using Preact
For every JSX files, you need to import "h" from preact
:
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
# Step (2): babel-plugin-transform-react-jsx
You need to handle "h" pragma with babel-plugin-transform-react-jsx
.
yarn add --dev babel-plugin-transform-react-jsx
.babelrc
plugins: [
['@babel/transform-react-jsx', { pragma: 'h' }]
]
# Step (3): React + ReactDOM
While it works perfectly fine with (1) and (2) only,
but your external React libraries
are importing react
and react-dom
,
and you need resolutions for the name.
webpack.base.js
resolve: {
...
alias: {
...
...
'react': 'preact/compat',
'react-dom': 'preact/compat',
}
}
Dual-licensed under either of the followings.
Choose at your option.
- The UNLICENSE (LICENSE.UNLICENSE)
- MIT license (LICENSE.MIT)