Skip to content

feat: add Request/options inits and ad event modifiers to NativeAdvertisement - #8

Merged
Uhucream merged 38 commits into
mainfrom
claude/gadadloaderdelegate-usage-msa1sb
Jul 29, 2026
Merged

feat: add Request/options inits and ad event modifiers to NativeAdvertisement#8
Uhucream merged 38 commits into
mainfrom
claude/gadadloaderdelegate-usage-msa1sb

Conversation

@Uhucream

@Uhucream Uhucream commented Jul 23, 2026

Copy link
Copy Markdown
Owner
  • Add progressively disclosed NativeAdvertisement initializers so callers can
    pass a custom Request and GADAdLoaderOptions when they need them, keeping the
    ad-unit-id-only initializer as the simple default.
  • Deliver NativeAdDelegate events (onTap, onSwipeGesture, onWillPresent,
    onWillDismiss, onDismiss, onAdvertisementMuted) through modifiers on
    NativeAdvertisement, wired via a Coordinator on _RepresentedUINativeAdView so
    the loader stays load-only.
  • Remove the custom AdLoader injection TODO from the README and document the new
    request customization and ad event callbacks.

Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz

…tisement

- Add progressively disclosed NativeAdvertisement initializers so callers can
  pass a custom Request and GADAdLoaderOptions when they need them, keeping the
  ad-unit-id-only initializer as the simple default.
- Deliver NativeAdDelegate events (onTap, onSwipeGesture, onWillAppear,
  onWillDisappear, onDismiss, onAdvertisementMuted) through modifiers on
  NativeAdvertisement, wired via a Coordinator on _RepresentedUINativeAdView so
  the loader stays load-only.
- Remove the custom AdLoader injection TODO from the README and document the new
  request customization and ad event callbacks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 005476d147

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// The NativeAd instance only exists after the async load completes, so this is the
// only point where its delegate can be wired. The event callbacks are already held
// by the Coordinator from makeCoordinator, so nothing else needs to be pushed here.
nativeAd.delegate = context.coordinator

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh coordinator callbacks on update

When the event closures change after this UIViewRepresentable has been mounted (for example a callback captures a parent value such as the current screen/ad id, or swaps to a new analytics object), SwiftUI keeps the existing coordinator rather than rebuilding it for the new struct value. Since updateUIView only reassigns nativeAd.delegate here and the coordinator stores the original closures as immutable lets, native ad events continue invoking stale handlers; update the coordinator's stored actions from the latest representable value before assigning it as the delegate.

Useful? React with 👍 / 👎.

claude added 3 commits July 23, 2026 16:59
Keep only the designated (adUnitId/request/options) initializer on the main
type declaration and move the two convenience initializers into a single
extension.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…rence

Hold a reference to the representable in the Coordinator and refresh it in
updateUIView instead of capturing the event closures once in makeCoordinator.
This prevents stale handlers when a callback closure captures a value that
changes across renders, while keeping updateUIView to a single assignment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
Describe what each modifier does from the caller's perspective (mirroring
SwiftUI's onAppear wording) instead of noting the internal handler, and drop the
imperative sentence from the callback comment while keeping the rationale for
the argument-less signature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz

Copilot AI 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.

Pull request overview

This PR extends NativeAdvertisement to support caller-provided AdMob Request and GADAdLoaderOptions, and surfaces native ad interaction lifecycle events via SwiftUI-style modifiers by wiring NativeAdDelegate through a UIViewRepresentable coordinator. The README is updated to document request customization and the new event callbacks.

Changes:

  • Add progressively disclosed NativeAdvertisement initializers supporting custom Request and ad loader options.
  • Forward NativeAdDelegate events (tap, swipe gesture click, full-screen present/dismiss, mute) into SwiftUI modifiers via _RepresentedUINativeAdView.Coordinator.
  • Update README to document request customization and ad event callbacks; remove the AdLoader injection TODO.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
