Creating Rimitive Adapters
An adapter abstracts tree operations for any node-based rendering target. It decouples view modules (el(), map(), match()) from specific platforms, allowing the same view code to work with DOM, server rendering, Canvas, WebGL, terminals, or custom renderers.
Adapters are minimal by design—just 5 core tree operations plus optional lifecycle hooks.
The Adapter Interface
import type { Adapter, AdapterConfig } from '@rimitive/view/adapter';
type Adapter<TConfig extends AdapterConfig> = {
// Core tree operations (required)
createNode: (
type: string,
props?: Record<string, unknown>,
parentContext?: ParentContext
) => TConfig['baseElement'];
setAttribute: (
node: TConfig['baseElement'],
key: string,
value: unknown
) => void;
appendChild: (
parent: TConfig['baseElement'],
child: TConfig['baseElement']
) => void;
removeChild: (
parent: TConfig['baseElement'],
child: TConfig['baseElement']
) => void;
insertBefore: (
parent: TConfig['baseElement'],
child: TConfig['baseElement'],
reference: TConfig['baseElement'] | null
) => void;
// Lifecycle hooks (optional)
beforeCreate?: (type: string, props?: Record<string, unknown>) => void;
onCreate?: (ref: NodeRef, parent: TConfig['baseElement']) => void;
beforeAttach?: (
ref: NodeRef,
parent: TConfig['baseElement'],
nextSibling: TConfig['baseElement'] | null
) => void;
onAttach?: (ref: NodeRef, parent: TConfig['baseElement']) => void;
beforeDestroy?: (ref: NodeRef, parent: TConfig['baseElement']) => void;
onDestroy?: (ref: NodeRef, parent: TConfig['baseElement']) => void;
};
AdapterConfig Type
Define types for your adapter to get autocomplete in el():
type AdapterConfig = {
props: object; // Maps tag names to prop types
elements: object; // Maps tag names to node types
events: object; // Maps event names to event types
baseElement: object; // Base node type for this adapter
};
Minimal Adapter Example
A simple in-memory adapter for testing or non-DOM environments:
import type { Adapter, AdapterConfig } from '@rimitive/view/adapter';
type SimpleNode = {
type: string;
props: Record<string, unknown>;
children: SimpleNode[];
};
type SimpleAdapterConfig = AdapterConfig & {
props: Record<string, Record<string, unknown>>;
elements: Record<string, SimpleNode>;
events: Record<string, Event>;
baseElement: SimpleNode;
};
function createSimpleAdapter(): Adapter<SimpleAdapterConfig> {
return {
createNode: (type, props) => ({
type,
props: { ...props },
children: [],
}),
setAttribute: (node, key, value) => {
node.props[key] = value;
},
appendChild: (parent, child) => {
parent.children.push(child);
},
removeChild: (parent, child) => {
const idx = parent.children.indexOf(child);
if (idx !== -1) parent.children.splice(idx, 1);
},
insertBefore: (parent, child, reference) => {
if (reference === null) {
parent.children.push(child);
} else {
const idx = parent.children.indexOf(reference);
if (idx !== -1) {
parent.children.splice(idx, 0, child);
} else {
parent.children.push(child);
}
}
},
};
}
Canvas Adapter Example
A more complex adapter for 2D canvas rendering:
import type { Adapter, AdapterConfig } from '@rimitive/view/adapter';
type CanvasNode = {
type: 'rect' | 'circle' | 'text' | 'group';
x: number;
y: number;
width?: number;
height?: number;
radius?: number;
fill?: string;
stroke?: string;
value?: string;
children: CanvasNode[];
parent: CanvasNode | null;
};
type CanvasAdapterConfig = AdapterConfig & {
props: {
rect: {
x?: number;
y?: number;
width?: number;
height?: number;
fill?: string;
};
circle: { x?: number; y?: number; radius?: number; fill?: string };
text: { x?: number; y?: number; value?: string; fill?: string };
group: { x?: number; y?: number };
};
elements: {
rect: CanvasNode;
circle: CanvasNode;
text: CanvasNode;
group: CanvasNode;
};
events: Record<string, never>;
baseElement: CanvasNode;
};
function createCanvasAdapter(
ctx: CanvasRenderingContext2D
): Adapter<CanvasAdapterConfig> {
let needsRedraw = false;
let root: CanvasNode | null = null;
function scheduleRedraw() {
if (needsRedraw) return;
needsRedraw = true;
requestAnimationFrame(() => {
needsRedraw = false;
if (root) render(root);
});
}
function render(node: CanvasNode, offsetX = 0, offsetY = 0) {
const x = (node.x ?? 0) + offsetX;
const y = (node.y ?? 0) + offsetY;
switch (node.type) {
case 'rect':
if (node.fill) {
ctx.fillStyle = node.fill;
ctx.fillRect(x, y, node.width ?? 0, node.height ?? 0);
}
break;
case 'circle':
ctx.beginPath();
ctx.arc(x, y, node.radius ?? 0, 0, Math.PI * 2);
if (node.fill) {
ctx.fillStyle = node.fill;
ctx.fill();
}
break;
case 'text':
if (node.fill) ctx.fillStyle = node.fill;
ctx.fillText(node.value ?? '', x, y);
break;
}
for (const child of node.children) {
render(child, x, y);
}
}
return {
createNode: (type, props) => {
const node: CanvasNode = {
type: type as CanvasNode['type'],
x: 0,
y: 0,
children: [],
parent: null,
...props,
};
return node;
},
setAttribute: (node, key, value) => {
(node as Record<string, unknown>)[key] = value;
scheduleRedraw();
},
appendChild: (parent, child) => {
child.parent = parent;
parent.children.push(child);
if (!root) root = parent;
scheduleRedraw();
},
removeChild: (parent, child) => {
const idx = parent.children.indexOf(child);
if (idx !== -1) {
parent.children.splice(idx, 1);
child.parent = null;
}
scheduleRedraw();
},
insertBefore: (parent, child, reference) => {
child.parent = parent;
if (reference === null) {
parent.children.push(child);
} else {
const idx = parent.children.indexOf(reference);
parent.children.splice(
idx !== -1 ? idx : parent.children.length,
0,
child
);
}
if (!root) root = parent;
scheduleRedraw();
},
};
}
Using Adapters with View Modules
Pass your adapter to view module factories:
import { compose } from '@rimitive/core';
import {
SignalModule,
ComputedModule,
EffectModule,
} from '@rimitive/signals/extend';
import { createElModule } from '@rimitive/view/el';
import { createMapModule } from '@rimitive/view/map';
import { createMatchModule } from '@rimitive/view/match';
import { MountModule } from '@rimitive/view/deps/mount';
// Create your adapter
const adapter = createCanvasAdapter(ctx);
// Compose with view modules
const svc = compose(
SignalModule,
ComputedModule,
EffectModule,
createElModule(adapter),
createMapModule(adapter),
createMatchModule(adapter),
MountModule
);
const { el, map, mount } = svc;
// Now el() creates canvas nodes instead of DOM nodes
const scene = el('group')(
el('rect').props({ x: 10, y: 10, width: 100, height: 50, fill: 'blue' }),
el('circle').props({ x: 200, y: 50, radius: 25, fill: 'red' })
);
Lifecycle Hooks
Use lifecycle hooks for advanced use cases:
| Hook | When | Use Cases |
|---|---|---|
beforeCreate | Before node creation | Hydration position setup |
onCreate | After node ref created | SSR markers, position sync |
beforeAttach | Before content attached | Hydration positioning |
onAttach | After content attached | SSR fragments, lifecycle callbacks |
beforeDestroy | Before removal | Exit animations |
onDestroy | After removal | Final cleanup |
Animation Example
const animatedAdapter: Adapter<DOMAdapterConfig> = {
...createDOMAdapter(),
beforeDestroy: (ref, parent) => {
if (ref.element instanceof HTMLElement) {
ref.element.classList.add('exiting');
// Note: actual animation delay requires more infrastructure
}
},
onDestroy: (ref, parent) => {
console.log('Node removed:', ref.element);
},
};
Cross-Renderer Composition
The parentContext parameter in createNode enables nested renderers:
createNode: (type, props, parentContext) => {
// parentContext.element is the parent node
// parentContext.adapter is the parent's adapter (if different)
// A canvas adapter nested in DOM could create a <canvas> element
// when it detects a DOM parent context
if (parentContext?.adapter !== this && type === 'canvas-root') {
return document.createElement('canvas');
}
return createCanvasNode(type, props);
};
Built-in Adapters
Rimitive provides these adapters:
| Adapter | Import | Purpose |
|---|---|---|
| DOM | @rimitive/view/adapters/dom | Browser rendering |
| Test | @rimitive/view/adapters/test | Unit testing (no DOM) |
| Server | @rimitive/ssr | Server-side rendering |
| Hydration | @rimitive/ssr | Rehydrating server HTML |
Testing Custom Adapters
Use the test adapter pattern for adapter tests:
import { describe, it, expect } from 'vitest';
describe('CanvasAdapter', () => {
it('creates nodes with correct type', () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const adapter = createCanvasAdapter(ctx);
const rect = adapter.createNode('rect', { x: 10, y: 20 });
expect(rect.type).toBe('rect');
expect(rect.x).toBe(10);
expect(rect.y).toBe(20);
});
it('builds tree structure', () => {
const adapter = createCanvasAdapter(ctx);
const group = adapter.createNode('group');
const rect = adapter.createNode('rect');
adapter.appendChild(group, rect);
expect(group.children).toContain(rect);
expect(rect.parent).toBe(group);
});
});
Hydration Adapter Pattern
The hydration adapter demonstrates a fundamentally different approach: reusing existing DOM instead of creating new nodes. This is essential for SSR where the HTML already exists.
Key differences from standard adapters:
- •
createNodewalks existing DOM instead of creating elements - •Position tracking via a mutable
path[]array - •Structure validation throws
HydrationMismatchon mismatches - •Fragment marker handling skips SSR comment markers
import type { Adapter } from '@rimitive/view/adapter';
import type { DOMAdapterConfig } from '@rimitive/view/adapters/dom';
class HydrationMismatch extends Error {
constructor(message: string) {
super(message);
this.name = 'HydrationMismatch';
}
}
function createHydrationAdapter(
containerEl: HTMLElement
): Adapter<DOMAdapterConfig> {
// Mutable position - path[i] is child index at depth i
const path: number[] = [];
function getNodeAtPath(root: Node, indices: number[]): Node {
let node = root;
for (const index of indices) {
node = node.childNodes[index];
if (!node) throw new HydrationMismatch(`No child at index ${index}`);
}
return node;
}
return {
createNode: (type, props) => {
// Text nodes - reuse existing
if (type === 'text') {
const node = getNodeAtPath(containerEl, path);
if (node.nodeType !== 3) {
throw new HydrationMismatch(`Expected text, got ${node.nodeName}`);
}
// Update text if needed
const text = (props?.value as string) ?? '';
if (node.textContent !== text) node.textContent = text;
// Advance to next sibling
path[path.length - 1]++;
return node;
}
// Element nodes - validate and reuse
const node = getNodeAtPath(containerEl, path);
if (
node.nodeType !== 1 ||
(node as Element).tagName.toLowerCase() !== type
) {
throw new HydrationMismatch(
`Expected <${type}>, got <${(node as Element).tagName}>`
);
}
// Enter element's children
path.push(0);
return node;
},
setAttribute: (node, key, value) => {
// Attach event handlers and update dynamic props
if (node.nodeType === 3 && key === 'value') {
node.textContent = String(value);
return;
}
Reflect.set(node, key, value);
},
appendChild: (parent, child) => {
// Element already attached - this is the "exit" signal
if (child.nodeType === 1 && child.parentNode === parent) {
path.pop(); // Exit element's children
path[path.length - 1]++; // Advance to next sibling
}
},
removeChild: () => {}, // No-op during hydration
insertBefore: (parent, child) => {
// Same exit signal as appendChild
if (child.nodeType === 1 && child.parentNode === parent) {
path.pop();
path[path.length - 1]++;
}
},
};
}
Mode Switching
After hydration completes, switch to a standard adapter for dynamic updates:
function createSwitchableAdapter(
hydrateAdapter: Adapter<DOMAdapterConfig>,
fallbackAdapter: Adapter<DOMAdapterConfig>
): Adapter<DOMAdapterConfig> & { switchToFallback: () => void } {
let current = hydrateAdapter;
return {
createNode: (type, props) => current.createNode(type, props),
setAttribute: (node, key, value) => current.setAttribute(node, key, value),
appendChild: (parent, child) => current.appendChild(parent, child),
removeChild: (parent, child) => current.removeChild(parent, child),
insertBefore: (parent, child, ref) =>
current.insertBefore(parent, child, ref),
switchToFallback: () => {
current = fallbackAdapter;
},
};
}
// Usage
const adapter = createSwitchableAdapter(
createHydrationAdapter(container),
createDOMAdapter()
);
// After hydration
adapter.switchToFallback();
When to Create a Custom Adapter
Good candidates:
- •Canvas/WebGL rendering
- •Terminal/CLI interfaces
- •Native mobile (React Native-style)
- •Game engines
- •PDF generation
- •Custom component systems
- •Hydration for custom SSR solutions
Not necessary for:
- •Standard web apps (use DOM adapter)
- •Server rendering (use SSR adapter)
- •Testing (use test adapter)
- •Standard hydration (use
@rimitive/ssr)