Skip to content

perf(octane): render computes, commit writes — runtime size redesign (#2043) - #2044

Draft
trueadm wants to merge 13 commits into
mainfrom
perf/runtime-size-redesign
Draft

trueadm wants to merge 13 commits into
mainfrom
perf/runtime-size-redesign

Conversation

@trueadm

@trueadm trueadm commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Why

With every feature in use (the shell_ app from #2027), Octane ships about 122 kB gzip of framework code where React 19.3 + alien-signals ships about 70 kB. Most of the gap comes from render writing to the live DOM, which forced nine separate ways to hide or undo those writes. #2043 explains the design.

Changes so far

  • Server render errors follow React's model (Hydration: collapse the hydration engine back into the mount path (Ripple/Svelte-style cursor), 23 kB → ~5 kB gzip #2028 decision).
    • @catch and ErrorBoundary no longer run on the server. An errored boundary ships its @pending/Suspense fallback marked for a client render, and with no Suspense boundary above it the render fails.
    • Rejection details are no longer serialized, and the server-catch replay paths are deleted.
    • Docs and changeset included.
  • Signals, thin core phase 1:
    • writable and sync-derived signals are value-only nodes;
    • devtools inspection is gated behind the profiling flag;
    • native read sources are one class;
    • signal-free apps fold away the implicit signal-child detection.
  • One renderer:
    • one generic host-prop writer for spreads and descriptors;
    • childSlot's portal, signal and Context handling is installed by the features that create it.

Size so far (shell_ app, framework gzip, against main 5594cb0)

Variant main this branch Δ
Full app 122,309 119,637 −2,672
No signals 106,311 102,081 −4,230
Base + hydrateRoot 76,413 72,583 −3,830
Base (createRoot) 58,005 55,076 −2,929

Roadmap (#2043)

  • Server errors on React's model
  • Signals thin core, phase 1
  • One renderer: shared prop writer, childSlot drivers
  • Commit functions with committed/next values for compiled templates
  • Per-slot applied/next structure (replaces the journal, deferred teardown, parking, off-screen capture and reveal windows)
  • ViewTransition applies queued commits (replaces DOMStage and staged commits)
  • Update queues for hooks, then signals (replaces held transitions and the signals coordinator)
  • Hydration wire v2 and cursor (Hydration: collapse the hydration engine back into the mount path (Ripple/Svelte-style cursor), 23 kB → ~5 kB gzip #2028)
  • Descriptor paths on commit functions

Validation

  • pnpm format:check
  • pnpm typecheck
  • pnpm test
  • Targeted tests per merged step:
    • server errors: 201 SSR/hydration files, octane + octane-prod
    • signals: all signals projects plus 64 octane/octane-prod signal files
    • one renderer: 505 files, octane + octane-prod
    • merge fix: childSlot/portal/signal-child files
  • perf-review: pending on the final diff
  • concise-code: pending on the final diff

Risk / follow-ups

Provenance

  • An agent produced this diff (agent-authored)

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Note

High Risk
Intentional SSR semantics change (no server catch arms, no serialized rejections) can break apps that relied on server-rendered error UI; plus broad runtime changes to ssrTry, hydration markers, and the signal graph.

Overview
This PR advances the render computes, commit writes runtime redesign (#2043): smaller client bundles, faster signals, and SSR error handling aligned with React.

Server render errors (breaking vs prior Octane): The server no longer renders @catch arms or <ErrorBoundary> fallbacks. Render errors and rejected use() inside a Suspense/@pending boundary call onError, emit that boundary’s pending fallback (marked for client rendering), and do not put rejection seeds or catch HTML on the wire. hydrateRoot re-renders those boundaries from client data and reports via onRecoverableError; @catch runs only if the client throws again. Errors with no enclosing Suspense fail the render (renderToString throws, streams use onShellError). The SSR compiler stops emitting server catch arms; ssrTry drops the catch callback path, rejection serialization, and TRY_CATCH_COMMENT adoption.

Signals & bundle size: Writable and plain derived cells use value-only storage (snapshots built on demand). DevTools/native-read inspection metadata ships only when profiling is on. Apps without signals defer implicit signal-child detection until the engine loads. The generic child slot drops portal/signal/context regimes unless those features are created (new descriptor-children reachability fixture).

Renderer cleanup: Shared host prop path naming (setHostProps), simplified suspense seed JSON (site metadata only, no rejection envelope), and related docs/changeset updates.

Reviewed by Cursor Bugbot for commit 338fbb4. Bugbot is set up for automated code reviews on this repo. Configure here.

trueadm and others added 12 commits October 13, 2026 13:19
… apps

childSlot checked every renderable child with isSignalHandle and enabled
signal bindings itself on a hit. That self-enable kept enableSignalBindings
reachable from every bundle, so SIGNAL_BINDINGS_ENABLED and
signalDocumentEnabled stayed writable and none of their guards folded.

Detection now runs only when bindings are enabled or the signal engine has
loaded (engine.ts registers native hydration on load), and a hit enables
the document capability directly. Neither flag has a writer in a
signal-free bundle, so the branch and the code behind it drop out.

shell_ app (oxc): nosignals -4,494 raw / -1,613 gzip, base -4,110 / -1,411,
full -77 / -29.

The new test renders a handle from a lazily imported signals module through
a renderer module that never imports signals; gating on
SIGNAL_BINDINGS_ENABLED alone fails it with "Objects are not valid as an
Octane child".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every native read source carried a DevTools inspect closure, and the
native read driver and retry leases carried inspectScope/inspect, so every
production signal bundle shipped inspectNativeNode and
native-read-inspection.ts. DevTools only install the native read inspector
under __OCTANE_PROFILE_ENABLED__, so these members now exist only there.

DOM bindings asked inspect() whether an observed source's owner had
retired. That is functional, so sources gain a cheap retired() member;
graph, view, historical and transition-candidate sources implement it.

shell_ app (oxc), full: -1,106 raw / -302 gzip.

Existing tests pin both sides: behavior-root's "ends quietly when a scope
observed only through a ... retires" cases (dev and prod) fail 8/8 when
the binding check keeps using inspect(), and the octane-signals-profile
devtools tests fail 3/3 when graph sources lose inspect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
React's server never runs an error boundary. ssrTry no longer renders a
@catch arm or an ErrorBoundary fallback: an error, or a rejected use(),
reports through onError and leaves the nearest Suspense boundary's
fallback, marked for a client render, in renderToString, prerender, and
streams before and after the shell. Buffered @try/@pending boundaries now
recover like JSX Suspense, with a new oct-client-error marker. A boundary
without @pending passes the error on, so with no Suspense boundary above
it the render fails.

The server no longer serializes rejection seeds, records isolated seed
runs, or compiles catch arms. The client drops the server-catch adoption
and replay path (takeServerCatch, replayCaughtTry, settleServerCatch,
adoptServerCatch, fallBackServerCatch, rejection seed decoding) and the
catch-arm adoption parameters of switchToCatch. A boundary the server
marked as errored, buffered or by $OCTRX, renders afresh and reports
error 344 through onRecoverableError, unless its catch arm takes a throw
in that render. Error codes 23 and 45 are retired.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
docs/ssr.md gains a Render errors section and drops the rejection seed
and server catch arm text; differences-from-react.md replaces the server
catch paragraph. The app-core root boundary comment and the React compat
pages follow, and a minor changeset describes the migration for apps
that relied on a server-rendered catch arm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every graph node stored its state as two objects (a NodeState wrapping a
snapshot), so each set() on a writable signal, and each recomputation of a
derived value, allocated both, through a conditional spread, inside a
signalBatch closure.

A node whose state is plain and ready is now value-only: `last` holds the
value and the ready state, which is also its retained state, is built only
when something asks for `state` or `lastState` (snapshot(), a public
subscriber, serialization, a transition fork). Writable cells start that
way; plain derived$ and compiler-selected scalar computations return their
value through evaluate() and stay that way while their dependencies carry
no activity and they read no foreign owner. Pending, failed, async and
cross-owner results keep stored states exactly as before, and the
accessors make every existing reader, including the transition fork
machinery, see the states it saw before. get() reads a value-only node
without building a state; candidate, historical and adoption reads still
go through readNode. set() no longer allocates a batch closure.

Resource cells installed their retry as an own property after
construction, which gave async nodes their own hidden class. The retry now
lives in a declared retryRequest field: %HaveSameMap reports one map for
writable, derived, failed and async nodes (async differed before).

Measured on a production esbuild bundle of octane/signals (ns/op, median
of 7, same session, scratch harness):
  set, no subscribers        44.8 -> 14.1
  set + get                  65.7 -> 28.2
  set + native subscriber   122.0 -> 77.4
  set + public subscriber   123.8 -> 108.8
  set -> derived -> native  189.7 -> 119.2
  batch 10 sets/10 derived 2218.6 -> 1702.9
scoped-signals bench (--sizes=100,1000, 4 interleaved rounds, median),
candidate/base geomean: write_sparse 0.880, batch_write_all 0.895,
create 0.898, write_equal 0.950, switch_dependency 0.953,
read_cached 1.030, dispose 1.038, continuous cycle 1.103.

shell_ app (oxc), full: +1,314 raw / +346 gzip. The fork machinery keeps
every async-state helper reachable, so no code drops out yet.

The new signals-state test pins equality cut-off for derived values: it
fails with [1, 1, NaN, NaN, 0, 0, 0] when a recomputed equal value is
treated as a change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Each graph node built a native read source per read mode as an object of
five closures, and a declaration view built a second graph source plus a
forwarding object of five more. The field mapping that caches a source
per read mode was copied into readNode, unchangedLiveRead, the view
source and transition-candidate, which reached the graph through the
candidate bridge's createNativeSource.

A source is now one GraphReadSource (node, read mode, optional view
record) with prototype methods; a view's source forwards to its canonical
cell once promoted, exactly as the closures did. DevTools inspect is
installed on the prototype only under the profiling flag.
ScopedNode.nativeReadSource(read) owns the per-mode cache, so
transition-candidate calls it on the node it already holds and the bridge
loses createNativeSource.

The other 21 bridge entries stay: transition-action, transition-candidate
and candidate-producers sit in the renderer's module graph (runtime.ts
imports transition-candidate, which imports transition-action; islands
builds import candidate-producers), and bundle-boundaries asserts that an
ordinary client never resolves signals/graph.ts. The symbol-tagged notify
functions stay too: transition-action discovers presentation consumers
through them, and the dom-binding modules publish them.

shell_ app (oxc), full: +104 raw / -3 gzip (a closure-based variant of
the same centralization measured -48 / -33, at five closures per source).

The new pending-promotion test reads a declaration view natively, promotes
it, and checks that the render's witness stays valid and its lease follows
the canonical cell; it fails with "expected NaN to be 1" when a promoted
view source stops forwarding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ptors

Element descriptors had their own prop writers (applyDeoptProps,
patchDeoptProps, applyDeoptProp, setDeoptClass) beside setSpread, with the
same identity diff and removal path. Descriptor hosts now call setHostProps,
the body setSpread already used; setSpread keeps only the spread-only work
(children tracking for raw HTML, skipped keys, the function form action) so
descriptor-only bundles do not retain it. A DESCRIPTOR_HOST flag keeps the
descriptor-specific behavior: unread scoped children, ref stamping for raw
teardown, React's input checkedness initialization, and identity-kept raw
HTML. hostComponent keeps applyHostProps, whose live re-assertion contract
differs on purpose.

shell_ app (oxc): full gz 122,309 -> 122,291 (raw +188), base gz 58,005 ->
58,047 (raw +176); bundles with both spreads and descriptors drop the
duplicate writers. js-framework octane-ts (plain createElement): production
calls -2.6% to -7.7% per operation, DOM mutations unchanged, paired timing
within noise. The loops iterate with for-in so descriptor updates allocate no
key arrays (an Object.keys version measured +3% to +12% slower).

Adds a spread/descriptor parity test; the first unification, which re-applied
an unchanged raw-HTML object on descriptor hosts, fails it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…from their creators

childSlot named renderPortalState, the implicit signal-handle binding and
useContextInternal directly, so every app that renders a renderable hole
shipped all three graphs. Each value can only come from its creator, so the
creator now installs the regime as a driver: createPortal installs the portal
child, enableSignalBindings the handle subscription, and context creation the
Context read. childSlot keeps only truthiness tests.

shell_ app (oxc), against the previous commit: full gz 122,291 -> 119,422
(-2,869; raw -9,047), base gz 58,047 -> 55,076 (-2,971; raw -9,362).
js-framework work counters: no increase on tsrx, jsx or the plain
createElement fixture.

Adds a Context-as-child React 19 parity test (no prior coverage) and a
bundle-reachability descriptor scenario that drops all three regimes while
the context and hydrate-frame scenarios prove the oracle still sees them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
# Conflicts:
#	benchmarks/README.md
#	benchmarks/bundle-size/run-minimal.mjs
#	packages/octane/src/runtime.ts
…undles after the merge

The merged childSlot reached the signal-child binder through a local copy of
the driver, which the minifier does not fold, and bindImplicitSignalChild and
enableSignalBindings referenced each other, a cycle the minifier cannot drop.
Test the driver and the engine-loaded flag directly, and enable the signal
document directly from the binder, as the signal-free gate already did.
Signal-free shell base: 56,518 -> 55,076 gzip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@github-actions github-actions Bot added agent-authored Diff or issue body substantially produced by an AI agent perf PR: performance work labels Oct 13, 2026
…untime-size-redesign

# Conflicts:
#	packages/octane/src/signals/graph.ts
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
⚠️ Deployment skipped
View logs
octane-website-mcp 338fbb4 Oct 13 2026, 01:54 PM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
⚠️ Deployment skipped
View logs
octane-website 338fbb4 Oct 13 2026, 01:54 PM

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared a fix for 1 of the 2 issues found in the latest run.

  • ✅ Fixed: Catch-only stream errors recover
    • The SSR compiler now emits propagateSuspense=true for catch-only @Try boundaries (handler present, no @pending), matching ErrorBoundary behavior so suspensions rethrow to the nearest Suspense boundary instead of silently registering as a stream boundary.

Create PR

Or push these changes by commenting:

@cursor push da3f9b382f
Preview (da3f9b382f)
diff --git a/packages/octane/src/compiler/compile.js b/packages/octane/src/compiler/compile.js
--- a/packages/octane/src/compiler/compile.js
+++ b/packages/octane/src/compiler/compile.js
@@ -16638,25 +16638,31 @@
 	// boundary registration + `<template data-oct-b>` sentinel). As React's server
 	// never runs an error boundary, the @catch arm is client-only: any other
 	// thrown error marks a @pending boundary for a client render, or rethrows.
+	// A catch-only boundary (handler but no @pending) propagates suspensions
+	// exactly like ErrorBoundary, so it never registers as a stream boundary
+	// whose later rejection would be silently recovered.
 	// Output is byte-identical to the old inline try/catch emit for buffered
 	// renders (hydration compatibility).
 	// `siteKey` is a stable source-position hash so a boundary keeps its identity
 	// across streaming passes (the runtime adds the frame path per instance).
+	const catchOnly =
+		node.handler && !node.propagateSuspense && pendFn.type === 'Literal' && pendFn.value === null;
+	const propagateSuspense = node.propagateSuspense === true || catchOnly;
 	const trailing = [];
 	// A presentation-binding view names its @try region on the boundary's own
 	// range; the arm suffix is appended by ssrTry once it knows what it rendered.
 	const bindingSite = node._octaneBindingSite
 		? inheritOriginLoc(b.literal(bindingMarker(node._octaneBindingSite, 'y')), node)
 		: null;
-	if (componentNs !== null || node.propagateSuspense === true || bindingSite !== null) {
+	if (componentNs !== null || propagateSuspense || bindingSite !== null) {
 		trailing.push(
 			componentNs === null
 				? inheritOriginLoc(b.id('undefined'), node)
 				: inheritOriginLoc(b.literal(componentNs, JSON.stringify(componentNs)), node),
 		);
 	}
-	if (node.propagateSuspense === true || bindingSite !== null) {
-		trailing.push(inheritOriginLoc(b.literal(node.propagateSuspense === true), node));
+	if (propagateSuspense || bindingSite !== null) {
+		trailing.push(inheritOriginLoc(b.literal(propagateSuspense), node));
 	}
 	if (bindingSite !== null) trailing.push(bindingSite);
 	return ssrCall(

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 338fbb4. Configure here.

bindingMarker,
)
)
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lazy signal children never bind

High Severity

childSlot only binds an implicit signal handle when SIGNAL_CHILD is installed or NATIVE_SIGNAL_HYDRATION is on. Loading octane/signals sets neither: SIGNAL_CHILD is assigned only by compiled enableSignalBindings, and NATIVE_SIGNAL_HYDRATION is an early-hydration flag. A handle from a lazily imported module therefore skips bindImplicitSignalChild and is treated as an ordinary object, so it does not render its value or subscribe to updates.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 338fbb4. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bugbot Autofix determined this is a false positive.

The signal engine module (engine.ts line 113) calls registerNativeSignalHydration() at module scope, which sets NATIVE_SIGNAL_HYDRATION to true whenever octane/signals is imported, so the else-if guard correctly reaches bindImplicitSignalChild for lazily imported handles.

You can send follow-ups to the cloud agent here.

return ssrTryArm(inner, 2, bindingMarker);
}
// Only a Suspense boundary, or one that already registered, recovers.
if (pendFn === null && entry === undefined) throw e;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catch-only stream errors recover

Medium Severity

A @try with only @catch still registers as a stream boundary when it suspends, because propagateSuspense stays false. The new recover gate then treats a later throw as handled whenever entry already exists, even with pendFn === null. That ships an empty fallback and a client-render instruction instead of failing the stream, which the React-model docs say a catch-only @try must do.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 338fbb4. Configure here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-authored Diff or issue body substantially produced by an AI agent perf PR: performance work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant