Having a good developer experience is the key to improve productivity, satisfaction and the impact of our work. In the frontend world, Hot Module Replacement is a way to significantly speed up development, let’s talk about how we can apply this technique with FE Client Extensions.
It is not required, but highly recommended that you read Saving Cloud (SaaS) resources for FE Client Extension , as it covers concepts that will help you understand this own post, and also share the same project configuration. And again, I’ll explain this considering a project bundling with vite.
HMR in a nutshell
Quoting webpack’s documentation, Hot Module Replacement (HMR):
It allows all kinds of modules to be updated at runtime without the need for a full refresh.
In a FE Client Extension development, it means we can keep our editor and browser at each others side, not needing to redeploy our CXs, just saving our source code files and see the update going on on the browser window. Isn’t it a huge improvement in our development experience?
So let’s analyze our CX and see how that will work.
Reviewing CX structure
Looking at what we generate as an output, and what we defined in our client-extension.yaml, this is what we have: for each CX, we point its respective URL to a bundled .js file. And this is the case for deploying it to production/production-like environments.
As I mentioned before, if you want to go deeper on all the details of this setup, read the post I mentioned at the top.
Now , think about it, if we want a CX, in PROD, pointing to a concrete bundled file, what would be the more flexible thing to point it to, in a flexible environment, like the local-dev one?
🥁🥁🥁🥁🥁
The client-extension.dev.yaml file
If you thought, “I should point it to the source code file itself”, you thought it right, congratulations!
When we’re developing, our “entry point” is the source code file itself. But we have two things to consider:
We can’t change the
client-extension.yamlto reflect our source structure, as it is not the case for PROD environments; andFile system files are not actually watched by the browser, so we would need to serve them, in a watchable way.
Thankfully, we have the answer for those problem:
Since 2023.Q3 we have the ability to create a specific file for declaring CX’s specific properties in local-dev environment, it is the
client-extension.dev.yamlfile; andWe can use the vite development server to serve the source files.
So, that’s what our CX dev file should looks like:
## JS Import Maps Entry
customer-library-importmap-a:
baseURL: "http://localhost:5173"
url: "/o/customer-library/src/js-import-map-entry/cx-a/export.ts"
customer-library-importmap-b:
baseURL: "http://localhost:5173"
url: "/o/customer-library/src/js-import-map-entry/cx-b/export.ts"
customer-library-importmap-c:
baseURL: "http://localhost:5173"
url: "/o/customer-library/src/js-import-map-entry/cx-c/export.ts"
customer-library-importmap-d:
baseURL: "http://localhost:5173"
url: "/o/customer-library/src/js-import-map-entry/cx-d/export.ts"
## Custom Element
customer-library-custom-element-a:
baseURL: "http://localhost:5173"
urls:
- "/o/customer-library/src/custom-element/cx-a/export.ts"
customer-library-custom-element-b:
baseURL: "http://localhost:5173"
urls:
- "/o/customer-library/src/custom-element/cx-b/export.ts"
## CSS
customer-library-styles:
baseURL: "http://localhost:5173"
url: "/o/customer-library/src/null"
## JS
customer-library-global-a:
baseURL: "http://localhost:5173"
url: "/o/customer-library/src/global-js/cx-a/export.ts"
## FDS Cell Renderer
customer-library-fds-cell-renderer-a:
baseURL: "http://localhost:5173"
url: "/o/customer-library/src/fds-cell-renderer/cx-a/export.ts"
customer-library-fds-cell-renderer-b:
baseURL: "http://localhost:5173"
url: "/o/customer-library/src/fds-cell-renderer/cx-b/export.ts"
customer-library-fds-cell-renderer-c:
baseURL: "http://localhost:5173"
url: "/o/customer-library/src/fds-cell-renderer/cx-c/export.ts"
A few things to notice here:
As we defined our build to have
base: "/o/customer-library"(it is in thevite.config.ts), we need to use it as a prefix to the source file, since vite dev server will serve it under that path.import { defineConfig } from "vite";export default defineConfig({
// Here we define a base for the import resolution,
// it should be the same name as the directory's,
// just including the /o/ at the beginning:
base: "/o/customer-library",// ...
});Since we’re not serving those files under Liferay’s umbrella, we need to define the
baseURLproperty, so all the assets will be properly served from the right host.localhost:5173is a default value for vite, feel free to change it onserver.portconfiguration option, just be sure to reflect that in theclient-extension.dev.yamlfile.customer-library-stylesis pointingurlto"/o/customer-library/src/null"because it will originally point to a bundled file, and not be updated. As CSS will be hold in memory at development mode, we can safely “escape it” like that.
This way we can skip one step in the development process, that is deploying every change of a CX, saving time and effort. Kind of simple, right? But, are we done?
Truth be told.
The vite configuration above works fine for JS/TS modules, but at the end, it doesn’t actually replaces the module “without a full reload”. It does saves the deployment step, but still reloads the page.
For globalJS and jsImportMapsEntry this behavior is actually okay, as we’ll probably have other modules, possible fragments and WDTs importing/calling some of that code, so due to the whole Liferay JS running, having a full page reload is, at the end, helpful.
CSS files will be refreshed without any issue.
And what about customElements? In a pure JS/TS approach, still, full page reload. What about React apps? Still, full page… wait! We can handle that! 😃
Handling actual HMR for React Custom Elements
Let’s start by revisiting our client-extension.dev.yaml and add this dev-only entries:
customer-library-vite-client:
name: Customer Library - Vite Client
type: globalJS
baseURL: "http://localhost:5173"
url: "/o/customer-library/@vite/client"
scriptElementAttributes:
type: "module"
customer-library-vite-refresh:
name: Customer Library - Vite Refresh
type: globalJS
baseURL: "http://localhost:5173"
url: "/o/customer-library/@vite/refresh.js"
scriptElementAttributes:
type: "module"
About customer-library-vite-client, the /o/customer-library/@vite/client is served by vite’s dev server, so it virtually exists (in memory).
About customer-library-vite-refresh, we must to create the @vite/refresh.js file. Add the directory and the file:
client-extensions/customer-library
├── ...
└── @vite
└── refresh.js
And that’s what we should have in refresh.js:
import { injectIntoGlobalHook } from "/o/customer-library/@react-refresh";
injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
Now, you must add this to a Liferay layout (Page, Master Page Template, Display Page Template), like adding any other globalJS client extension.
The only thing is that refresh must be before the client one, so it will load all the modules properly.
Example of how the Client Extensions should be added to a page.
That’s it. Now we can truly experience a HMR for our custom elements. 😊
