Repository navigation
Fix gray thumbnails and title-only cards in Reader related posts - #26264
Conversation
A related post whose first media is a video came with a YouTube embed URL as its featured media. The cell tried to load it as an image and showed a gray box. A post with no excerpt and no image showed only its title, which looked like a stray heading. Only image media is used for the thumbnail now, the cell hides the parts it does not have, and posts with neither an image nor an excerpt are left out of the section.
…e next to the cell
Generated by 🚫 Danger |
jkmassel
left a comment
There was a problem hiding this comment.
CMM-2593 and CMM-2594 are fixed ✅. On the Art post from the description, "Embed gallery: services and sizes" shows its title and excerpt with no gray box, and "Comment Crash" is gone. The new RemoteReaderSimplePostTests pass. Checked on an iPhone 18 Pro simulator (iOS 27.0) in light mode. iPad and dark mode were not checked.
CMM-2595 is not fixed. The thumbnails stay gray because image addresses that redirect are rejected. Details and a fix are in the inline comment.
Two minor things, not blocking:
hasContentForRelatedPostCarddecides which posts are dropped, but no test covers it. An excerpt with only HTML and an image with no excerpt would be good cases.- The excerpt is converted to plain text twice for each post: once in the filter, once in
configure(for:).
| } | ||
| let featuredImageURL = post.featuredImageURL | ||
| featuredImageView.isHidden = featuredImageURL == nil | ||
| featuredImageView.downloadImage(from: featuredImageURL) |
There was a problem hiding this comment.
This doesn't fix CMM-2595. The thumbnails in that issue still stay gray with this PR. The cause is in downloadImage, not in which address we pick.
What happens
- Both posts in CMM-2595 have a valid
featured_image, onen-blog.files.wordpress.com. This PR picks the same address as before. *.files.wordpress.comanswers with a 301 redirect toen.blog.wordpress.com/wp-content/uploads/….URLSessionfollows the redirect, soresponse.urlis the new address.downloadImage(usingRequest:)only accepts the image whenresponse?.url == url. After any redirect that's false, so it reportsImageDownloadError.urlMismatchand the view keeps its gray background.
Who is affected: any image loaded through downloadImage whose address redirects. Older WordPress.com posts use files.wordpress.com addresses, so in related posts it's mostly older WordPress.com posts.
Suggested fix
UIImageView+Networking.swift isn't part of this PR's diff, so this can't be a suggestion:
DispatchQueue.main.async {
- if response?.url == url {
- ImageCache.shared.setImage(image, forKey: url.absoluteString)
- handleSuccess(image, url)
- } else {
- failure?(ImageDownloadError.urlMismatch)
- }
-
+ ImageCache.shared.setImage(image, forKey: url.absoluteString)
+ handleSuccess(image, url)
self?.downloadTask = nil
}The check looks like protection against reused cells, but it compares the response with the address the same call asked for, so it never catches a reused cell. Starting a new download already cancels the old one. This changes every caller of downloadImage, not just Reader. If you'd rather keep this PR to Reader, dropping "Fixes CMM-2595" from the description works too.
How this was checked
| Check | Result |
|---|---|
curl -L on both featured_image addresses |
200 after 1 redirect |
| Scratch unit test, fake server, direct image | Loads ✅ |
| Scratch unit test, fake server, image behind a 301 | Fails with urlMismatch ❌ |
| Same test with the fix above | Loads ✅ (all 38 WordPressUIUnitTests pass) |
| Simulator, this PR, "Stop Paying Extra to Keep Your Site Safe" → More from WordPress.com Blog | Both thumbnails gray |
| Same build with only the check skipped | Both thumbnails load |
| This PR | Check skipped |
|---|---|
![]() |
![]() |
Scratch test
import Foundation
import Testing
import UIKit
@testable import WordPressUI
/// Serves `/old.png` as a 301 to `/new.png`, and `/new.png` (or `/direct.png`) as a PNG.
private final class RedirectingImageProtocol: URLProtocol {
static let png: Data = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).pngData { context in
UIColor.red.setFill()
context.fill(CGRect(x: 0, y: 0, width: 4, height: 4))
}
override class func canInit(with request: URLRequest) -> Bool {
request.url?.host == "scratch-redirect.test"
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let url = request.url!
if url.path == "/old.png" {
let target = URL(string: "https://scratch-redirect.test/new.png")!
let response = HTTPURLResponse(url: url, statusCode: 301, httpVersion: "HTTP/1.1",
headerFields: ["Location": target.absoluteString])!
client?.urlProtocol(self, wasRedirectedTo: URLRequest(url: target), redirectResponse: response)
return
}
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "image/png"])!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Self.png)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
@MainActor
@Suite(.serialized)
struct ScratchImageRedirectTests {
private func load(_ urlString: String) async -> String {
URLProtocol.registerClass(RedirectingImageProtocol.self)
defer { URLProtocol.unregisterClass(RedirectingImageProtocol.self) }
let imageView = UIImageView()
imageView.backgroundColor = .placeholderText
return await withCheckedContinuation { continuation in
imageView.downloadImage(
from: URL(string: urlString),
success: { _ in continuation.resume(returning: "success") },
failure: { error in continuation.resume(returning: "failure: \(String(describing: error))") }
)
}
}
@Test func directImageLoads() async {
let result = await load("https://scratch-redirect.test/direct.png")
#expect(result == "success")
}
/// `https://en-blog.files.wordpress.com/...jpg` answers 301 → `https://en.blog.wordpress.com/wp-content/uploads/...jpg`.
@Test func redirectedImageLoads() async {
let result = await load("https://scratch-redirect.test/old.png")
#expect(result == "success")
}
}
jkmassel
left a comment
There was a problem hiding this comment.
Requesting changes so this doesn't merge while the description still says it fixes CMM-2595. Those thumbnails still stay gray with this PR (details, proof and a fix in #26264 (comment)).
Either change works for me:
- Apply the fix in
UIImageView+Networking.swift. - Drop "Fixes CMM-2595" from the description and handle it separately.
The CMM-2593 and CMM-2594 changes look good.
|
I incorrectly assumed 2595 is fixed, too. I have removed it and will create a separate PR for that. |
jkmassel
left a comment
There was a problem hiding this comment.
Approving now that CMM-2595 has moved to #26296.
Checked again at 2da1fad05d:
- CMM-2593 and CMM-2594 are fixed ✅. We compared trunk and this branch on an iPhone 18 Pro simulator (iOS 27.0), on a real post whose related posts use YouTube embeds as their featured media. Video posts no longer show a gray box, and a title-only post is left out.
- Every live related post we sampled has a
typeon itsfeatured_media(227 posts: 144 image, 13 video, 70 empty). So requiringtype == "image"doesn't drop real images. It also matches the rule inRemoteReaderPost.m. - iPad and dark mode were not checked.
The earlier nits still apply and don't block. One more: RemoteReaderSimplePostTests.swift changes when you run swift format on it.


Description
Fixes https://linear.app/a8c/issue/CMM-2593/related-posts-in-one-section-look-inconsistent
Fixes https://linear.app/a8c/issue/CMM-2594/related-post-shows-a-gray-box-for-a-video-post
To reproduce the issues: In Reader, open Text after a calendar embed and scroll to More from Art. “Embed gallery: services and sizes” shows a gray box, while “Comment Crash” shows only a title and looks like a stray heading.
Testing instructions
On an iPad in dark mode, open the post above in Reader and scroll to More from Art. Confirm that “Embed gallery: services and sizes” shows its title and excerpt without a gray box, and “Comment Crash” is absent. Check that related posts with images still show their thumbnails.