From 737dec7819a263b8a7a4b6a70b121807d3a46fa5 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Fri, 10 Jul 2026 22:40:44 -0700 Subject: [PATCH 1/3] [Fix] Avoid caching failed image inflation --- AsyncImageView/Renderers/CacheRenderer.swift | 20 ++-- .../Renderers/FallbackRenderer.swift | 3 +- .../Renderers/ImageInflaterRenderer.swift | 101 +++++++++++++----- .../Renderers/ImageProcessingRenderer.swift | 6 +- .../Renderers/MulticastedRenderer.swift | 57 +++++++++- AsyncImageView/Renderers/Renderer.swift | 18 ++++ AsyncImageViewTests/CacheRendererSpec.swift | 69 ++++++++++++ .../ImageInflaterRendererSpec.swift | 3 +- 8 files changed, 231 insertions(+), 46 deletions(-) create mode 100644 AsyncImageViewTests/CacheRendererSpec.swift diff --git a/AsyncImageView/Renderers/CacheRenderer.swift b/AsyncImageView/Renderers/CacheRenderer.swift index 4c20fb7f..e7a7ccf1 100644 --- a/AsyncImageView/Renderers/CacheRenderer.swift +++ b/AsyncImageView/Renderers/CacheRenderer.swift @@ -32,7 +32,7 @@ public final class CacheRenderer< public func renderImageWithData(_ data: Renderer.Data) -> SignalProducer { return SignalProducer { [cache = self.cache] in Result( - cache.valueForKey(data)?.image.asCacheHit, + cache.valueForKey(data)?.asCacheHit, failWith: CacheRendererError.imageNotFound ) } @@ -41,9 +41,11 @@ public final class CacheRenderer< return renderer .renderImageWithData(data) .on(value: { [cache = self.cache] result in - cache.setValue(result, forKey: data) + if result.shouldCache { + cache.setValue(result, forKey: data) + } }) - .map { $0.image.asCacheMiss } + .map { $0.asCacheMiss } } } } @@ -86,18 +88,20 @@ private enum CacheRendererError: Error { case imageNotFound } -private extension UIImage { +private extension RenderResultType { var asCacheHit: ImageResult { return ImageResult( - image: self, - cacheHit: true + image: self.image, + cacheHit: true, + shouldCache: self.shouldCache ) } var asCacheMiss: ImageResult { return ImageResult( - image: self, - cacheHit: false + image: self.image, + cacheHit: false, + shouldCache: self.shouldCache ) } } diff --git a/AsyncImageView/Renderers/FallbackRenderer.swift b/AsyncImageView/Renderers/FallbackRenderer.swift index 4e717acf..343e0e11 100644 --- a/AsyncImageView/Renderers/FallbackRenderer.swift +++ b/AsyncImageView/Renderers/FallbackRenderer.swift @@ -60,7 +60,8 @@ extension RenderResultType { fileprivate var asResult: ImageResult { return ImageResult( image: self.image, - cacheHit: self.cacheHit + cacheHit: self.cacheHit, + shouldCache: self.shouldCache ) } } diff --git a/AsyncImageView/Renderers/ImageInflaterRenderer.swift b/AsyncImageView/Renderers/ImageInflaterRenderer.swift index 0c350feb..7d18272d 100644 --- a/AsyncImageView/Renderers/ImageInflaterRenderer.swift +++ b/AsyncImageView/Renderers/ImageInflaterRenderer.swift @@ -21,30 +21,53 @@ public final class ImageInflaterRenderer: RendererType { private let screenScale: CGFloat private let opaque: Bool private let contentMode: ContentMode + private let bitmapContextFactory: UIImage.BitmapContextFactory - public init( + public convenience init( renderer: Renderer, screenScale: CGFloat, opaque: Bool, contentMode: ContentMode = .defaultMode ) { + self.init( + renderer: renderer, + screenScale: screenScale, + opaque: opaque, + contentMode: contentMode, + bitmapContextFactory: UIImage.makeBitmapContext + ) + } + + internal init( + renderer: Renderer, + screenScale: CGFloat, + opaque: Bool, + contentMode: ContentMode = .defaultMode, + bitmapContextFactory: @escaping UIImage.BitmapContextFactory + ) { self.renderer = renderer self.screenScale = screenScale self.opaque = opaque self.contentMode = contentMode + self.bitmapContextFactory = bitmapContextFactory } public func renderImageWithData(_ data: Data) -> SignalProducer { return self.renderer.renderImageWithData(data) - .map { [screenScale = self.screenScale, opaque = self.opaque, contentMode = self.contentMode] result in - let inflatedImage = result.image.inflate( + .map { [screenScale = self.screenScale, opaque = self.opaque, contentMode = self.contentMode, bitmapContextFactory = self.bitmapContextFactory] result in + let inflationResult = result.image.inflate( withSize: data.size, scale: screenScale, opaque: opaque, - contentMode: contentMode + contentMode: contentMode, + bitmapContextFactory: bitmapContextFactory ) - return ImageResult(image: inflatedImage, cacheHit: result.cacheHit) + return ImageResult( + image: inflationResult.image, + cacheHit: result.cacheHit, + shouldCache: result.shouldCache && inflationResult.didProcess + ) } .start(on: QueueScheduler()) } @@ -78,17 +101,37 @@ extension UIImage { _ bitmapInfo: UInt32 ) -> CGContext? - internal func inflate( - withSize size: CGSize, - scale: CGFloat, - opaque: Bool, - contentMode: ImageInflaterRendererContentMode - ) -> UIImage { + internal static func makeBitmapContext( + width: Int, + height: Int, + bytesPerRow: Int, + colorSpace: CGColorSpace, + bitmapInfo: UInt32 + ) -> CGContext? { + return CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo + ) + } + + internal func inflate( + withSize size: CGSize, + scale: CGFloat, + opaque: Bool, + contentMode: ImageInflaterRendererContentMode, + bitmapContextFactory: BitmapContextFactory = UIImage.makeBitmapContext + ) -> BitmapContextProcessingResult { return self.processImageWithBitmapContext( withSize: size, scale: scale, opaque: opaque, contentMode: contentMode, + bitmapContextFactory: bitmapContextFactory, renderingBlock: { _, _, _, imageDrawing in imageDrawing() } @@ -100,19 +143,9 @@ extension UIImage { scale: CGFloat, opaque: Bool, contentMode: ImageInflaterRendererContentMode, - bitmapContextFactory: BitmapContextFactory = { width, height, bytesPerRow, colorSpace, bitmapInfo in - CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: bytesPerRow, - space: colorSpace, - bitmapInfo: bitmapInfo - ) - }, + bitmapContextFactory: BitmapContextFactory = UIImage.makeBitmapContext, renderingBlock: (_ image: UIImage, _ context: CGContext, _ contextSize: CGSize, _ imageDrawing: () -> Void) -> Void) - -> UIImage { + -> BitmapContextProcessingResult { precondition(size.width > 0 && size.height > 0, "Invalid size: \(size.width)x\(size.height)") let colorSpace = CGColorSpaceCreateDeviceRGB() @@ -131,7 +164,7 @@ extension UIImage { colorSpace, bitmapInfo ) else { - return self + return BitmapContextProcessingResult(image: self, didProcess: false) } renderingBlock( @@ -149,14 +182,26 @@ extension UIImage { } ) - return UIImage( - cgImage: bitmapContext.makeImage()!, - scale: scale, - orientation: self.imageOrientation + guard let processedImage = bitmapContext.makeImage() else { + return BitmapContextProcessingResult(image: self, didProcess: false) + } + + return BitmapContextProcessingResult( + image: UIImage( + cgImage: processedImage, + scale: scale, + orientation: self.imageOrientation + ), + didProcess: true ) } } +internal struct BitmapContextProcessingResult { + let image: UIImage + let didProcess: Bool +} + extension RendererType { public func inflatedWithScale( _ screenScale: CGFloat, diff --git a/AsyncImageView/Renderers/ImageProcessingRenderer.swift b/AsyncImageView/Renderers/ImageProcessingRenderer.swift index c00b07ea..c3bac07e 100644 --- a/AsyncImageView/Renderers/ImageProcessingRenderer.swift +++ b/AsyncImageView/Renderers/ImageProcessingRenderer.swift @@ -46,8 +46,8 @@ public final class ImageProcessingRenderer: RendererType image.processImageWithBitmapContext( withSize: data.size, scale: scale, - opaque: opaque, - contentMode: contentMode, + opaque: opaque, + contentMode: contentMode, renderingBlock: { image, context, contextSize, imageDrawingBlock in block( image, @@ -57,7 +57,7 @@ public final class ImageProcessingRenderer: RendererType imageDrawingBlock ) } - ) + ).image } } } diff --git a/AsyncImageView/Renderers/MulticastedRenderer.swift b/AsyncImageView/Renderers/MulticastedRenderer.swift index 5de27ffa..f1bf9a3c 100644 --- a/AsyncImageView/Renderers/MulticastedRenderer.swift +++ b/AsyncImageView/Renderers/MulticastedRenderer.swift @@ -13,6 +13,15 @@ import ReactiveSwift // The initial value is `nil`. private typealias ImageProperty = Property +private final class WeakImagePropertyReference { + weak var value: ImageProperty? +} + +private struct ImagePropertyEvictionState { + let propertyReference = WeakImagePropertyReference() + var shouldEvict = false +} + /// `RendererType` decorator which guarantees that images for a given `RenderDataType` /// are only rendered once, and multicasted to every observer. public final class MulticastedRenderer< @@ -60,13 +69,43 @@ public final class MulticastedRenderer< if let property = cache[data] { result = property } else { + let evictionState = Atomic(ImagePropertyEvictionState()) + let producer = renderer.createProducerForRenderingData(data) + .on(value: { [propertyCache = self.cache] result in + guard !result.shouldCache else { return } + + var property: ImageProperty? + + evictionState.modify { state in + state.shouldEvict = true + property = state.propertyReference.value + } + + guard let property = property else { return } + + propertyCache.modify { cache in + if cache[data] === property { + cache.removeValue(forKey: data) + } + } + }) + result = ImageProperty( initial: nil, - then: renderer.createProducerForRenderingData(data) - .map(Optional.init) + then: producer.map(Optional.init) ) cache[data] = result + + let shouldEvict = evictionState.modify { state -> Bool in + state.propertyReference.value = result + + return state.shouldEvict + } + + if shouldEvict { + cache.removeValue(forKey: data) + } } } @@ -96,9 +135,17 @@ extension RendererType { fileprivate func createProducerForRenderingData(_ data: Data) -> SignalProducer { return self.renderImageWithData(data) .flatMap(.concat) { result in - return SignalProducer([ - ImageResult(image: result.image, cacheHit: false), - ImageResult(image: result.image, cacheHit: true) + let cacheMiss = ImageResult( + image: result.image, + cacheHit: false, + shouldCache: result.shouldCache + ) + + guard result.shouldCache else { return SignalProducer(value: cacheMiss) } + + return SignalProducer([ + cacheMiss, + ImageResult(image: result.image, cacheHit: true, shouldCache: true) ]) } } diff --git a/AsyncImageView/Renderers/Renderer.swift b/AsyncImageView/Renderers/Renderer.swift index db15091c..d45addfd 100644 --- a/AsyncImageView/Renderers/Renderer.swift +++ b/AsyncImageView/Renderers/Renderer.swift @@ -20,16 +20,34 @@ public protocol RenderResultType { var cacheHit: Bool { get } } +internal extension RenderResultType { + var shouldCache: Bool { + return (self as? CachePolicyProviding)?.shouldCache ?? true + } +} + public struct ImageResult: RenderResultType { public let image: UIImage public let cacheHit: Bool + internal let shouldCache: Bool public init(image: UIImage, cacheHit: Bool) { + self.init(image: image, cacheHit: cacheHit, shouldCache: true) + } + + internal init(image: UIImage, cacheHit: Bool, shouldCache: Bool) { self.image = image self.cacheHit = cacheHit + self.shouldCache = shouldCache } } +private protocol CachePolicyProviding { + var shouldCache: Bool { get } +} + +extension ImageResult: CachePolicyProviding {} + extension UIImage: RenderResultType { public var image: UIImage { return self diff --git a/AsyncImageViewTests/CacheRendererSpec.swift b/AsyncImageViewTests/CacheRendererSpec.swift new file mode 100644 index 00000000..63002045 --- /dev/null +++ b/AsyncImageViewTests/CacheRendererSpec.swift @@ -0,0 +1,69 @@ +// +// CacheRendererSpec.swift +// AsyncImageView +// +// Created by Nacho Soto on 7/10/26. +// Copyright © 2026 Nacho Soto. All rights reserved. +// + +import Quick +import Nimble +import UIKit + +@testable import AsyncImageView + +class CacheRendererSpec: QuickSpec { + override class func spec() { + describe("CacheRenderer") { + it("does not cache the original image after inflation fails") { + let data = TestRenderData( + data: .a, + size: CGSize(width: 20, height: 30) + ) + let sourceRenderer = TestRenderer() + let cache = InMemoryCache(cacheName: #function) + var shouldFailContextCreation = true + + let renderer = ImageInflaterRenderer( + renderer: sourceRenderer, + screenScale: 2, + opaque: false, + bitmapContextFactory: { width, height, bytesPerRow, colorSpace, bitmapInfo in + guard !shouldFailContextCreation else { return nil } + + return UIImage.makeBitmapContext( + width: width, + height: height, + bytesPerRow: bytesPerRow, + colorSpace: colorSpace, + bitmapInfo: bitmapInfo + ) + } + ) + .withCache(cache) + .multicasted() + + let fallbackResult = renderer.renderImageWithData(data).single()?.get() + + expect(fallbackResult?.image.scale) == data.data.rawValue + expect(fallbackResult?.shouldCache) == false + expect(cache.valueForKey(data)).to(beNil()) + expect(sourceRenderer.renderedImages.value.count) == 1 + + shouldFailContextCreation = false + + let inflatedResult = renderer.renderImageWithData(data).single()?.get() + + expect(inflatedResult?.image.scale) == 2 + expect(inflatedResult?.shouldCache) == true + expect(cache.valueForKey(data)).toNot(beNil()) + expect(sourceRenderer.renderedImages.value.count) == 2 + + let cachedResult = renderer.renderImageWithData(data).single()?.get() + + expect(cachedResult?.cacheHit) == true + expect(sourceRenderer.renderedImages.value.count) == 2 + } + } + } +} diff --git a/AsyncImageViewTests/ImageInflaterRendererSpec.swift b/AsyncImageViewTests/ImageInflaterRendererSpec.swift index 8169c1ca..f794db15 100644 --- a/AsyncImageViewTests/ImageInflaterRendererSpec.swift +++ b/AsyncImageViewTests/ImageInflaterRendererSpec.swift @@ -39,7 +39,8 @@ class ImageInflaterRendererSpec: QuickSpec { expect(didAttemptContextCreation) == true expect(didRender) == false - expect(result).to(beIdenticalTo(image)) + expect(result.image).to(beIdenticalTo(image)) + expect(result.didProcess) == false } } From 3e6963921700ee3c2d1fe3c1a9c094185238e95d Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Sat, 11 Jul 2026 08:15:24 -0700 Subject: [PATCH 2/3] [Fix] Simplify failed inflation caching --- .../Renderers/MulticastedRenderer.swift | 55 ++----------------- AsyncImageViewTests/CacheRendererSpec.swift | 1 - 2 files changed, 4 insertions(+), 52 deletions(-) diff --git a/AsyncImageView/Renderers/MulticastedRenderer.swift b/AsyncImageView/Renderers/MulticastedRenderer.swift index f1bf9a3c..99dcca8e 100644 --- a/AsyncImageView/Renderers/MulticastedRenderer.swift +++ b/AsyncImageView/Renderers/MulticastedRenderer.swift @@ -13,15 +13,6 @@ import ReactiveSwift // The initial value is `nil`. private typealias ImageProperty = Property -private final class WeakImagePropertyReference { - weak var value: ImageProperty? -} - -private struct ImagePropertyEvictionState { - let propertyReference = WeakImagePropertyReference() - var shouldEvict = false -} - /// `RendererType` decorator which guarantees that images for a given `RenderDataType` /// are only rendered once, and multicasted to every observer. public final class MulticastedRenderer< @@ -69,43 +60,13 @@ public final class MulticastedRenderer< if let property = cache[data] { result = property } else { - let evictionState = Atomic(ImagePropertyEvictionState()) - let producer = renderer.createProducerForRenderingData(data) - .on(value: { [propertyCache = self.cache] result in - guard !result.shouldCache else { return } - - var property: ImageProperty? - - evictionState.modify { state in - state.shouldEvict = true - property = state.propertyReference.value - } - - guard let property = property else { return } - - propertyCache.modify { cache in - if cache[data] === property { - cache.removeValue(forKey: data) - } - } - }) - result = ImageProperty( initial: nil, - then: producer.map(Optional.init) + then: renderer.createProducerForRenderingData(data) + .map(Optional.init) ) cache[data] = result - - let shouldEvict = evictionState.modify { state -> Bool in - state.propertyReference.value = result - - return state.shouldEvict - } - - if shouldEvict { - cache.removeValue(forKey: data) - } } } @@ -135,17 +96,9 @@ extension RendererType { fileprivate func createProducerForRenderingData(_ data: Data) -> SignalProducer { return self.renderImageWithData(data) .flatMap(.concat) { result in - let cacheMiss = ImageResult( - image: result.image, - cacheHit: false, - shouldCache: result.shouldCache - ) - - guard result.shouldCache else { return SignalProducer(value: cacheMiss) } - return SignalProducer([ - cacheMiss, - ImageResult(image: result.image, cacheHit: true, shouldCache: true) + ImageResult(image: result.image, cacheHit: false, shouldCache: result.shouldCache), + ImageResult(image: result.image, cacheHit: true, shouldCache: result.shouldCache) ]) } } diff --git a/AsyncImageViewTests/CacheRendererSpec.swift b/AsyncImageViewTests/CacheRendererSpec.swift index 63002045..b87ad225 100644 --- a/AsyncImageViewTests/CacheRendererSpec.swift +++ b/AsyncImageViewTests/CacheRendererSpec.swift @@ -41,7 +41,6 @@ class CacheRendererSpec: QuickSpec { } ) .withCache(cache) - .multicasted() let fallbackResult = renderer.renderImageWithData(data).single()?.get() From eb1e3732284d7bc721d5f3e19b5a069d7a8f764f Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Sat, 11 Jul 2026 09:50:31 -0700 Subject: [PATCH 3/3] [Fix] Preserve processing cache policy --- .../Renderers/ImageInflaterRenderer.swift | 8 +- .../Renderers/ImageProcessingRenderer.swift | 83 +++++++++++++------ AsyncImageViewTests/CacheRendererSpec.swift | 46 ++++++++++ 3 files changed, 110 insertions(+), 27 deletions(-) diff --git a/AsyncImageView/Renderers/ImageInflaterRenderer.swift b/AsyncImageView/Renderers/ImageInflaterRenderer.swift index 7d18272d..626385af 100644 --- a/AsyncImageView/Renderers/ImageInflaterRenderer.swift +++ b/AsyncImageView/Renderers/ImageInflaterRenderer.swift @@ -100,6 +100,7 @@ extension UIImage { _ colorSpace: CGColorSpace, _ bitmapInfo: UInt32 ) -> CGContext? + internal typealias BitmapImageFactory = (_ context: CGContext) -> CGImage? internal static func makeBitmapContext( width: Int, @@ -119,6 +120,10 @@ extension UIImage { ) } + internal static func makeBitmapImage(_ context: CGContext) -> CGImage? { + return context.makeImage() + } + internal func inflate( withSize size: CGSize, scale: CGFloat, @@ -144,6 +149,7 @@ extension UIImage { opaque: Bool, contentMode: ImageInflaterRendererContentMode, bitmapContextFactory: BitmapContextFactory = UIImage.makeBitmapContext, + bitmapImageFactory: BitmapImageFactory = UIImage.makeBitmapImage, renderingBlock: (_ image: UIImage, _ context: CGContext, _ contextSize: CGSize, _ imageDrawing: () -> Void) -> Void) -> BitmapContextProcessingResult { precondition(size.width > 0 && size.height > 0, "Invalid size: \(size.width)x\(size.height)") @@ -182,7 +188,7 @@ extension UIImage { } ) - guard let processedImage = bitmapContext.makeImage() else { + guard let processedImage = bitmapImageFactory(bitmapContext) else { return BitmapContextProcessingResult(image: self, didProcess: false) } diff --git a/AsyncImageView/Renderers/ImageProcessingRenderer.swift b/AsyncImageView/Renderers/ImageProcessingRenderer.swift index c3bac07e..45114b76 100644 --- a/AsyncImageView/Renderers/ImageProcessingRenderer.swift +++ b/AsyncImageView/Renderers/ImageProcessingRenderer.swift @@ -13,51 +13,82 @@ import ReactiveSwift /// `RendererType` decorator that allows rendering a new image derived from the original one. public final class ImageProcessingRenderer: RendererType { public typealias Block = (_ image: UIImage, _ context: CGContext, _ contextSize: CGSize, _ data: Renderer.Data, _ imageDrawingBlock: () -> Void) -> Void + public typealias Data = Renderer.Data + public typealias Error = Renderer.Error + public typealias RenderResult = ImageResult private let renderer: Renderer private let scale: CGFloat private let opaque: Bool - private let contentMode: ImageInflaterRendererContentMode + private let contentMode: ImageInflaterRendererContentMode private let renderingBlock: Block + private let bitmapImageFactory: UIImage.BitmapImageFactory private let schedulerCreator: () -> Scheduler - public init( + public convenience init( renderer: Renderer, scale: CGFloat, opaque: Bool, contentMode: ImageInflaterRendererContentMode = .defaultMode, renderingBlock: @escaping Block, schedulerCreator: @escaping () -> Scheduler = { QueueScheduler() }) { - self.renderer = renderer - self.scale = scale - self.opaque = opaque - self.contentMode = contentMode - self.renderingBlock = renderingBlock + self.init( + renderer: renderer, + scale: scale, + opaque: opaque, + contentMode: contentMode, + renderingBlock: renderingBlock, + bitmapImageFactory: UIImage.makeBitmapImage, + schedulerCreator: schedulerCreator + ) + } + + internal init( + renderer: Renderer, + scale: CGFloat, + opaque: Bool, + contentMode: ImageInflaterRendererContentMode = .defaultMode, + renderingBlock: @escaping Block, + bitmapImageFactory: @escaping UIImage.BitmapImageFactory, + schedulerCreator: @escaping () -> Scheduler = { QueueScheduler() } + ) { + self.renderer = renderer + self.scale = scale + self.opaque = opaque + self.contentMode = contentMode + self.renderingBlock = renderingBlock + self.bitmapImageFactory = bitmapImageFactory - self.schedulerCreator = schedulerCreator + self.schedulerCreator = schedulerCreator } - public func renderImageWithData(_ data: Renderer.Data) -> SignalProducer { + public func renderImageWithData(_ data: Data) -> SignalProducer { return self.renderer.renderImageWithData(data) .observe(on: self.schedulerCreator()) - .map { $0.image } - .map { [scale = self.scale, opaque = self.opaque, block = self.renderingBlock, contentMode = self.contentMode] image in - image.processImageWithBitmapContext( - withSize: data.size, - scale: scale, - opaque: opaque, - contentMode: contentMode, - renderingBlock: { image, context, contextSize, imageDrawingBlock in - block( - image, - context, - contextSize, - data, - imageDrawingBlock - ) - } - ).image + .map { [scale = self.scale, opaque = self.opaque, block = self.renderingBlock, contentMode = self.contentMode, bitmapImageFactory = self.bitmapImageFactory] result in + let processingResult = result.image.processImageWithBitmapContext( + withSize: data.size, + scale: scale, + opaque: opaque, + contentMode: contentMode, + bitmapImageFactory: bitmapImageFactory, + renderingBlock: { image, context, contextSize, imageDrawingBlock in + block( + image, + context, + contextSize, + data, + imageDrawingBlock + ) + } + ) + + return ImageResult( + image: processingResult.image, + cacheHit: result.cacheHit, + shouldCache: result.shouldCache && processingResult.didProcess + ) } } } diff --git a/AsyncImageViewTests/CacheRendererSpec.swift b/AsyncImageViewTests/CacheRendererSpec.swift index b87ad225..6a6ba2df 100644 --- a/AsyncImageViewTests/CacheRendererSpec.swift +++ b/AsyncImageViewTests/CacheRendererSpec.swift @@ -63,6 +63,52 @@ class CacheRendererSpec: QuickSpec { expect(cachedResult?.cacheHit) == true expect(sourceRenderer.renderedImages.value.count) == 2 } + + it("does not cache the original image after processing output creation fails") { + let data = TestRenderData( + data: .a, + size: CGSize(width: 20, height: 30) + ) + let sourceRenderer = TestRenderer() + let cache = InMemoryCache(cacheName: #function) + var shouldFailImageCreation = true + + let renderer = ImageProcessingRenderer( + renderer: sourceRenderer, + scale: 2, + opaque: false, + renderingBlock: { _, _, _, _, imageDrawing in + imageDrawing() + }, + bitmapImageFactory: { context in + guard !shouldFailImageCreation else { return nil } + + return context.makeImage() + } + ) + .withCache(cache) + + let fallbackResult = renderer.renderImageWithData(data).single()?.get() + + expect(fallbackResult?.image.scale) == data.data.rawValue + expect(fallbackResult?.shouldCache) == false + expect(cache.valueForKey(data)).to(beNil()) + expect(sourceRenderer.renderedImages.value.count) == 1 + + shouldFailImageCreation = false + + let processedResult = renderer.renderImageWithData(data).single()?.get() + + expect(processedResult?.image.scale) == 2 + expect(processedResult?.shouldCache) == true + expect(cache.valueForKey(data)).toNot(beNil()) + expect(sourceRenderer.renderedImages.value.count) == 2 + + let cachedResult = renderer.renderImageWithData(data).single()?.get() + + expect(cachedResult?.cacheHit) == true + expect(sourceRenderer.renderedImages.value.count) == 2 + } } } }