Introducing DomWise — Serenity's New JSX Library for Real DOM
Over the past few years, the Serenity ecosystem has undergone a significant transformation. What began as a jQuery UI widget framework has evolved into a modern, JSX-driven programming model — and at the heart of this evolution is DomWise (@serenity-is/domwise).
DomWise is a lightweight, high-performance JSX library that compiles your JSX templates directly into real DOM nodes — no virtual DOM, no diffing, no reconciliation overhead. It is designed from the ground up for the Serenity application framework, embracing direct DOM manipulation and working harmoniously with widget lifecycles.
The Journey: From jQuery to Real DOM
Serenity's widget model was originally built over a decade ago around jQuery UI widgets. Widgets would attach themselves to existing DOM elements, manipulate the DOM directly, and manage their own lifecycle through init() and destroy() methods. This model was imperative, predictable, and worked well — but it was tied to jQuery.
As we began transitioning away from jQuery toward a modern JSX-based programming model, we evaluated several options. Virtual DOM libraries like React were the obvious choice, but their reconciliation engine created fundamental conflicts with widgets that modify the DOM imperatively.
As we noted in the Serenity 8.2.1 release notes:
"While VDOM has its own set of advantages, they do not outweigh the issues that may arise when dealing with code or external components that manipulate the DOM directly."
This led us to jsx-dom, a library that compiles JSX into real DOM elements without a virtual DOM layer. It served us well, but as our needs grew, we found ourselves wanting more: reactive programming support via signals, tighter lifecycle integration with widgets, and the ability to quickly add features and fix issues without waiting for upstream releases.
Enter DomWise
DomWise builds on the foundations of jsx-dom, dom-expressions, and tsx-dom, adding:
- First-class reactive signals via
@preact/signals-core— signals work natively as attribute values, class/style bindings, and children, with automatic DOM updates. - Comprehensive lifecycle management — integrates seamlessly with Serenity's widget disposal patterns.
- Lowercased HTML attributes — attributes like
tabindex,readonly, andformatch actual HTML, making it easy to copy-paste Bootstrap and other HTML snippets directly into your.tsxfiles. Event handlers remain camelCase (onClick,onChange) for compatibility. - A declarative
Showcomponent — conditional rendering with reactive support, similar to SolidJS's<Show>. - Hooks —
useClassList,usePropBinding,useText,useUpdatableComputed, and more.
Why Not Virtual DOM?
The decision to avoid VDOM is not an accident — it is a deliberate architectural choice driven by Serenity's widget model. Here is why real DOM nodes win for us:
No Reconciliation Conflicts
Widgets can freely append, remove, or modify DOM nodes without worrying about a VDOM diffing algorithm reverting their changes. This is critical when third-party libraries (Select2, Flatpickr, SortableJS, etc.) directly manipulate the DOM.
class HybridWidget extends Widget<any> {
private imperativePart: HTMLDivElement;
protected override renderContents() {
return (
<div class="hybrid">
<div class="jsx-part">Created via JSX</div>
<div ref={el => this.imperativePart = el} class="imperative-part"></div>
</div>
);
}
// Direct DOM manipulation — perfectly safe, no VDOM conflicts
someMethod() {
const child = document.createElement('span');
child.textContent = 'Added imperatively';
this.imperativePart.appendChild(child);
}
}
Seamless Migration
Existing widgets that call appendChild, modify innerHTML, attach event listeners, or integrate with third-party libraries continue to work without wrappers or workarounds.
Predictable Lifecycle
Elements are created once and live until explicitly removed. There is no re-render cycle that might reconstruct or detach widget-bound elements.
Framework Flexibility
While DomWise is our primary programming model, you can freely use React, Preact, Vue, or any other framework in parts of your application without cross-framework reconciliation issues. For example, the StartSharp dashboard page uses Preact for its Chat widget while other widgets on the same page use DomWise.
Reactive Signals at the Core
DomWise re-exports the full @preact/signals-core API, making signals a first-class citizen. You can use signals directly in JSX:
import { signal } from "@serenity-is/domwise";
const count = signal(0);
document.body.appendChild(
<div>
<p>Count: {count}</p>
<button onClick={() => count.value++}>Increment</button>
</div>
);
Signals work seamlessly as attribute values, class bindings, style bindings, and children — and the DOM updates automatically when the signal changes, without triggering a full re-render.
Conditional Rendering with Show
The Show component provides declarative conditional rendering with reactive signal support:
import { signal, Show } from "@serenity-is/domwise";
const isLoggedIn = signal(false);
document.body.appendChild(
<div>
<Show when={isLoggedIn} fallback={<button onClick={() => isLoggedIn.value = true}>Log in</button>}>
<button onClick={() => isLoggedIn.value = false}>Log out</button>
</Show>
</div>
);
Serenity Widget Integration
DomWise is the foundation of Serenity's JSX rendering pipeline. It works hand-in-hand with the Widget base class from @serenity-is/corelib to provide a predictable, imperative-friendly component model.
renderContents vs render — A Once-Only Rendering Model
Unlike React class components where render() is called on every state/prop change, Serenity widgets follow a simpler, once-only rendering model:
render()— Returns the widget's root DOM node. Called once by the JSX runtime when the widget is instantiated (e.g.,<MyWidget />). It callsinit(), which triggersinternalRenderContents().renderContents()— Called exactly once during widget initialization to populate the widget's DOM node. Override this method to provide JSX content:
class MyWidget extends Widget<any> {
protected override renderContents() {
return (
<div class="my-widget">
<button onClick={e => this.handleClick(e)}>Click me</button>
</div>
);
}
}
The afterRender internal queue ensures renderContents is only invoked once — the queue is deleted after the first call. This is fundamentally different from React's render(), which is called on every state or prop change.
Direct DOM Updates
After renderContents, widgets update their DOM by directly manipulating elements — changing text content, toggling classes, adding or removing children. No VDOM diffing is needed. For automatic DOM updates, signals update the DOM in-place without triggering a full re-render:
class CounterWidget extends Widget<any> {
private count = signal(0);
protected override renderContents() {
return (
<div>
<p>Count: {this.count}</p>
<button onClick={() => this.count.value++}>Increment</button>
</div>
);
}
}
Widget Lifecycle Integration
DomWise's lifecycle system automatically hooks into Serenity's widget disposal mechanism. When a widget's destroy() method is called, DomWise:
- Fires the
"disposing"custom event on the widget'sdomNode. - Cleans up all signal subscriptions associated with the element.
- Removes event listeners registered via Fluent or DomWise's event system.
- Recursively disposes child widgets through the widget association system.
This ensures no memory leaks when widgets are dynamically created and destroyed, even when signals and event handlers are involved. The addDisposingListener and removeDisposingListener functions from DomWise are used internally by the Widget base class to register its destroy() method.
JSX Syntax That Feels Like HTML
DomWise uses lowercased HTML attributes to match actual HTML, making it easy to copy and paste Bootstrap and other HTML snippets directly into your .tsx files:
<div class="container">
<header>
<h1>Title</h1>
</header>
<main>
<p>Content</p>
</main>
</div>
Class Bindings
The class attribute accepts strings, objects, arrays, and signals:
// Object — keys with truthy values are added
<div class={{ hidden: isHidden, active: true, disabled: false }} />
// Array — falsy values filtered out
<div class={[condition && "active", "base", ["nested"]]} />
// Signal
<div class={{ active: signal }} />
Events
Event listeners are attached directly as DOM properties. Standard events use lowercase names, custom events preserve their original casing:
<button onClick={e => handleClick(e)}>Click me</button>
<input onChange={e => validate(e.target.value)} />
// Bulk event registration
<div on={{ click: handleClick, contextmenu: handleContextMenu }} />
Refs, Dataset, and More
Refs, dataset binding, dangerouslySetInnerHTML, and other special attributes work as you would expect:
const inputRef = createRef<HTMLInputElement>();
<input ref={inputRef} />
<div dataset={{ user: "guest", theme: "dark" }} />
How It Is Used in Serene/StartSharp Applications
One of the nicest aspects of DomWise in the context of Serene and StartSharp applications is that you typically do not need to install it manually. The package is brought in automatically through the NuGet package dependency chain:
- Your
.csprojfile referencesSerenity.Corelib(and optionally other Serenity packages). Serenity.Corelibhas a NuGet dependency onSerenity.DomWise, which ships the DomWise JavaScript and TypeScript files as embedded static web assets under itsdist/directory.- When you run
npm installorpnpm install, thepreinstall/pnpm:devPreinstallscript defined in yourpackage.jsonexecutesdotnet build -target:RestoreNodeTypes. - The
RestoreNodeTypesMSBuild target, defined inSerenity.Net.Web.targets(shipped via theSerenity.Net.WebNuGet package), scans all referenced NuGet and project packages for theirdist/directories and copies the files to your project'snode_modules/.dotnet/folder. - The same target automatically inserts or updates the corresponding entries in your
package.jsondependencies section, pointing them to the localnode_modules/.dotnet/paths.
After running npm install, your package.json will contain entries like:
"@serenity-is/domwise": "./node_modules/.dotnet/serenity.domwise"
This system ensures that your npm dependencies are always kept in sync with the NuGet package versions you have referenced — without any manual version management. It also accommodates Serenity packages (such as Serenity.Extensions, Serenity.Pro.Extensions) that do not have published npm registry counterparts.
TypeScript Configuration
To use DomWise with the automatic JSX transform, configure your tsconfig.json:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@serenity-is/domwise"
}
}
This allows you to use JSX without importing anything. DomWise also provides a /jsx-runtime entry point for automatic runtime resolution and a /jsx-dev-runtime entry point for development mode.
Getting Started
Whether you are starting a new Serene/StartSharp project or migrating an existing one, adopting DomWise is straightforward:
- For new projects: The templates already use DomWise out of the box.
- For existing projects: Update your
package.jsonto replacejsx-domwith@serenity-is/domwise, set thejsxImportSourceintsconfig.json, and remove the@preact/signals-coredependency (signals are re-exported from DomWise). - For standalone use: Install directly via
npm install @serenity-is/domwise.
Summary
DomWise represents a significant step forward for the Serenity ecosystem. By choosing real DOM nodes over virtual DOM, we preserve the imperative flexibility that Serenity widgets need while embracing modern JSX syntax and reactive programming patterns. The result is a library that feels natural to both seasoned Serenity developers and newcomers alike.
With its seamless NuGet/npm integration, comprehensive lifecycle management, and first-class signal support, DomWise is the foundation upon which the next generation of Serenity applications will be built.
DomWise is open source under the MIT license. See the README for full documentation, API reference, and usage examples.