Crank Component Specification

Living Standard,

This version:
https://crank.js.org/spec
Issue Tracking:
GitHub
Editor:
Brian Kim (bikeshaving)

Abstract

This document specifies the component model for Crank.js: element creation, component types and their lifecycles, reconciliation, and the renderer interface. The core framework is renderer-agnostic; DOM-specific behavior is noted where applicable.

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:

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:

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:

(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
A generator component has three regions: setup (before the loop), render (inside the loop), and cleanup (after the loop). Local variables are state; 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...of or no loop)

The component must block while children render, identical to sync generators. yield evaluates to the settled rendered result directly, not a promise. This is the default behavior. When the component iterates its context with for...of (calling Symbol.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 calls next() on each update but does not deliver props via iteration.

for await...of mode (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. yield must evaluate to a yield promise — a Promise that resolves to the rendered result once children have settled. Child errors are delivered through the yield promise or injected via throw() 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 or refresh() 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 calls Symbol.asyncIterator on 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 isExecuting is true or isUnmounted is true, 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 schedule callbacks each return a promise, the framework must wait for all of them (as with Promise.all). On subsequent renders, promise return values must be ignored. When called with no arguments, returns a Promise<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 from after callbacks must be ignored. When called with no arguments, returns a Promise<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 schedule and after, 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 a Promise<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, or cleanup() callbacks. If cleanup() is called after the component has already been unmounted (isUnmounted is true), 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 provide with the same key.
dispatchEvent(event)
Dispatches an Event on the component context and invokes the matching on* prop on the component element, if present. See § 10 Events.
addEventListener / removeEventListener
EventTarget methods 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.

  1. The framework walks new children sequentially. For each child, it checks whether the old child at the current position has the same key.

  2. If keys match (or both are unkeyed), the framework advances both pointers.

  3. On the first key mismatch, the framework builds a map from keys to old children.

  4. For each subsequent keyed new child, the framework looks up the old child by key in the map.

  5. For each subsequent unkeyed new child, the framework skips over keyed old children to find the next unkeyed one.

  6. 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:

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.

  1. If no inflight execution exists, the framework must start one immediately.

  2. 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.

  3. 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:

  1. Set isExecuting to true.

  2. Call the component function (for function components) or iterator.next(previousResult) (for generators).

  3. Set isExecuting to false.

  4. Diff the yielded/returned children against the previous tree.

  5. Commit host mutations.

  6. Call ref callbacks and fire schedule callbacks (host nodes created, not yet inserted).

  7. Insert host nodes into the host environment.

  8. Fire after callbacks (host nodes live in environment).

8.2. On Unmount

The framework must execute the following steps in order:

  1. Fire cleanup callbacks (§ 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.

  2. For generators and async generators, the framework attempts a natural exit before forcing termination:

    • If the component is in a context iteration loop (for...of or for await...of), the framework calls next() 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...of mode, the framework continues calling next() 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(). The finally block executes as part of the iterator protocol.

  3. Unmount children recursively.

8.3. Commit Sequence

After each diff, the framework runs the commit sequence:

  1. Commit host mutations (create or patch host nodes, recursively commit children).

  2. Call ref callbacks (§ 13 Special Props) and fire schedule callbacks (§ 4.2 Methods). Host nodes exist but are not yet inserted.

  3. Insert host nodes into the host environment.

  4. Fire after callbacks (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:

  1. Capturing phase — the event walks from the root context down to the target, invoking listeners registered with {capture: true}.

  2. 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 — calling this.dispatchEvent(new Event("change")) triggers the parent’s onchange prop callback.

  3. Bubbling phase — if event.bubbles is true, 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

11.2. React Compatibility

The DOM renderer accepts React-style prop names and normalizes them:

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:

<div class={{"active": isActive, "disabled": isDisabled}} />

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:

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 is true.

String prop

jsx`<div class="foo">` or jsx`<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:

14.3.4. Whitespace

The template tag normalizes whitespace to match JSX conventions:

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.

const MyComponent = lazy(() => import("./MyComponent.js"));

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.

<Suspense fallback={<div>Loading...</div>}>
  <AsyncComponent />
</Suspense>
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.

Conformance

Conformance requirements are expressed with a combination of descriptive assertions and RFC 2119 terminology. The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in the normative parts of this document are to be interpreted as described in RFC 2119. However, for readability, these words do not appear in all uppercase letters in this specification.

All of the text of this specification is normative except sections explicitly marked as non-normative, examples, and notes. [RFC2119]

Examples in this specification are introduced with the words “for example” or are set apart from the normative text with class="example", like this:

This is an example of an informative example.

Informative notes begin with the word “Note” and are set apart from the normative text with class="note", like this:

Note, this is an informative note.

Index

Terms defined by this specification

References

Normative References

[RFC2119]
S. Bradner. Key words for use in RFCs to Indicate Requirement Levels. March 1997. Best Current Practice. URL: https://datatracker.ietf.org/doc/html/rfc2119