Simple Components#
We want custom elements, defined in Python, which we can use in HTML. In this step we arrange a proper registry which can communicate back to JavaScript. We also see the trick to create JS classes after startup.
Why? How#
Our ultimate goal is to have <my-counter count="0"></my-counter>
in our user’s HTML.
We want the definition – even the existence – of <my-counter>
to be in Python.
But custom elements must be a JS class, registered in the customElements
DOM object.
How will we avoid making our developers write JS?
Here’s how:
The Python side “discovers” component definitions
Then, introspects them to build a little registry
The JS side grabs that registry from the Python side
For each entry, a custom class is created dynamically, at run time
We’ll go a little further than that in the next step. But that’s the strategy for now.
Discovery#
We already have a test which confirms my-counter
is in the registry.
Let’s write an implementation: an actual component, plus the discovery process.
Here’s a simple, dataclass-based component to add in __init__.py
:
from dataclasses import dataclass
@dataclass
class MyCounter:
pass
We could write a test for it, but at this stage, there’s no real logic. We can trust that Python’s dataclass machinery is already tested.
Next, let’s write a tiny function that registers a component.
We can see into the future and know – we’d like a helper to automate getting from MyCounter
to my-counter
.
First, a test in test_init.py
:
def test_to_element_case():
result = to_element_case("MyCounter")
assert result == "my-counter"
Not only does this test fail, but all of test_init.py
fails.
We’ll focus our efforts in this test file.
The implementation in __init__.py
is simple:
import re
def to_element_case(camel_str):
"""Convert MyCounter class name to my-counter custom element name."""
return re.sub("([A-Z0-9])", r"-\1", camel_str).lower().lstrip("-")
Registration#
“Something” will tell the system to put a component in the registry.
First, let’s define “the registry” as a global defined_elements
dictionary.
In __init__.py
:
defined_elements = {}
A test to confirm it exists and starts empty:
def test_initial_globals():
assert defined_elements == {}
Now a function register
which is passed a component:
def register(component):
element_name = to_element_case(component.__name__)
defined_elements[element_name] = component
Once the imports are added, the test runs, but fails.
Our get_registry
is still hardwired.
Let’s fix that next.
Getting the registry#
We’ll circle back and fix the broken test which presumed the registry contained [1, 2, 3]
.
We’ll also write a test that checks get_registry
:
def test_get_registry():
assert get_registry() == []
def test_register_new_component():
assert get_registry() == []
register(MyCounter)
registry = get_registry()
assert registry == [dict(name="my-counter")]
With this failing test, let’s fix get_registry
:
def get_registry():
return [
dict(
name=component_name,
)
for component_name, component in defined_elements.items()
]
This function now acts as a mediator between the JS side and the Python side. It dumps the registry into a format best-used in JS.
Loading the “app”#
Let’s revisit the layers of the cake:
index.html
loads themain.js
main moduleThe main module makes a worker module
Main tells worker to initialize a Pyodide instance
Worker tells main it has initialized Pyodide
We’ll later introduce the idea of the “app” that will be loaded into Pyodide.
For now, it’s just a bundled MyCounter
component.
Main will need to tell the worker to load components.
Let’s start with a test in test_init.py
:
def test_initialize_app():
assert get_registry() == []
Hmm, this ended quickly. We haven’t registered anything yet – why is this test failing.
Because it still has the state from the previous registration.
Remember, our defined_elements
“database” is a global.
We’ll need a test and implementation for resetting the registry:
def test_reset_registry():
"""Clear previous test's registration."""
reset_registry()
assert get_registry() == []
Then, in __init__.py
, the reset function and the initialize_app
:
def reset_registry():
"""Used by tests to put the globals back in original state."""
defined_elements.clear()
def initialize_app():
register(MyCounter)
Now we use the reset in our test, and with the proper imports, it passes:
def test_initialize_app():
reset_registry()
assert get_registry() == []
initialize_app()
assert get_registry() == [dict(name="my-counter")]
We don’t want to have to do this reset dance all the time so we’ll write a pytest fixture for later use:
Let’s write a pytest fixture:
import pytest
@pytest.fixture
def initialized_app():
"""Reset the registry and setup counter app."""
reset_registry()
initialize_app()
Worker initializes components#
When the worker starts, there is no Pyodide. The main module sends a message saying “initialize Pyodide”, which returns a message when it is done. In that return message, we want to then say “initialize the app”, where “app” is a collection of Pyodide Components.
At them moment, worker.test.js
fails.
It’s expecting my-counter
to already be in the registry.
But we just made it a manual, explicit step: load Pyodide, then load components.
First, a test in worker.test.js
for the loadApp
function itself:
test("has MyCounter in registry", async () => {
await initialize();
expect(self.registry.length).to.equal(0);
await loadApp();
expect(self.registry.length).to.equal(1);
const myCounter = self.registry[0];
expect(myCounter.get("name")).to.equal("my-counter");
});
Remember to import loadApp
.
Next, an implementation:
export async function loadApp() {
self.pyodide_components.initialize_app();
self.registry = self.pyodide_components.get_registry().toJs();
return {messageType: "finished-loadapp", messageValue: self.registry};
}
With this, the test passes. One more step: we need a handler for the message dispatcher.
test("processes a load-app message", async () => {
await initialize();
const msg = { messageType: "load-app" };
const result = await dispatcher(msg);
expect(result.messageType).to.equal("finished-loadapp");
});
We need to make a change to dispatcher
to handle a load-app
message:
if (messageType === "initialize") {
await initialize();
return {
messageType: "initialized",
messageValue: "Pyodide app is initialized",
};
} else if (messageType === "load-app") {
return await loadApp();
}
The test passes.
Main is able to send the worker a load-app
message and receive back an updated registry.
A test for custom elements#
Let’s now hook this up to the main module and allow <my-counter>
to exist in HTML.
First, a failing test.
We’ll do so as part of main.test.js
.
Add to the beforeEach
a usage:
beforeEach(() => {
document.body.innerHTML = `<span id="status"></span><my-counter id="mc1">Placeholder</my-counter>`;
});
A test to see that this node exists, with Placeholder
as the content:
test("has a placeholder my-counter", async () => {
const status = document.getElementById("mc1");
expect(status.innerText).to.equal("Placeholder");
});
That’s a good start. Let’s start writing the part that makes face custom element classes on the fly, then hooks them into the registry messaging.
Fake custom element classes#
Ok, here’s the fun part: dynamic custom elements!
We will have a makeCustomElement
function that acts as a factory.
You call it with the name you want – such as my-counter
– and it returns a class.
Our message handler will then register that class as a custom element.
First, a test:
test("construct a custom element", () => {
const factory = makeCustomElement("my-counter");
expect(factory).is.a("function");
const element = new factory();
expect(element.name).to.equal("my-counter");
});
Now, an implementation:
export function makeCustomElement(name) {
return class extends HTMLElement {
constructor() {
super();
this.name = name;
}
connectedCallback() {
this.innerHTML = `<div>Element: <em>${this.name}</em></div>`;
}
};
}
We’re close!
Now we need to wire this up to the customElement.define
function.
Put elements in the custom element registry#
The main module will receive a finished-loadapp
message when the registry is updated.
Let’s implement that, but first, with a test:
test("initialize the registry", () => {
expect(window.customElements.get("my-counter")).not.to.exist;
const thisEntry = new Map();
thisEntry.set("name", "my-counter");
finishedLoadApp([thisEntry]);
expect(window.customElements.get("my-counter")).to.exist;
});
And now, with an implementation of finishedLoadApp
:
export function finishedLoadApp(registryEntries) {
// When an app loads components, the worker gives us an updated registry.
registryEntries.forEach((entry) => {
const name = entry.get("name");
customElements.define(name, makeCustomElement(name));
});
}
And the test now passes. We have defined a custom element in the custom element registry.
Get the custom element innerHTML#
Our tests have a document
with HTML setup in beforeEach
.
Is the placeholder text replaced with the connectedCallback
text?
Let’s write a test:
test("find the custom element innerHTML", () => {
expect(window.customElements.get("my-counter")).not.to.exist;
});
Hmm, failed quickly.
We lost test isolation again, because window.customElements
– which is an instance of CustomElementRegistry
– is already popuplated.
Let’s fix that first by resetting the Happy DOM window
in beforeEach
:
beforeEach(() => {
window = new Window();
document.body.innerHTML = `<span id="status"></span><my-counter id="mc1">Placeholder</my-counter>`;
});
That test now passes.
Now finish the test to see if we can trigger connectedCallback
:
test("find the custom element innerHTML", () => {
expect(window.customElements.get("my-counter")).not.to.exist;
const thisEntry = new Map();
thisEntry.set("name", "my-counter");
finishedLoadApp([thisEntry]);
document.body.innerHTML = `<my-counter id="mc1">Placeholder</my-counter>`;
const mc1 = document.getElementById("mc1");
expect(mc1.innerHTML).to.equal("<div>Element: <em>my-counter</em></div>");
});
It was a little finicky, but…we were able to do custom elements in Happy DOM, without a Chromium browser. To wrap up, let’s register the message handler for the worker’s message to the main module:
export const messageHandlers = {
initialized: finishedInitialize,
"finished-loadapp": finishedLoadApp,
};
Then, in finishedInitialize
, the main needs to tell the worker to load the app:
export function finishedInitialize(messageValue) {
const status = document.getElementById("status");
status.innerText = messageValue;
worker.postMessage({
messageType: "load-app",
});
}
Wire into index.html#
Let’s see if we can get a working web page, in a browser. We’ll add this in the body:
<div>
<my-counter></my-counter>
</div>
Now let’s wrap up with a Playwright E2E test.
We’ll add to the test in test_pages.py
:
# Did the custom element render into the innerHTML?
my_counter = fake_page.wait_for_selector("my-counter em")
assert my_counter.text_content() == "my-counter"