Summary
After #5019 (queueMicrotask/structuredClone/atob/btoa as first-class function values) fixed #5015, react-reconciler's updateContainerSync succeeds, but the next call — flushSyncWork() — throws TypeError: value is not a function. This is a distinct bug from #5015: it reproduces even with an arrow scheduleMicrotask (so it is independent of the queueMicrotask value fix).
This is the next wall on the ink end-to-end path (#348), immediately after #5015.
Root cause (localized)
The non-callable is a 0-arg call inside commitRoot in react-reconciler.production.js:
function commitRoot(root, finishedWork, …) {
root.cancelPendingCommit = null;
do flushPendingEffects(); // <-- flushPendingEffects reads back as `undefined`
while (0 !== pendingEffectsStatus);
…
flushMutationEffects();
flushLayoutEffects();
flushSpawnedWork();
}
flushPendingEffects (and its siblings) are forward-referenced, hoisted sibling function declarations, defined after commitRoot inside the giant module.exports = function($$$config){ … } factory, and captured by commitRoot as boxed (mutable) captures. At the time commitRoot runs (during flushSyncWork), the captured box for flushPendingEffects still holds undefined — i.e. the function-declaration's box assignment and the captured box have desynced (or the late assignment never reached the captured box).
Confirmed via --debug-symbols + lldb: the failing js_closure_call0 site is the do flushPendingEffects() call (the instruction reads js_closure_get_capture_f64(this, N) → js_box_get → 0x1/undefined), immediately after the cancelPendingCommit = null js_put_value_set.
Repro
import React from 'react';
import createReconciler from 'react-reconciler';
const noop = () => {};
const rec: any = createReconciler({
supportsMutation: true, supportsMicrotasks: true,
getRootHostContext: () => ({}), getChildHostContext: () => ({}), getPublicInstance: (i: any)=>i,
prepareForCommit: () => null, resetAfterCommit: noop, clearContainer: noop,
createInstance: () => ({ children: [] }), createTextInstance: () => ({ text: '' }),
appendInitialChild: noop, finalizeInitialChildren: () => false, shouldSetTextContent: () => false,
appendChild: noop, appendChildToContainer: noop, insertBefore: noop, removeChild: noop, removeChildFromContainer: noop,
commitUpdate: noop, commitTextUpdate: noop, detachDeletedInstance: noop, getCurrentEventPriority: () => 0,
maySuspendCommit: () => false,
// independent of #5015: an arrow here still reproduces
scheduleMicrotask: (cb: any) => queueMicrotask(cb), scheduleTimeout: setTimeout, cancelTimeout: clearTimeout, noTimeout: -1,
preparePortalMount: noop, prepareScopeUpdate: noop, getInstanceFromScope: () => null, getInstanceFromNode: () => null,
beforeActiveInstanceBlur: noop, afterActiveInstanceBlur: noop,
});
const container = rec.createContainer({ children: [] }, 0, null, false, null, 'id', ()=>{}, ()=>{}, ()=>{}, null);
function App() { return React.createElement('host', null, 'hi'); }
rec.updateContainerSync(React.createElement(App), container, null, noop); // OK after #5019
rec.flushSyncWork(); // → TypeError: value is not a function (flushPendingEffects undefined)
(compilePackages: ["react","react-reconciler","scheduler"], perry.define NODE_ENV=production.)
Notes
Related
Summary
After #5019 (queueMicrotask/structuredClone/atob/btoa as first-class function values) fixed #5015, react-reconciler's
updateContainerSyncsucceeds, but the next call —flushSyncWork()— throwsTypeError: value is not a function. This is a distinct bug from #5015: it reproduces even with an arrowscheduleMicrotask(so it is independent of the queueMicrotask value fix).This is the next wall on the ink end-to-end path (#348), immediately after #5015.
Root cause (localized)
The non-callable is a 0-arg call inside
commitRootinreact-reconciler.production.js:flushPendingEffects(and its siblings) are forward-referenced, hoisted sibling function declarations, defined aftercommitRootinside the giantmodule.exports = function($$$config){ … }factory, and captured bycommitRootas boxed (mutable) captures. At the timecommitRootruns (duringflushSyncWork), the captured box forflushPendingEffectsstill holdsundefined— i.e. the function-declaration's box assignment and the captured box have desynced (or the late assignment never reached the captured box).Confirmed via
--debug-symbols+ lldb: the failingjs_closure_call0site is thedo flushPendingEffects()call (the instruction readsjs_closure_get_capture_f64(this, N)→js_box_get→0x1/undefined), immediately after thecancelPendingCommit = nulljs_put_value_set.Repro
(
compilePackages: ["react","react-reconciler","scheduler"],perry.defineNODE_ENV=production.)Notes
ReactSharedInternals.Hdispatcher invisible to react → 'Invalid hook call' null dispatcher (blocks all React renderers) #4950 / Captured Object.assign alias causes react-reconciler HostRoot render hang #2564 (cross-module / captured callable identity).crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs(nested fn-decl →Stmt::Let { Closure }, capture/mutable-capture detection + HIR: function-scopedfunctiondeclarations not hoisted — silent runtime undefined #569 hoisting) and the codegen box-allocation/sharing for hoisted function-decl locals.Related
updateContainerSyncthrows 'value is not a function' on first render (minimal 30-line repro, no ink) — blocks all React renderers #5015 / fix(hir): queueMicrotask/structuredClone/atob/btoa as first-class function values (#5015) #5019 (the wall this one is immediately behind), Compileink(React-based TUI framework) end-to-end viaperry.compilePackages#348 (ink end-to-end)