08 · The ad that clicks itself
A feed with an ad cell whose tap handlers pile up on reused cells — find why ads open when nobody tapped them, and why clicks are counted twice.
Shape: find the bug · Reported: eBay — a debugging round built around "why does the ad keep clicking when user didn't click it" · Verified: snippet and fix compiled in Swift 6 mode against the iOS SDK, and the tests ran on the iOS Simulator, Swift 6.4
"This is our home feed. Posts, with an ad every few rows. Users say an ad sometimes opens when they tapped a normal post, and the ads team says our click numbers are higher than the ad network's. Every so often it crashes after pull-to-refresh. Find out why."
final class FeedCell: UITableViewCell {
let titleLabel = UILabel()
let ctaButton = UIButton(type: .system)
var onTap: (() -> Void)?
@objc func cardTapped() { onTap?() }
}
final class FeedViewController: UITableViewController {
private var items: [FeedItem] = [] // enum FeedItem { case post(Post), ad(Ad) }
private let analytics = Analytics.shared
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "FeedCell", for: indexPath) as! FeedCell
switch items[indexPath.row] {
case .post(let post):
cell.titleLabel.text = post.title
cell.ctaButton.isHidden = true
case .ad(let ad):
cell.titleLabel.text = ad.headline
cell.ctaButton.isHidden = false
cell.ctaButton.setTitle(ad.callToAction, for: .normal)
cell.ctaButton.addAction(UIAction { [weak self] _ in
self?.openAd(at: indexPath)
}, for: .touchUpInside)
let tap = UITapGestureRecognizer(target: cell, action: #selector(FeedCell.cardTapped))
cell.contentView.addGestureRecognizer(tap)
cell.onTap = { [weak self] in
self?.openAd(at: indexPath)
}
}
return cell
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell,
forRowAt indexPath: IndexPath) {
if case .ad(let ad) = items[indexPath.row] {
analytics.track("ad_impression", ["id": ad.id])
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if case .post(let post) = items[indexPath.row] {
show(PostViewController(post: post), sender: self)
}
}
func didRefresh(with newItems: [FeedItem]) {
items = newItems
tableView.reloadData()
}
private func openAd(at indexPath: IndexPath) {
guard case .ad(let ad) = items[indexPath.row] else { return }
analytics.track("ad_click", ["id": ad.id, "position": indexPath.row])
UIApplication.shared.open(ad.url)
}
}This compiles cleanly in Swift 6 mode (checked, with small stubs for Post, Ad and a main-actor Analytics). Nothing here is a compiler problem. It's a lifecycle problem.
A hint, if you're stuck
- How many
FeedCellobjects exist when 200 rows have scrolled past? Fewer than you think. - Read the
.postbranch and list what it doesn't reset. addActionandaddGestureRecognizerboth add. What removes?indexPathis captured when the cell is configured. When is the closure run?
The key — what I expect a senior to find
- The crash after refresh: a stale
indexPath. Each closure captures the row number from the moment it was configured, and old closures are never removed (see 3). Refresh to a shorter feed, tap, and an old closure readsitems[indexPath.row]past the end — crash. Look the ad up at tap time, by its id. - Posts open ads (a reported bug). One cell class and one reuse identifier serve both kinds of row. A cell that once showed an ad keeps its tap recogniser and its
onTapwhen it's reused for a post, because the.postbranch resets neither. The user taps the post and the old ad opens. - Handlers pile up: one tap, several clicks (the inflated numbers).
addActionadds a new closure every time the cell is configured as an ad and never removes the old ones (checked: one cell reused for three ads logged three clicks from one tap). Gesture recognisers pile up the same way. - Clicks for the wrong ad. A stale row number after a refresh can point at a different ad. The wrong ad opens and the wrong advertiser is billed — the invalid clicks ad networks penalise.
didRefreshmay run off the main thread. If the network layer calls it from its completion handler,reloadData()runs on a background queue. Swift 6 mode stops that at compile time, because the controller is@MainActor; Swift 5 mode doesn't.ad.urlis opened unchecked. The URL comes from an ad server, andopenaccepts any scheme —tel:, another app's deep link. Allowhttpsonly.- The card tap bypasses the table. The recogniser on
contentViewcancels the touch, sodidSelectRowAtnever runs for a reused post either. AnddidSelectRowAtignores ads, so the ad card only works through the leaked recogniser. NodeselectRow, so the row stays grey. - Impressions counted on every appearance.
willDisplayruns each time the cell scrolls back and after everyreloadData(). Count each ad once (aSetof ids); real ad SDKs also wait until half the ad has been visible for a second. - No
prepareForReuse, one cell for two kinds. There is no place where leftover state is cleared. Clear the closure inprepareForReuse; ideally give ads their ownAdCell. - Handlers wired in the wrong place. Wiring belongs where the cell is created, once.
cellForRowshould only set values, never add things. - Untestable side effects.
Analytics.sharedis reached directly, so no test can check "one tap, one click". Inject the tracker. as! FeedCell. It crashes if the storyboard prototype's class is wrong. That's a programmer error, so the force cast is defensible — say why rather than hide it behindas?.- Note:
addTargetwould have hidden this. Adding the same target and selector twice keeps one entry. A freshUIActionclosure has no such dedupe, so moving to closures is what made this bug appear.
The idea behind it
A table view doesn't make one cell per row. It makes roughly a screenful and reuses them: when a row scrolls off the top, its cell is put in a pool, and the next row coming in from the bottom gets that same object back from dequeueReusableCell. This is cell reuse, and it's why a feed of 10,000 rows stays light.
The catch is that a reused cell still carries everything you did to it last time. Setting a property is safe: the next configure overwrites it. Adding something is not: an added action, an added gesture recogniser or an added subview stays, and the next configure adds another on top.
Think of a hotel room. Housekeeping changes the sheets for each guest — that's setting a value. But if a guest booked a 6 a.m. wake-up call and nobody cancels it, the next guest gets woken at 6 too. prepareForReuse is housekeeping: the one place to cancel what the last guest set up.
The second half is time. A closure that captures indexPath remembers a row number, not a row. When the data changes, the number points somewhere else. Remember the item's identity and look it up when the tap actually happens.
The fix
Same storyboard cell, same controller, same Analytics. Only the marked lines change: the addAction and gesture lines in cellForRow are gone, and cardTapped with them.
@MainActor
protocol AdTracking { // key 11: the one seam
func track(_ event: String, _ properties: [String: Any])
}
extension Analytics: AdTracking {}
final class FeedCell: UITableViewCell {
let titleLabel = UILabel()
lazy var ctaButton: UIButton = { // key 10: wired once, at creation
let button = UIButton(type: .system)
button.addAction(UIAction { [weak self] _ in self?.onTap?() }, for: .touchUpInside)
return button
}()
var onTap: (() -> Void)?
override func prepareForReuse() { // key 9
super.prepareForReuse()
onTap = nil
}
}
final class FeedViewController: UITableViewController {
private var items: [FeedItem] = [] // enum FeedItem { case post(Post), ad(Ad) }
var analytics: any AdTracking = Analytics.shared // key 11
private var loggedImpressions: Set<String> = [] // key 8
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "FeedCell", for: indexPath) as! FeedCell
switch items[indexPath.row] {
case .post(let post):
cell.titleLabel.text = post.title
cell.ctaButton.isHidden = true
case .ad(let ad):
cell.titleLabel.text = ad.headline
cell.ctaButton.isHidden = false
cell.ctaButton.setTitle(ad.callToAction, for: .normal)
cell.onTap = { [weak self] in self?.openAd(id: ad.id) } // keys 1–4: set, don't add
}
return cell
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell,
forRowAt indexPath: IndexPath) {
if case .ad(let ad) = items[indexPath.row], loggedImpressions.insert(ad.id).inserted { // key 8
analytics.track("ad_impression", ["id": ad.id])
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) // key 7
switch items[indexPath.row] {
case .post(let post): show(PostViewController(post: post), sender: self)
case .ad(let ad): openAd(id: ad.id) // key 7: the card tap, no gesture
}
}
func didRefresh(with newItems: [FeedItem]) {
items = newItems
tableView.reloadData()
}
private func openAd(id: String) { // keys 1, 4: look up by id, now
guard let row = items.firstIndex(where: { if case .ad(let ad) = $0 { ad.id == id } else { false } }),
case .ad(let ad) = items[row] else { return }
analytics.track("ad_click", ["id": ad.id, "position": row])
UIApplication.shared.open(ad.url)
}
}Said out loud, not coded: separate AdCell and PostCell; allow only https ad URLs behind an injected URL opener; real impression rules (half visible for a second, reset per session); and in Swift 5 mode, make sure didRefresh is called on main.
Why each piece:
- The button is wired once, when it's created. A
lazy varclosure runs once, so the cell owns one permanent action that calls whateveronTapit holds.[weak self]because the cell owns the button, which owns the action. prepareForReuseclearsonTap. A cell reused for a post has nothing left to fire.openAd(id:)looks the ad up at tap time. After a refresh it finds the ad wherever it is now, or does nothing if it's gone. No stale row, no out-of-range crash.- The card tap goes through
didSelectRowAt. The table hands you the current index path, so there's no recogniser to leak and no swallowed selection.
Now write the tests
"Good. Now write me the tests that would have caught the inflated click numbers."
What I'd test, and why
- One tap on a reused cell clicks only the ad on screen. The cell is reused for ads a, b and c; one tap logs one click, for c. This is the pile-up, so it goes first.
- A cell reused for a post opens nothing — the reported "posts open ads" bug.
- An impression is logged once per ad — scrolling past three times counts once.
- A tap on an ad a refresh removed does nothing — no click, and no out-of-range crash.
I wouldn't test layout or UIKit's touch handling; sendActions(for: .touchUpInside) stands in for the finger.
The seam. analytics became a var with a default, so the storyboard still builds the screen and a test swaps in a fake tracker that writes events down. OneCellTableView is a table with one cell in its pool: every dequeue hands back the same cell after prepareForReuse, just as UIKit does, so reuse happens on demand. The fake ads use a URL scheme nothing handles, so open does nothing.
import Testing
import UIKit
/// A fake tracker: it only writes down what it was told.
@MainActor
final class FakeAdTracker: AdTracking {
private(set) var events: [String] = []
func track(_ event: String, _ properties: [String: Any]) {
events.append("\(event) \(properties["id"] ?? "")")
}
}
/// A table with one cell in its reuse pool: every dequeue hands back the same cell, like UIKit.
final class OneCellTableView: UITableView {
let cell = FeedCell(style: .default, reuseIdentifier: "FeedCell")
override func dequeueReusableCell(withIdentifier identifier: String,
for indexPath: IndexPath) -> UITableViewCell {
cell.prepareForReuse()
return cell
}
}
@MainActor
struct FeedViewControllerTests {
let tracker = FakeAdTracker()
let table = OneCellTableView()
func makeFeed(_ items: [FeedItem]) -> FeedViewController {
let feed = FeedViewController(style: .plain)
feed.tableView = table
feed.analytics = tracker
feed.didRefresh(with: items)
return feed
}
func ad(_ id: String) -> FeedItem {
// A scheme nothing handles, so UIApplication.open does nothing in the test.
.ad(Ad(id: id, headline: id, callToAction: "Open", url: URL(string: "test-ad://\(id)")!))
}
func configureRow(_ row: Int, in feed: FeedViewController) {
_ = feed.tableView(table, cellForRowAt: IndexPath(row: row, section: 0))
}
@Test func oneTapOnAReusedCellClicksOnlyTheAdOnScreen() {
let feed = makeFeed([ad("a"), ad("b"), ad("c")])
for row in 0..<3 { configureRow(row, in: feed) } // one cell, reused three times
table.cell.ctaButton.sendActions(for: .touchUpInside)
#expect(tracker.events == ["ad_click c"])
}
@Test func cellReusedForAPostOpensNothing() {
let feed = makeFeed([ad("a"), .post(Post(title: "Hello"))])
configureRow(0, in: feed)
configureRow(1, in: feed) // the ad's cell now shows the post
table.cell.ctaButton.sendActions(for: .touchUpInside)
#expect(tracker.events == [])
}
@Test func impressionIsLoggedOncePerAd() {
let feed = makeFeed([.post(Post(title: "Hello")), ad("a")])
for _ in 0..<3 { // the ad scrolls on screen three times
feed.tableView(table, willDisplay: UITableViewCell(), forRowAt: IndexPath(row: 1, section: 0))
}
#expect(tracker.events == ["ad_impression a"])
}
@Test func tapOnAnAdThatARefreshRemovedDoesNothing() {
let feed = makeFeed([.post(Post(title: "Hello")), ad("a")])
configureRow(1, in: feed)
feed.didRefresh(with: [.post(Post(title: "Hello"))]) // shorter feed, ad gone
table.cell.ctaButton.sendActions(for: .touchUpInside)
#expect(tracker.events == []) // and no out-of-range crash
}
}Ran on the iOS Simulator (Swift 6 mode): 4 tests, all passed.
What I'd ask next
- "How would you have found this without reading the code?" — Put a breakpoint in
openAdand tap once. If it's hit three times, look at the backtrace: three separate closures. Or logctaButton.enumerateEventHandlersin the cell (iOS 14+) to count what's attached. - "Why not
tableView.indexPath(for: cell)at tap time instead of an id?" — That also works: the tapped cell is on screen, so it returns its current row. I keep the id because it's what analytics needs anyway, and it doesn't depend on the cell at all. - "Would a diffable data source fix the stale index?" — It helps: you key rows by identifier, so you naturally think in ids. It doesn't stop you capturing an
indexPathin a closure. - "Why does
[weak self]matter in the button's action?" — The cell owns the button, the button owns the action, and the action's closure would own the cell. That's a loop, and every cell in the pool would leak. - "What's the SwiftUI version of this bug?" — Much rarer, because a
Button's action is part of the view value and is replaced on every update. The stale-data half survives, though: capture an index in a closure and it goes stale the same way. Use the item's id.