●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 36 days left. If your app calls it, check the migration target●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 36 days left. If your app calls it, check the migration target●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Fullscreen Ads Stopped Showing After Going Back and Forth Between Screens — The Ad Holder Was Still Holding a Closed Screen
Fullscreen ads vanished after reopening a screen because a reused ad holder kept notifying a closed screen. A holder that swaps only its listener target, tested in Node.
Open a wallpaper, go back, open another, go back. In a wallpaper app, screens are created and closed over and over while someone is using it.
Somewhere in that back-and-forth, fullscreen ads stopped appearing. The first screen I opened showed one. But once I went back and reopened a screen, the moment that should have shown an ad simply passed with nothing happening. No crash, no error message.
Reuse the ad object instead of recreating it, but swap who it notifies every time the screen changes. That one sentence is what the fix came down to.
This happened in a native wallpaper app that I run as an indie developer, but the same shape is easy to build in the Expo apps Rork exports. Below is the pattern rewritten in TypeScript and run on Node 22 to check it.
The only symptom was "it stops from the second time"
When a bug gives you little to go on, it helps to write the observations in a table and pin down what differs. For this kind of bug, they line up like this.
What you observe
What it tells you
The first screen shows an ad
Ad unit IDs and review status are fine, so the ad configuration is right
A screen reopened after going back does not
The problem is on the receiving side of "loaded", not the showing side
No error appears anywhere
This is not an SDK failure; notifications are going somewhere else
Quitting and relaunching the app brings back one ad
State lives somewhere long-lived at app level, not in the screen
Suspecting the ad settings first tends to be the long way around. If the settings are right and ads stop from the second time on, suspect who the notification is reaching.
What was happening: a first screen left inside a "don't recreate" design
If you create a fullscreen ad object per screen, loading starts over each time. So I kept one app-wide static holder and reused it. That part was a reasonable decision, I think.
The problem was that the holder also kept a reference to the first screen that used it. Even after that screen closed, the holder was still holding it. A new screen would ask for an ad and get the same object. But the "loaded" notification flew to the closed screen. The new screen stayed on "preparing" forever.
The fix was not to recreate the object. It was to keep the object and update only the reference to the current screen. Later, the same shape showed up in a rewarded-ad holder as well. Do not let a reused object hold a direct reference to a screen is now the first item on my checklist.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can tell in a few minutes whether ads that stopped appearing after screen navigation are a settings problem or a notification-routing problem
✦You can reuse a fullscreen ad object without recreating it, swap only the listener target to the current screen, and unit-test that holder away from the ad SDK
✦You can head off the ordering trap where an old screen's cleanup wipes out the new screen's connection, before it ships
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Before fixing anything, reproduce the broken form as it was. This is a re-creation of the idea, not a quote of the original code. In place of the ad SDK I made a stand-in that has only one property of the real thing: events arrive later.
With the broken holder, only the first of five screens could show an ad. The four "loaded" notifications for screens 2 through 5 all went to the closed first screen. Not one screen crashed. A wrong destination for one notification is enough to stop ads completely.
The fix: don't recreate the ad, swap only the listener target
Here is the fixed holder. Three things changed.
// holder-fixed.ts — 直した形。広告は作り直さず、通知先だけを最新に差し替えるimport { FakeInterstitial } from "./fake-ad.ts";import type { Host } from "./screen.ts";let ad: FakeInterstitial | null = null;let host: Host | null = null; // 今の通知先(いなければ null)let ready = false; // 広告の状態は、画面ではなく部品が持つfunction ensureAd(): FakeInterstitial { if (ad) return ad; const created = new FakeInterstitial(); created.addListener("loaded", () => { ready = true; host?.onReady(); }); created.addListener("closed", () => { ready = false; host?.onClosed(); created.load(); }); created.load(); ad = created; return created;}export function attach(next: Host): void { ensureAd(); host = next; if (ready) next.onReady(); // すでに読み込み済みなら、新しい画面にもすぐ伝える}export function detach(prev: Host): void { if (host === prev) host = null; // 自分が今の通知先のときだけ外す}export function show(): boolean { if (!ready || !ad) return false; ad.show(); return true;}
First, whether the ad is loaded is now held by the holder, not by a screen. When screens change, the state stays with the holder.
Second, attach tells the new screen immediately if an ad is already loaded. Without that, a screen opened after loading finished would stay on "preparing" until the next notification.
Third, detach only detaches when the caller is the current target. This was the most surprising part, and the next section covers it.
Against intuition: an old screen's cleanup can wipe out the new screen's connection
Detach when the screen closes. Written naively, detach just empties the target. But during a screen transition, the new screen's attach can run first and the old screen's detach can arrive afterward. The order can differ by library and version, so the safe move is to make it work in either order. With the new screen first, an unconditional detach erases the new screen's connection too.
I tried both versions on the same sequences.
// For comparison: detach without a conditionexport function detachUnconditional(): void { host = null;}
Sequence
Target with the fixed holder
Target with unconditional detach
attach(A) → attach(B) → detach(A)
B
None (B's connection is erased too)
attach(A) → detach(A) → attach(B)
B
B
The table shows results checked by tests. In the first sequence the ad loads, but nobody is told, and again no ad appears. Adding the single condition "only if I am the current target" to detach left the target as B in either order.
One more point: in many stack-style navigators, going back does not re-run the "mounted" logic of the previous screen, because that screen was kept alive in the background. "Attach on mount, detach on unmount" therefore leaves the returning screen disconnected. You need to connect only while the screen is visible.
Hooking it up to Expo Router
On the screen side, connect only while visible. I used Expo Router's useFocusEffect.
import { useCallback, useState } from "react";import { useFocusEffect } from "expo-router";import { attach, detach, show } from "./holder-fixed";export function useInterstitial() { const [ready, setReady] = useState(false); useFocusEffect( useCallback(() => { const host = { name: "screen", onReady: () => setReady(true), onClosed: () => setReady(false), }; attach(host); return () => detach(host); // only detaches if it is still the same host }, []) ); return { ready, show };}
I have not covered this glue with automated tests the way I did the holder. Please check it on a device by moving between screens several times. For the doubled execution you see in development (connect, disconnect, connect), the holder itself is tested to keep its target after attach → detach → attach.
The same shape again: a static collection not emptied before rebuilding
In the native wallpaper app, a similar bug showed up in an image list. A static collection that held the list was not emptied before being rebuilt, so items piled up across restarts, and the slider's upper bound grew to four times the real item count.
I reproduced that shape too, with the item count taken from the length of a module-level array.
// catalog.ts — 一覧の件数を、モジュールが持つ配列から出している例let items: string[] = [];// 壊れた形: 読み込みのたびに足していくexport function loadCatalogBuggy(fresh: string[]): void { items.push(...fresh);}// 直した形: 作り直す前に、いったん空にする(まとめて置き換える)export function loadCatalogFixed(fresh: string[]): void { items = [...fresh];}export function sliderMax(): number { return items.length;}
Here is the result of loading a five-item list four times.
How it loads
Slider maximum
Append (broken)
20
Replace (fixed)
5
By the fourth load the maximum was 20, four times the real count of 5. Empty a static collection before you rebuild it. What makes this hard to notice is that writing "append" already assumes the list grows with every load.
Pitfalls: three places that stay broken when you think you fixed it
What you do when show() returns false. The holder quietly returns false when no ad is ready. If the caller ignores the return value, all that remains is a button that does nothing. Give the screen a path that moves on without an ad.
The rewarded-ad holder. It is easy to fix the fullscreen holder and leave the rewarded one as it was. This same shape came back in a rewarded holder for me. If you keep a holder, give it the same attach and detach shape.
Other static holders. Anything placed at module level outlives its screens. The search in the next section finds them.
A first step you can take today
Count how many things in your app outlive a screen. Look for top-level let declarations and empty Map, Set, or array declarations at module level.
Among the lines that turn up, anything that holds a reference to a screen or a screen's callback is a candidate for this bug. It is not limited to ads; the same arrangement can happen with purchase state or location subscriptions.
One last thought. Checking where a notification is going is plain work, but it was a much shorter road than continuing to search for the cause in the ad settings. I hope it helps you narrow down the same symptom.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.