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:
| Capture | Inferred behavior |
|---|---|
Other component-local values and module-scope let/var | Tracked |
| State setters, reducer dispatchers, refs, and state getters | Omitted as stable |
useEffectEvent results | Omitted because Effect Events are non-reactive |
Imports and unreassigned module-scope const/function/class | Omitted as program-lifetime identities |
A local const naming one of those stable values, or a literal | Omitted |
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(...)tracksprops.onChange. - An inherited method tracks its receiver:
count.toFixed(2)trackscount, becauseNumber.prototype.toFixedis 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 call | Dependency argument |
|---|---|
Local transparent wrapper in .tsrx/.tsx | Inferred |
| Nested transparent wrappers in the same module | Inferred |
Wrapper call in plain .ts/.js | Required explicitly |
| Imported or method-style wrapper | Required explicitly |
| Wrapper that inspects or transforms its callback/dependencies | Required 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()andconst 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 unrelateduseFormat()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'sevent.nativeEvent.stopImmediatePropagation(). Call both stop methods to stop both queues. Neither method cancels the default action; usepreventDefault()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
Eventobject, not a ReactSyntheticEventwrapper. There is no event pooling, andevent.currentTargetis the handler's element. mouseenter/pointerenterfamilies are the real per-element native events — no synthesis fromover/out.onFocus/onBluruse the browser's bubblingfocusin/focusoutevents, including capture variants; the event object retains that native type.- There are no synthetic
onChange/onBeforeInput/onSelectpolyfills — use the native events (onInputetc.). - Root listeners are non-passive.
preventDefault()inonWheeloronTouchMovecan cancel scrolling when the native event is cancelable. - Native
keypressevents are delivered even when theircharCodeis 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 click → input →
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 (autofocus → autoFocus, defaultvalue → defaultValue,
defaultchecked → defaultChecked, 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
textlengthinstead of following React's imperative warning path. styleaccepts a CSS string as well as an object, including!importantand 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;scaleis unitless andcssFloataliasesfloat.- 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/@trybody. 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 aclass={[…]}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} />stampstheme.$classon a scope;apply={[a, b]}composes. Same-module targets inline as literals (static templates stay hoisted); imported targets are runtimetheme.$classreads.- CSS emits in lexical pre-order. On the client
injectStyleis 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_VALUE—applyneeds an expression:apply={theme}orapply={[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 twoapplyattributes; pass an array.STYLE_APPLY_UNSUPPORTED_HOST—applyon a<style href precedence>resource or a<style>inside<head>.STYLE_RESERVED_CLASS_KEY— a.$classselector 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/@trybody (a plainreturn <…>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 thanapplyon 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>(noonLoad/onError) is a global resource: deduped by href across the page, hoisted intodocument.headwith adata-precedenceattribute, 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-hrefmark 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, andapplyon 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</stylefails closed in SSR with a development diagnostic (raw-text serialization cannot escape it; the client inserts viatextContent, which is always safe).preloadModule/preinitModulejoinpreload/preinit/preconnect/prefetchDNS.- Classification is static: a spread-carried
precedence/asynckeeps the ordinary element path, matching the compile-time head-hoist model. - React's hoist EXCLUSIONS apply:
itemProp-bearing<meta>/<link>stay with theiritemScopehost; 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 (foreignObjectchildren 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 (
urgentvstransition) 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/
@pendingboundary, 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). useSyncExternalStoreskips 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. useIdgenerates:<prefix>in-<n>:identifiers (React 19.2 uses_r_<n>_). Both are opaque; only the format differs.versionreports Octane's own package version (0.x), not a React version — ported code gating onversion >= '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
__warmplans, 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 (
@catchreceives 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:
| Shape | Current behavior |
|---|---|
Independent use() calls inside an imported custom hook | Start 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 update | Holds 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, anduseIdvalues survive. Hook slots are keyed bySymbol.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.renderroots; - Server Components/RSC, including
cache(),cacheSignal(), andhydrateRoot'sformStateoption; StrictModedouble-invoke;Profiler,SuspenseList,forwardRef, orcreateRef;captureOwnerStackand development owner-stack collection (diagnostics dedupe per rendering block instead);- partial pre-rendering (
resume/resumeAndPrerenderand 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.