Sources/AdMobUI/Components/UIViewRepresentable/_RepresentedUINativeAdView.swift Adds callback closures + a coordinator implementing NativeAdDelegate to forward native ad events to SwiftUI.
Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisement.swift Adds request/options initializers and event modifiers; passes callbacks into the representable.
Sources/AdMobUI/Components/AdMobNativeAd/NativeAdLoader.swift Allows configuring the loader with a custom Request and ad loader options, and reuses the provided request for loads.
README.md Documents request customization and the new ad event callback modifiers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisement.swift
claude and others added 15 commits July 23, 2026 17:16
Describe the concrete problem (per-view loaders re-requesting ads on scroll in
List/LazyVStack) and the intended direction (ad pool/cache keyed by ad unit id,
batch preloading via GADMultipleAdsAdLoaderOptions, opt-in shared-loader API).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…IView

.equatable() skipped updateUIView whenever only the event callback closures
changed, since the representable's Equatable implementation intentionally
ignores them to avoid comparing captured state. That left the coordinator
holding stale callbacks in exactly the case the parent-reference refresh was
meant to fix, since the refresh never ran either.

Move the same comparison (same NativeAd instance, same element frames) into
updateUIView as an early return, and refresh the coordinator unconditionally
before it. Cost is unchanged (same O(n) comparison, no extra allocation), and
the heavy work (asset registration, constraint rebuilding) still only runs
when something actually changed.

Also add dismantleUIView to unregister the ad view when a cell is torn down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
Pure rename in preparation for giving this per-view object a second
responsibility: borrowing from a shared NativeAdvertisementLoader instead of
always running its own request. No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…oader

NativeAdvertisement used to own a private AdLoader and request a fresh ad in
onAppear. In a List/LazyVStack, SwiftUI destroys and recreates a cell as it
scrolls out and back into view, so every reappearance re-requested an ad even
though a native ad's asset views were only ever bound to one NativeAdView at a
time. Ad requests are billed and rate limited, so a scrolling feed could send
far more requests than the number of ads it actually shows.

Add NativeAdvertisementLoader, a shared pool keyed by ad unit id that loads
ads once, lends them out to views, and takes them back when a view goes away,
so a cell scrolling back into view reuses an ad instead of re-requesting one.
It keeps multiple AdLoaders in flight per ad unit id (Configuration's
maximumConcurrentLoads) to preserve today's parallel first-load behavior, can
batch a single request into several ads via Configuration.numberOfAdvertisements
(opt-in, since AdMob only serves Google ads and disables mediation for a
multi-ad request), and drops ads older than the SDK's ~1 hour validity window.

NativeAdvertisementBinder now either drives its own AdLoader (when the caller
passed an explicit request/options) or borrows from the shared loader supplied
through the new `View.nativeAdvertisementLoader(_:)` environment modifier,
returning what it borrowed on deinit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…TODO

Add a "Reusing ads in a feed" section explaining the default reuse behavior,
how to configure NativeAdvertisementLoader.Configuration (including the
mediation trade-off of numberOfAdvertisements and the ~1 hour validity
window), and why an explicit request: opts a view out of the shared loader.

The "Improve performance" TODO is resolved by this change, so it's removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…-msa1sb' into claude/gadadloaderdelegate-usage-msa1sb
NativeAdvertisementLoader is a public type, so methods satisfying the public
NativeAdLoaderDelegate/AdLoaderDelegate requirements must themselves be
public.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
Use NotificationCenter's Combine publisher (iOS 13+, compatible with this
package's iOS 14 minimum) instead of the @objc selector-based observer API.
The AnyCancellable cancels itself on deinit, so the explicit deinit/
removeObserver pair is no longer needed either.

