Repository navigation
Conversation
… 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]>
…untime-size-redesign # Conflicts: # packages/octane/src/signals/graph.ts
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
View logs |
octane-website-mcp | 338fbb4 | Oct 13 2026, 01:54 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
View logs |
octane-website | 338fbb4 | Oct 13 2026, 01:54 PM |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
Bugbot Autofix prepared a fix for 1 of the 2 issues found in the latest run.
- ✅ Fixed: Catch-only stream errors recover
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; |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 338fbb4. Configure here.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 338fbb4. Configure here.



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
@catchandErrorBoundaryno 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.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)Roadmap (#2043)
childSlotdriversnextvalues for compiled templatesapplied/nextstructure (replaces the journal, deferred teardown, parking, off-screen capture and reveal windows)DOMStageand staged commits)Validation
pnpm format:checkpnpm typecheckpnpm testoctane+octane-prodoctane/octane-prodsignal filesoctane+octane-prodperf-review: pending on the final diffconcise-code: pending on the final diffRisk / follow-ups
Provenance
agent-authored)🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith 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
@catcharms or<ErrorBoundary>fallbacks. Render errors and rejecteduse()inside a Suspense/@pendingboundary callonError, emit that boundary’s pending fallback (marked for client rendering), and do not put rejection seeds or catch HTML on the wire.hydrateRootre-renders those boundaries from client data and reports viaonRecoverableError;@catchruns only if the client throws again. Errors with no enclosing Suspense fail the render (renderToStringthrows, streams useonShellError). The SSR compiler stops emitting server catch arms;ssrTrydrops the catch callback path, rejection serialization, andTRY_CATCH_COMMENTadoption.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-childrenreachability 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.