Skip to content

Fix: dispatch image load callbacks to main thread atomically#364

Open
GlennBrann wants to merge 2 commits intoSDWebImage:masterfrom
GlennBrann:fix/completion-callbacks-main-thread
Open

Fix: dispatch image load callbacks to main thread atomically#364
GlennBrann wants to merge 2 commits intoSDWebImage:masterfrom
GlennBrann:fix/completion-callbacks-main-thread

Conversation

@GlennBrann
Copy link

@GlennBrann GlennBrann commented Mar 6, 2026

Problem

SDWebImage invokes progress and completion callbacks on background threads. ImageManager writes to its
@Published-equivalent properties directly in those callbacks, which is a data race — SwiftUI reads
these properties on the main thread during rendering.

Additionally, the existing didSet approach fires objectWillChange.send() once per property (up to 5
times per load completion), scheduling 5 separate SwiftUI render passes for what should be a single update.

Fix

Move the entire body of both the progress and completion callbacks into DispatchQueue.main.async.
This ensures:

  • All property writes happen on the main thread
  • A single objectWillChange.send() before all mutations collapses them into one render pass
  • indicatorStatus.progress (previously written off-main in the progress block) is also safe

The didSet observers on image, imageData, cacheType, error, and isIncremental are removed
since objectWillChange is now sent manually and correctly timed.

Relationship to PR #359

PR #359 addresses the same issue but via a per-instance DispatchQueue on the callback closures.
The maintainer raised a valid concern about queue proliferation (one queue per ImageManager = one per
image cell in a grid). This approach avoids that entirely — no new synchronization primitives are added.

Summary by CodeRabbit

  • Bug Fixes
    • Progress and completion callbacks for image loading now always run on the main thread, improving UI responsiveness and reliability.
    • Reduced redundant change notifications during image load updates, leading to fewer unnecessary UI refreshes and smoother rendering.

Progress and completion callbacks from SDWebImage are invoked on
background threads. Writing ObservableObject-published properties
off the main thread is a data race with SwiftUI's rendering.

Changes:
- Remove per-property didSet dispatches (up to 5 render passes each load)
- Move entire progress block body into DispatchQueue.main.async so
  indicatorStatus.progress is always written on main thread
- Move entire completion block body into DispatchQueue.main.async so
  all property writes are atomic and on the correct thread
- Single objectWillChange.send() before all mutations -> one render pass

Avoids the per-instance DispatchQueue overhead raised in PR SDWebImage#359
while fixing the same threading bugs more directly.
@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: be85f9df-5586-4d55-940c-8027f3eb0c68

📥 Commits

Reviewing files that changed from the base of the PR and between 260b16b and f442952.

📒 Files selected for processing (1)
  • SDWebImageSwiftUI/Classes/AnimatedImage.swift

📝 Walkthrough

Walkthrough

ImageManager centralizes state updates and ensures progress/completion callbacks run on the main thread; AnimatedImage also moves its progress callback to the main thread.

Changes

Cohort / File(s) Summary
Image Manager Threading & Reactivity
SDWebImageSwiftUI/Classes/ImageManager.swift
Removed property didSet observers that auto-triggered objectWillChange; load(url:options:context:) now dispatches progress and completion to the main thread, emits a single objectWillChange, and updates state inside a withTransaction block; unified handling of cancelled errors and callback invocation.
Animated Image Progress Dispatch
SDWebImageSwiftUI/Classes/AnimatedImage.swift
Progress callback invocation moved onto the main thread and invoked via self.imageHandler.progressBlock? inside the progress handler to ensure main-thread delivery.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped through threads and tidied state,

Progress now lands where UI awaits,
One change of will, one tidy spin,
Callbacks on main — neat as a pin!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: dispatching image load callbacks (progress and completion) to the main thread atomically to fix data races and consolidate state updates.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
SDWebImageSwiftUI/Classes/ImageManager.swift (1)

70-75: Redundant guard let self before dispatch.

Line 71's guard let self creates a strong reference that is immediately re-captured weakly by [weak self] on line 74. This pattern is confusing and unnecessary.

Remove line 71 and keep only the guard inside the async block:

♻️ Proposed fix
         }) { [weak self] (image, data, error, cacheType, finished, _) in
-            guard let self else { return }
             // Completion may be called on any thread — always dispatch to main so
             // withTransaction and all property writes (SwiftUI APIs) are thread-safe.
             DispatchQueue.main.async { [weak self] in
                 guard let self else { return }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@SDWebImageSwiftUI/Classes/ImageManager.swift` around lines 70 - 75, In the
completion closure inside ImageManager.swift remove the outer immediate strong
capture check (the first "guard let self" before DispatchQueue.main.async) and
rely only on the inner guard in the DispatchQueue.main.async block; ensure the
outer closure uses [weak self] in its capture list and the inner async block
still uses [weak self] with "guard let self else { return }" so all SwiftUI
property writes (in methods like withTransaction or any property mutations
inside ImageManager) happen on the main thread and you avoid the redundant
strong capture.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@SDWebImageSwiftUI/Classes/ImageManager.swift`:
- Around line 97-99: The failure path in ImageManager currently passes an empty
NSError() to failureBlock when image is nil and error is nil; replace that with
a meaningful error (either an SDWebImage error constant or a newly constructed
NSError) so callers get diagnostics. In practice, update the else branch that
calls self.failureBlock?(error ?? NSError()) to build and pass a domain-specific
NSError (e.g. domain "SDWebImageSwiftUI.ImageManager", nonzero code, and
NSLocalizedDescriptionKey like "Image is nil but no error returned") or map to
an appropriate SDWebImage error type before invoking failureBlock.

---

Nitpick comments:
In `@SDWebImageSwiftUI/Classes/ImageManager.swift`:
- Around line 70-75: In the completion closure inside ImageManager.swift remove
the outer immediate strong capture check (the first "guard let self" before
DispatchQueue.main.async) and rely only on the inner guard in the
DispatchQueue.main.async block; ensure the outer closure uses [weak self] in its
capture list and the inner async block still uses [weak self] with "guard let
self else { return }" so all SwiftUI property writes (in methods like
withTransaction or any property mutations inside ImageManager) happen on the
main thread and you avoid the redundant strong capture.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2e8b390c-b956-4e8f-b7cb-3ecc1cbff994

📥 Commits

Reviewing files that changed from the base of the PR and between d1f7b2b and 260b16b.

📒 Files selected for processing (1)
  • SDWebImageSwiftUI/Classes/ImageManager.swift

Comment on lines +97 to +99
} else {
self.failureBlock?(error ?? NSError())
}
Copy link

@coderabbitai coderabbitai bot Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace NSError() with a meaningful error.

When image is nil but error is also nil (unexpected edge case), the failure block receives an empty NSError() with no domain, code, or description. This provides no diagnostic value.

Consider creating a domain-specific error or using SDWebImage's error types:

🛠️ Proposed fix
                         } else {
-                            self.failureBlock?(error ?? NSError())
+                            self.failureBlock?(error ?? NSError(domain: "SDWebImageSwiftUI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unknown image loading failure"]))
                         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else {
self.failureBlock?(error ?? NSError())
}
} else {
self.failureBlock?(error ?? NSError(domain: "SDWebImageSwiftUI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unknown image loading failure"]))
}
🧰 Tools
🪛 SwiftLint (0.63.2)

[Warning] 98-98: Discouraged direct initialization of types that can be harmful

(discouraged_direct_init)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@SDWebImageSwiftUI/Classes/ImageManager.swift` around lines 97 - 99, The
failure path in ImageManager currently passes an empty NSError() to failureBlock
when image is nil and error is nil; replace that with a meaningful error (either
an SDWebImage error constant or a newly constructed NSError) so callers get
diagnostics. In practice, update the else branch that calls
self.failureBlock?(error ?? NSError()) to build and pass a domain-specific
NSError (e.g. domain "SDWebImageSwiftUI.ImageManager", nonzero code, and
NSLocalizedDescriptionKey like "Image is nil but no error returned") or map to
an appropriate SDWebImage error type before invoking failureBlock.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was existing so hesitant about changing this.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GlennBrann — That's a fair point. Since this code predates the PR, changing it here would be out of scope. Would you like me to open a follow-up issue to track improving the fallback error (and suppress the SwiftLint discouraged_direct_init warning) in a future PR?

(˶˃ ᵕ ˂˶)

@dreampiggy
Copy link
Collaborator

dreampiggy commented Mar 8, 2026

The only behavior break for exists SDWebImage objc user (I Doubt whether there are), is that progressBlock is called on the main queue, instead of global queue.

The history reason why I hacked there using custom objectChangeHandler also contains this.

But, I agree for SwiftUI user, no actual user who want the handle block which is called on the non-main queue. But this progress block behavior changes should also effect some other code in this repo. You can check that ProgressiveView or any other code in WebImage

And, for AnimatedImage since we designed to make them equivalent, that progressBlock on AnimatedImage should also dispatch on main queue ?

So, in conclusion. Please ensure that whatever code that trigger the final user code's progress callback, is exeucted on the main queue.

Mirrors the ImageManager fix: move the user-facing progressBlock call
inside DispatchQueue.main.async so it is always invoked on the main
thread, consistent with WebImage behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@GlennBrann
Copy link
Author

The only behavior break for exists SDWebImage objc user (I Doubt whether there are), is that progressBlock is called on the main queue, instead of global queue.

The history reason why I hacked there using custom objectChangeHandler also contains this.

But, I agree for SwiftUI user, no actual user who want the handle block which is called on the non-main queue. But this progress block behavior changes should also effect some other code in this repo. You can check that ProgressiveView or any other code in WebImage

And, for AnimatedImage since we designed to make them equivalent, that progressBlock on AnimatedImage should also dispatch on main queue ?

So, in conclusion. Please ensure that whatever code that trigger the final user code's progress callback, is exeucted on the main queue.

@dreampiggy Good catch. I applied the same fix to AnimatedImage. The progressBlock call is now inside the DispatchQueue.main.async block alongside the indicator update, so both always run on the main thread. Pushed in the latest commit.

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.

2 participants