The purpose of this template is:
- Creating complex components in React and using them inside React and non-React applications.
- Release once, available everywhere. So, all the consumer apps get updated automatically.
To do that, we use Web Components to define custom html elements, e.g <wl-movie-details/>
, and we use createRoot and createPortal to fully separate widgets rendering from the hosted app rendering. Then we deploy the scripts on a CDN to allow all consumers to get the same version.
For example a simple HTML app can use the following MovieDetails
React component inside their pages as regular HTML tags:
function MovieDetails({ mode, showRanking }: MovieDetailsProps) {
return <Content>...</Content>;
}
<head>
<link rel="modulepreload" href="https://cdn.platform.workleap-dev.com/movie-widgets/index.js" />
<script type="module" blocking="render">
import { MovieWidgets } from "https://cdn.platform.workleap-dev.com/movie-widgets/index.js";
MovieWidgets.initialize();
</script>
</head>
<body>
<div>
<div>Selected Movie Details:</div>
<wl-movie-details mode="modal" show-ranking="true" />
<wl-movie-finder />
</div>
</body>
- The
packages/r2wc
package contains the core functionality that will be used by different widgets. - The
packages/movie-widgets
package is a sample template to show how to build framework-agnostic widgets. - The
apps
folder contains a few examples to testpackages/movie-widgets
across different frameworks.
This repo is a package + template repo which also has some examples to see how this strategy works in action. After cloning the repo:
- Run
pnpm install && pnpm build
inside the root, - To run the sample apps run
pnpm dev
inside the Vanilla-Js app or the React app folders.
Important
Whenever you make a change inside the packages/movie-widgets
you only need to run pnpm build
and then refresh your running apps. It is becuase of having dist folder as cdn
for each app: VanillaJs and React.
You can follow the next steps to see how you can change and see the result.
It is recommended having a separate package for this purpose. It helps managing different builds easily. For the rest, packages/movie-widgets
showcases this.
Just build your regular React components and put them in the src
folder.
You are free to create any kind of React component, just there are some rules for components that are being exposed as Web Components:
-
They should NOT have
children
. e.g.:function NotAllowedWebComponent({ children }: { children: ReactNode }) { return <div>{children}</div>; }
-
JSX
props are NOT not allowed as they cannot be translated easily to the similar HTML properties. e.g.:function NotAllowedWebComponent({ header }: { header: ReactNode }) { return <div>{header}</div>; }
Note
The above constraints are ONLY for components that are getting exposed as web components. Any inner components can be implemented as usual.
Here is a valid component which we can later make a web component based on it. Note that <Layout/>
, <Header/>
, <Content/>
are inner components.
// src/MovieDetails.tsx
export interface MovieDetailsProps {
showRanking: boolean;
onBuy: (movie : MovieData, count: number) => void;
mode: "modal" | "inline";
}
export function MovieDetails({ showRanking, mode, onBuy }: MovieDetailsProps) {
const { selectedMovie } = useAppContext();
return (
<Layout mode={mode}>
<Header />
<Content>
{movie.details}
</Content>
<Button onClick={()=> onBuy(selectedMovie, 1);}>
</Layout>
);
}
Widgets inside the same project could share context as a regular React app. This context will be used at the rendering step. All widgets are getting rendered inside this context provider (check the widgets.ts file). For example:
// src/WidgetsContextProvider.tsx
export function WidgetsContextProvider({ children }: {
children?: React.ReactNode | undefined;
}) {
const [isResultOpen, setIsResultOpen] = useState(false);
return (
<AppContext.Provider value={{ isResultOpen, setIsResultOpen }}>
{children}
</AppContext.Provider>
);
}
Caution
This context will be used at app level. If you are using client routers like React Router, this context stay live between client page navigations. By client page navigation, we mean the full refresh doesn't happen.
It is a great tool if you want to keep an state between page navigations (e.g. keeping the chatbox open).
Warning
The above element is NOT being associated with any DOM element, and the {children}
(i.e widgets) are being rendered in different DOM nodes (Thanks to React createPortal). So, if any of above providers generate DOM element, they are not being present in DOM hierarchy.
There are scenarios where you want to pass down some high level settings that are being used by all widgets. For example:
- Passing app current theme or language
- Passing backend API URL, app name, app logo, etc.
If you have only one widget, it is ok to pass them through it as widget props, but if you have multiple widgets, it is not perfect to do the same for all widgets. To do that, add these settings to WidgetsSettings
and modify previously created WidgetsContextProvider
to handle them.
// src/WidgetsContextProvider.tsx
export interface WidgetsSettings {
theme: "light" | "dark" | "system";
language: string;
}
export function WidgetsContextProvider({ children, ...props }: PropsWithChildren<WidgetsSettings>) {
const [theme, setTheme] = useState(props.theme);
useEffect(() => {
setTheme(props.theme);
}, [props.theme]);
return (
<I18nProvider lang={props.language}>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</I18nProvider>
);
}
Pay attention to the useEffect
in the above code. We need it if we wrap a setting with useSate
. In this case, the passed value to useState
is only for initiation and it is not getting updated on later calls. As the host app can change the widgets settings through the update
method, we need to use useEffect
to make sure the state gets the changes.
Note
You need to merge the two above examples if you support both optional "Sharing context" and passing down "Widgets Settings" use cases.
In this section we create custom elements (part of Web Components) to expose our React components as framework agnostic widgets. We don't need to create custom elements for inner components (e.g. Body
, Item
, Header
)
To make life easier, we moved the generic codes to the @workleap/r2wc
package (React to Web Component).
To create a custom element,
- inherit from the
WebComponentHTMLElement<Props, ObservedAttributesType>
class (inside@workleap/r2wc
package), where: - (optional)
Props
is the React componentProps
type. - (optional)
ObservedAttributesType
is the union type for new observed HTML attributes. This helps to keep type safety for them. You will see the usage in the following example.
Define these properties in the inherited class:
tagName
(required): To set the HTML attribute name.reactComponent
(required): to set the related React component,observedAttributes
: to set the newly added HTML attributes if there is any.map
: to define a map for HTML attributes to React props, and HTML events to React callback props.initialStyle
: to define initial style for the Web Component before having it rendered. it is really helpful to prevent layout shifting while waiting the web component to get rendered.
It is also required to define a ObservedAttributesType
union type based on the observedAttributes
values.
// src/MovieDetailsElement.tsx
import { WebComponentHTMLElement, type Map } from "@workleap/r2wc"
type ObservedAttributesType = (typeof MovieDetailsElement.observedAttributes)[number];
export class MovieDetailsElement extends WebComponentHTMLElement<MovieDetailsProps, ObservedAttributesType> {
static tagName = "wl-movie-details";
static observedAttributes = ["show-ranking", "mode"] as const;
get reactComponent() {
return MovieDetails;
}
get map(): Map<MovieDetailsProps, ObservedAttributesType> {
return {
attributes: {
"show-ranking": { to: "showRanking", convert: value => value === "true" },
"mode": "mode"
},
events: {
"on-buy": "onBuy"
}
};
}
get initialStyle(): Partial<CSSStyleDeclaration> {
return {
display: "block",
height: "48px"
};
}
}
Note
static observedAttributes
- Attribute names should follow Kebab-Case naming convention.
- Follow the array with
as const
to get type-safety support.
Â
get map()
It has two parts: attributes
and events
.
-
attributes
The left side of each map item is attribute name, and the right side says how to map from attribute to the related React prop. You have to define a map for all attributes defined in
observedAttributes
array.The right side of the map could be:
- The React property name defined in
Props
, or - An object that also defines how to convert the passed HTML attribute value from string.
{to: 'propName', convert: (value:string)=> PropertyType}
- The React property name defined in
Â
-
events
This section defines new HTML events for related callbacks in
Props
. Note that unlikeobservedAttributes
, we don't need to define these event names separately.
Tip
The base class has the data
property (not attribute) for the underlying React component. In other words, you can use the data
property in Javascript to get and set all React props regardless of declaring attributes or events.
Put the created file inside the src
folder.
The host app needs an API to register and initialize the widgets. WidgetsManager
class does this for you. To do that, create the widgets.ts file and create a new instance of the WidgetsManager
class. Its construcor accepts this parameters:
elements
(required): An array of widgets to register. Without having them registered, you cannot use them in the host app.contextProvider
: to pass shared context provider, e.g.WidgetsContextProvider
.contextProviderProps
: to pass initial context provider props. It is helpful when you want to initiate some props through build time, and not leaving it toinitialize
function. For example, when you want to inject event emitter and expose the function through theextend
method. One popular example could be any type of refresh data methods.ignoreLoadingCss
: if you want the host to load the related CSS file, set this to true. Otherwise the manager will load the css file automatically.syncRendering
: (not recommended) If you want the web component rendering happens syncronously. It may be useful for critical widgets that need to be present as soon as the page gets loaded. It is not recommended as it uses flushSync behind the hood and it may affect the overal page load time.
Then set the result to a const and export it from the module.
If you like to have access to it across the whole document, you can assign it to a window variable.
// src/widgets.ts
const MovieWidgets = new WidgetsManager({
elements: [MovieDetailsElement, MoviePopUpElement],
contextProvider: WidgetsContextProvider
});
window.MovieWidgets = MovieWidgets;
export { MovieWidgets };
Important
You need to create a types.d.ts
file and add the following declaration to be able to define window.MovieWidgets
at window level:
import type { IWidgetsManager } from "@workleap/r2wc";
import type { WidgetsSettings } from "./WidgetsContextProvider.tsx";
export declare global {
interface Window {
MovieWidgets?: IWidgetsManager<WidgetsSettings>;
}
}
If you don't have contextProvider
, simply ignore it:
// src/widgets.ts
const MovieWidgets = new WidgetsManager({
elements: [MovieDetailsElement, MoviePopUpElement]
});
window.MovieWidgets = MovieWidgets;
export { MovieWidgets };
WidgetsManager
loades the related CSS
file automatically at the time of load. If you want to load the CSS file manually, you can pass the ignoreLoadingCss: true
to the constructor.
WidgetsManager
class exposes the following API which is being used inside the host apps.
initialize(settings: WidgetsSettings)
: To initiate the widgets and pass the initial state ofWidgetsSettings
.update(settings: Partial<WidgetsSettings>)
: To change the state ofWidgetsSettings
. You only need to pass the changed settings.settings: WidgetsSettings
: To get the current widgets settings.unmount()
: To unmount the rendered elements. You can callinitialize
after to get a fresh rendering. It is mostly helpful in test environments (like Storybook) where you need to re-initialize the widgets without reloading the whole page. Note that this function doesn't remove the widgest tags from the page. It only removes the rendered content.
To add custom functionalities to WidgetsManager
you can simply use the extends<T>(data: T)
function. All the passed data will be injected to the instanciated WidgetsManager
and will be exposed through provided variable. In the following example, window.MovieWidgets.refreshData()
is a valid and type-safe method.
export declare global {
interface Extensions {
refreshData: ()=> void;
}
interface Window {
MovieWidgets?: IWidgetsManager<WidgetsSettings> & Extensions;
}
}
const refrehHandler = new InvokeMethodHandler();
const MovieWidgets = new WidgetsManager({
elements: [MovieDetailsElement, MoviePopUpElement],
contextProvider: WidgetsContextProvider,
contextProviderProps: {
refreshHandler: refrehHandler
}
}).extends({
refreshData : refrehHandler.emit.bind(refrehHandler)
});
window.MovieWidgets = MovieWidgets;
export { MovieWidgets };
If you want to ease the process of using your defined web components inside React hosts, you have to create the following files and later set up the package to export them properly.
First, declare types for defined Web Components inside the /src/helpers/react/types.d.ts file:
import type { WebComponentHTMLElement } from "@workleap/r2wc";
export declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace JSX {
interface IntrinsicElements {
"wl-movie-details": WebComponentHTMLElement;
"wl-movie-finder": WebComponentHTMLElement;
}
}
}
Second, define wrapper components inside src/helpers/react/index.ts to ease the usage. Make sure you export both types.d.ts
file as mentioned below:
import { createWebComponent } from "@workleap/r2wc/helpers/react";
import type { MovieDetailsProps } from "../../MovieDetails.tsx";
export type * from "../../types.d.ts";
export type * from "./types.d.ts";
export const MovieDetails = createWebComponent<MovieDetailsProps>("wl-movie-details");
export const MovieFinder = createWebComponent("wl-movie-finder");
Tip
With above definitions, the host app can easily use the component's props similar to how they defined, but just through the data
property:
function Page() {
return (
<MovideDetails data={{mode: "inline", showRanking: true}} />
);
}
Without these helpers, consumers have to do more to be able to use Web Components.
Everything is ready! We setup two different builds. One for CDN output, and the other a helper package for React hosts.
Just make sure you have setup the tsup.build.cdn.ts file correctly:
export default defineBuildConfig({
entry: ["src/index.ts"],
outDir: "dist/cdn",
noExternal: [/.*/],
dts: false,
minify: true,
treeshake: true
});
After running the pnpm build
inside the package, you will get index.ts
and index.css
files inside the dist/cdn
folder. You need them in host apps to load and render widgets.
Important
This output is NOT for packaging. It is expected to be downloaded by the host apps from a CDN.
If you have defined React helpers, you have to build the special output of your package as weel.
First, create the tsup.build.react.ts file. Rememer to enable dts: true
:
export default defineBuildConfig({
entry: ["src/helpers/react/index.ts"],
outDir: "dist/react",
dts: true,
clean: true,
sourcemap: true
});
Second, add the following to the package.json:
"name": "@samples/movie-widgets",
"version": "1.0.0",
"main": "dist/react/index.js",
"types": "dist/react/index.d.ts",
"exports": {
"./react": {
"import": "./dist/react/index.js",
"types": "./dist/react/index.d.ts"
}
},
"files": [
"dist"
],
The above ensures you can import @samples/movie-widgets/helpers/react
inside the React hosts.
The generated files inside the /dist/cdn
folder (i.e index.js
and index.css
) need to be made available to our widgets consumers.
Caution
The total combined size of these two files (after minification and Gzip compression) SHOULD NOT exceed 100KB.
You can use this extension to see the Gzip size of the generated files.
These files need to be hosted in an Azure storage container, served via Azure Front Door. The storage system automatically handles compression in Brotli (.br
) or Gzip (.gz
) formats. Make sure to activate the compression in the optimization settings of the Front Door profile. That storage will need to be set up by the team that owns the widgets project.
Currently, the deployment process involves manually uploading the index.js and index.css files to the Azure storage container.
- Navigate to the Azure portal and locate the appropriate storage account and container.
- Upload the files to the container. Make sure to replace any existing files.
- The CDN will automatically serve the updated versions with appropriate compression.
This proof of concept does not currently have an automated deployment process. However, the team that owns the widgets project could set up a CI/CD pipeline to automatically deploy the changes to the Azure storage container.
After deployment, the files will be available on a public URL:
For instance, for this template, the URLs are:
- JavaScript: https://cdn.workleap.com/movie-widgets/index.css
- CSS: https://cdn.workleap.com/movie-widgets/index.css
Versioning is not required in the default setup since we aim to avoid breaking changes. By avoiding breaking changes, all consumers can continue using the latest version of the files without needing to update their applications.
- You should prioritize deprecating old features instead of removing them,
- You should use feature flags to opt-in to new features that would otherwise be breaking changes.
However, in the event that breaking changes need to be introduced, versioned folders can be added to the CDN.
For example, breaking changes might be deployed to:
- JavaScript: https://cdn.workleap.com/movie-widgets/2/index.js
- CSS: https://cdn.workleap.com/movie-widgets/2/index.css
In such cases, consumers will need to manually update the URLs in their applications to point to the new version (/2/index.js and /2/index.css). Since breaking changes are involved, this manual update is necessary to ensure compatibility with the new version.
How to consume framework agnostic widget?
The framework agnostic widget can be consumed directly in any HTML page by referencing the deployed CDN files. To include the widgets in your project, use the following snippet:
<link rel="modulepreload" href="https://cdn.workleap.com/movie-widgets/index.js" />
<script type="module" src="https://cdn.platform.workleap-dev.com/movie-widgets/index.js"></script>
Important
The modulepreload
is almost enough but to get better perforamnce, add the script
tag right after to run it immediately. This helps on faster custom element registeration. Without it, the script will run when the first usage comes up.
Tip
The css
file is being loaded automatically through the above script if you haven't disabled it.
Once this is added to the HTML page, the script can now inject the new Web Components into the page. This can be done through calling initialize
method.
An example usage of the widget in an HTML page:
<html lang="en">
<head>
<link rel="modulepreload" href="/cdn/movie-widgets/index.js" />
<script type="module" src="https://cdn.platform.workleap-dev.com/movie-widgets/index.js"></script>
<script type="module">
import { MovieWidgets } from "/cdn/movie-widgets/index.js";
MovieWidgets.initialize({ theme: "light" });
const movieDetails = document.getElementByTagName("movie-details")
movieDetails.addEventListener("on-buy", function (movie, count) {
alert(`bought ${count} tickets of ${movie.title}`);
});
</script>
</head>
<body>
<wl-movie-details mode="inline" show-ranking="true" />
<wl-movie-finder />
</body>
</html>
if you have defined React helpers, you can easily import @samples/movie-widgets
package and then use the generated Web Components wrappers:
import { MovieDetails, MovieFinder } from "@samples/movie-widgets/react";
<MovieDetails data={{ mode: "modal", showRanking: true, onBuy: buyTickets }} />
<MovieDetails data={{ showRanking: false, mode: "inline" }} />
<MovieFinder style={{ fontWeight: "bold" }} />
Note
You can use regular HTML attributes, like style
, with these components.
This part is pretty similar to VanillaJS example. As we load this package from CDN, NOT as a package, we have to load it separately in index.html
file:
<html lang="en">
<head>
<link rel="modulepreload" href="/cdn/movie-widgets/index.js" />
<script type="module" src="https://cdn.platform.workleap-dev.com/movie-widgets/index.js"></script>
<script type="module">
import { MovieWidgets } from "/cdn/movie-widgets/index.js";
MovieWidgets.initialize({ theme: "light" });
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>
Even if the current POC is working, there are some improvements that we will look at in the future:
- Possibility of implementing the widget using the Shadow DOM to avoid conflicts with the host app styles.
- Pushing all elements to render inside Shadow root. Currently Orbiter renders Modal and Menu at document level which causes them to get their styles from the main document, not the shadow root styles.
- Strategy to load some components dynamically to decrease the whole package size
- Use SSR + Declarative Shadow Dom to boost performance and remove flickering at all