Summary
ReactRunner is its own error boundary, but getDerivedStateFromProps re-transpiles and re-evaluates the page source on every render and unconditionally sets error: null. React runs getDerivedStateFromProps before the re-render that follows getDerivedStateFromError, so the error the boundary just caught is discarded, a fresh element is built, and the same throw happens again — escaping past ReactRunner's own fallback.
Evidence
packages/react-runtime/src/index.tsx:69-78:
static getDerivedStateFromProps(props: ReactRunnerProps): Partial<ReactRunnerState> | null {
try {
return { element: generateElement(props.code, props.scope), error: null }; // ← always clears
} catch (error) {
return { element: null, error: error as Error };
}
}
static getDerivedStateFromError(error: Error): Partial<ReactRunnerState> {
return { error };
}
Observed, not inferred. While writing the negative-control case in #2951, a react page rendering an identifier that is absent from scope did not produce the "React page error" panel (react-page.tsx:170-175). The ReferenceError escaped to SchemaRenderer's boundary instead:
Component "home" failed to render
TotallyNotARegisteredBlock is not defined
The test therefore asserts on the error message rather than on the panel — pinning the panel would have pinned this quirk. See packages/components/src/__tests__/react-page-scope.test.tsx.
Impact
- The
fallback prop is dead for render-phase errors. ReactKindPage passes a styled, page-specific fallback that authors never see; they get the generic renderer error instead. Only errors thrown during eval (caught by the try above) reach it.
onError may not fire as intended — componentDidUpdate gates on this.state.error, which the next getDerivedStateFromProps has already cleared.
- Latent state loss (not yet observed). Every eval produces a new
Page function identity, so if the scope object identity changes, the element type changes and React remounts the subtree, wiping the page's useState. Today scope is memoised on [schema, adapter] (react-page.tsx:126-138) and both are stable in the cases I exercised — I verified interactive state does survive in apps/console/src/sdui-workbench-preview.tsx. But anything that makes the adapter identity unstable would turn this into user-visible state loss with no obvious cause. Flagging it as a hazard, not a current defect.
Possible directions
- Memoise the transpile+eval on
(code, scope) instead of redoing it every render — fixes the wasted work, the identity churn, and the error reset together.
- Only clear
error when code/scope actually changed, rather than unconditionally.
- Add tests for
@object-ui/react-runtime, which currently has none.
Context
Found while adding scope-contract tests in #2951.
Summary
ReactRunneris its own error boundary, butgetDerivedStateFromPropsre-transpiles and re-evaluates the page source on every render and unconditionally setserror: null. React runsgetDerivedStateFromPropsbefore the re-render that followsgetDerivedStateFromError, so the error the boundary just caught is discarded, a fresh element is built, and the same throw happens again — escaping pastReactRunner's ownfallback.Evidence
packages/react-runtime/src/index.tsx:69-78:Observed, not inferred. While writing the negative-control case in #2951, a react page rendering an identifier that is absent from scope did not produce the "React page error" panel (
react-page.tsx:170-175). TheReferenceErrorescaped toSchemaRenderer's boundary instead:The test therefore asserts on the error message rather than on the panel — pinning the panel would have pinned this quirk. See
packages/components/src/__tests__/react-page-scope.test.tsx.Impact
fallbackprop is dead for render-phase errors.ReactKindPagepasses a styled, page-specific fallback that authors never see; they get the generic renderer error instead. Only errors thrown during eval (caught by thetryabove) reach it.onErrormay not fire as intended —componentDidUpdategates onthis.state.error, which the nextgetDerivedStateFromPropshas already cleared.Pagefunction identity, so if thescopeobject identity changes, the element type changes and React remounts the subtree, wiping the page'suseState. Todayscopeis memoised on[schema, adapter](react-page.tsx:126-138) and both are stable in the cases I exercised — I verified interactive state does survive inapps/console/src/sdui-workbench-preview.tsx. But anything that makes the adapter identity unstable would turn this into user-visible state loss with no obvious cause. Flagging it as a hazard, not a current defect.Possible directions
(code, scope)instead of redoing it every render — fixes the wasted work, the identity churn, and the error reset together.errorwhencode/scopeactually changed, rather than unconditionally.@object-ui/react-runtime, which currently has none.Context
Found while adding scope-contract tests in #2951.