Also trim the class doc comment back to describing what the type is; the
usage recipe involving View.nativeAdvertisementLoader(_:) belongs in the
README, not in NativeAdvertisementLoader's own summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
- Rename the binder's loadAd(preferring:) to loadAd(with loader:); the old
  "sharedLoader" parameter name read too close to "source.adLoader" (the
  SDK's AdLoader) in the same scope.
- Rename NativeAdvertisementBinder.OwnSource to Source (mirrors the "where do
  ads come from" naming Kingfisher uses for its own Source type).
- Drop the redundant "AdUnitId" from every forAdUnitId/adUnitId parameter
  pair across NativeAdvertisementLoader/Binder; Swift API Design Guidelines
  call for the preposition alone (for adUnitId:) since the internal name
  already carries the meaning.
- Name every firstIndex(where:) result by what it found instead of leaving
  it as bare `index` (availableEntryIndex / matchingEntryIndex).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
giveBack(_:for:) dropped an expired entry without calling ensureLoadInFlight,
so any waiter already queued for that ad unit id (registered via
lend(for:requester:onChange:)) would never be resolved: nothing else would
have triggered a new load on their behalf. This is reachable whenever an ad is
held long enough to cross the ~1 hour retention window before being returned,
e.g. scrolling back to a long-idle cell.

Also document several other deliberate, non-obvious choices that had no
comment explaining them: why purgeExpiredEntries/trimRetainedAdvertisements
only ever touch idle (not lent) entries, why a load that returns zero ads is
failed out to every waiter instead of retried automatically, and why
dismantleUIView unregisters the ad view (a returned pool ad can be lent
straight back out to a different view).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
The last two runs failed identically before compiling a single source file:
"Unable to find a destination matching ... { generic:1, platform:iOS
Simulator }" because the current macos-15-arm64 runner image doesn't have
the iOS simulator runtime installed. This library has no test target (the
workflow's own has_tests check confirms it), so it never needs to run on a
simulator — generic/platform=iOS builds and links for iOS without requiring
any simulator runtime to be present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…ng feedback

- Rewrite adLoaderDidFinishLoading's guard as an if/early-return so the
  function's tail is the normal continuation (ensureLoadInFlight) and the
  terminal-failure branch reads as the exceptional case, not the other way
  around.
- Rewrite nativeAdvertisementLoader(_:)'s doc comment to state what it
  achieves (ads shared/reused across the subtree), not the environment
  plumbing used to do it, mirroring AsyncImage's asyncImageURLSession(_:).
- Add the previously-missing nativeAdDidRecordImpression handling, exposed
  as onImpressionRecorded(perform:).
- Rename onWillAppear/onWillDisappear to onWillPresent/onWillDismiss to
  match the underlying nativeAdWillPresentScreen/nativeAdWillDismissScreen
  delegate methods (these are full-screen presentation events, not the
  SwiftUI view-appearance ones onDismiss already avoided colliding with).
- Rename NativeAdvertisementBinder's sharedLoader to advertisementLoader:
  it holds whatever loader was attached via loadAd(with:), not necessarily
  NativeAdvertisementLoader.shared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…default

Google's native ads guidance for iOS asks apps to (1) precache the list of
ads a feed is about to show, (2) clear and reload the cache after ~1 hour,
(3) cache only what's about to be visible, and (4) discard ads once they're
no longer needed. This addressed (2) only lazily (on lend/giveBack) and used
a maximumRetainedAdvertisements default that was looser than "only what's
visible."

- Add NativeAdvertisementLoader.prefetch(_:for:), so an app can request ads
  ahead of a list appearing instead of relying on each cell's onAppear to
  trigger a load. It only tops up what's missing (idle entries plus what
  in-flight loads are expected to yield) and never re-requests once enough
  supply exists.
- Sweep every ad unit id for expired entries proactively — on a 5 minute
  timer and on returning to the foreground — instead of only discovering
  staleness the next time something calls lend/giveBack for that specific ad
  unit id.
- Lower the default maximumRetainedAdvertisements from 10 to 5, and clamp it
  internally to never go below numberOfAdvertisements (a lower retention cap
  would otherwise trim members of a batch the moment it arrives, wasting
  part of what was just paid for).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
…sion

The compiler flagged @preconcurrency on the AdLoaderDelegate conformance as
having no effect, so remove it.

Also move prefetch(_:for:) out of the lend/cancelLending/giveBack extension
(the borrow-and-return protocol the Binder uses) into its own extension,
since precaching is a separate, directly caller-facing concern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
Every other "how many" knob on NativeAdvertisementLoader (numberOfAdvertisements,
maximumConcurrentLoads, maximumRetainedAdvertisements) is set once via
Configuration when the loader is constructed. Letting prefetch take its own
count reintroduced the same decision at the call site through a second,
easily out-of-sync source of truth, and left the caller guessing what the
number should represent (ad slots on screen? the whole list?).

prefetch(for:) now simply tops up to Configuration.maximumRetainedAdvertisements,
which the caller already set for the list in question. Also fix the DocC
symbol reference syntax (``Type/member``, not a plain code span with a dot).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz

Copilot AI 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.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementLoader.swift:35

  • Configuration documents valid ranges (e.g. numberOfAdvertisements 1...5), but the initializer currently accepts any values. Invalid values can flow into MultipleAdsAdLoaderOptions.numberOfAds or concurrency/retention logic and lead to undefined SDK behavior; it’s better to fail fast with preconditions (or clamp) at initialization time.
    public init(configuration: Configuration) {
        self.configuration = configuration

Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementBinder.swift:84

  • When NativeAdvertisementBinder is using the caller-supplied request/options path, loadAd(with:) will call source.adLoader.load(...) on every .onAppear, even after a successful load. That can trigger repeated (billed/rate-limited) loads when the view reappears (e.g. navigation/tab switches). Consider skipping the load once a .success ad is already present.
    func loadAd(with loader: NativeAdvertisementLoader) {
        if let source {
            source.adLoader.load(source.request)

            return
        }

.github/workflows/build-and-test.yml:60

  • Building for generic/platform=iOS can unexpectedly require device signing settings and diverges from the simulator destination used for tests. Unless there’s a specific reason to build the iphoneos SDK here, prefer building the same destination as tests (simulator) to avoid CI failures due to code signing/provisioning.
          xcodebuild build \
            -scheme AdMobUI \
            -destination 'generic/platform=iOS'

- NativeAdvertisementBinder.loadAd(with:) never guarded the own-request
  (request:/options: init) path against being called again: every onAppear
  (a NavigationStack pop, a TabView switch bringing the view back, etc.)
  re-called source.adLoader.load(_:) even after a successful load, sending
  another billed request. Add the same idempotency guard already used on the
  shared-loader path, via a hasStartedOwnLoad flag.
- A caller-configured Configuration.maximumConcurrentLoads of 0 or less made
  ensureLoadInFlight's "is a slot free" check permanently false, so a waiter
  registered via lend(for:requester:onChange:) would sit stuck forever with
  no load ever allowed to start and no error surfaced. Clamp it to at least 1
  the same way maximumRetainedAdvertisements is already clamped against
  numberOfAdvertisements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Amm6MWc8iGzN76MgbfwrMz
claude added 2 commits July 29, 2026 12:43
…n-thread requirement

_RepresentedUINativeAdView.updateUIView(_:context:) set
context.coordinator.parent = self after the "guard let nativeAd else
{ return }", contradicting its own comment that the refresh happens
"even when the rest of this update is skipped below." Move it above
the guard so it always runs, matching the comment's intent.

Also document that NativeAdvertisementLoader must be driven from the
main thread: it mutates several plain dictionaries from its public API
and from AdLoaderDelegate/NativeAdLoaderDelegate callbacks with no
internal synchronization, relying entirely on the fact that SwiftUI
(and the ad SDK's own callbacks) only ever touch it there.
…inactor

A doc comment saying "call this from the main thread" is not a
contract, it's a suggestion nobody is forced to follow. Mark
NativeAdvertisementLoader @mainactor so the compiler actually rejects
calls from off the main thread, instead of only documenting the
requirement. NativeAdvertisementBinder is marked @mainactor too since
it calls straight into the loader synchronously and is itself only
ever driven from SwiftUI.

shared is nonisolated(unsafe) since reading a reference to an
already-initialized singleton is safe from any thread; only the
loader's actual mutable state needs the actor's protection.

Copilot AI 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.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementLoader.swift:18

  • NativeAdvertisementLoader is @MainActor, but shared is declared nonisolated(unsafe). Because static let initialization can occur on whatever thread first touches it, this can construct the loader off the main actor (setting up UIKit notifications/timers) and violate the class’s actor-isolation assumptions.
@MainActor
public final class NativeAdvertisementLoader: NSObject {
    public nonisolated(unsafe) static let shared: NativeAdvertisementLoader = .init(configuration: .default)

Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementBinder.swift:102

  • When using the caller-supplied AdLoader path, the binder never handles adLoaderDidFinishLoading(_:). If the SDK finishes without calling didReceive or didFail (the loader has a defensive noAdvertisementReceived fallback for this), the view will stay stuck in .empty indefinitely.
extension NativeAdvertisementBinder: NativeAdLoaderDelegate {
    func adLoader(_ adLoader: AdLoader, didReceive nativeAd: NativeAd) {
        self.nativeAdvertisementPhase = .success(nativeAd)
    }

    func adLoader(_ adLoader: AdLoader, didFailToReceiveAdWithError error: any Error) {
        self.nativeAdvertisementPhase = .failure(error)
    }
}

.github/workflows/build-and-test.yml:60

  • This repo currently has no SwiftPM test targets (so the Build step always runs), and the destination was changed to generic/platform=iOS. Building for a generic iOS (device) destination can introduce code-signing/device-SDK related CI failures; using the Simulator destination (and/or explicitly disabling code signing) tends to be more robust for library builds.
          set -o pipefail
          xcodebuild build \
            -scheme AdMobUI \
            -destination 'generic/platform=iOS'

Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisement.swift:161

  • The PR description says the new modifiers are onWillAppear / onWillDisappear, but the implementation (and README) add onWillPresent / onWillDismiss (plus onImpressionRecorded). Please update the PR description to match the shipped API names so reviewers/users aren’t misled.
    /// Adds an action to perform before the ad presents a full screen view.
    /// - Parameter action: The action to perform.
    public func onWillPresent(perform action: @escaping () -> Void) -> Self {
        var view: Self = self

        view.onWillPresentAction = action

        return view
    }

    /// Adds an action to perform before the ad's full screen view is dismissed.
    /// - Parameter action: The action to perform.
    public func onWillDismiss(perform action: @escaping () -> Void) -> Self {
        var view: Self = self

        view.onWillDismissAction = action

        return view
    }

claude added 3 commits July 29, 2026 13:18
…ct via delegate adaptor

Mark each function that touches the loader's shared mutable state
@mainactor individually instead of the whole class. init(configuration:)
and the pure configuration-reading helpers (effectiveMaximumRetainedAdvertisements,
effectiveMaximumConcurrentLoads, effectiveNumberOfAdvertisements, isExpired)
stay nonisolated since they never touch that state, which also means
`shared` no longer needs nonisolated(unsafe): its initializer doesn't
cross into isolated code.

Introduce NativeAdLoaderDelegateAdaptor, a small NSObject-based type that
bridges AdLoader's Objective-C delegate callbacks to plain closures. Both
NativeAdvertisementLoader and NativeAdvertisementBinder used to inherit
NSObject solely to satisfy that delegate protocol themselves; now they
just hand each AdLoader its own adaptor instance and drop NSObject
entirely.
The review question "why are there two places creating an AdLoader" came
from a documentation gap, not a structural one. NativeAdvertisementBinder
drives its own AdLoader for the request:/options: initializer and borrows
from a shared NativeAdvertisementLoader otherwise, but nothing in the code
said why. Both initializers now state which path they take and why, and
NativeAdvertisementLoader.startLoad(for:) notes that its AdLoader is the
pool's, feeding shared inventory rather than one view.

Also fixes three compile errors that had CI red: deinit is nonisolated
even on a @mainactor class, so it can neither read @published's
synthesized accessor nor call the loader's @mainactor methods directly.
A plain currentNativeAd property mirrors what deinit needs, and the
giveBack/cancelLending calls hop through a Task without capturing self.

Two smaller fixes:

- EnvironmentValues.nativeAdvertisementLoader now defaults to nil instead
  of .shared, so reading it no longer builds the shared loader and starts
  the subscriptions and repeating timer its init sets up. .shared is
  substituted in loadAd(with:) on the borrowing path only.
- dismantleUIView was written as an instance method taking context:,
  which satisfies no UIViewRepresentable requirement and never ran,
  leaving unregisterAdView() uncalled. Corrected to the static
  coordinator: form.
The comment narrated the fix (instance method vs. static) rather than
stating anything about the resulting code. The static + coordinator:
signature already speaks for itself.

Copilot AI 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.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementBinder.swift:132

  • loadAd(with:) only lends/loads once per binder instance (guard !hasStartedOwnLoad / guard advertisementLoader == nil). After a terminal failure, subsequent onAppear events will never retry, even though NativeAdvertisementLoader’s finish-loading path expects callers to re-run lend(...) to retry. Consider allowing retry when the phase is .failure, while still preventing duplicate loads when a request is already in flight or has succeeded.
    func loadAd(with loader: NativeAdvertisementLoader?) {
        if let source {
            // Without this guard, every onAppear (e.g. a NavigationStack pop or TabView switch
            // bringing this view back) would call load(_:) again and send another billed
            // request, even though the first one already succeeded or is still in flight.

.github/workflows/build-and-test.yml:60

  • The CI “Build” step now targets generic/platform=iOS instead of the simulator. Building for device typically requires code signing and can fail on GitHub Actions runners when no signing identity is configured. If the goal is a sign-free build check, keep the destination on iOS Simulator.
          xcodebuild build \
            -scheme AdMobUI \
            -destination 'generic/platform=iOS'

…an ad

The own-AdLoader path passed a no-op for onFinishLoading, so a request
that completed without either didReceive or didFailToReceiveAdWithError
firing (a case the SDK documents as possible but not expected) left the
phase stuck at .empty indefinitely. Mirror the shared pool's existing
fallback: fail with NativeAdvertisementLoaderError.noAdvertisementReceived
only if the phase is still .empty when the load finishes.

Copilot AI 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.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

Sources/AdMobUI/Components/AdMobNativeAd/NativeAdLoaderDelegateAdaptor.swift:28

  • Initializer doesn’t call super.init(). As an NSObject subclass, this won’t compile (Swift requires super.init() before returning).
    init(
        onReceive: @escaping (_ adLoader: AdLoader, _ nativeAd: NativeAd) -> Void,
        onFailure: @escaping (_ adLoader: AdLoader, _ error: any Error) -> Void,
        onFinishLoading: @escaping (_ adLoader: AdLoader) -> Void
    ) {
        self.onReceive = onReceive
        self.onFailure = onFailure
        self.onFinishLoading = onFinishLoading
    }

Comment on lines 57 to +59
xcodebuild build \
-scheme AdMobUI \
-destination 'generic/platform=iOS Simulator'
-destination 'generic/platform=iOS'
claude and others added 8 commits July 29, 2026 15:51
…empty

NativeAdvertisementPhase has no case of its own for "loading", so .empty
already covers both "not started" and "in flight". Leaving a
finished-but-nothing-received result at .empty too would erase the
distinction between those states and "the attempt is settled" for
callers, which is why this falls back to .failure instead — matching
what the shared pool's own finish-loading handler already does.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Both delegateAdaptor and delegateAdaptorsByAdLoader are assigned but
never read back within their own type. That's intentional: AdLoader.delegate
is weak, so without a strong reference held somewhere, the adaptor would be
deallocated as soon as the initializer/startLoad(for:) returns, silently
breaking every subsequent delegate callback.
…njection

NativeAdvertisement(adUnitId:request:) and (adUnitId:request:options:)
did the same thing as applying a NativeAdvertisementLoader whose
Configuration carries that request — Configuration already exposes both
request and options, so the two were the same knob.

The initializer form was also the worse of the two. Its loader lived
inside the view's own @StateObject, so a List rebuilding a cell threw
away the loaded ads with it and reloaded from scratch, which is the very
thing the shared loader exists to prevent. A loader the caller holds
survives view recreation and keeps its inventory.

This leaves one customization axis, matching Kingfisher and NukeUI, both
of which expose loader replacement and no initializer variants. It also
collapses NativeAdvertisementBinder to a single path: Source,
delegateAdaptor, hasStartedOwnLoad and the own-AdLoader branch are all
gone, and AdLoader is now constructed in exactly one place
(NativeAdvertisementLoader.startLoad(for:)).

Not a breaking change: main only ever exposed init(adUnitId:adContent:).
The removed initializers were added in this unmerged branch.
Both comments described who used the code rather than what the code is,
so each one needed editing whenever a consumer changed — and the
startLoad(for:) one had already gone stale, still claiming that
NativeAdvertisementBinder drives an AdLoader of its own.

NativeAdLoaderDelegateAdaptor now states what it does for any type that
adopts it. startLoad(for:) loses its comment entirely: it existed to
explain why a second AdLoader construction site was there, and there is
only one now.
"Borrowing" named the internal lend/giveBack model, not something a
caller needs to know. State the effect that actually matters to them:
a view that reappears without a new identity doesn't send another
request.
… style

"Apply X to choose Y" told the reader what to do; Apple's own discussion
prose (e.g. AsyncImage.init(request:scale:)) states what happens instead.

Copilot AI 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.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

.github/workflows/build-and-test.yml:59

  • The build-only job now targets generic/platform=iOS (device). If this workflow runs on PRs without tests, CI will no longer compile the package for the iOS Simulator, which can hide simulator-only build issues (and diverges from the xcodebuild test destination below). Consider keeping the destination as iOS Simulator here as well.
            -destination 'generic/platform=iOS'

Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisementBinder.swift:63

  • loadAd(with:) permanently no-ops after the first call because it only checks advertisementLoader == nil. That makes a .failure phase effectively non-retryable for a view that reappears with the same identity, which contradicts the loader’s own retry strategy (it expects the caller to call lend again after terminal failure). Consider allowing retries when the current phase is .failure, while still preventing duplicate waiter registration during an in-flight request.
        // Asking again once a loader is bound would duplicate a billed request.
        guard advertisementLoader == nil else { return }

Sources/AdMobUI/Components/AdMobNativeAd/NativeAdvertisement.swift:121

  • PR description mentions delivering onWillAppear / onWillDisappear callbacks, but the public API added here is onWillPresent / onWillDismiss (matching the underlying NativeAdDelegate screen presentation callbacks). Please update the PR description (or rename the API) so the documented callback names match what’s actually shipped.
    /// Adds an action to perform before the ad presents a full screen view.
    /// - Parameter action: The action to perform.
    public func onWillPresent(perform action: @escaping () -> Void) -> Self {
        var view: Self = self

        view.onWillPresentAction = action

        return view
    }

    /// Adds an action to perform before the ad's full screen view is dismissed.
    /// - Parameter action: The action to perform.
    public func onWillDismiss(perform action: @escaping () -> Void) -> Self {

@Uhucream
Uhucream merged commit 06e32b9 into main Jul 29, 2026
1 check passed
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.

3 participants