Skip to content

Buffer async CallInvoker work with module calls - #58313

Open
javache wants to merge 1 commit into
react:mainfrom
javache:export-D118456662
Open

Buffer async CallInvoker work with module calls#58313
javache wants to merge 1 commit into
react:mainfrom
javache:export-D118456662

Conversation

@javache

@javache javache commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary:
In bridgeless, native reaches JS by two routes that end in the same
RuntimeScheduler queue but get there differently. callFunctionOnModule goes
through the instance's BufferedRuntimeExecutor; the CallInvoker goes
straight to scheduleTask. The CallInvoker therefore skips the buffer entirely
and can reach the runtime while a module call issued earlier is still parked,
unflushed, because the bundle is mid-evaluation. Native code that issues both
cannot rely on the order it issued them in, and Task is a min-heap on
now() + timeout(priority) with no insertion tiebreak, so equal priorities do
not settle it either.

Gives the two channels the same buffering. BufferedRuntimeExecutor gains a
priority-carrying execute, so work routed through it keeps the scheduler
priority it was submitted with instead of collapsing to the executor default,
and buffered work from both overloads stays in one submission-ordered stream.
BufferedCallInvoker sits on that executor and becomes the bridgeless
jsCallInvoker on Android, iOS and macOS.

invokeSync deliberately keeps going straight to the scheduler: a synchronous
call cannot wait for a flush that only happens once the bundle has run.

Behind enableBufferedCallInvoker, default true. ReactInstance picks between
the buffered invoker and the existing RuntimeSchedulerCallInvoker in one
place, so the platform call sites are identical either way and the change is
revertible at runtime — it moves when native-issued async work first reaches JS
during startup, which is the intended contract but affects every native module.

One lifetime hazard this surfaces, worth knowing about beyond this diff:
BufferedRuntimeExecutor reaches the scheduler through a raw pointer captured
at construction, which is safe only while the owning instance is alive. A
CallInvoker is routinely held across instance teardown, so BufferedCallInvoker
guards every async dispatch on a weak reference to the scheduler and drops the
work when it has expired — the same contract RuntimeSchedulerCallInvoker has.
Without that guard this reliably segfaults on a reload.

Changelog:
[General][Changed] - Async CallInvoker work is now buffered alongside callable module calls, so it no longer runs before the JS bundle has finished evaluating

Differential Revision: D118456662

Summary:
In bridgeless, native reaches JS by two routes that end in the same
`RuntimeScheduler` queue but get there differently. `callFunctionOnModule` goes
through the instance's `BufferedRuntimeExecutor`; the `CallInvoker` goes
straight to `scheduleTask`. The CallInvoker therefore skips the buffer entirely
and can reach the runtime while a module call issued earlier is still parked,
unflushed, because the bundle is mid-evaluation. Native code that issues both
cannot rely on the order it issued them in, and `Task` is a min-heap on
`now() + timeout(priority)` with no insertion tiebreak, so equal priorities do
not settle it either.

Gives the two channels the same buffering. `BufferedRuntimeExecutor` gains a
priority-carrying `execute`, so work routed through it keeps the scheduler
priority it was submitted with instead of collapsing to the executor default,
and buffered work from both overloads stays in one submission-ordered stream.
`BufferedCallInvoker` sits on that executor and becomes the bridgeless
`jsCallInvoker` on Android, iOS and macOS.

`invokeSync` deliberately keeps going straight to the scheduler: a synchronous
call cannot wait for a flush that only happens once the bundle has run.

Behind `enableBufferedCallInvoker`, default true. `ReactInstance` picks between
the buffered invoker and the existing `RuntimeSchedulerCallInvoker` in one
place, so the platform call sites are identical either way and the change is
revertible at runtime — it moves when native-issued async work first reaches JS
during startup, which is the intended contract but affects every native module.

One lifetime hazard this surfaces, worth knowing about beyond this diff:
`BufferedRuntimeExecutor` reaches the scheduler through a raw pointer captured
at construction, which is safe only while the owning instance is alive. A
CallInvoker is routinely held across instance teardown, so `BufferedCallInvoker`
guards every async dispatch on a weak reference to the scheduler and drops the
work when it has expired — the same contract `RuntimeSchedulerCallInvoker` has.
Without that guard this reliably segfaults on a reload.

Changelog:
[General][Changed] - Async `CallInvoker` work is now buffered alongside callable module calls, so it no longer runs before the JS bundle has finished evaluating

Differential Revision: D118456662
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 3, 2026
@facebook-github-tools facebook-github-tools Bot added p: Facebook Partner: Facebook Partner labels Sep 3, 2026
@meta-codesync

meta-codesync Bot commented Sep 3, 2026

Copy link
Copy Markdown

@javache has exported this pull request. If you are a Meta employee, you can view the originating Diff in D118456662.

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. meta-exported p: Facebook Partner: Facebook Partner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant