●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)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●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)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●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…
My Ads Never Came Back After a Subscription Expired: Fixing the Direction of an Ad-Free Check
An ad-free flag stayed on after the subscription expired because the check pointed the wrong way. Here is how I rebuilt it to wait for two purchase queries, decide only from confirmed facts, and keep the last value on failure.
I was writing out a decision flow line by line one night. One of my wallpaper apps, which I run as an indie developer, removes ads after a purchase. What I had never done, I realized, was trace what happens to that "ads removed" state after the subscription expires.
When I traced it, I stopped. The branch that brings ads back was not written anywhere.
What I had was a lapsed subscription with the ads still off. An ad shown to a paying user gets noticed fast and turns into a support message. The opposite accident, ads that stay off for someone who no longer pays, bothers nobody, so nobody reports it. It simply continues. I found it on the Android side, and it became the first item on the checklist I wrote before carrying the same logic over to iOS.
What I want to say first is that the fix was not about how to use a billing SDK. It was about the direction of the check and the way it waits. What follows is a write-up of that idea, rebuilt as code I could run, and then run.
One sentence that describes the bug: the direction was reversed
Put into words, the broken check read: "if the flag is not ad-free right now, set it to false." What it should have said was: "if no purchase is confirmed, set it to false." The subject was the flag's own current value instead of a confirmed fact.
I will not paste the original code. Here is the idea, reconstructed in TypeScript.
// adfree.ts — composing the ad-free flag from two purchase queriesexport type Source = "subscription" | "oneTime"; // subscription / one-time purchaseexport type QueryResult = | { ok: true; active: boolean } | { ok: false; reason: string };export type Results = Record<Source, QueryResult>;// A reconstruction of the broken direction (the idea, not a quote of the real code)export function applyBuggy(prev: boolean, r: Results): boolean { const purchased = (r.subscription.ok && r.subscription.active) || (r.oneTime.ok && r.oneTime.active); if (purchased) return true; if (!prev) return false; // "if not ad-free right now, set false": the direction is backwards return prev; // even after expiry, the previous true stays}
This code works correctly up to the moment a purchase is confirmed, which is why checking right after a purchase reveals nothing. It breaks only once, after the subscription lapses. When the query answers "none" and prev is still true, it keeps returning true.
Tests on the working side never find this kind of bug. "Buy it and the ads disappear" stays green no matter how often you repeat it. Test the direction that removes ads and the direction that brings them back separately. I put that at the top of my checklist.
Wait for both sources, then decide only from confirmed facts
There was not one purchase query but two, for example a subscription and a one-time purchase. If you set false the moment one source says "none", ads flash on screen when the other source is about to say "active".
I rewrote the decision as three rules.
Outcome of the two queries
Value to set
Why
At least one confirmed "active"
true
One piece of proof is enough, even if the other call failed
Both confirmed "inactive"
false
You may only clear the flag after hearing "inactive" from every source
A failure and no proof of active
keep the previous value
Do not manufacture an answer when you cannot decide
Here are those rules as a function.
// Fixed: decide only from confirmed facts; keep the previous value when undecidableexport function composeAdFree(prev: boolean, r: Results): boolean { const list = [r.subscription, r.oneTime]; // One confirmed "active" is enough, even if the other source failed if (list.some((x) => x.ok && x.active)) return true; // false only when every source confirmed "inactive" if (list.every((x) => x.ok && !x.active)) return false; // A failure and no proof of active: undecidable, so keep the previous value return prev;}
Why write it this way? The key is giving QueryResult a third state, "failed". active: false means "I asked and it was inactive". ok: false means "I could not ask". If both collapse into one false, a dropped connection becomes a change in billing state.
✦
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'll be able to check, with a table of two-source outcomes, whether your own app's ad-free flag really returns to false when a subscription lapses
✦You'll have a small decision function that waits for both queries, never invents an answer on a failed call, and can be unit-tested without your billing SDK
✦You'll avoid both opposite accidents before shipping: a paying user who briefly sees an ad, and a lapsed user whose ads never come back
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.
With the function in place I laid out every combination and fed the same inputs to the broken version and the fixed one. This is the result from Node 22.
Case
Previous
Broken
Fixed
A Subscription expired, no one-time purchase
true
true (ads never return)
false
B Only the subscription query failed
true
true
true (previous kept)
C Subscription failed, one-time active
false
true
true
D Both failed, previous false
false
false
false
E Both failed, previous true
true
true
true
F Subscription active
false
true
true
The two versions differ only in case A. The whole bug lived in that one line. The other five rows exist to confirm that fixing it did not change anything else, and the table shows that at a glance.
A controller that stale responses cannot overwrite
The function alone was not enough. In a real app the check runs every time the app returns to the foreground and right after a purchase. When checks overlap, an older one can return late and overwrite a newer result.
First I reproduced the form that applies each source as it arrives.
// naive.ts — applying each source as it arrives (for reproduction)import type { QueryResult } from "./adfree.ts";export function makeNaive(initial: boolean, log: (s: string) => void) { let state = initial; return (r: QueryResult) => { const next = r.ok ? r.active : state; // decides from the one result that just arrived if (next !== state) { state = next; log(`naive -> ${next}`); } };}
Giving it a subscription "inactive" first and a one-time "active" afterwards produced naive -> false / naive -> true. Ads come back, then vanish again: a brief flicker for a paying user.
The fixed version gives the controller a generation number.
Inside refresh() it does three things: waits for both sources with Promise.all, turns a thrown exception into a "failed" value so nothing crashes, and discards its result if a newer refresh has started.
This is what I observed when I delayed and reordered the responses.
Situation
Result
Subscription returns "inactive" at 10 ms, one-time returns "active" at 40 ms
onChange never fires and the state stays true
Two refreshes in a row; the newer returns "inactive" first, the older returns "active" later
onChange(false) fires once; the stale response is discarded and the state stays false
The subscription query throws (one-time inactive)
No crash; the previous true is kept
The second row is my favorite. The old answer says active and the new answer says inactive. Which one is right is not decided by speed. A newer answer must never lose to an older one. A generation number keeps that promise with a single integer.
A sketch for wiring this into an Expo app
From here on it is a sketch whose types I have not checked and which I have not run on a device. I wrote it assuming an app generated with Rork could connect the same way.
// useAdFree.tsx — re-ask both sources whenever the app returns to the foreground (sketch, not run on a device)import { useEffect, useRef, useState } from "react";import { AppState } from "react-native";import AsyncStorage from "@react-native-async-storage/async-storage";import { AdFreeController, type QueryResult } from "./adfree";const KEY = "adfree:last";// Wrap your billing SDK calls here (report failures as ok:false)async function querySubscription(): Promise<QueryResult> { return { ok: false, reason: "not implemented" };}async function queryOneTime(): Promise<QueryResult> { return { ok: false, reason: "not implemented" };}export function useAdFree(): boolean { const [adFree, setAdFree] = useState(false); const ctl = useRef<AdFreeController | null>(null); useEffect(() => { let alive = true; (async () => { const saved = (await AsyncStorage.getItem(KEY)) === "1"; if (!alive) return; setAdFree(saved); ctl.current = new AdFreeController( { subscription: querySubscription, oneTime: queryOneTime }, saved, (v) => { setAdFree(v); AsyncStorage.setItem(KEY, v ? "1" : "0").catch(() => {}); }, ); await ctl.current.refresh(); })(); const sub = AppState.addEventListener("change", (s) => { if (s === "active") ctl.current?.refresh(); }); return () => { alive = false; sub.remove(); }; }, []); return adFree;}
What does it solve? It stores the last value on the device and uses it as the initial value at launch. When both queries are in, it replaces it. Until then, someone whose last value was true sees no ads, and someone whose last value was false does. The previous value decides how the app looks while it waits.
1. Do not give "none" and "could not ask" the same value. Folding false, undefined, and an exception into a single false turns a flaky connection straight into a billing-state change. Give failure its own place in the result type.
2. Write the returning-direction test first. Alongside "buying removes ads", write "expiry brings ads back" with equal weight. The broken version failed only in case A.
4. OR a time-limited ad pause on separately. If a rewarded ad pauses ads for a while, keep that as its own state and combine it with OR at the end. Mixed into the purchase decision, you can no longer tell which reason made it true when it expires.
What to do first tomorrow
In your own app, find the place where the ad-free value goes back to false when no purchase is confirmed. If you cannot find it, you may be in the state I was in.
If you do find it, check once whether that branch reads a confirmed fact or the flag's own current value. Then replace the six-row table above with your own two sources and fill it in.
When a question could not be answered, do not invent the answer. I try to keep to that line even on days with a bad connection, and even on days when I would like the ads back.
What I ran here is the TypeScript component rebuilt from the idea and its automated tests, and nothing more. I have not included device behavior in the original Android app or a check against StoreKit on the iOS side.
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.