Skip to content

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

Open
GlennBrann wants to merge 1 commit intoSDWebImage:masterfrom
GlennBrann:fix/completion-callbacks-main-thread
Open

Fix: dispatch image load callbacks to main thread atomically#364
GlennBrann wants to merge 1 commit 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

Release Notes

Bug Fixes

  • Improved thread handling for image loading operations to ensure progress and completion callbacks consistently dispatch on the main thread, resulting in more responsive UI updates and enhanced reliability.

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

📝 Walkthrough

Walkthrough

The ImageManager refactors its reactive update mechanism by removing property didSet observers and consolidating state updates into the completion closure with explicit main-thread dispatch and transaction handling.

Changes

Cohort / File(s) Summary
Image Manager Threading & Reactivity
SDWebImageSwiftUI/Classes/ImageManager.swift
Removed automatic objectWillChange triggering from property didSet observers; refactored load() method to dispatch progress and completion updates to main thread with explicit state management via withTransaction block and consolidated callback invocation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 With didSet observers gone, the manager now stands lean,
Threading through main queues with transactions clean,
Progress dispatched, completion awaits its turn,
State flows as one, no scattered burns,
Reactivity refined, a rabbit's delight!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: dispatching callbacks to the main thread atomically. This directly addresses the core problem and fix described in the PR objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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.

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