Building WebComponents with Fable - Elmish - React
I see tons of good examples in Fable and build nice web apps with Fable and Elmish and React. But I have a client, which needs to build WebComponents. So I've done some research, it should be possible to build web components with the nice way of Fable Elmish. And it is possible.
But there are some troubles to get t to work. So I write here the Steps to success. Maybe I or someone else can write an extension to Fable encapsulate all the boiler plate stuff.
1. Create new project with the fable-elmish-react app
Create an Fable-Elmish-React Example App with following commands:
dotnet new -i Fable.Template.Elmish.React
dotnet new fable-elmish-react -n mywebcomponent
cd mywebcomponent
yarn
dotnet restore
2. Now the importent stuff - now we build a webComponent
After some research, I find out, that you can actually render react stuff in the shadowDOM of a webComponent. In JS style, it looks like:
class WebComponent extends HTMLElement {
const mountPoint = document.createElement("div");
const shadowRoot = this.attachShadow({
mode: "open"
});
shadowRoot.appendChild(mountPoint);
ReactDOM.render(<a href={url}>{name}</a>, mountPoint);
}
customElements.define('web-component', WebComponent);
So, it should be possible to implement that only for the nice emlish-react stuff.
How would it look like. Something like this:
type WebComponent() =
inherit HTMLElement()
member this.connectedCallback() =
let shadowRoot = createShadowRoot()
let mountPoint = document.createElement "div"
shadowRoot.appendChild mountPoint
// Look this familiar? Yes thats basically the code of the App-init-stuff
Program.mkProgram init update root
|> Program.toNavigable (parseHash pageParser) urlUpdate
|> Program.withDebugger
|> Program.withHMR
|> initReact mountPoint
|> Program.run
()
CustomElements.define "web-component"
Okay, define a class which inherits from an HTMLElement.
The method connectCallback() is one of the life cycle methods of a webcomponent.
And what we do is run our Program stuff inside this method.
Easy isn't it? Okay, we need some boilerplate stuff. Like "createShadowRoot" or "initReact".
3. The Boilerplate Stuff to translate this into the right javascript
1. HTMLElement()
Fable has already an interface of HTMLElement. But we don't need this. We need an empty abstract class. We need to flag this as global to avoid that this class will be translated.
```fsharp
[<Global>]
type HTMLElement() = class end
```
If we inherit a class from this. Fable will translate this into "class MyClass extends HTMLElement"
2. createShadowRoot()
Here we use Emit to generate the matching javascript code to create a ShadowDOM root. There is also a createShadowRoot() function in Javascript, but the Polyfill for Browsers, that doesn't support custom elements only recognize this function. (Hint: the type ShadowRoot is defined on No. 4)
fsharp [<Emit("this.attachShadow({ mode: 'open' });")>] let createShadowRoot() : ShadowRoot = jsNative
3. document.creatElement is part of Fable
4. shadowRoot.appendChild
Here is the missing ShadowRoot type. Here we defint one member to append a HTMLElement (The Fable interface, not our placeholder in No. 1)
fsharp [<Global>] type ShadowRoot() = [<Emit("$0.appendChild($1);")>] member this.appendChild (el:Fable.Import.Browser.HTMLElement) = jsNative
5. |> initReact mountPoint
This function will be intiate the ReactDOM.render part. It's a little modified version of the react Program.withReact" function.
```fsharp
// Common for the "lazyView2With" function
open Elmish.React.Common
let initReact mountPoint (program:Elmish.Program<_,_,_,_>) =
let mutable lastRequest = None
let setState model dispatch =
Fable.Import.ReactDom.render(
lazyView2With (fun x y -> obj.ReferenceEquals(x,y)) program.view model dispatch,
mountPoint
)
{ program with setState = setState }
```
6. CustomElements.define
This one is also an "Emit".
fsharp module CustomElements = [<Emit("customElements.define($0,WebComponent);")>] let define (elementName:string) = jsNative
4. Let's run our code - first try
Before we run our code, we need to modify the index.html in the public folder. Instead of the <div id="elmish-app"></div>
we replace this with: <web-component></web-component>
So letzt run this with:
cd src
dotnet fable yarn-start
and goto http://locahost:8080/
And what did we see? An Error in the console:
After some research, we need to link the "custom-elements-es5-adapter.js" library:
So add the following script tag to the head of the index.html
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.0.3/custom-elements-es5-adapter.js"></script>
And now. We see some thing, that looks like our elmish app.
But wait, what's happen with the CSS.
After some research, the CSS doesn't leak into the ShadowDOM and vice versa. (The polyfill is maybe an exception to this. But Chrome...)
So what we need is to add our style sheets to the ShadowDOM.
5. Modify webpack.config.js to get a separate css file
At first we need a separate CSS file. Currently it's bundled to the bundle.js.
This is how we change that:
- Install "extract-text-webpack-plugin" with
yarn add extract-text-webpack-plugin
- now you have to modify the webpack.config.js
var path = require("path");
var webpack = require("webpack");
var fableUtils = require("fable-utils");
// Add these 2 line here !!!
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const extractCSS = new ExtractTextPlugin('styles.min.css');
// ...
... stuff ...
module: {
rules: [
// modify the sass RULE!!! to
{
test: /\.sass$/,
use: extractCSS.extract([
"css-loader",
"sass-loader"
])
}
]
},
plugins: isProduction ? [] : [
new webpack.HotModuleReplacementPlugin(),
new webpack.NamedModulesPlugin(),
// Add these entry to the plugnis array!
extractCSS
]
After the modifications, you have to restart dotnet fable yarn-start
Now we have our styles.min.css.
6. Add the CSS to the ShadowDOM, so that our WebComponent doesn't look like crap
Inside out connectedCallback function we need to add the CSS link.
After we create the ShadowRoot we put these lines into the method
member this.connectedCallback() =
let shadowRoot = createShadowRoot()
// Here these 5 lines !!!
let style = createStyleSheetElement()
style.rel <- "stylesheet"
style.``type`` <- "text/css"
style.href <- "styles.min.css"
shadowRoot.appendChild style
// already in the method
let mountPoint = document.createElement "div"
Now we had to define some additional boilerplate, so that Fable translate this for us correctly.
Please read the commentaries for further explanations.
// We need an placeholder for the HTMLLinkElement
// the 3 value members will be filled with the needed informations
[<Global>]
type HTMLLinkElement() =
member val rel : string = "" with get,set
member val ``type`` : string = "" with get,set
member val href : string = "" with get,set
// the create function
[<Emit("document.createElement('link');")>]
let createStyleSheetElement():HTMLLinkElement = jsNative
// Extend the ShadowRoot with the second method!
[<Global>]
type ShadowRoot() =
[<Emit("$0.appendChild($1);")>]
member this.appendChild (el:Fable.Import.Browser.HTMLElement) = jsNative
// Extend the ShadowRoot class with this member!
member this.appendChild (el:HTMLLinkElement) = jsNative
Also add the following line to the index.html:
<link rel="stylesheet" href="styles.min.css" />
The font isn't getting right, if you do not add the styles to the index.html. This is something for further research.
So well ... It's looking nice.
If we go to the Counter and press plus or minus, than nothing will happen. Also if we enter our name on the home screen. But hey, we can navigate.
So what do we do now?
7. The problems with the events inside the ShadowDOM
After some research, again. It's a problem in React in the ShadowDOM. The React Event have to be retargeted.
Lucky for us, Lukas Bombach published a package to fix this error: (Lukas. Thank you: https://github.com/LukasBombach)
Here ist the package: https://www.npmjs.com/package/react-shadow-dom-retarget-events
So we have to import that library and run retargetEvents(shadowRoot); after we rendered the react stuff.
At first, we add a new line at the end of our WebComponent class:
let shadowRoot = createShadowRoot()
let style = createStyleSheetElement()
...
... Stuff ...
|> Program.run
// Here we fix the problem
retargetEvents(shadowRoot)
()
Hey, but we should install at first the missing package:
yarn add react-shadow-dom-retarget-events
Now we need some boilerplate, so that our little function will run.
[<Import("default","react-shadow-dom-retarget-events")>]
let retargetEvents shadowDom = jsNative
After restart dotnet fable yarn-start, it works like a charm. Pressing the buttons, our webcomponent works as it should.
If you add a new <web-component></web-component>
tag in the index.html, you will see, that the web components works independently. With one exception. The navigation is synchronized, because the browser address will be used to navigate.
This (maybe) issue is for another day.
8. The attributes of a web component
As you know, you can also add attributes to a web component.
So the idea is, that we modify the init functions to accept parameter. So we can change the initial state of our component with attributes.
So add this "counter-start" attribute to the web-component tag.
And maybe add a second below that without an attribute. like this:
<web-component counter-start="999"></web-component>
<web-component></web-component>
For example the inital start value of the counter.
open the State.fs file and adjust the init function and add a new parameter:
let init counterStart result =
let (counter, counterCmd) = Counter.State.init()
// here we set the new counter value
let counter = counterStart
let (home, homeCmd) = Home.State.init()
let (model, cmd) =
urlUpdate result
{ currentPage = Home
counter = counter
home = home }
model, Cmd.batch [ cmd
Cmd.map CounterMsg counterCmd
Cmd.map HomeMsg homeCmd ]
We can also edit the init function of the counter state itself, so that we give the start value to it. But for the demo purppose this little change is enough.
After we change the init function our code has an error.
Back to our App.fs
We add a new line in out web component function ("this.connectedCallback()")
let counterStart = getAttribute "counter-start"
This function gives us the value of the attribute.
As always, we nee some boilterplate to let this function work.
[<Emit("this.getAttribute($0);")>]
let getAttribute (name:string) : string = jsNative
In this case I return a string.
We need to parse the string into an int, in order to work as out new init() function parameter.
let counterStart = getAttribute "counter-start"
let counterStart' =
if counterStart = null then 0 else counterStart |> int
yeah, I know. NULL ! But hey, javascript returns sometimes null. So wrap it in an options or something. For the demo it's enough. (I said that before, or?)
Now we can build a new init function with the new parameter:
let counterStart = this.getAttribute "counter-start"
let counterStart' =
if counterStart = null then 0 else counterStart |> int
// define a new init function
let init = init counterStart'
Program.mkProgram init update root
Now you should see this:
9. Polyfill
At the end I will also add to scripts to the index.html to polyfill the custom elements and the shadowDOM for browsers like Edge or IE, which aren't currently support web components natively.
<script src="https://unpkg.com/@webcomponents/custom-elements@1.2.0/custom-elements.min.js"></script>
<script src="https://unpkg.com/@webcomponents/shadydom@1.1.3/shadydom.min.js"></script>
10. All together ...
The new State.fs file:
module App.State
open Elmish
open Elmish.Browser.Navigation
open Elmish.Browser.UrlParser
open Fable.Import.Browser
open Global
open Types
let pageParser: Parser<Page->Page,Page> =
oneOf [
map About (s "about")
map Counter (s "counter")
map Home (s "home")
]
let urlUpdate (result: Option<Page>) model =
match result with
| None ->
console.error("Error parsing url")
model,Navigation.modifyUrl (toHash model.currentPage)
| Some page ->
{ model with currentPage = page }, []
let init counterStart result =
let (counter, counterCmd) = Counter.State.init()
let counter = counterStart
let (home, homeCmd) = Home.State.init()
let (model, cmd) =
urlUpdate result
{ currentPage = Home
counter = counter
home = home }
model, Cmd.batch [ cmd
Cmd.map CounterMsg counterCmd
Cmd.map HomeMsg homeCmd ]
let update msg model =
match msg with
| CounterMsg msg ->
let (counter, counterCmd) = Counter.State.update msg model.counter
{ model with counter = counter }, Cmd.map CounterMsg counterCmd
| HomeMsg msg ->
let (home, homeCmd) = Home.State.update msg model.home
{ model with home = home }, Cmd.map HomeMsg homeCmd
The new App.fs
module App.View
open Elmish
open Elmish.Browser.Navigation
open Elmish.Browser.UrlParser
open Fable.Core
open Fable.Core.JsInterop
open Fable.Import
open Fable.Import.Browser
open Types
open App.State
open Global
importAll "../sass/main.sass"
open Fable.Helpers.React
open Fable.Helpers.React.Props
let menuItem label page currentPage =
li
[ ]
[ a
[ classList [ "is-active", page = currentPage ]
Href (toHash page) ]
[ str label ] ]
let menu currentPage =
aside
[ ClassName "menu" ]
[ p
[ ClassName "menu-label" ]
[ str "General" ]
ul
[ ClassName "menu-list" ]
[ menuItem "Home" Home currentPage
menuItem "Counter sample" Counter currentPage
menuItem "About" Page.About currentPage ] ]
let root model dispatch =
let pageHtml =
function
| Page.About -> Info.View.root
| Counter -> Counter.View.root model.counter (CounterMsg >> dispatch)
| Home -> Home.View.root model.home (HomeMsg >> dispatch)
div
[]
[ div
[ ClassName "navbar-bg" ]
[ div
[ ClassName "container" ]
[ Navbar.View.root ] ]
div
[ ClassName "section" ]
[ div
[ ClassName "container" ]
[ div
[ ClassName "columns" ]
[ div
[ ClassName "column is-3" ]
[ menu model.currentPage ]
div
[ ClassName "column" ]
[ pageHtml model.currentPage ] ] ] ] ]
open Elmish.React
open Elmish.Debug
open Elmish.HMR
type WebComponent() =
inherit HTMLElement()
member this.connectedCallback() =
let shadowRoot = createShadowRoot()
let style = createStyleSheetElement()
style.rel <- "stylesheet"
style.``type`` <- "text/css"
style.href <- "styles.min.css"
shadowRoot.appendChild style
let mountPoint = document.createElement "div"
shadowRoot.appendChild mountPoint
let counterStart = getAttribute "counter-start"
let counterStart' =
if counterStart = null then 0 else counterStart |> int
let init = init counterStart'
// Look this familiar? Yes thats basically the code of the App-init-stuff
Program.mkProgram init update root
|> Program.toNavigable (parseHash pageParser) urlUpdate
|> Program.withDebugger
|> Program.withHMR
|> initReact mountPoint
|> Program.run
retargetEvents(shadowRoot)
()
CustomElements.define "web-component"
The boilerplate stuff in the WebComponents.fs
module WebComponents
open Fable.Core
open Fable.Core.JsInterop
open Fable.Import
open Fable.Import.Browser
open Elmish.React.Common
open Fable.Import.React
module CustomElements =
[<Emit("var res = customElements.define($0,WebComponent);console.log(res)")>]
let define (elementName:string) = jsNative
[<Global>]
type HTMLElement() = class end
[<Global>]
type HTMLLinkElement() =
member val rel : string = "" with get,set
member val ``type`` : string = "" with get,set
member val href : string = "" with get,set
[<Global>]
type ShadowRoot() =
[<Emit("$0.appendChild($1);")>]
member this.appendChild (el:Fable.Import.Browser.HTMLElement) = jsNative
member this.appendChild (el:HTMLLinkElement) = jsNative
// import retargetEvents from 'react-shadow-dom-retarget-events';
[<Import("default","react-shadow-dom-retarget-events")>]
let retargetEvents shadowDom = jsNative
[<Emit("this.getAttribute($0);")>]
let getAttribute (name:string) : string = jsNative
[<Emit("this.attachShadow({ mode: 'open' });")>]
let createShadowRoot() : ShadowRoot = jsNative
[<Emit("document.createElement('link');")>]
let createStyleSheetElement():HTMLLinkElement = jsNative
let initReact mountPoint (program:Elmish.Program<_,_,_,_>) =
let mutable lastRequest = None
let setState model dispatch =
Fable.Import.ReactDom.render(
lazyView2With (fun x y -> obj.ReferenceEquals(x,y)) program.view model dispatch,
mountPoint
)
{ program with setState = setState }
The new index.html
<!doctype html>
<html>
<head>
<title>Elmish Fable App</title>
<meta http-equiv='Content-Type' content='text/html; charset=utf-8'>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" type="image/png" href="img/favicon-32x32.png" sizes="32x32" />
<link rel="shortcut icon" type="image/png" href="img/favicon-16x16.png" sizes="16x16" />
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css" />
<link rel="stylesheet" href="styles.min.css" />
<script src="https://cdn.polyfill.io/v2/polyfill.js?features=es6"></script>
<script src="https://unpkg.com/@webcomponents/webcomponentsjs@2.0.3/custom-elements-es5-adapter.js"></script>
<script src="https://unpkg.com/@webcomponents/custom-elements@1.2.0/custom-elements.min.js"></script>
<script src="https://unpkg.com/@webcomponents/shadydom@1.1.3/shadydom.min.js"></script>
</head>
<body>
<web-component counter-start="999"></web-component>
<web-component></web-component>
<script src="bundle.js"></script>
</body>
</html>
11. Yeah!
So I hope I could helped someone with this. In my case, I maybe will try to develop this idea any further and mayber I can use it for a customer in the future.
And I hope that I will deploy the code on Github.
If you have any critics, questions or other things, please contact me.
Daniel