Skip to content

Missing hideInstance/unhideInstance host config methods break every <Suspense> boundary #21

Description

@joemckie

Summary

hostConfig sets supportsMutation: true but never implements hideInstance, unhideInstance, hideTextInstance or unhideTextInstance. React calls these whenever an Offscreen subtree toggles visibility — which is exactly what a <Suspense> boundary does every time it falls back and then resolves.

The result is that any <Suspense> boundary throws TypeError: callback is not a function from commitMutationEffectsOnFiber the moment its children resolve. This makes Suspense — and therefore any suspense-based data library (React Query, Apollo's useSuspenseQuery, React.lazy, use()) — unusable.

Reproduction

Fresh install, no framework:

npm install @orchetron/storm@0.2.0 react@19
// repro.tsx
import { render, Box, Text } from "@orchetron/storm";
import React, { Suspense } from "react";

let done = false;
const promise = new Promise((r) => setTimeout(() => { done = true; r(); }, 100));

function Suspender() {
  if (!done) throw promise;
  return <Text>resolved</Text>;
}

render(
  <Suspense fallback={<Text>loading…</Text>}>
    <Box>
      <Suspender />
    </Box>
  </Suspense>,
);
node --import=@swc-node/register/esm-register repro.tsx

Output:

loading…resolved
TUI render error: TypeError: callback is not a function
    at runWithFiberInDEV (…/react-reconciler/cjs/react-reconciler.development.js:522:16)
    at commitMutationEffectsOnFiber (…/react-reconciler/cjs/react-reconciler.development.js:10840:29)
    at recursivelyTraverseMutationEffects (…/react-reconciler/cjs/react-reconciler.development.js:10518:11)
    at commitMutationEffectsOnFiber (…/react-reconciler/cjs/react-reconciler.development.js:10779:11)
    …

The fallback and the resolved content both paint, then the commit throws.

Cause

react-reconciler.development.js:10828-10845 walks the host instances of an Offscreen tree and calls hideInstance / unhideInstance on each:

a: if (((current = null), supportsMutation))
  for (root = finishedWork; ; ) {
    if (5 === root.tag || ) {
      if (null === current) {
        wasHidden = current = root;
        try {
          (props = wasHidden.stateNode),
            hoistableRoot
              ? runWithFiberInDEV(wasHidden, hideInstance, props)
              : runWithFiberInDEV(wasHidden, unhideInstance, wasHidden.stateNode, wasHidden.memoizedProps);
        } catch (error) {  }

runWithFiberInDEV invokes its second argument as callback, so an undefined host method surfaces as the generic callback is not a function rather than naming the missing method.

These four methods are required for any mutation-mode host config, not optional. grep -rn "hideInstance" dist/ returns nothing.

Suggested fix

src/reconciler/host.ts. The layout engine already skips nodes with display: "none" (src/layout/engine.tsvisibleChildren filters, and the early return at the computeLayout entry), so hiding can just flip that prop:

function setDisplay(instance, display) {
    if (instance.props["display"] === display) return;
    // props objects are frozen by React — replace, never mutate
    instance.props = { ...instance.props, display };
    instance._cachedRunsVersion = undefined;
    instance._runsDirty = true;
    if (instance.layoutNode) {
        // replacing the object (not mutating) is what marks the node dirty,
        // same reasoning as the comment in commitUpdate
        instance.layoutNode.props = extractLayoutProps(instance.type, instance.props);
    }
}

// in hostConfig:
hideInstance(instance) {
    setDisplay(instance, "none");
},
unhideInstance(instance, props) {
    setDisplay(instance, props["display"]);
},
hideTextInstance(textInstance) {
    textInstance.text = "";
},
unhideTextInstance(textInstance, text) {
    textInstance.text = text;
},

I'm running this as a pnpm patch against dist/ in a real app (Apollo useSuspenseQuery behind <Suspense>, several routed pages). It fixes the crash — the app now boots and renders its data instead of dying on first commit. To be clear about the extent of my testing: that's a boot-and-render check plus the repro above, not sustained use, and I haven't run it against your test suite. Happy to open a PR against src/ if the approach looks right.

One thing I did not dig into: whether hideTextInstance blanking .text is enough, or whether the original text needs preserving for anything else. React hands the text back to unhideTextInstance, so the round-trip is fine, but if a tui-text parent caches styled runs off child text there may be an invalidation to do there too.

Related

The reason this took a while to diagnose is a second, independent bug: if the suspending tree is wrapped in an <ErrorBoundary>, the boundary catches this TypeError, and then the reconciler's logCaughtError throws onCaughtError is not a function and kills the process — so you never see the error above. Filed separately as #22.

Environment

  • @orchetron/storm 0.2.0 (latest on npm)
  • react 19.2.8, react-reconciler 0.31.0
  • Node 24.15.0, Linux

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions