Differences from React

September 19, 2026 · View on GitHub

Octane implements React's programming model — the same hooks, memo, context, portals, Suspense, transitions, actions, and SSR/streaming APIs. Its core suite contains 3,900+ distinct behavioral tests; production-compiler executions rerun the normal cases and are not additional unique coverage. That is a local suite count, not a count of tests ported from React. The exact pinned snapshot, source-attributed React scenarios, classifications, and coverage are tracked in the generated React parity coverage report.

The differences below are deliberate; parity outside them is the goal. Examples omit routine imports and types unless they affect the behavior being shown.

No rules of hooks (except plain JS loops)

Hooks may sit behind a condition or after an early return:

// React: hook order cannot change after a conditional return.
// Octane: every hook call site owns its own stable slot.
function Editor({ editable, initialValue }) {
  if (!editable) return <ReadOnly />;

  const [draft, setDraft] = useState(initialValue);
  return (
    <input
      value={draft}
      onInput={(event) => setDraft(event.currentTarget.value)}
    />
  );
}

This is valid in Octane because the compiler assigns each hook call site a stable slot; hooks are not identified by call order.

A plain JavaScript loop is the exception:

for (const item of items) {
  useState(false); // Compile error: every iteration would share one slot.
}

@for (const item of items; key item.id) {
  const [open, setOpen] = useState(false); // Separate state for each key.
  <Row item={item} open={open} onToggle={() => setOpen(!open)} />
}

Use a keyed @for template directive or extract a child component so every item has its own render scope. use() and useContext are exempt from the loop restriction because they are keyed by call order and context identity, respectively.

Compiler-inferred hook dependencies

Dependency arrays are optional for useEffect, useLayoutEffect, useInsertionEffect, useMemo, useCallback, and useImperativeHandle:

import { save } from './api';

useEffect(() => save(order.id)); // Inferred: [order.id]
useEffect(() => save(order.id), [order]); // Exactly [order]
useEffect(() => save(order.id), []); // Exactly []; never rewritten
useEffect(() => save(order.id), null); // Run after every render

useEffect(makeEffect()); // Compile error: pass an array or null

Omitting the argument asks the compiler to derive it from the callback's lexical captures. An explicit array is authoritative and keeps React's exact behavior; null opts out of tracking. Opaque callback creation such as useEffect(makeEffect()) needs an explicit array or null, because evaluating it again to discover dependencies would change program behavior.

Direct built-in hook calls

Inference applies wherever Octane processes a supported built-in hook call, including inside a custom hook authored in .tsrx, .tsx, or plain .ts/.js. For example, a custom hook in plain TypeScript can omit both dependency arrays:

import { useEffect, useMemo } from 'octane';

export function useLoggedValue(value: string, log: (value: string) => void) {
  const formatted = useMemo(() => value.toUpperCase());

  useEffect(() => {
    log(formatted);
  });

  return formatted;
}

The memo tracks value; the effect tracks formatted and log. The custom hook's caller supplies no dependency array.

The analysis tracks one-level member paths and distinguishes values that can change between renders:

CaptureInferred behavior
Other component-local values and module-scope let/varTracked
State setters, reducer dispatchers, refs, and state gettersOmitted as stable
useEffectEvent resultsOmitted because Effect Events are non-reactive
Imports and unreassigned module-scope const/function/classOmitted as program-lifetime identities
A local const naming one of those stable values, or a literalOmitted

A member read through a stable module binding, such as CONFIG.mode, is also omitted. Mutating such an object in place is therefore not witnessed by a dependency array; state that should drive rendering belongs in state, context, or a store rather than a module singleton.

Member reads inside a conditional branch, after a possible early exit, or protected by exception handling preserve that protection. For one-level reads, the inferred array inspects an own property descriptor: a data property tracks its value, while an accessor or inherited property tracks its receiver without invoking a getter. An absent property tracks undefined. Failed reflection probes track the receiver, leaving the authored callback responsible for the read and its exception handling. Thus a guarded props.onChange(...) still tracks a stable own callback when the props container changes, while method getters stay behind their authored guard. Null and undefined receivers use separate module-local markers, so a failed receiver read does not compare equal to a successful own-data read of null or undefined. These markers are created once per module, rather than once per probe. These guarded probes allocate a property descriptor; ordinary unguarded one-level method calls retain the allocation-free comparison below.

A one-level method call tracks the value that can change between renders. The compiled array selects that value on each render, based on where the method lives:

  • An own function property tracks itself: props.onChange(...) tracks props.onChange.
  • An inherited method tracks its receiver: count.toFixed(2) tracks count, because Number.prototype.toFixed is one function for every number.
  • An absent handler in an optional call tracks a stable undefined: props.onReady?.() does not re-run its hook until a handler is passed.

Deeper calls such as cart.items.push(x) track their receiver path (cart.items), unchanged.

Some closures declare that their body runs in another context, not during render. A directive from a known list marks them: 'use gpu' (TypeGPU shader code) and 'worklet' (Reanimated UI-thread code). Inside such a closure, the inferred array tracks only the root variables it captures and reads none of their properties:

const pipeline = useMemo(() =>
  root.createRenderPipeline({
    fragment: () => {
      'use gpu';
      return timeUniform.$; // legal only in shader code — never read at render
    },
  }),
); // Inferred: [root, timeUniform]

The list is deliberate. Same-context hints ('use strict', React Compiler's 'use memo'/'use no memo') and markers with their own semantics ('use server', 'use cache') do not truncate.

Calls to custom wrappers

Inferring a missing dependency argument at a call to a custom wrapper is a separate, narrower operation:

function useTrackedEffect(effect, dependencies) {
  useEffect(effect, dependencies);
}

useTrackedEffect(() => save(order.id)); // Local transparent wrapper: inferred.
importedTrackedEffect(() => save(order.id), [order.id]); // Explicit array.
Wrapper callDependency argument
Local transparent wrapper in .tsrx/.tsxInferred
Nested transparent wrappers in the same moduleInferred
Wrapper call in plain .ts/.jsRequired explicitly
Imported or method-style wrapperRequired explicitly
Wrapper that inspects or transforms its callback/dependenciesRequired explicitly

A transparent wrapper forwards its callback and final dependency parameter to a supported built-in hook. The compiler does not infer that contract from a use* name alone. Plain .ts/.js compilation still infers direct built-in hook calls inside a custom hook; it only declines to modify calls to wrappers.

Dependency values as callback arguments

Octane passes a hook's dependency values as positional arguments to useMemo factories and effect callbacks. This extension also applies to an explicitly supplied dependency array; callbacks written for React usually ignore these arguments. A callback with default parameters can observe the difference. Client and server useMemo use the same convention.

Automatic memoization and calls in templates

Production builds automatically memoize component regions. The default compatibility mode is conservative about calls whose receivers can hide mutable state:

{formatPrice(cents)} // May memoize: formatPrice is imported.
{formatLabel(row)} // May memoize: same-module immutable projection.

{row.getValue()} // Re-runs: member call.
{format(row.get())} // Re-runs: an argument contains a member call.
{localFormat(row)} // Re-runs: component-local callee.
{use(resource)} // Re-runs: hook and suspension point.

A call keeps its surrounding region memoizable only when the callee is an imported binding or an unreassigned same-module function whose body is itself a value projection. Arguments must satisfy the same rule.

In compatibility mode, member calls fail closed because the receiver may be a live object: header.getIsSorted() can return a new answer while header retains the same identity. A module helper that merely wraps that method has the same hazard and does not qualify. Component-local callees, hooks (including unstable_use*), new Foo(), and tagged templates also keep their region unmemoized. This changes only the optimization—the region safely re-runs.

A region is allowed to memoize past a mutable module-level variable, whether read directly or returned by an imported helper; module state that must drive rendering belongs in state or context. Octane cannot read across a module boundary, so an imported helper is taken at its word — that is the one place this analysis trusts rather than proves, and it matches React Compiler's own assumption.

This preserves ordinary React rendering for live receivers; it is not a promise to reproduce every React Compiler optimization. React Compiler also identifies APIs with interior mutability, including TanStack Table v8, as incompatible with memoization. Stable function or object identity alone does not prove a result is unchanged. React Compiler lint classifications and option-sensitive output are useful comparison evidence, not Octane configuration: changing JSX outlining, adding a debug hook, or using React's "use memo" directives does not alter Octane's compatibility/Strong boundary.

Strong mode and render calls

A module that opts into "use strong" asserts a stricter contract: rendering is a referentially transparent projection of props, state, context, and immutable snapshots. Every user-authored call evaluated for render output must return the same value for the same witnessed inputs and must not perform application-visible work. That assertion covers local and imported functions, static and computed methods, call-produced callees, constructors, tagged templates, and synchronously invoked callbacks.

Production client builds can therefore condition eligible regions on their witnessed inputs without using use* spelling as a purity oracle. An ordinary function named useFormat is no less memoizable than format; Strong does not reintroduce React's Rules of Hooks through a naming heuristic. Actual hooks keep their compiler-owned setup semantics. The compiler recognizes built-in hooks by import provenance, including optional calls, and resolves same-module custom-hook declarations and function-valued module bindings by lexical binding. Transitive and cyclic paths reach a fixed point, while reassigned module bindings stay on the conservative setup path. Their context subscriptions, state cells, suspension points, and effect lifecycles remain outside ordinary projection caches.

For an eligible operation, the cache guard witnesses the callable and its receiver as well as explicit arguments. A derived receiver such as factory().read(input) is represented by the factory, its receiver, and its arguments rather than by a transient returned-object identity. Witnesses use Object.is equality at component and ordinary-list projection boundaries, so repeated NaN is stable while 0 and -0 invalidate separately. A certified keyed-selection operand retains the authored strict-equality semantics while other list captures still use Object.is. Hiding hook state, a state getter, ref.current, a clock or random value, mutable module state, or an external live store behind a render call violates the assertion because the result can change without a witnessed input changing.

Compiler diagnostics catch a useful subset of violations, but they are not a whole-program proof and unknown call shapes do not fall back to compatibility behavior. Calling imported code from a Strong module asserts that the particular render use is snapshot-safe; it does not make the library's live objects immutable. Keep a consumer of live accessors in compatibility mode, or read an actual reactive snapshot and pass it into a separate Strong component.

Compatibility keeps a member call live only while the render scope containing that call actually executes. It does not create a subscription and cannot make a stable live object safe across memo, an unchanged child boundary, or Strong memoization. The supported migration is to subscribe and select in compatibility code, then cross the Strong boundary with the selected value:

// SelectionBridge.tsrx — compatibility mode
function SelectionBridge({ table, row }) @{
  <table.Subscribe
    source={table.atoms.rowSelection}
    selector={(selection) => !!selection[row.id]}
  >
    {(selected) => <StrongSelectionRow label={row.original.name} selected={selected} />}
  </table.Subscribe>
}
// StrongSelectionRow.tsrx
"use strong";

function StrongSelectionRow({ label, selected }) @{
  <li data-selected={selected ? '1' : '0'}>{label}</li>
}

Passing only row, shallow-copying an object that still contains its live methods, or forcing an unrelated render without selecting selected does not create a snapshot witness.

Keyed rows and logging

A key preserves a surviving row's DOM identity; it does not promise that its JavaScript body runs only once. In Strong production builds, diagnostic calls such as console.log('row', item.id) do not disqualify an otherwise eligible row from reuse. Do not rely on logging, metrics, mutation, allocation, or any other render-time call count: evaluation can differ in production, development, HMR, profiling, server rendering, hydration, retries, and aborted work.

A changed captured value still invalidates a row, including captures inside its event handlers:

onClick={() => setItems(items.filter((entry) => entry.id !== item.id))}

Appending changes items, so surviving rows must receive a handler for the new snapshot. Skipping that update could make removing an original row also discard the appended item. If removal should use the latest state, a functional update does not capture the parent array:

onClick={() => setItems((current) => current.filter((entry) => entry.id !== item.id))}

The first form remains correct and supported. Strong mode does not change its closure semantics or promise to skip its reevaluation.

Local mutation and retained rows

Mutation of fresh render-local data is supported when it finishes before that data escapes. A plain JavaScript loop has normal sequential JavaScript semantics:

function Labels({ items }) @{
  const labels = [];
  for (const item of items) labels.push(item.label);
  <p>{labels.join(', ')}</p>
}

A keyed @for body is different: every surviving key owns a retained render scope that may be evaluated independently. Writing a binding declared outside the row would make its output depend on which other rows happened to run and in what order. Strong reports that shape as OCTANE_STRONG_RETAINED_ROW_MUTATION:

let position = 0;
@for (const item of items; key item.id) {
  position++; // Strong compile error: shared across retained rows.
  <li>{String(position)}</li>
}

Use setup to build a complete value before it escapes, keep mutable scratch data inside one row, or use the directive's index binding when position is the desired input:

@for (const item of items; index position; key item.id) {
  <li>{String(position + 1)}</li>
}

Compatibility mode accepts a cross-row write but does not promise a retained row evaluation order, so it must not determine rendered output there either.

Derived values are cached at their declaration

const labels = formatRows(rows); // Cached on [rows]: imported projection.
const items = virtualizer.getVirtualItems(); // Not cached: member call.
const visible = todos.filter((todo) => !todo.completed); // Cached only for proven state snapshots.
let freshLabels = formatRows(rows); // Not cached: `let` is an escape hatch.

An eligible const keeps the same identity until its tracked component-local inputs change. This lets a region key on the identity of a derived value instead of seeing a new array or object on every render.

The same callee rule governs declaration caching. In compatibility mode, the virtualizer call must stay live because its window can move while the virtualizer object keeps the same identity. Most member calls, including arbitrary items.filter(...) calls, therefore remain uncached.

The production .tsrx compiler admits one narrow exception: a native filter projection over a state value created by a genuine useState([]) call when every setter use remains private and produces a fresh, provably ordinary array snapshot. The predicate must only inspect an own data property, and a runtime guard verifies the array's dense entries, item properties, native methods, constructor, and species before reusing an unchanged snapshot. Unknown aliases, mutable receivers, getters, proxies, sparse arrays, overridden methods, custom species, and unproven predicates retain the existing live path. Use an explicit useMemo when an otherwise unsupported identity needs caching.

Within the same proven component, a single-token class object driven by primitive state can reuse its existing per-binding change guard. Controlled value and checked bindings still reassert their values on every commit.

Also never cached in compatibility mode:

  • Hook-shaped calls. const s = useThing() and const s = unstable_useThing() conservatively keep their setup live. Strong instead recognizes actual built-in hooks by import provenance and same-module function or function-valued custom-hook setup by lexical call-graph analysis; an unrelated useFormat() remains an ordinary pure-call assertion.
  • Values the render tree never reads. A calculation used only by an event handler pays nothing.

Within those bounds the contract is pure render: the cached value is reused while its tracked inputs are unchanged, so a projection that reads state no input witnesses keeps its old value. Pass such state through useState/useReducer/context and it is witnessed normally.

useState / useReducer current-state getters

Both state hooks have an Octane-only third tuple member: a stable getState function that reads the latest scheduled state.

const [draft, setDraft, getDraft] = useState('');
const [total, dispatch, getTotal] = useReducer(reducer, 0);

async function saveLater() {
  await readyToSave();
  save(getDraft()); // Latest scheduled state, not this render's stale value.
}

function add(amount) {
  dispatch(amount);
  console.log(getTotal()); // Latest scheduled reducer state.
}

getState is the conventional generic name for this stable zero-argument getter; the example uses domain-specific names instead. It replaces the common React pattern of synchronizing a ref solely for delayed or async callbacks.

The getter reads the latest scheduled hook-cell value and does not subscribe or render. During pending work it can therefore be newer than the currently committed DOM. The compiler emits a getter-enabled hook only when tuple index 2 can be observed, preserving the existing runtime path and allocation profile for ordinary two-item destructuring. Escaped or ambiguous tuples conservatively receive the complete three-item shape.

Linked state updates without a render-time setter

React sometimes keeps local state in sync with a changing prop by calling a setter during render:

const [previousUserId, setPreviousUserId] = useState(user.id);
const [name, setName] = useState(user.name);

if (previousUserId !== user.id) {
  setPreviousUserId(user.id);
  setName(user.name);
}

Octane's useLinkedState expresses the same intent directly:

const [name, setName] = useLinkedState(user.id, () => user.name);

The value remains editable with setName. When user.id changes, the hook returns the new name immediately, without an effect, a user setter during render, or a second render attempt. The calculation receives the previous { source, value } when a source changes, or undefined on the first render, so it can preserve useful parts of the old value. sourceEqual and valueEqual default to Object.is; pass custom comparators as an optional third argument. The tuple also supports the same optional latest-value getter as useState.

Optional Strong mode

Strong modules also require inferred dependencies, compiler-owned memoization, keyed template lists in .tsrx, and branded HTML values. Standard keyed JSX mapping remains supported in .tsx. See the Strong compiler checks and migration table.

Strong mode opts into the immutable render-snapshot contract above and adds compile-time checks for detectable violations. Opt into one module with a directive before its imports:

"use strong";

import { useLinkedState } from 'octane';

Alternatively, enable it across application-owned modules with compiler: { strong: true } in octane.config.ts. Installed dependencies stay in compatibility mode unless their own source opts in.

A Strong module cannot call a state updater during render or synchronously while setting up an effect, and it cannot read or assign to a useRef object's current during render (OCTANE_STRONG_RENDER_REF_READ for reads). It also rejects calling a known third-tuple state getter during render (OCTANE_STRONG_RENDER_STATE_GETTER_CALL): the getter can observe scheduled state that differs from the render snapshot. Read the state tuple's first member when rendering; call the getter in events, effects, or deferred work. Reading a reassigned module-scope let or var during render is also an error (OCTANE_STRONG_RENDER_MODULE_STATE_READ): move changing values into state or context, or pass an immutable snapshot as a prop. The checks follow provable synchronous calls through local callbacks and useEffectEvent. Calling a statically known Effect Event during render or including it in an explicit hook dependency list is also a compile error. Strong modules use normal const declarations for automatic memoization. Manual memo hooks and non-equivalent explicit dependencies are errors; equivalent arrays keep their behavior and produce a hint.

The compiler also rejects render-time writes through a provable state snapshot (OCTANE_STRONG_RENDER_SNAPSHOT_MUTATION) and direct calls to known non-idempotent globals such as Date.now() and Math.random() (OCTANE_STRONG_RENDER_IMPURE_CALL). These checks follow supported aliases and synchronous helpers; they do not prove arbitrary method bodies or imported code pure. Lazy state initialization may obtain an initial timestamp or random value.

Reading unshadowed window, document, localStorage, sessionStorage, navigator, location, or matchMedia during render reports OCTANE_STRONG_RENDER_AMBIENT_READ. This includes typeof guards and constant aliases of those browser handles. Reading a globalThis property also reports the error, including computed properties whose names are unknown, except for known standard language builtins such as globalThis.JSON. Reading the globalThis object itself remains supported. Shadowed local names and scalar snapshots captured at module initialization are not browser-handle reads. The analysis does not generally prove aliases obtained through defaults, returned values, containers, arbitrary deeper properties, or imports. Those values still have to satisfy the render-snapshot contract.

For changing browser state, use useSyncExternalStore with a server snapshot and render its returned snapshot. Browser reads in its snapshot callbacks, events, effects, and deferred callbacks remain supported. Lazy useState and useReducer initializers may also read browser state for an initial value. They still run during server rendering: guard unavailable browser APIs and ensure the server and client agree on initial output. A typeof window guard inside an ordinary render calculation does not make the calculation snapshot-safe.

The directive is also an author assertion for production memoization, not just a request for diagnostics. Render output must not observe changing data through a stable ref, state getter, module variable, external store object, or hidden hook. All user-authored render calls and synchronously invoked callbacks must be pure for their witnessed inputs. Function names—including use* names—do not change that contract or disable the optimization.

Strong mode also checks where compiler-managed work belongs. A locally declared built-in hook value used only inside one nested @{…} block belongs beside that block's JSX, as does an effect observing only its local values. An outer declaration reports OCTANE_STRONG_HOOK_LOCALITY and names the block and source line, including any local helpers that must move with the value. Hooks and effects used only in an @if, keyed @for, @switch, or @try arm can remain in the parent scope, preserving their parent lifetime. A hook shared by sibling template blocks stays in their common scope. Moving a hook into a nested @{…} block gives it that block's lifetime, even when the previous block contained only JSX and served as transparent grouping.

A local callback used by a native onX event can be inline or declared beside its JSX, including <button {onClick} />. A named callback declared outside the sole deeper nested @{…} block containing its direct event use reports OCTANE_STRONG_EVENT_HANDLER_LOCALITY; a callback declared in the same scope as the JSX is valid. Shared, imported, and forwarded callbacks remain supported. Locality diagnostics use authored source locations and preserve declaration positions in client, server, and editor compilation. Strong also adds eligible hook-input caches in development and production, so opting in can change generated code. The eligibility rules describe which declarations keep a stable identity. Setting compiler: { strong: true } applies these checks and caches across application-owned modules; installed dependencies opt in separately.

Event handlers, genuinely deferred callbacks, effect cleanup, effects that synchronize an external system, and normal DOM or timer refs remain supported. Replace prop-driven state resets with useLinkedState instead of calling a setter during render.

JSX values follow the represented render scope

Moving compiler-authored JSX into a variable, prop, array, or other value position does not move its represented context provider, Suspense boundary, or error boundary:

const Theme = createContext('outer');
const theme = {
  get current() {
    return use(Theme);
  },
};

function Page() {
  const content = (
    <Theme value="inner">
      <span data-theme={theme.current}>{theme.current}</span>
    </Theme>
  );

  return <main>{content}</main>; // data-theme="inner" and text "inner".
}

When Octane renders that stored subtree, the entire compiler-authored element record, including a dynamic root type, its props, and descendant expressions, resolves after its represented provider or boundary has entered its scope. This includes implicit user code in getters, Proxy traps, coercion hooks, iterators, and computed keys, not only explicit function calls. React evaluates JSX expressions while constructing the caller's element, before that element's represented provider or boundary renders; Octane intentionally follows the visible rendered tree instead.

JSX values remain inspectable element descriptors: isValidElement, Children.only, cloneElement, and ordinary type/props/children inspection keep their existing contracts. Inspecting a deferred element record as data resolves it in the inspecting caller's current scope; it does not enter a provider or boundary that has not rendered. Static JSX, explicit createElement(...) calls, and event or render-prop callbacks retain ordinary JavaScript evaluation semantics.

Template children and inspection

Children authored inside an @{ ... } template normally arrive at a component as a compiled render body. Rendering {children} works, but that body is not an element descriptor: Children.count, Children.map, Children.toArray, Children.only, and cloneElement cannot inspect its contents. JSX returned from an ordinary function and explicit createElement values remain inspectable.

Mark components that inspect or clone their children with the public descriptorChildren compiler marker:

import { Children, cloneElement, descriptorChildren } from 'octane';

const Slot = descriptorChildren(function Slot({ children }) {
  return cloneElement(Children.only(children), { 'data-slot': true });
});

function Example() @{
  <Slot><button>Save</button></Slot>
}

The marker leaves the component's runtime identity unchanged and requests descriptor children at compiled call sites. The bundler follows marked exports through imports; a standalone compiler caller can mark a local binding explicitly. For a function-as-child API that also accepts ordinary template children, use typeof children === 'function' && !isChildrenBlock(children) before invoking a render prop. A compiled children body must be rendered through Octane.

Template branch identity

Each @if/@else, @switch arm, and template-lowered conditional arm owns a separate render scope. Switching arms remounts their contents even when both arms contain the same component or host tag. Uncontrolled values and child state therefore reset. To preserve identity, keep one component outside the branch and make its props conditional, such as <Row value={active ? a : b} />. Ordinary returned element descriptors follow their type and key identity instead.

Raw script and scoped style bodies

In .tsrx, the body of <script> is JavaScript source and the body of <style> is scoped CSS source. They are not JSX expression containers. <script>{JSON.stringify(data)}</script> therefore writes those literal source characters, and <style>{css}</style> is not valid scoped CSS syntax. Use a prop for dynamic content:

<script type="application/ld+json" children={JSON.stringify(data)} />
<style children={css} />
<style dangerouslySetInnerHTML={{ __html: css }} />

A style element with explicit content props is an ordinary host element. A literal CSS body retains Octane's scoped-style compilation. These forms also work through createElement for runtime-created content.

Native event objects, no synthetic event layer

The public type names follow that contract too. MouseEvent<T>, MouseEventHandler<T>, InputEvent<T>, and the other event families describe native DOM events with a typed currentTarget; they have no nativeEvent, persist, or synthetic propagation methods. HTMLAttributes, the specialized host attribute types, CSSProperties, Ref, FC, and ComponentProps can be imported from octane. ReactNode and ReactElement are migration aliases for Octane values; prefer OctaneNode and ElementDescriptor in new code. These aliases do not make Octane elements renderable by a React root.

Delegated capture and bubbling are scoped to each root's portion of the event path. Native listeners between nested roots run before the next root's handlers, and can prevent the event from reaching that root. Shadow roots and distributed slots use the browser's composed path without replacing its retargeted target or relatedTarget. Portals still propagate through their logical ancestors. A portal rendered into a closed shadow root physically inside its own outer root is a known interop limit: the outer listener cannot see the hidden portal boundary, so shared outer ancestors can receive both deliveries. React also exhibits duplicate delivery in this topology. Native non-bubbling families (toggle, dialog close/cancel, media, load/error) emulate logical bubbling from a root capture listener; their interleaving with direct native listeners is not identical to React's event plugins.

Within a delegated phase, propagation cancellation follows React's separation between its framework queue and native dispatch, while keeping a native event:

  • event.stopPropagation() in an Octane handler stops the remaining handlers in that logical phase and calls the browser's native method.
  • An earlier external listener on the same root calling native stopPropagation() does not truncate Octane's handler queue. An external listener below the root can still prevent that queue from receiving the event.
  • event.stopImmediatePropagation() stops later native listeners on the same node, but does not truncate the logical phase already running. This corresponds to React's event.nativeEvent.stopImmediatePropagation(). Call both stop methods to stop both queues. Neither method cancels the default action; use preventDefault() for that.

The native cancelBubble flag remains visible; it is not cleared to continue a logical queue. Calling a native prototype method directly or setting that flag operates on native propagation, not the separate framework queue. An immutable, non-configurable own stop method also remains native-only: Octane does not replace it or change the identity of the event.

What differs is the event API and synthesis layer:

<button
  onClick={(event) => {
    console.log(event instanceof MouseEvent); // true
  }}
/>
  • Handlers receive the browser's real Event object, not a React SyntheticEvent wrapper. There is no event pooling, and event.currentTarget is the handler's element.
  • mouseenter/pointerenter families are the real per-element native events — no synthesis from over/out.
  • onFocus/onBlur use the browser's bubbling focusin/focusout events, including capture variants; the event object retains that native type.
  • There are no synthetic onChange/onBeforeInput/onSelect polyfills — use the native events (onInput etc.).
  • Root listeners are non-passive. preventDefault() in onWheel or onTouchMove can cancel scrolling when the native event is cancelable.
  • Native keypress events are delivered even when their charCode is zero; Octane does not apply React's printable-character filter.
  • Non-bubbling event families are available on any host, rather than only React's selected host types. A spread that introduces a previously unseen event installs its delegated listener lazily, after native container listeners that were registered earlier.

A noop onclick is stamped on delegation roots for iOS Safari, not on every element.

Controlled components, native events

Controlled value/checked on <input>/<textarea>/<select> match React (2026-07-08): the prop drives the DOM property and reasserts on every commit and after discrete events (rejected edits snap back), IME composition is respected, radio groups restore as a group, <select value> projects options (single + multiple), and defaultValue/defaultChecked are the uncontrolled escape hatch. Hydration adopts pre-hydration user input, then the first commit/discrete event reasserts. <textarea> with children AND a value/defaultValue prop is a compile error (the prop owns the content).

Uncontrolled defaultValue updates change an input or textarea's reset baseline without replacing its live value. A select uses defaultValue on mount and when multiple changes. A textarea authored with children instead has a live text binding; use defaultValue when later renders must preserve user edits. Removing a controlled textarea's value retains its current content.

A function form action has no action attribute while intercepted. Octane does not serialize React's JavaScript-URL sentinel. onSubmit and ancestor handlers run before the action and may cancel it with preventDefault().

What differs is the event layer: there is no synthetic onChange. onInput is the per-keystroke handler for text controls (the native change event fires when the browser commits the edit, usually on blur); checkboxes/radios/selects retain their normal native change behavior. Migration of React-style text editing is a rename:

<input value={text} onChange={(e) => setText(e.target.value)} /> // React
<input value={text} onInput={(e) => setText(e.target.value)} /> // Octane

OCTANE_NATIVE_TEXT_ONCHANGE warns when a statically known text-entry host has onChange/onChangeCapture but no usable input handler. It covers <textarea> and <input> with a missing or invalid type, or a text, search, url, tel, password, email, or number type. A development fallback handles unresolved spreads, dynamic host/type values, and de-optimized createElement calls. It is nonfatal and never changes which event runs. Selects, checkboxes, radios, file inputs, custom elements, and component callbacks named onChange are not warned. Capture handlers receive the corresponding onInputCapture guidance.

Native commit behavior is sometimes exactly the intent. Use an uncontrolled value and the explicit JS-only suppression in that case:

<input
  defaultValue={savedDraft}
  onChange={(event) => save(event.currentTarget.value)}
  suppressNativeChangeWarning
/>

suppressNativeChangeWarning only suppresses this diagnostic. It does not serialize to HTML, rename an event, add a listener, or alter controlled-state restoration. Do not add a noop onInput merely to silence the warning.

Checkbox/radio activation follows the platform's clickinput → non-cancelable change sequence. preventDefault() in native onChange therefore cannot roll the toggle back:

<input type="checkbox" onClick={(event) => event.preventDefault()} />

Cancel in onClick when rollback is intended. React's synthetic checkable onChange is click-backed and can cancel activation at that callback, an intentional event-layer divergence. Octane still lets native input/change handlers observe the prospective checked state before restoring rejected controlled state and radio cousins.

Form actions (<form action={fn}>, useActionState, useFormStatus, useOptimistic, requestFormReset, auto-reset) match React 19; an action error does not cancel queued dispatches (octane keeps threading).

Attributes: native names, React's value rules

Attribute values follow React (matched 2026-07-08):

<div hidden={1} /> // hidden=""
<div hidden={0} /> // attribute omitted
<div title={true} /> // omitted with a development warning
<div spellcheck={false} /> // spellcheck="false"

This includes boolean and overloaded-boolean normalization, property writes for muted/multiple/selected, commit-phase autoFocus, aria-* stringification, empty src/href removal (except <a>), function/symbol removal, dangerouslySetInnerHTML validation, and canonical camelCase aliases such as strokeWidth, xlinkHref, className, and htmlFor.

What still differs: attribute names pass through natively — native spellings are the idiom and simply work:

<form accept-charset="utf-8" />

Diagnostic coverage is expanding progressively from the latest upstream behavior without shipping React's complete possibleStandardNames table as runtime data. Today, a curated slice of genuinely-broken casings warns in development (autofocusautoFocus, defaultvaluedefaultValue, defaultcheckeddefaultChecked, lowercase on* function props → camelCase).

Other current differences:

  • Odd objects coerce leniently via toString() (with a development [object Object] warning) instead of throwing.
  • Octane retains <area href=""> as a current-document hyperlink; React strips it.
  • The browser parser canonicalizes a statically authored lowercase SVG textlength instead of following React's imperative warning path.
  • style accepts a CSS string as well as an object, including !important and kebab-case property names. Object updates own only their authored properties; a string owns the complete inline declaration. Numeric object values receive units from the shared property table; scale is unitless and cssFloat aliases float.
  • Child boundaries can leave empty comment anchors inside an option; its visible text and selected value are the supported observations.

Development diagnostics and production errors

Octane progressively ports applicable development warnings and errors from the latest upstream React/ReactDOM source. For a diagnostic classified as exact parity, its tests cover observable details such as the trigger, console channel or thrown constructor, message variants, dedupe lifetime, recovery, and component/source context. Current coverage is intentionally partial: the latest-main diagnostic inventory records implemented, pending, adapted, divergent, and unsupported families. Diagnostics are adapted when an intentional Octane difference changes the useful guidance; React-only APIs remain outside the supported surface.

An unknown event prop on an ordinary host can name a real native event instead of being discarded. Template @for accepts iterable inputs, including maps and generators, and supports inferred keys; React's missing-key and unsupported iterator warnings do not describe those directives. Symbol renderables are stringified as text. Error messages use Octane's vocabulary, and caught-error logging and owner-stack context do not promise React's console transcript.

Development builds retain complete messages. Framework-authored errors in the core DOM client and server runtimes that must still throw in production use an Octane-owned error-code catalog and compact links to https://octanejs.dev/errors/<code>. Octane's numbers are unrelated to React's and are append-only so an already-deployed bundle continues to decode correctly. Compiler diagnostics keep their symbolic OCTANE_* codes, and user-thrown or transported error messages are never rewritten as framework errors.

class/className compose clsx-style

<div class={['button', { active: selected }, ['compact']]} />
// selected=true → class="button active compact"

<div className={['a', 'b']} />
// React: class="a,b"
// Octane: class="a b"

Strings, numbers, arrays, objects, and nesting compose at every client and SSR apply site with byte-identical results. A nullish or false result removes the attribute; an empty string writes class="".

Scoped <style>, $class, and apply

React has no styling primitive; Octane's .tsrx dialect has sibling-scoped <style> blocks (RFC tsrx-org/RFCs#1), covered in tsrx-basics.md: a block styles its siblings and everything below them, and sibling blocks share one hash. The contract in brief:

  • A block is a child of an element or a fragment and styles the items beside it and everything below them; it never styles the element that contains it. Every children list holding a block is a scope with its own hash; blocks in one list share it and compile to one injectStyle(hash, css). To style an element, make the block and the element fragment siblings. A @{ … } or directive body holds one output node, so a block beside it is the parser's multiple-outputs error; wrap both in a fragment.
  • Raw CSS in <style> is TSRX template syntax, allowed only inside a @{ … } or @if/@for/@switch/@try body. Plain TSX keeps React's rule: <style>{css}</style> is an ordinary element, passed through untouched.
  • Elements carry authored classes, enclosing scope hashes (outer → inner), applied theme classes, composed through the clsx rules above, so a class={[…]} value and the stamped hashes serialize identically on the client and the server.
  • const theme = <style>…</style> is a class map: { $class: '<hash>', dark: '<hash> dark', … }. <style apply={theme} /> stamps theme.$class on a scope; apply={[a, b]} composes. Same-module targets inline as literals (static templates stay hoisted); imported targets are runtime theme.$class reads.
  • CSS emits in lexical pre-order. On the client injectStyle is a module-level statement (import order orders sheets across modules); on the server it runs inside the component body per request, so a render collects CSS only for the components it rendered. A control-flow scope's CSS always ships; only the class stamping follows the branch.
  • <style href precedence> is a Float resource (below) and stays outside the scope model.

The compiler reports these as compile errors, each carrying its code:

  • STYLE_APPLY_VALUEapply needs an expression: apply={theme} or apply={[a, b]}.
  • STYLE_APPLY_TARGET — the value does not resolve to a <style> block or an import (a spread or hole in the array counts).
  • STYLE_APPLY_BEFORE_DECLARATION — the target block is declared after the block that applies it.
  • STYLE_APPLY_DUPLICATE — a block has two apply attributes; pass an array.
  • STYLE_APPLY_UNSUPPORTED_HOSTapply on a <style href precedence> resource or a <style> inside <head>.
  • STYLE_RESERVED_CLASS_KEY — a .$class selector in an assigned block.
  • STYLE_STANDALONE_AT_MODULE_SCOPE — a bodied <style> statement at module scope that is not assigned.
  • STYLE_STANDALONE_OUTSIDE_TEMPLATE — raw CSS in a <style> outside every @{ … } or @if/@for/@switch/@try body (a plain return <…> function, a module-scope element). Use <style>{css}</style> in TSX, or assign the block.
  • STYLE_STANDALONE_NEEDS_FRAGMENT — a <style> as the lone output of a @{ … } or directive body. Wrap it with the output it styles in a fragment.
  • STYLE_UNKNOWN_ATTRIBUTE — any attribute other than apply on a scoped block.
  • CSS_GLOBAL_PLACEMENT:global(...) in the middle of a selector sequence or nested inside a pseudo-class.

Context: direct provider component, no Provider or Consumer

createContext returns a context that is itself the provider component — React 19's <MyContext value={…}> form is the supported shape. MyContext.Provider does not exist; known legacy .Provider access reports the OCTANE_CONTEXT_PROVIDER compiler error. Replace <MyContext.Provider value={value}> with <MyContext value={value}>, and pass MyContext directly to createElement or root.render. The render-prop <MyContext.Consumer> does not exist and will not be added: Octane's slot-keyed hooks make use(MyContext)/useContext legal behind any condition, which is the pattern Consumer existed to work around. Read the context in the child (or an inline component) instead.

In development, accessing .Consumer logs a one-time migration diagnostic and still returns undefined, so feature probes (MyContext.Consumer || fallback) behave exactly as in production. The upstream Consumer test scenarios that protect observable behavior (defaults, propagation, bailout, hidden subtrees, suspended boundaries) are ported with useContext-reading components; the scenarios that exist only to exercise the render-prop API surface are recorded as non-goals in the parity ledger.

Document metadata and Float resources

Hoisted <title>/<meta>/<link> follow React 19's model with two differences:

  • Ownership is per compile site, not per content. Each authored element owns one head element; two components (or two renders of one component list item) each rendering <meta name="description"> produce two tags, where React dedupes links by href and treats <title> as a singleton. An element's tag updates reactively and is removed when its owning scope unmounts.
  • <title> accepts any children Octane can stringify — multiple children and expressions concatenate. React 19 errors on non-string title children.

Metadata and resources hoist from ANY depth, matching React: an element nested inside a host partitions out of the body on both the client and the server, and a hoist inside an @if arm registers only while that arm renders. Two more Fizz-parity behaviors on the server: <meta charSet> and <meta name="viewport"> serialize at the FRONT of the head (charset first — parsers only honor it within the first 1024 bytes — then viewport, then everything else in discovery order), and hoistables authored inside a pending boundary's fallback are dropped transitively (a completed boundary nested in a fallback is still fallback territory; the streamed head would outlive the fallback it came from).

React Float resources are supported with React's semantics:

  • <link rel="stylesheet" href precedence> (no onLoad/onError) is a global resource: deduped by href across the page, hoisted into document.head with a data-precedence attribute, grouped by precedence in first-encounter order (later same-precedence sheets append to their group), and retained after unmount. First encounter follows tree discovery order — parent before child, suspended arms at reveal — so client mounts, SSR, and React agree on group order. First instance wins; later differing props do not retarget a live sheet.
  • <script async src> (no children/handlers) hoists and dedupes by src, and is likewise never removed.
  • <style href precedence> is a STYLE RESOURCE: its plain CSS ships by href identity, sharing the stylesheet dedupe namespace and precedence-group ordering with link resources (data-precedence/data-href mark the tags). The CSS is NOT scoped — every other <style>, whether standalone in a template scope or assigned (const theme = <style>…</style>), belongs to Octane's sibling-scope model above, and apply on a resource is an error (STYLE_APPLY_UNSUPPORTED_HOST). Two adaptations: Octane emits one <style> tag per resource rather than merging same-precedence rules into a single tag (grouping and order are preserved), and CSS containing </style fails closed in SSR with a development diagnostic (raw-text serialization cannot escape it; the client inserts via textContent, which is always safe).
  • preloadModule/preinitModule join preload/preinit/preconnect/ prefetchDNS.
  • Classification is static: a spread-carried precedence/async keeps the ordinary element path, matching the compile-time head-hoist model.
  • React's hoist EXCLUSIONS apply: itemProp-bearing <meta>/<link> stay with their itemScope host; metadata/resources that are direct children of <noscript> stay in the fallback content (one nesting level today — metadata wrapped in a further host INSIDE <noscript> still hoists; a documented bound, not a contract); and nothing hoists from an SVG lexical scope (foreignObject children re-enter the HTML rules). One template-model bound: <meta> inside <svg> on a pure client mount is relocated by the HTML parser's foreign-content breakout rules — it still never becomes document metadata, but it cannot be kept inside the <svg> the way React's imperative element construction keeps it. SSR serializes it inline correctly.

Out of scope, deliberately: suspensey commits (React's suspend-until-the-stylesheet-loads behavior; Octane inserts the sheet and continues).

Resource hints share the Float identity model — including React's option semantics: font preloads always emit crossorigin="" (anonymous) regardless of the caller's value; connection/integrity options seeded by a preload carry onto the matching preinit's real tag (and the server coalesces the redundant preload out of the head fold); preconnect identity includes the CORS mode; responsive image preloads (imageSrcSet) omit the fallback href; unknown option keys are dropped; non-string hrefs warn in development and no-op; and a module src is ONE executable identity across preinitModule and <script async type="module" src> on both the server pass and the hydrating client. In detail: preinit(href, {as: 'style'}) IS a stylesheet resource (it honors a precedence option, default "default", joins the precedence groups, and dedupes against <link rel="stylesheet" precedence>), preinit(as: 'script') dedupes against <script async src>, and a preload/preloadModule issued after the matching init is a no-op (the upgrade is one-way: preload-then-init keeps both tags). Image preloads carrying imageSrcSet key on the srcset+sizes pair rather than the fallback href. Malformed calls (missing/invalid as, empty href) warn in development and stay no-ops. Options serialize through their canonical attributes (fetchPriority, imageSrcSet, imageSizes, media, integrity, …); unknown option keys are dropped, matching React.

Reconciler: LIS moves, identical results

The keyed reconciler minimizes DOM moves (LIS) instead of React's lastPlacedIndex. Survivor node identity and final order are guaranteed and stress-tested (including under mid-reconcile throws); only the set of physically-moved nodes can differ.

Scheduler: synchronous, two priorities

Renders are microtask-batched and run to completion — no lanes, yields, time-slicing, expiration, or selective hydration. Priority changes Suspense behavior, not whether ordinary work is time-sliced:

setPage(next); // If it suspends, show the pending fallback.
startTransition(() => setPage(next)); // Keep the previous content while pending.

Delegated events commit on React's batchedUpdates schedule. The outermost dispatch of a discrete event such as click, keydown, input, or submit flushes synchronously only when a controlled value/checked host armed a state restore during that dispatch (an accepted edit commits, an unheard edit snaps back, both before the dispatch returns). Every other handler update stays in the microtask batch. For a browser-dispatched event that microtask runs before the next native listener and before the default action, so later listeners and the next interaction observe committed state; a script-dispatched event (dispatchEvent, click(), requestSubmit()) commits only after the dispatching script yields, so outside code that dispatches and then inspects the DOM sees the same pre-commit state it would under React. Tests wrap dispatches in act() or flushSync() for a synchronous commit, as with React. Continuous events such as mousemove, pointermove, and scroll retain microtask batching; Octane does not give them a separate interruptible priority lane. Ordinary delegated event updates do not join an unrelated pending async Action, while an explicit startTransition inside the handler still opts into transition work.

Already-visible Suspense content stays visible without a timeout during a transition, matching React's shell-retention contract. isPending stays true until the transition completes or is superseded. Initial boundaries and newly added nested boundaries may show their fallbacks: there is no previously visible content for those boundaries to preserve.

setTransitionFallbackTimeout(ms) is an Octane extension for applications that want a finite deadline. After that deadline, the pending fallback replaces the visible primary while isPending remains true. getTransitionFallbackTimeout() returns Infinity by default; setting Infinity restores the no-timeout policy for subsequent holds.

flushSync drains both priorities but leaves passive effects asynchronous:

flushSync(() => {
  setQuery('octane');
  startTransition(() => setResults(nextResults));
});
// Both updates committed; passive effects still run later.

Passive effects normally run after paint, including effects queued by discrete events and external-store updates. A listener installed by useEffect can miss another event dispatched before that drain. Use useLayoutEffect when the subscription must be installed by the end of the commit. Passive timing is not React's synchronous discrete-event effect timing.

root.unmount() drains pending passive work and its passive cleanups before returning, so root-owned subscriptions cannot outlive the unmount call.

Octane also skips React's extra same-value render after a previous state change. If a component body does run, its children can render even when the final state is unchanged; do not depend on React's incidental render counts. Updates across roots share a microtask wave, and an await continuation can observe the commit after setState(); await 0. Cross-component render-time updates join the current drain. Finite layout-effect cascades complete before DOM mutation observers run, including commits started by the scheduler. flushSync drains them before returning.

Commit phases retain Octane's ordering: insertion effects mount with connected DOM, refs attach before layout bodies, and layout cleanups can observe DOM already changed by the render walk. A changed ref detaches after its owner's layout cleanup; deletion cleanups interleave insertion and layout work. This does not provide React's per-component ref/layout interleaving or a global before-mutation ref-detach phase.

Other consequences:

  • Priority (urgent vs transition) governs Suspense hold semantics, not general commit deferral.
  • Fallback-visible boundaries whose retries fully stage reveal together, including refs and layout effects.
  • Retry-only Suspense reveals follow React's shared 300ms fallback window. Showing or filling a fallback advances the window, and retries wait if more than 10ms remains. Urgent updates and active act() scopes bypass this delay. A committed fallback inside hidden Activity contributes to the window; toggling Activity visibility alone does not. This is separate from the indefinite transition hold above; see Suspense retry timing.
  • Resource readers can suspend by throwing a thenable during render, on the client and during SSR; use() is not required. Pending and error fallback renders can also suspend through an enclosing pending boundary. A catch-only error boundary does not own suspension; promises thrown by effects remain application errors.
  • Without an enclosing Suspense/@pending boundary, the client root retains its committed screen, or stays empty on an initial mount, and retries when the thenable settles. Urgent and transition updates retry the latest inputs; superseding requests and unmounts cannot reveal stale work. Initially suspended hydration retains the server DOM until it can adopt it, attach refs, and run layout/passive effects. Actual rejections follow normal error-boundary/root-error routing. See root suspension coverage for the fix to issue #821.
  • Incomplete descriptor and memoized subtrees retry before Suspense reveals them, preserving mounted state and DOM identity. Completed siblings whose speculative commit work was discarded are revisited; unaffected memo and identity bailouts remain eligible. See descriptor retry coverage.
  • Same-identity synchronous rendering remains per-swap rather than using a global React-style work-in-progress tree. See Suspense divergence #4.
  • Multiple unhandled root errors in one flush throw an AggregateError; an unhandled error unmounts its root's whole tree (both match React).
  • useSyncExternalStore skips React's commit-time getSnapshot re-read for unchanged values (the concurrent-interleaving window it guards doesn't exist here).
  • A hidden <Activity> subtree renders synchronously in the same pass — there is no offscreen/idle lane deprioritizing hidden work. Compatible state and DOM are preserved; refs and layout/passive effects disconnect while hidden and reconnect on reveal. Insertion effects stay connected. Hidden suspension is contained by the Activity, but general structural-deletion atomicity still has the per-swap limitation above. See the Activity audit.
  • Suspense and Activity hide portal content with their ordinary descendants. Initially suspended primary DOM can remain connected but hidden before its first commit; hidden inputs still participate in native form submission and DOM queries still find those nodes. Effect/ref connection follows visibility, not physical DOM membership. Hook state created above the first suspending use() is retained for retry instead of discarding that initial state.
  • useId generates :<prefix>in-<n>: identifiers (React 19.2 uses _r_<n>_). Both are opaque; only the format differs.
  • version reports Octane's own package version (0.x), not a React version — ported code gating on version >= '19' must not rely on it. The same value is exported by the client, server, and static entry points.

Parallel use(): no suspense waterfalls

The compiler always applies its parallel-use() transform. When it can prove requests independent, idiomatic sequential use() code avoids the waterfall React incurs for the same source:

// These fetch functions are imported module bindings.
const profile = use(fetchProfile(id)); // Starts now.
const posts = use(fetchPosts(id)); // Starts with profile.
const avatar = use(fetchAvatar(profile.avatarId)); // Waits for profile.

The first two creations form one independent stratum: they start together and the boundary suspends once for the batch. The third has a true data dependency and remains sequential.

  • Creations are memoized per call site: use(fetchA(id)) compiles to a slot-keyed memo with member-path deps ([fetchA, id]), so replays never mint fresh promises and refetch happens exactly when inputs change.
  • Fetch trees warm across components: a suspended body prefetches descendants whose reachability and props are provably independent of the suspended data (compiled __warm plans, depth-capped recursion), so a nested async chain loads in max(latency), not levels × latency — benchmarks/async-waterfall: 20.1ms vs React's 307.3ms on a 10-level chain.
  • Unwrap order, hydration-seed order, rejection routing (@catch receives the first-in-order reason), and @pending/transition semantics are unchanged.
  • Runtime safety nets (React parity): a replay that creates a fresh promise for a slot that already holds one reuses the stored thenable ("uncached promise" dev warning), and a replay that discovers a new pending use() behind a data dependency gets a dev waterfall diagnostic.

Composition is regression-pinned in benchmarks/async-composition, whose dashboard fixture reads eight resources — seven independent, one truly dependent — through an imported custom hook and three sibling panels:

ShapeCurrent behavior
Independent use() calls inside an imported custom hookStart together: plain TypeScript custom hooks get the same memoize-and-batch treatment as component-local use()
Adjacent async children under a parent with no use()Start together: child warm plans register with active ancestors, so the first suspending descendant starts its siblings
A transition-wrapped updateHolds the boundary whole: no mixed old/new state, matching React

All three reach the workload's true dependency floor — 2 waves and 8 requests for both cold mount and transition update, against React's 6/3 waves and 35/25 requests — so only owner waits, on project.ownerId, and the update exposes zero intermediate states, the same as React.

A held boundary holds all of its own content. Octane renders and mutates in one walk, so a transition patches a boundary's bindings on the way down and only then finds that a descendant suspends; the same happens when a held boundary replays its body and part of the data has arrived. Both cases record what each binding replaced and put it back if the attempt suspends, inside the flush that made the change — nothing reaches the screen in between. Transitions stay monotonic: no visible rollback, no invalid intermediate structure, and a dependent value never renders against stale input.

Controlled value, checked and selected are held too. Each carries a default* mirror and a record of what was last projected, and all of it goes back together — restoring the node alone would leave the record believing the new value had already landed, so re-projecting it on resume would be skipped.

A held synchronous transition now defers its whole commit: content the transition patched outside the suspended boundary — shell text, attributes, keyed structure — reverts with the hold and lands in one step on resolve, with isPending staying on throughout. benchmarks/async-composition pins the update at zero exposed intermediate states, level with React. One residual is pinned at its own ceiling there: after the dependent request resolves, the promoted round re-creates the warm-started panel fetches (13 creations against the 8-call floor; the app-level cache serves the same promises, so nothing refetches over the network) until the resume/warm work in the deferred-commit plan restores the floor. Both need the transition to become a deferred commit — a keyed removal disposes blocks and runs their cleanups, which cannot be undone, and reverting content outside a boundary needs the reveal to re-render where the transition began rather than just the boundary. See Suspense divergence #4. The benchmark pins the exposed-state count at zero.

Root component entry points and container ownership

Both entry forms are valid:

// Choose either entry form:
root.render(<App />); // React-compatible
// or
root.render(App, props); // Octane extension

The second form avoids creating an element descriptor at application bootstrap. A bare function passed to root.render is therefore intentional, not an invalid-child warning.

Both createRoot and hydrateRoot accept an Element, a Document, or a DocumentFragment (including ShadowRoot). Document roots own the document element and preserve its doctype.

A document root replaces the document shell when its output does not contain <html> and <body>. In that case document.body is null, so it cannot be passed to another createRoot call. Update the existing document root or render a complete document shell first.

The first root.render() mounts synchronously. React's concurrent root queues its initial mount, so a render followed by an unmount in the same surrounding batch exposes no intermediate DOM there; Octane may expose the mounted DOM before its synchronous unmount leaves the same empty final state.

root.render(App, props);
console.log(container.firstChild !== null); // true

After root.unmount(), the root is permanently closed. If outside code removes some of a root's managed DOM first, unmount still performs safe cleanup instead of surfacing the browser's incidental NotFoundError from removing an already detached node.

Portal keys and component props

createPortal(children, target, key) accepts a string or number key, normalized to a string, so keyed portal arrays preserve their child state on reordering. Octane also supports a component body as the first argument and an object of component props as the third:

createPortal(<Menu />, overlay, 'menu');
createPortal(Menu, overlay, { open: true });

The object overload does not supply a reconciliation key. Use a keyed element inside the portal or the keyed renderable overload when identity must change.

lazy() module resolution

Like React, lazy(load) accepts a thenable that resolves to a module object with a default component. Octane additionally accepts a bare component as the resolved value, making named dynamic imports usable without a default-export shim:

const Chart = lazy(() =>
  import('./Chart').then((module) => module.Chart),
);

Creating a lazy wrapper does not call its loader. When an independently reachable sibling suspends, Octane may start the lazy module's existing loader early so its code loads alongside the pending work. The loader still runs only once per wrapper, and its resolved component and default-export getter are not evaluated until that component actually renders. Discovery does not cross a dormant deferred-hydration boundary.

Nested lazy wrappers are rejected.

React's Suspense and ViewTransition values are exotic element types and React rejects wrapping them in lazy(). Octane exposes those boundaries as ordinary component functions, so a lazy wrapper preserves their normal component behavior.

Errors: @try / @catch, not class boundaries

@try {
  <RiskyPanel />
} @catch (error, reset) {
  <button onClick={() => reset()}>Retry</button>
}

@catch (error, reset) and the JSX <ErrorBoundary> replace class error-boundary lifecycles. Catch fallbacks mount fresh nodes (like React's forceUnmountCurrentAndReconcile); deletion-phase and ref-detach errors route to the enclosing boundary.

React 19's root error-callback options are supported on createRoot and hydrateRoot: onCaughtError (a boundary claimed an error from the render, passive-effect, or ref-attach channel), onUncaughtError (no boundary claimed it — providing the callback replaces the default report, which otherwise rethrows render errors out of the flush and console.errors effect-channel errors; the failed root's tree still unmounts), and onRecoverableError (hydration recovered from a structural mismatch — see the hydration section). Each callback receives only the error: there is no errorInfo/componentStack second argument, matching the documented SSR onError shape (owner stacks are not part of Octane's API). Deletion-phase teardown errors (effect cleanups and ref detaches thrown while a subtree unmounts) keep their existing boundary routing and report through the same callbacks: boundary-claimed → onCaughtError, unclaimed → onUncaughtError (else the default console.error).

In non-suspending renders, first-mount and parent-driven catches report the original error once after the fallback commits, including its refs and layout effects. Inline reports retained by Suspense or Activity wait for reveal, and are discarded if their catch is abandoned before that commit.

Refs are props

Components receive refs as ordinary props; there is no forwardRef wrapper:

function Search({ ref }) @{
  <input ref={ref} />
}

<Search ref={[inputRef, measure]} />

A ref may be a callback, a { current } object, or an array of refs as shown above.

Fragment refs

An explicit <Fragment ref={ref}> provides a typed FragmentInstance without adding a wrapper element. This matches React's Canary Fragment-ref API; the <>...</> shorthand cannot accept a ref.

Refs also work through imported aliases, namespace members, JSX spreads, and createElement(Fragment, { ref }, ...) descriptors.

import { Fragment, type FragmentInstance, useRef } from 'octane';

function SearchFields() {
  const fields = useRef<FragmentInstance | null>(null);

  return (
    <Fragment ref={fields}>
      <input />
      <button onClick={() => fields.current?.focus()}>Focus first field</button>
    </Fragment>
  );
}

FragmentInstance exposes addEventListener, removeEventListener, dispatchEvent, focus, focusLast, blur, observeUsing, unobserveUsing, getClientRects, getRootNode, compareDocumentPosition, and scrollIntoView. Event listeners, observers, geometry, and scrolling target the Fragment's first-level DOM children; focus searches descendants depth-first. scrollIntoView accepts only an optional alignment boolean, not an options object. First-level children expose their owning instances through reactFragments: Set<FragmentInstance>.

Fragment refs are inactive during server rendering and attach during client hydration.

SSR and streaming

Rendering surface

The buffered renderers return Octane's scoped CSS beside the HTML:

const { html, css } = renderToString(App, props);

Renderable roots are also accepted: renderToString(createElement(App), options) and primitive, array, or null roots use the second argument for renderer options. The function-root extension uses renderToString(App, props, options).

renderToString, renderToStaticMarkup, and prerender all return { html, css }; React has no equivalent css field. css holds one <style data-octane="hash"> tag per style scope the request rendered — a component contributes one per sibling scope it owns — deduped by hash. Hoisted document metadata folds into html as React does. For a host that owns the surrounding <head>-bearing template, headChannel: 'separate' instead exposes RenderResult.head and StreamOptions.onHeadReady.

Plain string returns are text and are escaped. The compiler brands its already serialized HTML internally; user components must use descriptors or templates for markup. Style text uses raw CSS serialization, and script/style closing sequences are hardened to prevent an embedded closing tag from ending the host.

Serialization is not byte-identical to React: entity spelling, trailing CSS semicolons, boolean-attribute spelling, and empty comment separators can differ. renderToStaticMarkup retains Octane's separators. Automatic image preload hints are not synthesized. Host props remain lenient for custom-element objects, attributes such as selected on unrelated hosts, and void elements with children. Server style inputs accept strings and other values the style normalizer accepts, in addition to ordinary objects.

Server useSyncExternalStore falls back to getSnapshot when getServerSnapshot is absent. Transition starters and optimistic setters from server hooks are no-ops; a server hook's transition starter does not invoke its callback. Portals emit an empty placeholder. Unlike React's client-only error boundaries, template @catch also catches during SSR. JSX Suspense errors render its fallback and notify onError; a pending promise with no boundary makes a synchronous buffered render throw instead of returning partial HTML.

Streaming

renderToPipeableStream and renderToReadableStream stream out-of-order Suspense like Fizz, with these differences:

  • Octane performs per-round re-passes (the prerender cost model), not per-boundary incremental renders.
  • There is no selective hydration.
  • Head elements hoisted inside streamed boundaries are re-created on the client during hydration.

Octane leaves document and transport orchestration to the surrounding server. It has no Fizz bootstrap-script/module/import-map options, no doctype/preamble options (streaming a <html>-rooted document still emits <!DOCTYPE html> automatically), and no onHeaders or header-construction options. One nonce covers every inline style and script Octane emits rather than exposing separate script and style channels. progressiveChunkSize does not exist — Octane flushes per resolution wave, not by byte thresholds — and namespaceURI is inferred from the rendered root rather than accepted as an option.

React 19.2's partial pre-rendering — resume, resumeToPipeableStream, resumeAndPrerender, and the postpone/prelude protocol — is a non-goal: that request protocol is not part of Octane's public SSR surface. Relatedly, prerender resolves { html, css } (a complete buffered document), not React's { prelude: ReadableStream }. prerenderToNodeStream is implemented: it resolves after the complete render and exposes that complete document as a Node stream in prelude.

A readable stream's allReady settles after all boundary bytes have been accepted under consumer backpressure, so consumers should read the stream while awaiting it. Error callbacks report the original value without synthesizing React digests or React's errorInfo shape.

A pipeable stream's shell failure calls the destination's destroy(error) when available, including when piping begins after the failure. A minimal destination without destroy receives no body bytes and ends after onShellError reports the failure; its owner must choose the transport response.

Hydration

Attribute mismatches recover to the client value; React keeps the server value. Octane warns and rebuilds a mismatched subtree in place rather than throwing.

hydrateRoot's onRecoverableError option fires (dev AND prod) after a structural or text recovery — a rebuilt subtree, corrected text, or a discarded stale server range — coalesced to one report per root per microtask burst. Octane recovers per site rather than client-rendering a whole boundary, so attribute-level value patches do not report: production React does not detect those at all, and reporting Octane's extra detection would make the channel incomparable.

hydrateRoot has no formState option: resuming useActionState from an MPA form POST requires React's server-action state serialization, which is part of the RSC model Octane does not implement (the matching useActionState permalink argument is accepted for signature parity and ignored).

Production validates a template root's node type and tag, together with its dynamic binding and range sites. It does not walk arbitrary static descendants. Tag and text mismatches at inspected sites recover, but different static branches that share a tag may not be detected:

<!-- Server branch -->
<span class="compact">...</span>

<!-- Client branch -->
<span class="expanded">...</span>

Development recursively compares unambiguous static structure and attributes, warns, and rebuilds. It stops at dynamic holes, so unmatched static descendants outside an inspected range can remain. This is not React's full hydration walk.

Hot module updates remount the edited component

React Fast Refresh diffs the new element tree against the existing fiber tree, so an edit high in the tree can leave untouched descendants exactly where they were, DOM nodes and state included.

Octane's compiled bodies clone a static template and address it through a compile-time slot layout, so there is no element tree to diff. A hot update discards the edited component's committed render state and remounts it in place:

  • The component's own useState, useReducer, useRef, and useId values survive. Hook slots are keyed by Symbol.for('octane:<file>:<Component>.<hook>#<n>'), and a re-imported module produces the same Symbol identity.
  • Its memo and effect dependencies are invalidated, so an edited closure runs again even when its authored dependency array did not change.
  • Everything it rendered is torn down and rebuilt: descendant components, their state, their DOM identity, and uncontrolled input values. React would have kept the ones the edit did not touch.

A remount inside a hidden <Activity> remains hidden. Independently scheduled descendant renders and Suspense resumes reapply the nearest Activity's hide pass to any replacement elements or text, including output rebuilt by HMR, and reveal restores the authored display and text values. Activity and Suspense share hide ownership for overlapping DOM, so either boundary can reveal first without exposing the other boundary's content or capturing its temporary hidden styles as authored state.

Octane declines a refresh when the old and new compiler bodies use incompatible direct-template and returned-output layouts, or when a live block has no coherent, exclusively resettable DOM range. The emitted accept block then calls import.meta.hot.invalidate() for a full page reload.

Hook slot ids are numbered per module in source order, so inserting or reordering a hook call shifts every later hook's key in that file and remaps its state. That is a known limitation, not a supported edit.

Element-scoped View Transitions

Octane adds scope="element" to ViewTransition. A persistent direct host owns its native capture, descendant names, and pseudo-element handles. Independent scopes can animate concurrently after capture, while a batch spanning scopes still publishes one DOM commit. Omitted scopes inherit their nearest declaration or use the document. This extension falls back to a normal commit when Element.startViewTransition is unavailable; it is not a React 19.3 API. See View Transitions for host constraints, scheduling, and browser support.

Not implemented (by design)

Octane does not implement:

  • class components or legacy ReactDOM.render roots;
  • Server Components/RSC, including cache(), cacheSignal(), and hydrateRoot's formState option;
  • StrictMode double-invoke;
  • Profiler, SuspenseList, forwardRef, or createRef;
  • captureOwnerStack and development owner-stack collection (diagnostics dedupe per rendering block instead);
  • partial pre-rendering (resume/resumeAndPrerender and the postpone/prelude protocol — see SSR and streaming);
  • gesture View Transitions (useSwipeTransition / unstable_startGestureTransition), deferred until React stabilizes them.

useDebugValue is accepted as a no-op. Resource hints are supported (preload, preinit, preloadModule, preinitModule, preconnect, and prefetchDNS).

StrictMode is a pass-through wrapper; it does not replay renders or effects. unstable_batchedUpdates invokes its callback and preserves its arguments, result, and thrown errors because updates are already microtask-batched. useFormState is accepted as a deprecated alias for useActionState. unstable_Activity remains a compatibility alias for Octane's Activity; React 19.2.7 does not export that legacy name.

React 19 custom-element listener semantics are also supported: a function-valued custom on* prop attaches a real listener with the exact event name after on. For example, onFooBar listens for FooBar, while onfoobar listens for foobar; a trailing Capture selects capture. Recognized DOM handler names retain their native delegated behavior. The property-versus-attribute heuristic is not; custom-element values follow Octane's attribute-only pass-through policy.