Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pnpm playground # returns an example selector

## Projects

[`@bomb.sh/dom`](packages/dom/README.md) is a headless interactive DOM.
[`@bomb.sh/dom`](packages/dom/README.md) is a headless interactive DOM. Its framework integrations and demos live in `examples/preact`, `examples/react`, `examples/solid`, `examples/lit`, `examples/svelte`, and `examples/crank`.

## Support

Expand Down
11 changes: 11 additions & 0 deletions examples/crank/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Crank with @bomb.sh/dom

This example owns its framework adapter, framework dependencies, and acceptance tests. The core DOM package has no framework runtime dependencies. The adapter uses the public `@bomb.sh/dom` exports and the same tree and event implementation as ordinary DOM callers.

Run `pnpm --filter @bomb.sh/dom build`, then `pnpm --filter @examples/dom-crank test`, `pnpm --filter @examples/dom-crank typecheck`, or `pnpm --filter @examples/dom-crank demo`.

The tested scope covers mounting, native event handlers, text identity, asynchronous updates in independent documents, and cleanup. Browser styling, hydration, and framework-specific browser APIs are not implied. See [framework boundaries](../../packages/dom/docs/frameworks.md).

Crank’s own context-event declarations reference browser event interfaces. This fixture includes the DOM type library locally so those upstream declarations can be checked; its adapter still accepts only `@bomb.sh/dom` nodes and never casts them to browser Node. No DOM library is added to the core or workspace configuration.

The generic Crank adapter exercises generator refreshes and generator cleanup. Raw HTML, hydration, and SVG/MathML namespace propagation are not implemented by this example. Native custom event props can use `on:ExactCase`; JSX-style built-in prop spellings are translated only in the adapter.
27 changes: 27 additions & 0 deletions examples/crank/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@examples/dom-crank",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"demo": "node --experimental-transform-types src/demo.ts",
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "vitest run",
"format": "bsh format",
"format:check": "bsh format --check",
"lint": "bsh lint"
},
"dependencies": {
"@b9g/crank": "0.7.12",
"@bomb.sh/dom": "workspace:*"
},
"devDependencies": {
"@bomb.sh/tools": "^0.6.1",
"@types/node": "^22",
"@typescript/native-preview": "7.0.0-dev.20260623.1",
"vitest": "^4.1.2"
}
}
10 changes: 10 additions & 0 deletions examples/crank/src/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Document } from '@bomb.sh/dom';
import { createElement as h } from '@b9g/crank';
import { createRoot } from './index.ts';

const document = new Document();
const container = document.appendChild(document.createElement('box'));
const root = createRoot(container);
await root.render(h('text', null, 'Hello from Crank'));
console.info(container.textContent);
await root.unmount();
63 changes: 63 additions & 0 deletions examples/crank/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* oxlint-disable bombshell-dev/no-generic-error -- Reject invalid host callback values with standard TypeError exceptions. */
/* oxlint-disable bombshell-dev/exported-function-async -- Framework mounting and host factories are synchronous by contract. */
import { Renderer, type Children, type ElementValue } from '@b9g/crank';
import { Element, Text, type Node } from '@bomb.sh/dom';
import { updateHostProperties } from './properties.ts';

/** Crank's generic reconciler using the same native tree as all other adapters. */
const renderer = new Renderer<Node, null, Element>({
create({ tag, root }) {
if (!root) throw new TypeError('A Crank container is required');
if (typeof tag !== 'string') throw new TypeError('Unsupported Crank host tag');
return root.ownerDocument.createElement(tag);
},
text({ value, oldNode, root }) {
if (oldNode instanceof Text) {
oldNode.data = value;
return oldNode;
}
if (!root) throw new TypeError('A Crank container is required');
return root.ownerDocument.createTextNode(value);
},
patch({ node, props, oldProps }) {
if (!(node instanceof Element)) throw new TypeError('Expected an Element');
updateHostProperties(node, oldProps ?? {}, props);
},
arrange({ node, children }) {
let anchor = node.firstChild;
for (const child of children) {
if (child === anchor) anchor = anchor.nextSibling;
else node.insertBefore(child, anchor);
}
},
remove({ node, parentNode, isNested }) {
if (!isNested && node.parentNode === parentNode) parentNode.removeChild(node);
},
raw({ value }) {
if (typeof value === 'string')
throw new TypeError('Raw markup is not supported by this adapter');
return value;
},
});

export interface Root extends AsyncDisposable {
readonly element: Element;
render(children: Children): Promise<ElementValue<Node>>;
unmount(): Promise<void>;
}

/** Mount Crank generator components into an existing native element. */
export function createRoot(element: Element): Root {
return {
element,
async render(children) {
return renderer.render(children, element);
},
async unmount() {
await renderer.render(null, element);
},
async [Symbol.asyncDispose]() {
await this.unmount();
},
};
}
59 changes: 59 additions & 0 deletions examples/crank/src/properties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/* oxlint-disable bombshell-dev/max-params -- These callbacks match framework host property signatures. */
/* oxlint-disable bombshell-dev/exported-function-async -- Framework property mutation must complete in the caller’s synchronous update. */
/* oxlint-disable bombshell-dev/no-generic-error -- Unsupported prop values fail with TypeError at the adapter boundary. */
import type { Element } from '@bomb.sh/dom';
import type { Event } from '@bomb.sh/dom';

const handlers = new WeakMap<Element, Map<string, (event: Event) => void>>();

/** Translate framework prop spelling at the adapter boundary, never in the DOM. */
export function setHostProperty(element: Element, name: string, value: unknown): void {
if (name === 'children' || name === 'ref' || name === 'key') return;
if (name.startsWith('on')) {
let eventName = name.startsWith('on:') ? name.slice(3) : name.slice(2);
const capture = !name.startsWith('on:') && eventName.endsWith('Capture');
if (capture) eventName = eventName.slice(0, -7);
if (!name.startsWith('on:') && /^[A-Z]/.test(eventName)) eventName = eventName.toLowerCase();
let registered = handlers.get(element);
const previous = registered?.get(name);
if (previous) element.removeEventListener(eventName, previous, capture);
registered?.delete(name);
if (typeof value === 'function') {
if (!registered) handlers.set(element, (registered = new Map()));
const listener = (event: Event): void => {
value.call(element, event);
};
registered.set(name, listener);
element.addEventListener(eventName, listener, capture);
}
return;
}
if (name === 'style' || name === 'dangerouslySetInnerHTML') {
throw new TypeError(`${name} is not supported by this framework adapter`);
}
if (name === 'className') name = 'class';
if (name === 'value' && 'value' in element) {
Reflect.set(element, 'value', value ?? '');
return;
}
if (
value === null ||
value === undefined ||
(value === false && !name.startsWith('data-') && !name.startsWith('aria-'))
)
element.removeAttribute(name);
else element.setAttribute(name, value === true ? '' : String(value));
}

/** Apply removed and changed framework props to a native element. */
export function updateHostProperties(
element: Element,
previous: Readonly<Record<string, unknown>>,
next: Readonly<Record<string, unknown>>,
): void {
for (const name of Object.keys(previous))
if (!(name in next)) setHostProperty(element, name, undefined);
for (const [name, value] of Object.entries(next)) {
if (previous[name] !== value) setHostProperty(element, name, value);
}
}
75 changes: 75 additions & 0 deletions examples/crank/test/crank.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { createElement as h, type Context, type Element as CrankElement } from '@b9g/crank';
import { describe, expect, it } from 'vitest';
import { Document, Element, Text } from '@bomb.sh/dom';
import { Event } from '@bomb.sh/dom';
import { createRoot } from '../src/index.ts';

describe('actual Crank', () => {
it('runs generator components, refreshes asynchronously, and cleans up without globals', async () => {
const documents = [new Document(), new Document()];
await using first = createRoot(documents[0]!.createElement('box'));
await using second = createRoot(documents[1]!.createElement('box'));
const refreshes: Array<() => void> = [];
const cleanups: number[] = [];
function* Counter(
this: Context<{ index: number }>,
{ index }: { index: number },
): Generator<CrankElement> {
let count = 0;
refreshes[index] = () => {
count++;
void this.refresh();
};
this.cleanup(() => {
cleanups.push(index);
});
for (const _ of this) yield h('text', null, count);
}
await Promise.all([
first.render(h(Counter, { index: 0 })),
second.render(h(Counter, { index: 1 })),
]);
const original = first.element.firstChild;
assert.ok(original instanceof Element);
const content = original.firstChild;
assert.ok(content instanceof Text);
await Promise.resolve();
refreshes[0]?.();
refreshes[1]?.();
await expect.poll(() => first.element.textContent).toBe('1');
await expect.poll(() => second.element.textContent).toBe('1');
expect(first.element.firstChild).toBe(original);
expect(original.firstChild).toBe(content);
expect(content.ownerDocument).toBe(documents[0]);
expect(second.element.firstChild?.ownerDocument).toBe(documents[1]);
await first.unmount();
await second.unmount();
expect(cleanups).toEqual([0, 1]);
expect(first.element.childNodes).toHaveLength(0);
expect(globalThis).not.toHaveProperty('document');
});

it('reorders keyed nodes and replaces native event handlers', async () => {
const document = new Document();
await using root = createRoot(document.createElement('box'));
const calls: string[] = [];
const rows = (names: string[], label: string): CrankElement[] =>
names.map((name) =>
h(
'text',
{
key: name,
onClick: () => calls.push(label),
},
name,
),
);
await root.render(rows(['a', 'b', 'c'], 'before'));
const original = [...root.element.childNodes];
await root.render(rows(['c', 'a'], 'after'));
expect(root.element.childNodes).toEqual([original[2], original[0]]);
root.element.firstChild?.dispatchEvent(new Event('click'));
expect(calls).toEqual(['after']);
});
});
10 changes: 10 additions & 0 deletions examples/crank/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "@bomb.sh/tools/tsconfig.json",
"compilerOptions": {
"composite": false,
"types": ["node"],
"lib": ["ESNext", "DOM"],
"skipLibCheck": false
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
13 changes: 13 additions & 0 deletions examples/dom-realm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Persistent framework fixture realms

This private example package supplies the explicit bootstrap boundary used by the Lit and Svelte examples. Each `FixtureRealm` receives an existing `@bomb.sh/dom` document and creates a persistent Node `vm` context. The context exposes that document's custom-element registry and HTMLElement constructor, the actual DOM node/event constructors, a window event target, timers, and microtasks. Parsing is explicitly installed for template creation.

`bundleFixture()` uses esbuild's browser export condition and bundles the framework together with the example entry. Running that bundle separately in each context gives every document its own framework module state. Promises and callbacks retain their original context during later updates. Nothing installs or swaps the host process's `document`, `window`, or constructors.

The context uses the same DOM nodes as the host; it creates no second tree. It is a bootstrap example for trusted local modules, not an untrusted-code sandbox, browser API implementation, or script execution facility exported by the DOM package. It does not supply layout, stylesheet rendering, navigation, networking, or browser default input actions.

The adapter's TypeScript API accepts the package's actual `Document`, and code evaluation returns `unknown`. Framework browser modules are bundled JavaScript. This boundary does not claim that the package's public types structurally implement the complete browser `HTMLElement` expected by Lit or Svelte declarations.

Dependencies: `@bomb.sh/dom` provides the one tree; esbuild selects and bundles the browser framework modules; Node's built-in `vm` retains their separate lexical environments. No worker, browser process, CommonJS output, or framework code is added to the core package.

See [Node VM contexts](https://nodejs.org/api/vm.html#vmcreatecontextcontextobject-options). The DOM `window` facade here is deliberately limited to the capabilities exposed in `src/index.ts`.
24 changes: 24 additions & 0 deletions examples/dom-realm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@examples/dom-realm",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit -p tsconfig.json",
"format": "bsh format",
"format:check": "bsh format --check",
"lint": "bsh lint"
},
"dependencies": {
"@bomb.sh/dom": "workspace:*",
"esbuild": "^0.27.0"
},
"devDependencies": {
"@bomb.sh/tools": "^0.6.1",
"@types/node": "^22",
"@typescript/native-preview": "7.0.0-dev.20260623.1"
}
}
Loading
Loading