Skip to content

ReactRunner re-transpiles on every render, discarding its own error state #2954

Description

@os-zhuang

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

  1. 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.
  2. onError may not fire as intendedcomponentDidUpdate gates on this.state.error, which the next getDerivedStateFromProps has already cleared.
  3. 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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingsduiServer-Driven UI runtime

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions