Fix NSHostingView async rendering cycle on macOS#898
Conversation
Align requestUpdate(after:), setNeedsUpdate(), and layout() with SwiftUI 6.5.x so TimelineView no longer triggers a repeated CoreDisplayLink create/destroy cycle on macOS. - requestUpdate(after:): match SwiftUI's "delay == 0 || !isUpdating" branch. When delay > 0 and isUpdating, schedule setNeedsUpdate() via onNextMainRunLoop instead of setting needsDeferredUpdate. - setNeedsUpdate(): when isUpdating, set needsDeferredUpdate instead of triggering a reentrant setNeedsLayout. - layout() inner closure: repeat-while (max 8 iterations) with a !viewGraph.mayDeferUpdate guard before startAsyncRendering(), matching SwiftUI's structure. Drop the OSUI-only fallback.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #898 +/- ##
==========================================
- Coverage 26.71% 26.70% -0.02%
==========================================
Files 697 697
Lines 48907 48910 +3
==========================================
- Hits 13066 13061 -5
- Misses 35841 35849 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
🤖 Augment PR SummarySummary: Prevents a macOS Changes:
Technical Notes: macOS-only behavior change intended to match SwiftUI 26.5 🤖 Was this summary useful? React with 👍 or 👎 |
| needsDeferredUpdate = false | ||
| } | ||
| iteration += 1 | ||
| } while needsDeferredUpdate && iteration < 8 |
There was a problem hiding this comment.
If needsDeferredUpdate remains true after hitting the iteration < 8 cap, there’s no follow-up scheduling (e.g. another setNeedsUpdate()), so the view could drop an update and get stuck. Is it guaranteed that this condition can’t persist beyond the loop?
Severity: medium
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
There was a problem hiding this comment.
Pull request overview
This PR updates the macOS NSHostingView update scheduling and layout/render loop behavior to avoid a cross start/stop async rendering cycle triggered by TimelineView(.animation), aligning behavior with the macOS 26.5 SwiftUI NSHostingView binary.
Changes:
- Updates
requestUpdate(after:)so that updates requested duringrender()with a non-zero delay are scheduled viaonNextMainRunLoop { setNeedsUpdate() }(instead of togglingneedsDeferredUpdateand entering the async rendering path). - Changes
setNeedsUpdate()to avoid re-entrant layout scheduling whileisUpdatingby settingneedsDeferredUpdateinstead. - Reworks
layout()’s internal render loop to repeat while deferred updates are requested (up to 8 iterations) and only start async rendering when!viewGraph.mayDeferUpdate.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| needsDeferredUpdate = false | ||
| } | ||
| iteration += 1 | ||
| } while needsDeferredUpdate && iteration < 8 |
|
/uitest macos |
Summary
Fixes a cross-start/stop async rendering loop in
NSHostingViewtriggered byTimelineView(.animation)on macOS.Root cause
When
requestUpdate(after:)is called duringrender()(isUpdating == true) with a non-zero delay — e.g. ~16 ms fromTimelineView's next-frame schedule — OpenSwiftUI directly setneedsDeferredUpdate = true, which caused thelayout()closure to callstartAsyncRendering()and spin up aCoreDisplayLink.renderAsynctypically returnednil(e.g. due to pending transactions or incompleteupdateOutputsAsync), destroying the display link and queuing anotherrequestUpdate(after: 0)— repeating every layout pass.Changes
requestUpdate(after:): match SwiftUI'sdelay == 0 || !isUpdatingbranch.setNeedsUpdate(): whenisUpdating, setneedsDeferredUpdate = trueinstead of callingsetNeedsLayoutreentrantly.layout()inner closure: repeat-while (max 8 iterations) with!viewGraph.mayDeferUpdateguard beforestartAsyncRendering(). Drop the OSUI-onlyonNextMainRunLoop { setNeedsUpdate() }fallback (not present in SwiftUI).iOS / visionOS hosting paths (
UIHostingViewBase) are unaffected — this change is#if os(macOS)only.