1. Elements
An element is a lightweight object describing a node in the UI tree. Each element has a tag and props.
Elements are instances of the Element class, constructed with new Element(tag, props). The framework uses the term "tag" where the JSX specification uses "type." In practice, elements are created via JSX (see § 14 JSX) or tagged templates (see § 14.3 Template Tags) rather than direct construction.
The tag determines what the element represents:
-
Host element: the tag is a string (
"div","span") — the renderer creates a node in the host environment. -
Component element: the tag is a function — the framework invokes it as a component (see § 3 Component Invocation).
-
Symbol element: the tag is a symbol — used for special components like Portal, Raw, Copy, and Text (see § 12 Special Components).
Elements are identified across versions and realms by an internal $$typeof symbol, following the same pattern as React to protect against injection attacks. isElement(value) tests for this marker and returns true if the value is an element.
createElement(tag, props, ...children) is a convenience wrapper that constructs an element. It is the function called by JSX compilers (both the classic and automatic transforms). When props is null or undefined, it is normalized to an empty object ({}). createElement normalizes rest-argument children into a children prop: a single child is stored as-is (not wrapped in an array), multiple children are stored as an array, and no children means the prop is absent. The framework strips children from props before passing them to the renderer or component, and uses it to build the element tree. See § 6.5 Child Normalization for the normalization rules applied to children before diffing.
cloneElement(element) shallow-copies an element, producing a new element with the same tag and props.
2. Renderers
A renderer is the entry point to the framework. It translates the abstract element tree into mutations on a host environment — the platform-specific output target (e.g. the DOM, an HTML string, a native UI toolkit). Each renderer is parameterized by a result type (TResult) — the public value type returned by render and exposed to components via callbacks and yield expressions. For the DOM renderer, TResult is a DOM Node or array of Nodes; for the HTML renderer, it is a string.
2.1. render()
render(children, root?, bridge?) — the renderer must render the element tree into the given root. The renderer must cache state per root; subsequent calls to render with the same root must update in place. When children is null or undefined, the renderer must unmount the existing tree for that root and delete the cached state. When no root is provided, the renderer must perform a root-less render — producing output without mounting into a container. The HTML renderer uses this mode to render to a string. Returns Promise<TResult> | TResult.
2.2. hydrate()
hydrate(children, root, bridge?) — the renderer must attach to pre-existing content in the root rather than creating new host nodes. Used for server-rendered HTML. The framework walks the element tree and, for each host element, calls the adapter’s adopt operation instead of create. The adapter must return the child nodes of the adopted node; the framework removes any children not accounted for in the new tree. Props are patched onto adopted nodes so that event listeners and dynamic attributes are attached.
2.3. Bridging
The bridge parameter connects renderers so that events, provisions, and errors propagate across renderer boundaries.
2.4. Built-in Renderers
The framework ships two renderers:
-
DOM renderer (
@b9g/crank/dom) — renders into DOM Elements, creates DOM Nodes. -
HTML renderer (
@b9g/crank/html) — renders to HTML strings for server-side rendering.
The core framework is renderer-agnostic: it manages component lifecycles, reconciles element trees, and tracks state independently of any output target.
3. Component Invocation
A component is a function or a special symbol tag that the framework handles internally. User-defined components are functions; the framework also provides built-in components (Fragment, Portal, Raw, Copy, Text) identified by symbol tags (see § 12 Special Components). This section describes the invocation model for function components.
The framework must invoke a function component when an element tree containing a component element is passed to a renderer’s render() or hydrate() method, or when refresh() is called on the component or one of its ancestors. The framework must call it with:
-
thisbound to a component context object -
First argument: the current props object
-
Second argument: the same component context object (for arrow functions and destructuring)
(this: Context, props: TProps, ctx: Context) => Children | Promise<Children> | Iterator<Children> | AsyncIterator<Children>
The framework distinguishes component types by return value, not declaration syntax.
| Type | Syntax | Returns | Scope preserved |
|---|---|---|---|
| Function | function C(props)
| Children
| No |
| Generator | function *C(props)
| Iterator<Children>
| Yes |
| Async function | async function C(props)
| Promise<Children>
| No |
| Async generator | async function *C(props)
| AsyncIterator<Children>
| Yes |
refresh(callback) atomically mutates state and triggers a re-render; for...of this receives fresh props on each update.
function *Timer({message}) {
let seconds = 0;
// Setup: runs once on mount
const interval = setInterval(() => this.refresh(() => seconds++), 1000);
// Render: loop yields elements, receives fresh props each update
for ({message} of this) {
yield (
<div>
<p>{message}: {seconds}s</p>
<button onclick={() => this.refresh(() => seconds = 0)}>Reset</button>
</div>
);
}
// Cleanup: runs on unmount (after the loop exits)
clearInterval(interval);
}
3.1. Function Components
The framework must re-call the function on every update and render the return value as children. Function components must not block.
3.2. Generator Components
The framework must call the function once and store the returned iterator. On each subsequent update, the framework must call next(previousResult) where previousResult is the rendered result of the previous yield.
The framework must preserve the generator’s lexical scope across yields by retaining the iterator for as long as the component element occupies the same position in the element tree.
Generator components must block while their children render — previousResult must be a settled value, never a promise. The framework must call return() on unmount.
3.3. Async Function Components
The framework must re-call the function on every update. The component must block while its own async execution is pending, but must not block while its children render. See § 7.2 Enqueuing for queuing behavior.
3.4. Async Generator Components
The framework must call the function once and store the returned async iterator. Async generators operate in three modes, determined by how the component iterates its component context:
- Blocking mode (
for...ofor no loop) -
The component must block while children render, identical to sync generators.
yieldevaluates to the settled rendered result directly, not a promise. This is the default behavior. When the component iterates its context withfor...of(callingSymbol.iterator, e.g.for ({} of this)), the framework sets an internal flag and delivers fresh props through the loop. Without a loop, the framework still callsnext()on each update but does not deliver props via iteration. for await...ofmode (async iterator)-
The component executes continuously: it yields elements and the framework renders them without blocking the generator. The component must not block while children render.
yieldmust evaluate to a yield promise — aPromisethat resolves to the rendered result once children have settled. Child errors are delivered through the yield promise or injected viathrow()depending on whether the promise is observed (see § 9.2 Async Error Handling). The component must suspend at the bottom of the loop until new props are available orrefresh()is called. This mode enables racing patterns: multiple yields per update produce successive element trees that are raced via the chasing algorithm (§ 7.3 Chasing). The framework enters this mode when the component callsSymbol.asyncIteratoron its context (e.g.for await ({} of this)), which sets a separate internal flag.
4. Context
The framework must provide a component context object to each component instance.
4.1. Properties
| Property | Type | Description |
|---|---|---|
props
| TProps (readonly)
| Current props of the associated element. |
isExecuting
| boolean (readonly)
| true during the synchronous call to the component function or iterator.next(). Set to false before children are diffed.
|
isUnmounted
| boolean (readonly)
| true after the component has been unmounted.
|
4.2. Methods
refresh(callback?)- Enqueues a re-execution of the component. If a callback is provided, the framework must run it before re-executing. If the callback returns a promise, the framework must defer re-execution until the promise resolves; if the component has been unmounted by that time, re-execution must be skipped. If called while
isExecutingistrueorisUnmountedistrue, the framework must log an error and return the current element value. schedule(callback?)- Registers a callback that the framework must call after host nodes are created but before they are inserted into the host environment. The callback receives the element value. Callbacks are one-shot: they fire once and must be re-registered on subsequent renders. Multiple distinct callbacks may be registered; the framework must deduplicate callbacks by function identity (registering the same function reference twice in one update fires it once). If the callback returns a promise on the component’s initial render, the framework must defer insertion until the promise resolves; when multiple
schedulecallbacks each return a promise, the framework must wait for all of them (as withPromise.all). On subsequent renders, promise return values must be ignored. When called with no arguments, returns aPromise<TResult>that resolves with the element value at schedule time. after(callback?)- Registers a callback that the framework must call after host nodes are inserted into the host environment. The callback receives the element value. Same one-shot and deduplication semantics as
schedule. Promise return values fromaftercallbacks must be ignored. When called with no arguments, returns aPromise<TResult>that resolves with the element value at insertion time. cleanup(callback?)- Registers a callback that the framework must call when the component unmounts, before children are unmounted. The callback receives the element value. Unlike
scheduleandafter, cleanup callbacks are persistent: they must survive across renders and fire once on unmount. The framework must deduplicate callbacks by function identity. If a callback returns a promise, the framework must defer child unmounting until the promise resolves, keeping the component’s host nodes in the host environment while awaiting. When called with no arguments, returns aPromise<TResult>that resolves with the element value at unmount time. Components that acquire resources (timers, listeners, subscriptions) should clean them up via post-loop code,try/finally, orcleanup()callbacks. Ifcleanup()is called after the component has already been unmounted (isUnmountedistrue), the callback must fire immediately and synchronously with the last element value. provide(key, value)- Stores a provision on this component context, retrievable by descendants via
consume. Components should use symbols as provision keys to avoid collisions between unrelated libraries. consume(key)- Walks up the component context tree and returns the provision from the nearest ancestor that called
providewith the same key. dispatchEvent(event)- Dispatches an
Eventon the component context and invokes the matchingon*prop on the component element, if present. See § 10 Events. addEventListener/removeEventListenerEventTargetmethods that delegate to the host nodes produced by the component. See § 10 Events.
4.3. TypeScript
In TypeScript, generator components must annotate this explicitly because TypeScript does not infer this types for generator functions. The Context<T> type is parameterized by the component function itself or a props type — Context<typeof MyComponent> infers the props from the component definition.
5. Props Iteration
The component context implements both Symbol.iterator and Symbol.asyncIterator. Generator components may iterate the context with for...of this (or for await...of this) to receive fresh props on each update; components that do not need prop updates may use other iteration patterns.
5.1. Synchronous (for...of)
Each iteration must yield the current props object. If the iterator is advanced twice without the component yielding, the framework must throw a runtime error.
5.2. Asynchronous (for await...of)
Each iteration must yield the current props object. If new props are not yet available, the iteration must await until the framework provides them. When a component enters this mode, the framework switches to continuous execution where children render without blocking the generator.
6. Reconciliation
When the framework diffs children, it walks the new children array against the previous children array and decides for each position whether to reuse, replace, or remove an element.
To mount an element is to create it for the first time — invoking a component function, creating host nodes, and inserting them into the host environment. To unmount an element is to remove it — firing cleanup callbacks, calling return() on iterators, and removing host nodes. To update an element is to re-render it in place with new props — re-calling a function component or advancing a generator’s iterator.
6.1. Position-Based Matching
By default, the framework compares children at the same position — their index within a parent’s children array — by tag. If the tags match, the framework must reuse the existing element and update its props. If the tags differ, the framework must unmount the old element and mount a new one in its place.
| Old tag | New tag | Result |
|---|---|---|
"div"
| "div"
| Reuse — update props, diff children. |
"div"
| "span"
| Replace — unmount old, mount new. |
ComponentA
| ComponentA
| Reuse — update props, re-execute component. |
ComponentA
| ComponentB
| Replace — unmount old component, mount new. |
"div"
| (removed) | Remove — unmount old. |
| (none) | "div"
| Insert — mount new. |
6.2. Same-Reference Optimization
When a child element is the exact same object reference as the element currently occupying that position (identity equality, not structural equality), and the element has already been committed, the framework must skip re-rendering entirely. This allows components to cache element objects and reuse them to avoid unnecessary work.
6.3. Key-Based Matching
When children have key props, the framework matches children by key instead of position. This allows the framework to efficiently reorder, insert, and remove children. Keys with a value of null or undefined are ignored — the element is treated as unkeyed.
-
The framework walks new children sequentially. For each child, it checks whether the old child at the current position has the same key.
-
If keys match (or both are unkeyed), the framework advances both pointers.
-
On the first key mismatch, the framework builds a map from keys to old children.
-
For each subsequent keyed new child, the framework looks up the old child by key in the map.
-
For each subsequent unkeyed new child, the framework skips over keyed old children to find the next unkeyed one.
-
After processing all new children, any remaining old children not matched by key are unmounted.
Duplicate keys among siblings must log a warning. The framework must strip the key from the later occurrence, treating it as unkeyed; the first child with a given key retains the key.
6.4. Element Values
Every element in the tree produces an element value when rendered. For host elements (e.g. "div"), the element value is the node created by the renderer. For components and Fragments, the element value is the node or array of nodes produced by their children.
The element value is the argument passed to ref callbacks, schedule callbacks, after callbacks, and cleanup callbacks. It is also what the component context’s addEventListener and removeEventListener methods delegate to, and what yield evaluates to in generator components (as previousResult).
6.5. Child Normalization
A Child is any value that can appear in the element tree. The valid child types are: Element | string | number | boolean | null | undefined. Before diffing, the framework must normalize children:
-
Strings must be wrapped in a Text element, producing a text node via the renderer.
-
Numbers must be converted to strings and wrapped in a Text element.
-
nullandundefinedmust produce no output. A component returningundefinedrenders nothing. Components should returnnullfor intentional empty output; the framework logs a warning forundefinedreturns. -
Booleans (
trueandfalse) must produce no output. This enables the{condition && <Element />}pattern — when the condition isfalse, nothing is rendered. -
Non-string iterables (arrays, Sets, etc.) must be implicitly wrapped in a Fragment. Each item in the iterable becomes a Child at its own position in the element tree. This means components can return arrays or other non-iterator iterables to produce multiple siblings without a wrapper node.
-
Nested arrays must be flattened into the children array.
-
Elements (objects identified by the internal
$$typeofmarker — see § 1 Elements) must be diffed recursively.
This normalization applies everywhere children appear: the return/yield value of a component, the children prop, and the children of host elements.
7. Async Rendering
7.1. Blocking
Each component type has different blocking behavior. To block means the framework waits for the component’s pending work to settle before accepting its next update.
| Type | Blocks for own execution | Blocks for children |
|---|---|---|
| Function | No | No |
| Generator | No (sync) | Yes |
| Async function | Yes | No |
Async generator (for...of)
| Yes | Yes |
Async generator (for await...of)
| Yes | No |
When a component blocks, the framework separates the block duration (the component’s own execution) from the value duration (the full render including children). The enqueuing algorithm advances based on the block, not the value.
7.2. Enqueuing
When an async component is re-rendered while a previous execution is still pending, the framework enqueues at most one additional execution. The framework must maintain two slots per component: inflight and enqueued.
-
If no inflight execution exists, the framework must start one immediately.
-
If an inflight execution exists but no enqueued execution, the framework must create an enqueued execution that waits for the inflight to settle, then runs with the latest props.
-
If both inflight and enqueued executions exist, the framework must update the stored props but must not create a third execution. The enqueued execution must use whatever props are current when it starts.
When the inflight execution settles, the enqueued execution must be promoted to inflight. The framework must not allow more than one concurrent execution per component element, and the final render must reflect the latest props.
Async generator components in for await...of mode use the same enqueuing algorithm. Additionally, when such a component yields a value but new props are already available before the yield is processed, the framework should skip rendering the stale children. Only the most recent yield for a given update is rendered.
7.3. Chasing
When different async element trees are rendered into the same position and settle out of order, the framework must ensure that later renders always win. It does this by chasing: racing each render’s child values against the next render’s child values using Promise.race.
This produces a monotonic effect: if an earlier render settles first, its result is displayed until the later render settles. If the later render settles first, the earlier render’s result must not be displayed. The host environment must always reflect the most recently initiated render that has settled.
7.4. Fallbacks
When a new async element is rendered into a position where a previous element has already committed, the framework must preserve the previously rendered content until the new element settles for the first time. This prevents the output from going blank while async elements are pending. The fallback chain must be cleared once the element commits.
7.4.1. Fallback Resurrection
When a new element replaces an existing one at the same position and the tags differ, the framework must search the fallback chain for a retainer whose tag matches the new element’s tag. If a match is found, the framework must "resurrect" that retainer — reusing it instead of creating a new one — preserving the component’s state, generator scope, and subtree. The resurrected retainer becomes the active element and the displaced element becomes its fallback. This is useful when an async component (such as Suspense) re-renders and its child quickly returns to a previously rendered tag — the child’s state is preserved rather than remounted.
8. Execution Order
8.1. Per Update
The framework must execute the following steps in order:
-
Set
isExecutingtotrue. -
Call the component function (for function components) or
iterator.next(previousResult)(for generators). -
Set
isExecutingtofalse. -
Diff the yielded/returned children against the previous tree.
-
Commit host mutations.
-
Call
refcallbacks and fireschedulecallbacks (host nodes created, not yet inserted). -
Insert host nodes into the host environment.
-
Fire
aftercallbacks (host nodes live in environment).
8.2. On Unmount
The framework must execute the following steps in order:
-
Fire
cleanupcallbacks (§ 4.2 Methods). If the component is directly unmounted (not nested inside a host element that is itself being removed), async cleanup callbacks defer child unmounting and keep host nodes visible. When nested, the parent node is already gone — cleanup promises are still awaited but cannot defer visual removal. -
For generators and async generators, the framework attempts a natural exit before forcing termination:
-
If the component is in a context iteration loop (
for...oforfor await...of), the framework callsnext()on the iterator. The context iterator signals done, the loop condition becomes false, and the generator executes its cleanup code after the loop naturally. -
For
for await...ofmode, the framework continues callingnext()until the iterator is done, draining any remaining yields. -
If the iterator is still not done after the natural exit attempt (or if the component is not in a loop), the framework calls
return(). Thefinallyblock executes as part of the iterator protocol.
-
-
Unmount children recursively.
8.3. Commit Sequence
After each diff, the framework runs the commit sequence:
-
Commit host mutations (create or patch host nodes, recursively commit children).
-
Call
refcallbacks (§ 13 Special Props) and fireschedulecallbacks (§ 4.2 Methods). Host nodes exist but are not yet inserted. -
Insert host nodes into the host environment.
-
Fire
aftercallbacks (element is live in the document).
8.4. Callback Timing Reference
| Callback | When | Argument | Lifetime | Async return |
|---|---|---|---|---|
ref (prop)
| After host nodes created, before insertion | Element value | Per-render (set as a prop) | Ignored |
schedule(cb)
| After host nodes created, before insertion | Element value | One-shot | Defers insertion (initial render only) |
after(cb)
| After host nodes inserted into environment | Element value | One-shot | Ignored |
cleanup(cb)
| On unmount, before children unmount | Element value | Persistent | Defers child unmounting (direct unmount only) |
refresh(cb)
| Enqueues re-execution | None (cb runs before re-exec) | One-shot | Defers re-execution |
9. Error Handling
9.1. Error Injection
When a child component throws during rendering, the framework must call throw(error) on the nearest ancestor generator’s iterator. This causes the yield expression in the ancestor to throw the error. If the generator catches the error (via try/catch around yield), it may yield a recovery element tree and rendering continues. If uncaught, the error must propagate up the component context tree to the next ancestor generator.
9.2. Async Error Handling
For async generator components in for await...of mode, the framework tracks whether the promise returned by yield is being observed (via .then() or .catch()). If the promise is unobserved ("floating") and a child error occurs, the framework injects the error via throw(error) on the iterator. If the promise is observed, the framework rejects the promise, allowing the component to catch the error via await.
9.3. Generator Return on Error
If a generator component does not catch an injected error, the framework must not call return() — the iterator is already done because the uncaught throw() terminates it. The finally block of the generator, if present, still executes as part of the iterator protocol.
10. Events
The framework maps on* props to event listeners on the underlying host nodes. The framework supports both lowercase (onclick) and camelCase (onClick) event prop names, normalizing camelCase to lowercase before registration.
10.1. EventTarget Delegation
Every component context implements the EventTarget interface. The addEventListener and removeEventListener methods on a component context delegate to the top-level nodes in the component’s element value — calling this.addEventListener("click", handler) registers that listener on each top-level node, not on their descendants. If the component re-renders and produces different nodes, the framework updates the delegation: listeners are removed from old nodes and added to new ones. This allows components to listen to their own events without requiring a ref.
Note: In the DOM renderer, the element value is a DOM Node or array of DOM Nodes. Delegation registers listeners via the standard DOM addEventListener API on those nodes.
10.2. Bubbling
Component contexts form a tree that mirrors the element tree. dispatchEvent on a component context follows the standard DOM event propagation model across this tree:
-
Capturing phase — the event walks from the root context down to the target, invoking listeners registered with
{capture: true}. -
At-target phase — the event fires on the target context. The framework also invokes the matching
on*prop on the component’s element, if present. This is how components dispatch events to their parents — callingthis.dispatchEvent(new Event("change"))triggers the parent’sonchangeprop callback. -
Bubbling phase — if
event.bubblesistrue, the event walks back up from the target to the root, invoking non-capture listeners on each ancestor context.
stopPropagation() and stopImmediatePropagation() work as specified. Listener callbacks that throw are logged to the console rather than interrupting propagation. The return value follows the DOM spec: true unless preventDefault() was called.
11. DOM Attributes
Note: This section is specific to the DOM renderer.
11.1. Boolean and Nullish Props
-
truesets the attribute to an empty string (el.setAttribute(name, "")). -
falseornullremoves the attribute (el.removeAttribute(name)).
11.2. React Compatibility
The DOM renderer accepts React-style prop names and normalizes them:
-
classNameis treated asclass. -
Event props with a camelCase name where the third character is uppercase (
onClick,onChange) are lowercased before registration (onclick,onchange).
11.3. Style
The style prop accepts either a string or an object with CSS property names. Both kebab-case (font-size) and camelCase (fontSize) are supported; camelCase names are converted to kebab-case. Numeric values are automatically suffixed with px for properties that accept length units.
11.4. Class
The class prop (or className) accepts a string or an object. When an object is provided, keys are space-separated class names and values are booleans that toggle them:
11.5. innerHTML
The innerHTML prop bypasses child rendering entirely. When present, the renderer sets the node’s content from the prop value and skips child arrangement — any children passed alongside innerHTML are ignored.
In the DOM renderer, innerHTML is assigned directly to the element’s innerHTML property. In the HTML renderer, the prop value is emitted as the element’s content without escaping. Because innerHTML bypasses the framework’s escaping and child model, setting it to untrusted input creates a cross-site scripting vulnerability. Authors must sanitize any user-provided content before passing it as innerHTML.
11.6. Property vs Attribute Resolution
For each prop on a host element, the DOM renderer decides whether to set it as a DOM property or an HTML attribute. The default behavior tries the DOM property first: if the name exists on the element object and is writable, the renderer assigns it directly (e.g. el.value = "text"). Otherwise, it falls back to setAttribute.
This heuristic works for most cases, but some props are ambiguous — a name might exist as both a property and an attribute with different semantics. The prop: and attr: prefixes force a specific interpretation:
| Prefix | Behavior | Example |
|---|---|---|
prop:
| Always set as a DOM property | <input prop:value="text" /> → el.value = "text"
|
attr:
| Always set as an HTML attribute | <input attr:value="text" /> → el.setAttribute("value", "text")
|
Without a prefix, the renderer also handles these special cases for boolean coercion: when a string value maps to a boolean DOM property (e.g. <details open="false">), the renderer forces setAttribute to avoid the property coercing "false" to true.
12. Special Components
| Component | Tag | Props | Behavior |
|---|---|---|---|
Fragment
| ""
| children
| The framework renders children without a wrapper node. Fragment is used implicitly during child normalization (§ 6.5 Child Normalization). |
Text
| Symbol.for("crank.Text")
| value: string
| The framework creates a text node from the value prop. Text is used implicitly during child normalization (§ 6.5 Child Normalization).
|
Portal
| Symbol.for("crank.Portal")
| root?: object, children
| The framework renders children into the host node specified by the root prop.
|
Raw
| Symbol.for("crank.Raw")
| value: string | object
| The framework injects raw content (e.g. an HTML string or a host node) from the value prop.
|
Copy
| Symbol.for("crank.Copy")
| (none) | The framework preserves the previously rendered content at this position. |
13. Special Props
| Prop | Type | Behavior |
|---|---|---|
key
| any
| Used for reconciliation (§ 6.3 Key-Based Matching). Keyed children are matched by key, not position. |
ref
| (value: TResult) => unknown
| Called with the element value during the commit sequence (§ 8.3 Commit Sequence), before host nodes are inserted. Fires once on first commit, only for host elements; for component elements, ref is passed as a regular prop.
|
copy
| boolean | string
| true: the framework must preserve the entire subtree, skipping re-rendering. String: the framework must selectively copy props from the previous render using the prop selector syntax (see § 13.1 Prop Selector Syntax).
|
hydrate
| boolean | string
| true: the framework must adopt existing host nodes instead of creating new ones, patching all props onto the adopted node. String: the framework must adopt existing host nodes but treat props matched by the prop selector as quiet — they must not be patched, preserving the server-rendered values.
|
The Copy symbol may also be used as an individual prop value to skip patching that prop. When the renderer encounters Copy as a value, it substitutes the previous render’s value for that prop. The framework uses the name "Copy" at three levels: the Copy component (§ 12 Special Components) preserves an entire subtree at a given position; the copy prop (above) preserves all or selected props on a single host element; and Copy as a prop value preserves one individual prop. Each operates at a different granularity.
<div class={shouldPatch ? "new-class" : Copy} onclick={handler} />
Here, class retains its previous value when shouldPatch is false, while onclick is always re-patched.
The framework must strip all special props — and the children prop — before passing the remaining props to the renderer.
13.1. Prop Selector Syntax
The copy and hydrate props accept a prop selector string that specifies which props to include or exclude. The syntax is:
-
Tokens are separated by whitespace or commas.
-
A token without a prefix (e.g.
"class disabled") is inclusive: only the named props are selected. -
A token with a
!prefix (e.g."!class") is exclusive: all props except the named ones are selected. -
Mixing inclusive and exclusive tokens in a single selector must produce a runtime error.
For copy, the selected props are copied from the previous render’s values — unselected props are re-rendered normally. If "children" is among the selected props, child elements are also preserved. For hydrate, the selected props are quiet — the framework skips patching them onto the adopted node, preserving whatever the server rendered. Unselected props are patched normally so that event listeners and dynamic attributes are attached.
Selective copy — preserve class and disabled from the previous render, re-render everything else:
<div copy="class disabled" onclick={handler}>{children}</div>
Quiet hydration — adopt the server-rendered node but skip patching class and style, preserving server values while attaching event listeners:
<div hydrate="class style" onclick={handler}>{children}</div>
14. JSX
JSX is an XML-like syntax extension for JavaScript that provides a concise way to write element trees. A JSX compiler transforms <div class="foo">hello</div> into an Element constructor call. The framework supports two JSX compilation modes and a tagged template alternative.
14.1. JSX Import Source
The recommended approach uses the automatic JSX transform, where the compiler inserts element construction calls automatically — no import needed. Configure it with a file-level pragma comment:
/** @jsxImportSource @b9g/crank */
Or project-wide via tsconfig.json:
{ "compilerOptions" : { "jsx" : "react-jsx" , "jsxImportSource" : "@b9g/crank" } }
The framework provides jsx-runtime and jsx-dev-runtime modules at the @b9g/crank package root for use by JSX compilers. These modules export thin adapters that construct Element instances. Unlike React’s automatic transform, Crank’s adapter restores the key back into the props object (props.key = key). In Crank, key is not stripped by the JSX runtime — it passes through to createElement and is handled during reconciliation like any other special prop.
14.2. Classic createElement Transform
The classic transform calls createElement (§ 1 Elements), which normalizes children and constructs an Element. It requires an explicit import:
/** @jsxFactory createElement */
import {createElement} from "@b9g/crank";
Or project-wide via tsconfig.json:
{ "compilerOptions" : { "jsx" : "react" , "jsxFactory" : "createElement" } }
The automatic transform is preferred for new projects.
14.3. Template Tags
For environments without a JSX compiler, the jsx and html tagged template literals from @b9g/crank/jsx-tag produce elements using standard JavaScript. Both names refer to the same function. The @b9g/crank/standalone module re-exports these along with everything from the root @b9g/crank module and both built-in renderers (DOM and HTML), enabling single-file applications that run directly in browsers without a build step.
14.3.1. Tags
Opening tags use angle brackets. String tags are written literally; component and other expression tags must be interpolated:
jsx`<div />` // host element
jsx`<${Component} />` // component element
jsx`<${Fragment} />` // symbol element
Self-closing tags end with />. Non-self-closing tags require a closing tag, which supports three forms:
| Form | Syntax | Tag check |
|---|---|---|
| Symmetric | <${Component}></${Component}>
| must match opening tag; mismatch produces a SyntaxError.
|
| Comment-style | <${Component}><//Component>
| The text after // is not checked and serves as documentation.
|
| Shorthand | <${Component}><//>
| No check; closes the nearest open tag. |
14.3.2. Props
Props appear after the tag name, before > or />:
- Boolean prop
-
jsx`<button disabled>`— the prop value istrue. - String prop
-
jsx`<div class="foo">`orjsx`<div class='foo'>`— single or double quotes. JavaScript escape sequences (\n,\t,\xNN,\uNNNN,\u{N...}) are processed within quoted strings. - Expression prop
-
jsx`<div onclick=${handler}>`— the expression value is used directly. - Interpolated string prop
-
jsx`<div class="prefix ${value} suffix">`— expressions within a quoted string are coerced to strings and concatenated. - Spread prop
-
jsx`<div ...${obj}>`— the expression must be an object; its entries are merged into the props. Multiple spreads and named props are applied in source order.
14.3.3. Children
Between opening and closing tags, the template accepts:
-
Text — literal characters become string children.
-
Expressions —
${value}interpolations become children (elements, strings, arrays, etc.). -
Nested elements — child elements are parsed recursively.
-
Comments —
<!-- ... -->are ignored. Expressions within comments are discarded.
14.3.4. Whitespace
The template tag normalizes whitespace to match JSX conventions:
-
A newline and any surrounding whitespace is collapsed — text before a newline has trailing whitespace trimmed, and text after a newline has leading whitespace trimmed.
-
A backslash before a newline (
\at end of line) preserves the preceding whitespace and removes the backslash. -
Trailing whitespace at the end of the template is trimmed.
14.3.5. Caching
The template tag must cache parse results keyed by the raw template strings. On subsequent calls with the same template, the cached AST is reused and only the expression values are updated. This makes repeated renders efficient — the parsing cost is paid once per unique template site. The cache key is the JSON serialization of the raw strings array, so structurally identical templates share a cache entry even across different call sites.
15. Async Utilities
Note: This section is informative. It describes optional utilities exported from @b9g/crank/async that build on the core component model.
15.1. lazy()
lazy(initializer) creates a lazily-loaded component from an initializer function that returns a Promise resolving to a component or a module with a default export. The returned component is an async generator that awaits the initializer on first render, then delegates to the loaded component using for...of mode for all subsequent updates.
15.2. Suspense
Suspense is an async generator component that displays a fallback while its children are pending. It uses for await...of mode to yield successive element trees: first the fallback (with a configurable timeout defaulting to 300ms), then the resolved children.
| Prop | Type | Description |
|---|---|---|
children
| Children
| Content to display when loading is complete. |
fallback
| Children
| Content to display while children are pending. |
timeout
| number
| Milliseconds before showing fallback (default 300). Inherited from SuspenseList if nested.
|
15.3. SuspenseList
SuspenseList is a generator component that coordinates the reveal order of child Suspense components. It uses provisions to communicate with descendant Suspense components that register during the same render.
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<div>Loading A...</div>}>
<ComponentA />
</Suspense>
<Suspense fallback={<div>Loading B...</div>}>
<ComponentB />
</Suspense>
</SuspenseList>
| Prop | Type | Default | Description |
|---|---|---|---|
revealOrder
| "forwards" | "backwards" | "together"
| "forwards"
| "forwards": reveal in document order, waiting for predecessors. "backwards": reveal in reverse order, waiting for successors. "together": reveal all simultaneously when all are ready.
|
tail
| "collapsed" | "hidden"
| "collapsed"
| "collapsed": show fallback only for the next unresolved Suspense. "hidden": hide all fallbacks. Only applies when revealOrder is not "together".
|
timeout
| number
| (none) | Default timeout inherited by child Suspense components. |
children
| Children
| Elements containing Suspense components to coordinate. |