Skip to content

fix(core): reschedule when requestRender races the loop scheduling - #1086

Closed
PeterPonyu wants to merge 1 commit into
anomalyco:mainfrom
PeterPonyu:fix/opentui-789-reqRender-flag-after-loop
Closed

PeterPonyu wants to merge 1 commit into
anomalyco:mainfrom
PeterPonyu:fix/opentui-789-reqRender-flag-after-loop

Conversation

@PeterPonyu

Copy link
Copy Markdown

Closes #789.

What changed

If requestRender() fires while a frame is in flight (this.rendering is true), it is demoted to setting immediateRerenderRequested and returns. The loop's end-of-frame schedule decision at renderer.ts:3995 reads that flag and reschedules, but a request that arrives between that read and this.rendering = false in the finally block is dropped: the read already happened, and after the finally the renderer goes idle with no pending timer.

The reporter's RCA in #789 describes the symptom: blank screen with rendering=false scheduled=false running=false, the process otherwise healthy, modals still rendering on top — the next paint never fires.

The fix re-checks immediateRerenderRequested inside the loop's finally, clears it, and re-issues the request via setImmediate.

   } finally {
     this.rendering = false
     if (this._destroyPending) {
       this.finalizeDestroy()
+    } else if (this.immediateRerenderRequested) {
+      this.immediateRerenderRequested = false
+      setImmediate(() => {
+        if (!this._isDestroyed) this.requestRender()
+      })
     }
     this.resolveIdleIfNeeded()
   }

Why setImmediate (not nextTick)

On the one-off (paused / idle) scheduling path, activateFrame keeps updateScheduled = true until its own finally runs, and that finally runs after loop()'s finally. process.nextTick fires before microtasks resume, which means it runs before activateFrame's finally — at which point requestRender() is blocked by the !updateScheduled guard at line 1247 and silently no-ops. setImmediate runs after the microtask queue drains, by which point updateScheduled is back to false and the request schedules normally.

How I verified

cd packages/core && bun test src/tests/renderer.control.test.ts — 29 pass, 0 fail. New test "loop() finally reschedules when requestRender races the schedule decision (#789)" exercises the one-off path:

  1. Start + pause renderer
  2. Monkey-patch clock.clearTimeout (called in the loop's else branch at line 4004) to fire a sync requestRender() while rendering is still true
  3. Assert renderCount >= 2

Confirmed the test fails without the fix (Received: 1) and passes with it by reverting renderer.ts and re-running.

Also ran the broader bun test src/tests/renderer* sweep — 408 pass, 0 fail. bunx oxfmt applied.

…nomalyco#789)

If `requestRender()` fires while a frame is in flight (`this.rendering`
true), it is demoted to setting `immediateRerenderRequested` and
returns. The loop's end-of-frame schedule decision at line 3995
reads that flag — but a request that arrives between that read and
`this.rendering = false` in the finally block is dropped: the read
already happened, and after the finally the renderer goes idle with
no pending timer.

Per the reporter's RCA in anomalyco#789, this manifests as a blank screen with
the process healthy (`rendering=false scheduled=false running=false`)
and modals still rendering correctly on top — the next paint never
fires.

Re-check `immediateRerenderRequested` inside the loop's finally,
clear it, and re-issue. Defer the re-issue with `setImmediate`: on
the one-off (paused / idle) scheduling path, `activateFrame` keeps
`updateScheduled = true` until its own finally runs, which is after
ours. `process.nextTick` would fire before that, before the
`!updateScheduled` guard in `requestRender()` would let us through.
`setImmediate` runs after the microtask queue drains, by which point
`activateFrame`'s finally has cleared the flag.

The regression test exercises the one-off path explicitly (paused
mode), monkey-patches `clock.clearTimeout` to fire a sync
requestRender while `rendering` is still true, and asserts that at
least a second frame still happens. Without the fix, only one frame
renders before the renderer stalls.

Closes anomalyco#789

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render loop silently stalls when rendering flag blocks requestRender during event burst

2 participants