Status: accepted (2026-09-19)
“Can I use MUI?” is the question that decides whether a framework is usable at work. Behind it are three different questions with three different answers, and conflating them is how frameworks end up promising compatibility they cannot deliver:
A custom element is an element. Firsthand sets properties as properties, forwards
attributes, and delegates events. The only thing missing was event names with
hyphens: onSlChange lowercases to slchange, and the event is sl-change.
No casing of an identifier produces a hyphen.
So on: takes the name verbatim — on:sl-change, on:value-changed — in both
the compiler and the runtime. That is the whole of the change, and it makes
every web-component library usable with an import and no adapter. JSX permits
one colon in an attribute name, so the literal form takes no modifier;
onClick:capture remains the way to ask for that.
@firsthandjs/react is 0.66 kB and mounts a React root per bridged instance.
Props flow in through one effect, children stay Firsthand’s (the bridge gives
Firsthand an element inside the React tree and fills it through the ordinary
insert, because React cannot render DOM nodes and a Firsthand dynamic child is
not one), and events call back out.
It works — integrations/interop drives MUI’s Button, Chip and Slider in
Chromium. It also brings React itself, about 45 kB gzip against this
framework’s 5.87 kB, and everything below the bridge updates the React way.
Both facts are in the docs rather than buried, and the table there suggests an
element or a web component where one would do, because the bridge is worth its
size for a date picker and not for a button.
One React root and one host element per bridged instance, released on disposal — in a microtask, because React refuses to unmount a root while it is rendering.
fromReact(Component) is called once at module level; doing it inside a
component would mount a root per instance of the surrounding page.resolve.dedupe fixes it, and the README names
the error message so it is searchable.on: form is one more thing to know, and it only appears when you use a
library that needs it.useState, useEffect and a
reconciler so React components run unmodified. That is a second framework,
and nobody needs a second, less complete React.Two things were missing once an application was actually built on this.
React context does not cross bridge boundaries. Each bridge mounts its own
React root, so a ThemeProvider rendered through one bridge cannot reach a
button rendered through another — they are separate trees. MUI therefore kept
its default theme while the rest of the page changed. setReactWrapper(fn)
wraps every bridged root in the same elements, and because the wrapper is read
inside each root’s render effect, a signal read in it makes one theme switch
re-render every bridged component and nothing else.
Declaring a bridge per component is noise where it carries no information.
@firsthandjs/react/auto makes a React component an ordinary element; it is the
same bridge, cached per component type. fromReact remains for the case the
types cannot express — a React component wrapping Firsthand children — and for the
bridge’s own host and class props. See
ADR-0017 for the seam that makes it possible
without @firsthandjs/dom naming React.