@xdstriker/pulsedom
v0.2.0
Published
Tiny tree-shakable DOM render library with an optional .pl component compiler.
Downloads
596
Maintainers
Readme
PulseDOM
Tiny ESM render library focused on extreme DOM update efficiency, minimal bundle size, and tree-shakable imports.
The runtime is intentionally small. The optional .pl compiler is a build-time
tool and is exposed through separate package paths so it does not enter the
render bundle unless you import it.
Install
npm install @xdstriker/pulsedomWith Bun:
bun add @xdstriker/pulsedomWhy it exists
PulseDOM is built around a simple rule: render only what is needed, ship only what is imported.
- Scoped updates through boundary nodes.
- Reused event listeners where possible.
- Text templates update only when their referenced keys change.
- Runtime helpers are split into subpath exports for tree-shaking.
- The
.plsyntax compiler is optional and separate from the runtime.
This package currently ships TypeScript source as ESM. It is designed for Bun or modern bundlers that can consume TS/ESM package exports.
Basic Usage
import render, { objTree, setObjTree } from "@xdstriker/pulsedom";
import { button, component, div } from "@xdstriker/pulsedom/virtual-node";
let count = 0;
function Counter() {
return component("section", {
className: "counter",
children: [
div({ text: "Count: {count:0}" }),
button({
text: "+",
events: {
click: (_event, node) => {
count += 1;
render(objTree(), node, "update", { count });
}
}
})
]
});
}
setObjTree(Counter());
render(objTree(), document.getElementById("app")!);component(tag, props) creates an update boundary. When an event calls
render(objTree(), node, "update", state), PulseDOM finds the nearest boundary and
updates that scope.
Runtime Exports
import render, { objTree, setObjTree } from "@xdstriker/pulsedom";
import { component, div, button, h1, fragment, island, svg, ns, t } from "@xdstriker/pulsedom/virtual-node";
import { template } from "@xdstriker/pulsedom/template";
import tinyStore from "@xdstriker/pulsedom/store";Available package paths:
@xdstriker/pulsedom: render function and root tree store.@xdstriker/pulsedom/virtual-node: VNode creation helpers.@xdstriker/pulsedom/template: small{key:fallback}text template helper.@xdstriker/pulsedom/store: tiny state helpers.@xdstriker/pulsedom/compiler: optional.plcompiler.@xdstriker/pulsedom/pl-plugin: optional Bun build plugin for direct.plimports.
The package includes .d.ts files for strict TypeScript projects.
Text Templates
Text can reference state with an optional fallback:
div({ text: "Echo: {echo:empty}" });If echo is missing, the rendered text is Echo: empty. On update, PulseDOM only
recomputes template text when the changed state keys are referenced by that text.
Effects
Effects run after create and update. A node can receive one effect or multiple effects.
component("section", {
text: "Status: {status:idle}",
effect: [
(node, state, action) => {
(node as HTMLElement).dataset.action = action;
(node as HTMLElement).dataset.status = String(state.status ?? "idle");
},
(node) => {
const el = node as HTMLElement;
el.title = "Managed by PulseDOM";
return () => {
el.removeAttribute("title");
};
}
]
});Returning a function registers cleanup for the next effect run.
Dynamic Islands
PulseDOM does not ship a general list reconciler. For lists and conditionals, use small boundaries as replaceable islands:
import { replaceBoundary } from "@xdstriker/pulsedom";
import { button, div, island } from "@xdstriker/pulsedom/virtual-node";
function FilteredList(items: string[]) {
return island("section", {
children: [
button({
text: "Show active",
events: {
click: (_event, node) => {
replaceBoundary(node, FilteredList(["Ada", "Grace"]));
}
}
}),
...items.map((item) => div({ text: item }))
]
});
}This replaces the nearest boundary as a unit. It is intentionally explicit, so dynamic lists stay isolated without pulling a reconciler into the runtime.
Config-Driven Lazy Components
For production builds, components can be marked as dynamic without writing
import() at each call site. Declare the exports in pulsedom.config.json:
{
"dynamicComponents": [
{
"module": "./src/components/CapabilityComponents.ts",
"exports": ["CompiledServerDataComponent"],
"placeholder": {
"tagName": "section",
"className": "capability capability-loading",
"text": "Loading..."
}
}
]
}build.ts generates tiny wrappers that keep the same named exports, then
rewrites imports of the configured module during the Bun build. Each configured
export becomes a lazy boundary backed by a generated import() chunk.
SVG
Use svg(...) for SVG roots. Children inherit the SVG namespace, and ns(...)
can be used for explicit namespaced elements:
import { ns, svg } from "@xdstriker/pulsedom/virtual-node";
const searchIcon = svg({
viewBox: "0 0 16 16",
children: [
ns("path", { d: "M7 1a6 6 0 1 0 0 12" })
]
});Cleanup
For tests, route changes, or remounting a demo, reset the internal registry:
import { resetRender, unmount } from "@xdstriker/pulsedom";
resetRender();
unmount(document.getElementById("app"));Optional .pl Compiler
.pl is a friendlier component syntax that compiles into the current VNode
structure. The compiler is independent from the final render runtime.
component CounterActions {
fragment {
button("+", on:click=increase)
button("-", on:click=decrease)
}
}
component CounterCard {
div(isBoundary, className="counter") {
h2("Compiled counter")
div("Count: {count:0}")
CounterActions(increase=increase, decrease=decrease)
}
}Handlers can be declared inside the component:
component EchoCard {
handler updateMessage {
update { echo: event.target.value }
}
div(isBoundary, className="echo") {
input(placeholder="type here", on:input=updateMessage)
div("Echo: {echo:}")
}
}update { ... } compiles to a scoped render update for the current boundary.
Handlers can also use regular JavaScript and await.
handler loadUsers {
update { serverStatus: "loading" }
const response = await fetch("/api/mock/users");
const payload = await response.json();
update { serverStatus: "loaded", userCount: payload.users.length }
}Effects are also supported:
component OperationsWorkspace {
effect syncStatusStyle {
const element = node as HTMLElement;
element.dataset.status = String(state.workflowStatus ?? "idle");
}
effect mirrorTitle {
const element = node as HTMLElement;
element.title = `status: ${state.workflowStatus ?? "idle"}`;
}
div(isBoundary) {
div("Status: {workflowStatus:idle}")
}
}Compile a .pl file in this repository:
bun run compile:pl compiler/examples/Counter.pl generated/Counter.tsUse the compiler from code:
import { compilePL } from "@xdstriker/pulsedom/compiler";
const output = compilePL(source);Use direct .pl imports in a Bun build:
import { plPlugin } from "@xdstriker/pulsedom/pl-plugin";
await Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./build",
plugins: [plPlugin()]
});Then TypeScript can import compiled .pl components at build time:
import { CounterCard, EchoCard } from "./components/Counter.pl";Local Demo
Install dependencies:
bun installRun the demo server:
bun devbun dev runs build.ts, compiles the .pl examples into generated/pl,
builds the demo bundle, and starts index.js.
Run tests:
bun testPublishing Checklist
Before publishing to npm:
bun install
bun test
bun run compile:pl compiler/examples/Counter.pl generated/Counter.ts
npm pack --dry-run
npm publish --access publicUse npm pack --dry-run to verify that the package contains only the runtime
source, compiler source, examples, docs, license, and demo files expected by the
files field in package.json.
Notes For Maintainers
The performance and bundle goals are part of the design contract. When adding features, prefer small exported helpers, optional imports, scoped DOM work, and compiler output that stays close to the runtime VNode structure.
Implementation notes for future LLM/code agents live in AGENTS.md.
