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.ts — visibleChildren 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
Summary
hostConfigsetssupportsMutation: truebut never implementshideInstance,unhideInstance,hideTextInstanceorunhideTextInstance. 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 throwsTypeError: callback is not a functionfromcommitMutationEffectsOnFiberthe moment its children resolve. This makes Suspense — and therefore any suspense-based data library (React Query, Apollo'suseSuspenseQuery,React.lazy,use()) — unusable.Reproduction
Fresh install, no framework:
Output:
The fallback and the resolved content both paint, then the commit throws.
Cause
react-reconciler.development.js:10828-10845walks the host instances of an Offscreen tree and callshideInstance/unhideInstanceon each:runWithFiberInDEVinvokes its second argument ascallback, so an undefined host method surfaces as the genericcallback is not a functionrather 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 withdisplay: "none"(src/layout/engine.ts—visibleChildrenfilters, and the early return at thecomputeLayoutentry), so hiding can just flip that prop:I'm running this as a
pnpm patchagainstdist/in a real app (ApollouseSuspenseQuerybehind<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 againstsrc/if the approach looks right.One thing I did not dig into: whether
hideTextInstanceblanking.textis enough, or whether the original text needs preserving for anything else. React hands the text back tounhideTextInstance, so the round-trip is fine, but if atui-textparent 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 thisTypeError, and then the reconciler'slogCaughtErrorthrowsonCaughtError is not a functionand kills the process — so you never see the error above. Filed separately as #22.Environment
@orchetron/storm0.2.0 (latest on npm)react19.2.8,react-reconciler0.31.0