Skip to content

react-reconciler: flushSyncWork → commitRoot calls forward-declared hoisted flushPendingEffects as undefined (next wall after #5015) #5020

@proggeramlug

Description

@proggeramlug

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_get0x1/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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions