Skip to content

Add an isLoaded flag to the contentConfigure block - #2565

Open
mlight3 wants to merge 4 commits into
onevcat:masterfrom
mlight3:content-configure-is-loaded
Open

Add an isLoaded flag to the contentConfigure block#2565
mlight3 wants to merge 4 commits into
onevcat:masterfrom
mlight3:content-configure-is-loaded

Conversation

@mlight3

@mlight3 mlight3 commented Aug 10, 2026

Copy link
Copy Markdown

Motivation

contentConfigure hands the loaded image to the caller, but the block is also evaluated in two states where there is no retrieved image:

  • Before the loading finishes. On the default path (swiftUITransition == nil) the image branch is always rendered and only hidden with opacity(0) and a zero frame, so the block runs with an empty Image.
  • On failure, when the deprecated onFailureImage fills binder.loadedImage with a caller-supplied fallback.

The caller has no way to tell either of those apart from a real image.

What this adds

A contentConfigure(_:) overload that passes an isLoaded flag along with the image:

KFImage(url)
    .contentConfigure { image, isLoaded in
        if isLoaded {
            image.resizable().scaledToFit().overlay(Badge())
        } else {
            image
        }
    }
State loadedImage isLoaded
Before / during loading nil false
Partial image from progressive loading partial true
Success (cache or network) image true
Failure with the deprecated onFailureImage fallback false
Failure with onFailureView, or neither nil false

Progressive partial images are true on purpose. They come from a successful load in flight, and treating them as not loaded would drop the caller's sizing configuration and make the layout jump once the load completes.

Notes on the implementation

  • The existing single-argument overload keeps its signature and forwards to the new one, following the pattern placeholder already uses for its (Progress) -> P / () -> P pair.
  • Both overloads take a @ViewBuilder block now, so an if / else body behaves the same in either one. This is source compatible: it only widens the accepted block shapes, and a block with an explicit return is left untransformed.
  • isImageRenderable keeps its meaning and all of its uses (opacity, frame, branching). The fallback image still has to be rendered, so the new isImageLoaded is derived separately rather than changing it.
  • The view returned while isLoaded is false does not appear on screen, since the image branch stays hidden until an image is available. isLoaded is meant for gating the caller's own configuration, not for drawing loading or failure UI — placeholder and onFailureView own those states. This is spelled out in the DocC comment.
  • ImageBinder gains a usesFailureImage flag, reset at the start of start(). onFailureImage accepts a nil image, which leaves loadedImage empty while the flag is set; that state is not terminal, so without the reset a later successful load would still look like a fallback image.

Tests

Three tests in KFImageRendererTests, reusing the existing measureLayout harness. They apply a different frame(height:) per isLoaded value and assert the measured layout, so no extra recorder object is needed.

  • testContentConfigureReceivesIsLoadedAfterSuccess
  • testContentConfigureReceivesIsLoadedFalseForFailureImage — the non-zero measured height proves the image branch is rendered while isLoaded stays false
  • testFailureImageFlagIsResetWhenLoadingRestarts — covers the onFailureImage(nil) case described above

Two existing tests assign context.contentConfiguration directly and were updated for the new closure arity.

Verified on the iOS simulator: 400 tests, 0 failures. I also checked that the new tests fail in both directions when isImageLoaded is forced to a constant. Other platforms are left to CI.

CHANGELOG.md is untouched, since it looks like it is updated in the release commits.

The block is also evaluated before an image is available, and it is applied
to the fallback set by the deprecated `onFailureImage` as well. The caller
had no way to tell those states apart from a real image.

Add a `contentConfigure(_:)` overload that passes an `isLoaded` flag along
with the image. It is `true` only when the rendered image comes from the
cache or the network, including a partial image delivered by progressive
loading, so callers can gate configurations that only make sense for a
real image.

The existing single-argument overload keeps its signature and forwards to
the new one. Both take a `@ViewBuilder` block now, so a block with an
`if` / `else` works the same way in either of them.

@onevpaw onevpaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The API direction looks sound, but there is a blocking provenance issue when a cancelled request overlaps a restarted load.

A reproducible sequence at head 389df8869793911d60e30b01d7281c4c1111c173 is:

  1. Request A starts and is cancelled by cancelOnDisappear.
  2. Request B starts and clears usesFailureImage.
  3. A's delayed failure callback installs the fallback and sets usesFailureImage = true.
  4. B succeeds and installs the retrieved image, but does not reset that flag.

The final image is therefore real, while isImageLoaded remains false. A focused test forcing “A failure → B success” fails on XCTAssertFalse(binder.usesFailureImage); it passes after resetting usesFailureImage before each real-image assignment.

Please update the progressive and all success assignment paths so the image and its provenance change together, ideally through a shared helper, and add a regression test covering this stale-callback ordering.

onevpaw - an assistant to @onevcat

A cancelled request leaves `loadedImage` empty, so the binder can be
restarted before that request delivers its failure. When the failure
landed afterwards it installed the fallback image and recorded it, but the
restarted load then replaced only the image. A retrieved image was still
reported as a fallback, and `isLoaded` stayed `false` for it.

Route every `loadedImage` assignment through a single setter that records
where the image came from, so the two can no longer drift apart. The reset
at the start of `start()` goes away with it: it was the one remaining
place that changed the provenance without changing the image, and the
`onFailureImage(nil)` case it covered is handled by the setter now.
@mlight3

mlight3 commented Aug 11, 2026

Copy link
Copy Markdown
Author

Fixed in 2ef0424.

loadedImage and its provenance now change as a pair through a single setLoadedImage(_:isFailureImage:) on ImageBinder. It is used by the progressive setter, all three success branches, and both failure branches, so there is no assignment left that moves one without the other.

I also dropped the usesFailureImage = false reset at the top of start(). With the setter in place it was the one remaining place that changed the provenance without changing the image, which is exactly the drift this is meant to rule out. The onFailureImage(nil) case it used to cover is handled by the setter now: the fallback assignment records the provenance, and the next retrieved image clears it.

Regression test: testRetrievedImageClearsProvenanceLeftByAStaleFailureCallback. It starts the restarted load with a fresh cache key so it stays in flight, lets the cancelled request's failure land and install the fallback (asserting the flag is set at that point), then asserts the flag is cleared once the restarted load delivers its own image. Confirmed that it fails against the previous behaviour and passes with the setter.

Full suite on the iOS simulator: 400 tests, 0 failures.

@onevclaw onevclaw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The implementation correctly keeps the rendered image and its provenance in sync, including the stale failure → later success sequence. However, the public DocC note for contentConfigure currently contradicts the actual fallback behavior.

In KFImageProtocol.swift, the note says that a view returned for isLoaded == false is not displayed and that configuring loadTransition means the block only receives true. With onFailureImage, the fallback is rendered while isLoaded is false; this remains true when loadTransition is configured.

Please update the documentation to distinguish these cases:

  • Before any image exists, the default rendering path may evaluate the block while keeping the branch hidden.
  • An onFailureImage fallback is displayed and invokes the block with isLoaded == false.
  • loadTransition avoids the pre-load evaluation, but a displayed failure fallback still invokes the block with false.

A regression test covering loadTransition together with onFailureImage would also be valuable, though the documentation correction is the blocking issue here.

onevclaw - an assistant to @onevcat

Make `loadedImage` private(set) so the provenance-aware setter is the only
way to install an image, instead of relying on every future assignment site
remembering to go through it.

Move the stale-callback regression test to `ImageBinderTests`, where the
other binder-level tests live, and follow that file's provider and cache
key conventions. Fold the duplicated `contentConfigure` probe in the two
renderer tests into a shared helper.
The note claimed that a view returned while `isLoaded` is `false` never
reaches the screen, and that setting a load transition makes the block run
with `true` only. Neither holds once `onFailureImage` is set: the fallback
fills `loadedImage`, so the image branch renders and the block runs with
`false` on the default path and with a load transition alike. The existing
fallback test already asserted that non-zero layout, so the note
contradicted the suite it shipped with.

Split the note into the two cases, which differ in whether the returned
view is displayed, and pin the load transition case with a test.
@mlight3

mlight3 commented Aug 12, 2026

Copy link
Copy Markdown
Author

Fixed in b7264cc.

The note did contradict the behaviour. Once onFailureImage is set, the fallback fills loadedImage, so isImageRenderable becomes true and the block runs with isLoaded == false — displayed on the default path, and evaluated through the else if isImageRenderable branch when a load transition is set. testContentConfigureReceivesIsLoadedFalseForFailureImage already asserted that non-zero layout, so the note contradicted the suite it shipped with.

The note is now split into the two cases, which differ in whether the returned view reaches the screen:

  • Before any image exists, the default rendering path evaluates the block while keeping the image branch hidden, so what it returns is not displayed. A load transition skips that evaluation instead.
  • While an onFailureImage fallback is shown, the block is evaluated and its result displayed with isLoaded as false, on the default path and with a load transition alike.

I reworded the lead-in as well. It said the block "is also evaluated before the image is loaded", which stops being true once a load transition is set; it now says the block does not run only for images the caller retrieved, which covers both cases.

For the test you suggested, testContentConfigureReceivesIsLoadedFalseForFailureImageWithLoadTransition covers loadTransition together with onFailureImage. Forcing isImageLoaded to a constant fails both fallback tests, so the new one exercises the transition branch rather than repeating the existing one.

Full suite on the iOS simulator: 401 tests, 0 failures.

@mlight3
mlight3 requested review from onevclaw and onevpaw August 13, 2026 15:46
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.

4 participants