<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pragathi Jayaram</title>
    <description>The latest articles on DEV Community by Pragathi Jayaram (@pragathijayaram).</description>
    <link>https://dev.to/pragathijayaram</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4064360%2F590e266a-c0c8-4d48-9336-1150919d463e.png</url>
      <title>DEV Community: Pragathi Jayaram</title>
      <link>https://dev.to/pragathijayaram</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pragathijayaram"/>
    <language>en</language>
    <item>
      <title>What Apple actually checks: an App Store submission checklist for a health app</title>
      <dc:creator>Pragathi Jayaram</dc:creator>
      <pubDate>Mon, 12 Oct 2026 15:30:58 +0000</pubDate>
      <link>https://dev.to/pragathijayaram/what-apple-actually-checks-an-app-store-submission-checklist-for-a-health-app-a82</link>
      <guid>https://dev.to/pragathijayaram/what-apple-actually-checks-an-app-store-submission-checklist-for-a-health-app-a82</guid>
      <description>&lt;p&gt;Most App Store submission checklists are flat lists. They tell you what has to be true, in roughly the order Apple numbers its guidelines, which is not the order you can do the work in.&lt;/p&gt;

&lt;p&gt;This one is ordered by dependency. First the items with multi-week clocks that block everything downstream, then the things that live in the build, then the things that live in the listing, then the things that can only be checked on the build you are actually going to ship. If you follow it top to bottom, you should not find yourself blocked by something you could have started a month earlier.&lt;/p&gt;

&lt;p&gt;It comes out of getting a nutrition app with subscriptions, health data and a third-party AI path &lt;a href="https://pragathijayaram.com/articles/approved-on-the-first-submission/" rel="noopener noreferrer"&gt;through review on the first submission&lt;/a&gt;. That account is the story version. This is the part that generalises: every requirement below links to Apple's own published rule, so you can check me. Where something is my judgment rather than Apple's requirement, it says so.&lt;/p&gt;

&lt;p&gt;It is written Apple-first, for a small team or one person, and it assumes you are shipping something with an account, a subscription and some data you would rather not get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clocks you cannot compress
&lt;/h2&gt;

&lt;p&gt;Almost everything else on this list is work you control. These are not. Start them first, because no amount of effort later makes them go faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Developer Program enrollment, and which kind.&lt;/strong&gt; Individual or Organization. Organization requires a D-U-N-S number and verification of the legal entity, which takes time and can stall if your business is not registered in a form the process expects. Individual enrollment is immediate by comparison. The visible difference to users is the developer name on the listing. If you are a sole trader, Individual may also be the honest description of the legal reality. You can transfer later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paid Apps Agreement, plus tax and banking.&lt;/strong&gt; This is the one that catches people, because it does not only gate getting paid. Apple's guidance is explicit that the Paid Apps Agreement must be signed and &lt;strong&gt;Active&lt;/strong&gt; before in-app purchase products are available in the sandbox, which means you cannot test your purchase flow at all until it is done. Behind it sit a bank account and tax forms, and behind those, in most countries, a business registration and a tax number that arrive on somebody else's timetable.&lt;/p&gt;

&lt;p&gt;Chain it out and the sequence for a new business is roughly: register the business, file for a tax number, wait weeks, open a business bank account, complete Agreements Tax and Banking, wait for Active, and only then run your first sandbox purchase. Plan for that to be the longest pole in the whole project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ov4uyp43c250np5a77u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ov4uyp43c250np5a77u.png" alt="Two tracks converging on submission. The critical path runs business and tax registration, bank account, Agreements Tax and Banking, then Paid Apps Agreement active, at which point the first sandbox purchase becomes testable. The parallel track, which blocks nothing, runs developer enrollment, toolchain, build work and listing work." width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The top row sets your date. The bottom row is the part most people think of as the work.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If your app has subscriptions, the date you can first test a purchase is set by paperwork, not by engineering. Find that date early and work backwards from it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Toolchain minimums.&lt;/strong&gt; Apple publishes &lt;a href="https://developer.apple.com/news/upcoming-requirements/" rel="noopener noreferrer"&gt;SDK minimum requirements&lt;/a&gt; with hard dates. Since 28 April 2026, uploads must be built with Xcode 26 or later against an iOS 26 SDK or later. These deadlines arrive without a grace period, so check the current requirement before you plan a release, not after a failed upload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Age rating questionnaire.&lt;/strong&gt; Apple &lt;a href="https://developer.apple.com/news/?id=ks775ehf" rel="noopener noreferrer"&gt;replaced the age rating system&lt;/a&gt; in 2025. The tiers are 4+, 9+, 13+, 16+ and 18+; the old 12+ and 17+ are gone. The questionnaire added required questions on in-app controls, app capabilities, medical or wellness topics, and violent themes, and apps that had not answered it by 31 January 2026 were blocked from submitting new apps or updates. If you are returning to an app you have not touched in a while, check this before anything else, because it silently blocks submission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one checklist for everything stalls
&lt;/h2&gt;

&lt;p&gt;This is method rather than requirement, and it is the single change that made the rest tractable.&lt;/p&gt;

&lt;p&gt;A single list stalls because it is usually ordered by guideline number, and guideline order has nothing to do with what you can do today. You reach an item that needs a build you have not made, or a store agreement that is not active yet, and the list stops there while a dozen items you could have finished sit below it.&lt;/p&gt;

&lt;p&gt;Submission work splits into three kinds, separated by what unblocks them and by what makes a finished result worthless again.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Unblocked by&lt;/th&gt;
&lt;th&gt;Invalidated by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Verify state&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No debug logging in release; every permission string localised; no secrets in the package&lt;/td&gt;
&lt;td&gt;Nothing. Start today with only the repository open&lt;/td&gt;
&lt;td&gt;The next commit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Device tests&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cold launch on a real device; purchase, cancel, expire, restore&lt;/td&gt;
&lt;td&gt;A build. Anything involving purchases also needs an active Paid Apps Agreement&lt;/td&gt;
&lt;td&gt;A new build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Form filling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Listing copy, screenshots, privacy labels, age rating&lt;/td&gt;
&lt;td&gt;The console. Some fields will not accept anything until a build is uploaded&lt;/td&gt;
&lt;td&gt;Nothing. It persists&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Write them as three documents, because they have three different lifecycles. Keep them in one and you will either redo work that did not need redoing, or trust a check whose build no longer exists.&lt;/p&gt;

&lt;p&gt;One warning from experience: your audit will go stale. Apple's guidelines are a living document and rules move. Re-read the actual guideline text before you trust your own summary of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What has to be true in the build
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Privacy manifest.&lt;/strong&gt; Apps must include a &lt;a href="https://developer.apple.com/documentation/bundleresources/privacy-manifest-files" rel="noopener noreferrer"&gt;privacy manifest&lt;/a&gt;, a &lt;code&gt;PrivacyInfo.xcprivacy&lt;/code&gt; file declaring the data types your app collects and your reasons for calling certain APIs that could otherwise be used to fingerprint users. Third-party SDKs ship their own manifests and Xcode consolidates them.&lt;/p&gt;

&lt;p&gt;The practical point: find out what your toolchain generates for you before you hand-write anything. If you are on a managed framework, the SDK-contributed portion may already be handled, and the piece you actually have to declare is your own first-party data collection and tracking status. Check the merged file in a real build once rather than assuming either way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permission strings.&lt;/strong&gt; Every permission you request needs a usage description that explains &lt;em&gt;why&lt;/em&gt;, and it needs to exist in every language you ship. A string that says "This app needs camera access" is worse than useless; say what the camera is for. Also confirm you are not requesting permissions you no longer use, which happens quietly when a feature gets cut but its dependency does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Release hygiene.&lt;/strong&gt; Console logging stripped. Developer-only switches compiled out rather than merely hidden. No environment or secrets files inside the shipped package. Confirm all of these on a release build, because every one of them behaves differently in debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What has to be true in the listing
&lt;/h2&gt;

&lt;p&gt;Apple's &lt;a href="https://developer.apple.com/app-store/review/guidelines/" rel="noopener noreferrer"&gt;accurate metadata&lt;/a&gt; rules under 2.3 are more prescriptive than most people expect.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App names are limited to 30 characters&lt;/strong&gt; (2.3.7), and metadata must not be packed with trademarked terms, popular app names, pricing information or irrelevant phrases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every localization is a complete listing.&lt;/strong&gt; If you add a language, you owe it its own name, subtitle, keywords, description, screenshots. This roughly doubles the work per language, and it is not a place to paste machine translation, particularly if your app makes any claim about health.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field limits are enforced by the form, not documented in one place.&lt;/strong&gt; Apple publishes the 30-character name limit in the guidelines but does not publish every field's ceiling there. The description limit was 4,000 characters when I submitted. Treat the form as the authority, write to the limit deliberately, and leave yourself headroom, because a translation that fits today may not fit after an edit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legal URLs must be live at submission&lt;/strong&gt;, and if you ship more than one language, make them locale-explicit rather than relying on a single URL to guess. A privacy policy URL that 404s is an instant rejection and an easy one to avoid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscription terms belong in the description.&lt;/strong&gt; Guideline 3.1.2(c) requires that before asking someone to subscribe you clearly describe what they get for the price. In the EU, price-indication law wants the same information, so write it once and satisfy both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encoding surprises are real.&lt;/strong&gt; App Store Connect rejected &lt;code&gt;✓&lt;/code&gt; (U+2713) in my description as an invalid character while accepting German umlauts without complaint. If the form refuses your text and does not say why, suspect a non-ASCII decoration before you suspect anything clever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Age rating.&lt;/strong&gt; Answer the questionnaire against what your app actually does. One trap worth naming: if there is a mitigation checkbox about social media being disabled for under-13s, do not tick it reflexively. It asserts an integration you may not have, and it only applies if you declared a social surface in the first place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App privacy details.&lt;/strong&gt; The &lt;a href="https://developer.apple.com/app-store/app-privacy-details/" rel="noopener noreferrer"&gt;nutrition label&lt;/a&gt; is required to submit, must cover data collected by third-party code you integrate, and you are responsible for its accuracy. Inaccurate disclosures can get an app rejected or removed.&lt;/p&gt;

&lt;p&gt;Do this from a written inventory rather than from memory, and reconcile it against your privacy manifest so the two cannot contradict each other later. Working out what your analytics, crash reporting and payment SDKs each collect is the actual task; filling the form afterwards takes an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assets fail on spec, not on taste
&lt;/h2&gt;

&lt;p&gt;The most mechanical section, and the one where rejections feel most stupid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No alpha channel.&lt;/strong&gt; Apple's &lt;a href="https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications/" rel="noopener noreferrer"&gt;screenshot specifications&lt;/a&gt; state that images cannot include alpha channels or transparency. The check is per-pixel, so an image that looks completely opaque but was exported with an alpha channel still fails. The same applies to the 1024×1024 App Store icon. Design tools export with alpha by default, so flatten deliberately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshots must show the app in use.&lt;/strong&gt; Guideline 2.3.3 says they should not be merely title art, a login page or a splash screen. Overlaid text and images are allowed. My own opinion, not Apple's rule: use real screens rather than marketing renders, because a reviewer comparing your screenshots to your app is doing exactly the comparison you want to survive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata must suit a 4+ audience&lt;/strong&gt; regardless of your app's rating (2.3.8), which constrains screenshots as well as text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not reference other platforms&lt;/strong&gt; in your app or metadata (2.3.10). Scrub mentions of Android or other marketplaces from screenshots and copy.&lt;/p&gt;

&lt;p&gt;The same section as a checklist:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Asset&lt;/th&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Stated in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App icon, 1024×1024&lt;/td&gt;
&lt;td&gt;Flattened, no alpha channel, no transparency&lt;/td&gt;
&lt;td&gt;Screenshot specifications&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshots&lt;/td&gt;
&lt;td&gt;No alpha channel or transparency, checked per pixel&lt;/td&gt;
&lt;td&gt;Screenshot specifications&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshots&lt;/td&gt;
&lt;td&gt;Show the app in use, not title art, a login page or a splash screen&lt;/td&gt;
&lt;td&gt;Guideline 2.3.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App name&lt;/td&gt;
&lt;td&gt;30 characters&lt;/td&gt;
&lt;td&gt;Guideline 2.3.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All metadata&lt;/td&gt;
&lt;td&gt;Suitable for a 4+ audience regardless of the app's own rating&lt;/td&gt;
&lt;td&gt;Guideline 2.3.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App and metadata&lt;/td&gt;
&lt;td&gt;No names, icons or imagery of other mobile platforms or marketplaces&lt;/td&gt;
&lt;td&gt;Guideline 2.3.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Description, keywords, review notes&lt;/td&gt;
&lt;td&gt;Ceilings enforced by the form and not published in the guidelines; read them off App Store Connect&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the reviewer needs in order to do the job
&lt;/h2&gt;

&lt;p&gt;Guideline 2.1(a) is direct about this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Make sure your app has been tested on-device for bugs and stability before you submit it, and include demo account info (and turn on your back-end service!) if your app includes a login.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read past the obvious. What it asks for is an account through which the reviewer can see the whole app.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One account per state you gate on.&lt;/strong&gt; If your app has free and paid tiers, a free account alone will not show a reviewer the paid features. Provision one per state, and seed each one with realistic data, because empty states demonstrate nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seed the data.&lt;/strong&gt; A reviewer opening a populated app can evaluate it. A reviewer opening an empty one is evaluating your empty states.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review notes must be specific.&lt;/strong&gt; Guideline 2.3.1 requires new features and changes to be described with specificity in the Notes for Review, and states plainly that generic descriptions will be rejected. Write the notes as a route map: where each gated feature is, which account reaches it, what to tap. Check every label in the notes against the actual strings in the build, because a note describing a screen that does not exist costs a review cycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-app purchases must be visible and functional to the reviewer&lt;/strong&gt;, and 2.1(b) says that if a configured purchase cannot be found or reviewed in your app, you must explain why in the notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freeze once submitted.&lt;/strong&gt; Editing an item that is in review pulls it out of the queue. Decide what is going in, put it in, then leave it alone. That includes the demo accounts: do not sign into them, do not change their state.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The rules that bite health and wellness apps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Third-party AI consent.&lt;/strong&gt; Apple &lt;a href="https://developer.apple.com/news/?id=ey6d8onl" rel="noopener noreferrer"&gt;revised 5.1.2(i) on 13 November 2025&lt;/a&gt; to say you must clearly disclose where personal data will be shared with third parties, &lt;strong&gt;including with third-party AI&lt;/strong&gt;, and obtain explicit permission before doing so.&lt;/p&gt;

&lt;p&gt;Three things follow that people get wrong. Disclosure in a privacy policy is not permission; the permission has to exist in the product, before the first transmission. Name the provider rather than saying "third parties". And do not bundle it into your terms acceptance, because that is not explicit permission for this specific purpose.&lt;/p&gt;

&lt;p&gt;My own design position, not a rule: build the decline path so that declining costs the user the AI feature and nothing else. Consent that costs you the app is not meaningfully a choice, and under the GDPR it is unlikely to be freely given.&lt;/p&gt;

&lt;p&gt;If you process health data in the EU, there is a second layer. Health data is &lt;a href="https://eur-lex.europa.eu/eli/reg/2016/679/oj" rel="noopener noreferrer"&gt;Article 9&lt;/a&gt; special-category data under the GDPR and needs explicit consent in its own right, and the existence of a decline path may move your lawful basis for that feature from contract to consent, with document consequences. That is a separate piece of work from the Apple rule and it does not go away if you satisfy Apple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No medical claims.&lt;/strong&gt; Guideline 1.4.1 says medical apps that could provide inaccurate information, or be used to diagnose or treat, may be reviewed with greater scrutiny, that accuracy claims about health measurements must be supported by disclosed data and methodology, and that apps should remind users to check with a doctor. If you are wellness rather than medical, the posture has to be visible in the product and not only in your terms: disclaimers on generated output, no diagnostic language, no claim you cannot substantiate.&lt;/p&gt;

&lt;p&gt;In the EU there is a parallel constraint on what you may say about food and health at all. Permitted health claims are listed in &lt;a href="https://eur-lex.europa.eu/eli/reg/2012/432/oj" rel="noopener noreferrer"&gt;Regulation (EU) No 432/2012&lt;/a&gt;, and wording outside that list is a regulatory problem independent of Apple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account deletion.&lt;/strong&gt; Guideline 5.1.1(v) requires that if your app supports account creation, you must also offer account deletion within the app. Build it so it completes without contacting support, and test it end to end on a real account: authentication record gone, application data gone, and any external processor holding a record of that user cleaned up too. If the user has an active subscription, tell them deletion does not cancel it and where to cancel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimum functionality and saturated categories.&lt;/strong&gt; 4.2 asks for something more than a repackaged website. 4.3(b) says Apple will not accept new submissions in well-established categories unless they offer a meaningfully different or improved experience. If you are entering a crowded space, your listing has to make the difference legible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payments, and the requirement most people miss
&lt;/h2&gt;

&lt;p&gt;Unlocking features inside the app means in-app purchase (3.1.1). Beyond that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restore has to work, and has to be reachable.&lt;/strong&gt; 3.1.1 says you should make sure you have a restore mechanism for restorable purchases. The failure mode I hit, and the one worth checking today: restore that lives only on the paywall, which is the screen non-subscribers see. A subscribed user then has no route to it, including the paying demo account you gave the reviewer. Put restore somewhere reachable in every subscription state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prices and periods must match your configured products exactly&lt;/strong&gt;, in every language and currency you display them in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Describe what the subscription buys before asking for it&lt;/strong&gt; (3.1.2(c)).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the whole lifecycle, not the happy path.&lt;/strong&gt; Purchase, upgrade, downgrade, cancel, expire, confirm the app re-gates, restore while active, restore with nothing to restore, restore after signing out and back in. Every purchase bug I found lived in one of the paths that is not "buy it once and it works".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The last gate, and what happens after you pass it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Re-verify on the exact binary.&lt;/strong&gt; Production signing and production purchase configuration are not the same as development. A test that passed on a development build is evidence about that build. At minimum, run a cold launch and a real sandbox purchase on the binary you are submitting, then tag that commit, so the tag records what was verified rather than what was intended.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose your release option deliberately.&lt;/strong&gt; After approval you can have the version go live automatically, on a date, or &lt;a href="https://developer.apple.com/help/app-store-connect/manage-your-apps-availability/select-an-app-store-version-release-option/" rel="noopener noreferrer"&gt;manually&lt;/a&gt;. Manual leaves the approved version at &lt;strong&gt;Pending Developer Release&lt;/strong&gt; until you click Release This Version, which decouples "Apple approved it" from "it is live". For a first launch I would take manual every time: it gives you a window to do go-live work with the approval already in hand. Releasing can take up to 24 hours to appear on the store, so released is not the same as findable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For updates, consider phased release.&lt;/strong&gt; App Store Connect can &lt;a href="https://developer.apple.com/help/app-store-connect/update-your-app/release-a-version-update-in-phases/" rel="noopener noreferrer"&gt;roll an update out over seven days&lt;/a&gt; to users with automatic updates on, while anyone can still download it manually, and you can release to everyone at any point. Useful for anything with a regression risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If it comes back.&lt;/strong&gt; Rejections arrive in Resolution Center with the guideline cited. Answer the guideline you were given, in that thread, and resubmit; do not guess at what else might have been wrong.&lt;/p&gt;




&lt;p&gt;Two things I would tell anyone starting this. The first is that the schedule is set by the clocks you cannot compress and nothing else, so find those dates before you plan anything. The second is that almost none of the outcome is decided during review. It is decided in the weeks before, which is entirely the part you control.&lt;/p&gt;

&lt;p&gt;The account of doing all of the above for one app, including the bugs it turned up and what the reviewer actually touched, is &lt;a href="https://pragathijayaram.com/articles/approved-on-the-first-submission/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;No checklist survives contact with a different app. This one is as general as I could make it, which means yours will still raise something it does not answer. When it does, write to me at &lt;a href="mailto:hello@pragathijayaram.com"&gt;hello@pragathijayaram.com&lt;/a&gt; and say what you are shipping and where the list runs out. I would rather work through one specific case than add another general bullet.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>appstore</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Approved on the first submission: what it took to get a wellness app through App Review</title>
      <dc:creator>Pragathi Jayaram</dc:creator>
      <pubDate>Mon, 12 Oct 2026 15:19:56 +0000</pubDate>
      <link>https://dev.to/pragathijayaram/approved-on-the-first-submission-what-it-took-to-get-a-wellness-app-through-app-review-50je</link>
      <guid>https://dev.to/pragathijayaram/approved-on-the-first-submission-what-it-took-to-get-a-wellness-app-through-app-review-50je</guid>
      <description>&lt;p&gt;&lt;a href="https://pragathijayaram.com/portfolio/" rel="noopener noreferrer"&gt;Anovi&lt;/a&gt; went into App Review on 27 July and came out approved on 6 August. First submission, no rejection, nothing in Resolution Center.&lt;/p&gt;

&lt;p&gt;It is a nutrition app, which means it carries three things App Review looks at closely and carries them at the same time: auto-renewing subscriptions, health data, and an AI path that sends what you ate to a model somebody else operates. After about a decade of shipping software through other channels, it was the first app I had put in front of a consumer app store.&lt;/p&gt;

&lt;p&gt;The approval is not the interesting part. What produced it is. There was an audit written before submission was on the calendar, a test plan run against a real purchase on the first day that was legally possible, and a fortnight of metadata work where being careless and being wrong look identical from the outside.&lt;/p&gt;

&lt;p&gt;This is the account of one submission. The &lt;a href="https://pragathijayaram.com/articles/app-store-submission-checklist-health-app/" rel="noopener noreferrer"&gt;companion piece&lt;/a&gt; is the same material in checklist form, where every requirement traces back to Apple's published rules rather than to one submission.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fomciu4hscfuli6prag8t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fomciu4hscfuli6prag8t.png" alt="Timeline from 17 May to 6 August 2026. Preparation work runs densely from 17 May to submission on 27 July, followed by ten days in the review queue with no activity, then approval on 6 August." width="800" height="200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Markers are placed by actual date. The stretch to 27 July holds business registration, Apple's contract clocks, the build, the audit, the test pass and the listing, with waiting stacked between them. Everything that decided the outcome sits to the left of that date.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit came first, and then the audit needed auditing
&lt;/h2&gt;

&lt;p&gt;I wrote the rejection-risk audit on 17 May: about 175 checks across Apple guidelines, Play guidelines, trust and data handling, functional resilience, app-specific risks, and release-build hygiene. It went in before submission had a date, which was deliberate. A checklist written under deadline pressure is a checklist written to pass.&lt;/p&gt;

&lt;p&gt;The first useful thing executing it did was invalidate part of it. Apple's guidelines are not a fixed target, and neither is the EU regulation sitting underneath them. Three of the requirements this checklist depended on had changed, the most recent of them three weeks before I wrote it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apple had rewritten &lt;strong&gt;Guideline 5.1.2(i)&lt;/strong&gt; on &lt;a href="https://developer.apple.com/news/?id=ey6d8onl" rel="noopener noreferrer"&gt;13 November 2025&lt;/a&gt; to cover third-party AI explicitly. A checklist written against the earlier text does not mention AI data sharing at all.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;age rating system had been replaced&lt;/strong&gt;. The tiers are now 4+, 9+, 13+, 16+ and 18+, the old 12+ and 17+ are gone, and there is a new questionnaire covering in-app controls, app capabilities, violent themes and, relevant here, &lt;a href="https://developer.apple.com/news/?id=ks775ehf" rel="noopener noreferrer"&gt;medical or wellness topics&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;From &lt;strong&gt;28 April 2026&lt;/strong&gt;, uploads must be built with Xcode 26 and an iOS 26 SDK or later, per Apple's &lt;a href="https://developer.apple.com/news/upcoming-requirements/" rel="noopener noreferrer"&gt;SDK minimum requirements&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the first task was auditing the audit against what Apple actually publishes today, then extending it. That pass added the AI section, rewrote the age-rating items, added the SDK requirement, and added a section on health-app scrutiny under 1.4.1.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A submission checklist is a live document, because Apple's guidelines keep moving and the EU's do too, and nobody tells you when. Re-read the source before you trust your summary of it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Guideline 5.1.2(i): consent is not disclosure
&lt;/h2&gt;

&lt;p&gt;The first finding was the one that mattered. Apple's &lt;a href="https://developer.apple.com/app-store/review/guidelines/" rel="noopener noreferrer"&gt;App Review Guidelines&lt;/a&gt; now read, under 5.1.2(i):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You must clearly disclose where personal data will be shared with third parties, including with third-party AI, and obtain explicit permission before doing so.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Anovi hits this squarely. When you describe a meal in plain language, that text goes to Google Gemini for ingredient and nutrition estimation, with Anthropic as a fallback when Gemini is unavailable. Alongside the meal text goes any modification you made and, so the estimate does not poison you, your recorded food allergies.&lt;/p&gt;

&lt;p&gt;The word doing the work in that guideline is &lt;strong&gt;permission&lt;/strong&gt;. Naming Gemini and Anthropic in a privacy policy is disclosure, and disclosure is only half of what the rule asks for. The other half has to exist in the product, before the first transmission, as something the user actively passes through.&lt;/p&gt;

&lt;p&gt;There is a second layer underneath, because allergy information is health data. Under the GDPR that is &lt;a href="https://eur-lex.europa.eu/eli/reg/2016/679/oj" rel="noopener noreferrer"&gt;Article 9&lt;/a&gt; special-category data, which needs explicit consent in its own right. And because a decline path exists, the AI feature is voluntary enrichment rather than something needed to deliver the contract, which moved its lawful basis from Article 6(1)(b) to Article 6(1)(a) and pulled a chain of documents along behind it.&lt;/p&gt;

&lt;p&gt;What shipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;backend gate&lt;/strong&gt;. The AI routes return &lt;code&gt;403 ai_consent_required&lt;/code&gt; before any model is called. The block is server-side, so it holds regardless of what the client does.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;just-in-time sheet&lt;/strong&gt; on the client. An interceptor catches that specific 403, opens the consent sheet, and retries the original request if consent is granted, so the user's action is not lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Named providers and named data.&lt;/strong&gt; The sheet says Google Gemini and Anthropic, and it says meal description, modifications and allergy information. Not "service providers", not "third parties".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No identity travelling with it.&lt;/strong&gt; Nothing identifying the user is sent: no name, no email, no account. The allergy information goes on its own rather than attached to the rest, so what arrives at the provider is a constraint without a person behind it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Withdrawal&lt;/strong&gt;, in settings, with each grant and withdrawal written to an audit table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3npsb1nsvs5wol90nid.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl3npsb1nsvs5wol90nid.png" alt="The consent sheet, titled Allow AI nutrition analysis. It states which data is sent, names Google (Gemini) with Anthropic as a fallback, says no identifying information is sent and that allergy information is sent on its own, states the user can decline and still log meals manually, by barcode or from the catalogue, and can change the choice at any time in Settings. Buttons read Allow and Not now." width="800" height="1514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The gate as it ships. Providers named, data types named, the decline path stated on the sheet itself rather than buried in a policy.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The part I would defend hardest is the decline path. Turning it down leaves manual logging, barcode scanning and the whole catalogue fully working, which the sheet says on its face rather than leaving the user to find out. That was a design constraint, not a courtesy: consent that costs you the app is not freely given, and an implementation that blocks the whole feature and calls the resulting shrug "consent" is doing the paperwork rather than the thing.&lt;/p&gt;

&lt;p&gt;None of the rest of it was required either. The block could have sat on the client, where a modified build walks straight past it. The allergy list could have travelled attached to everything else, because sending one object is easier than sending two. The audit table of grants and withdrawals serves nobody except someone checking up on me. Each of those was a choice to do the more expensive thing, and the reason is what the data is. Someone tells you what they eat, and then tells you what will hurt them, so that the app does not hurt them. Forwarding that to a third party because a model performs better with it is a decision about a person, not about a payload.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Compliance tells you what you must not do. It does not tell you what you should not send.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The payment layer, tested on the first day that was possible
&lt;/h2&gt;

&lt;p&gt;The RevenueCat integration shipped on 11 June: purchase flow on both entry points, webhook, tier sync, deletion cascade. It could not be tested against a real purchase, because sandbox purchases require an active Paid Apps Agreement, and that was waiting on a business bank account, which was waiting on a tax number.&lt;/p&gt;

&lt;p&gt;The agreement went active on 17 June. That same day I ran the full subscription lifecycle in sandbox on the German storefront: buy monthly, buy yearly, manage, upgrade, downgrade, cancel, let it expire, confirm the app re-gates, restore while active, restore with nothing to restore, restore by logging out and back in.&lt;/p&gt;

&lt;p&gt;It found four defects and one decision I had made and then disagreed with. All five were closed the same day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The paywall that could not load.&lt;/strong&gt; The RevenueCat App User ID was keyed off a field that arrives with the user-profile request, and that request does not fire until onboarding completes. So throughout onboarding, the payment SDK was never configured, offerings came back empty, and the onboarding paywall could not load. The fix took identity from the auth-session JWT instead, so the payment layer configures the moment authentication resolves rather than waiting on a profile that structurally does not exist yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The paying user stuck in onboarding.&lt;/strong&gt; Buying from the paywall while inside onboarding only dismissed the paywall. Onboarding never completed, which left a user who had just paid stuck in a flow they could not exit. Onboarding and the paywall had been built separately, and when they were wired together the completion handoff was the join nobody made. A focus effect on the onboarding screen now completes it when the screen regains focus already subscribed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lapsed subscriber who could not re-subscribe.&lt;/strong&gt; Nothing was listening for subscription changes made outside the app, so the flag went stale when a subscription expired elsewhere. The paywall would then read that stale value, tell a lapsed subscriber they were still subscribed, and disable the purchase button. Somebody whose subscription had genuinely ended could not start a new one. Adding the update listener keeps the flag live and re-syncs the server-side tier with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The restore button nobody who needed it could reach.&lt;/strong&gt; The guideline says you &lt;a href="https://developer.apple.com/app-store/review/guidelines/" rel="noopener noreferrer"&gt;should make sure you have a restore mechanism&lt;/a&gt; for restorable purchases. Anovi had one. It lived on the paywall, and the paywall is what you see when you are &lt;em&gt;not&lt;/em&gt; subscribed. A subscribed user had no route to it anywhere. That includes the demo account App Review would use, which was provisioned as a paying account precisely so a reviewer could see the paid features. The fix was a Subscriptions screen under the profile tab exposing current plan, manage, upgrade and restore in every tier state.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffynmuk5scg3rp11my6ok.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffynmuk5scg3rp11my6ok.png" alt="The Subscriptions screen shown while subscribed: a Current plan card reading Anovi Pro, a Manage Subscription row, and a Restore Purchases row described as recovering a subscription from a previous install or device." width="800" height="780"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The screen in the state that mattered. Restore is reachable here while subscribed, which is exactly where it was not reachable before.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The fifth was not a defect. The onboarding upgrade button had been deliberately wired to the annual plan, on the theory that fewer choices convert better. Seen in the sandbox, next to a monthly price the user was never offered, the theory did not survive looking at it. It now opens the full paywall and the plan choice belongs to the user.&lt;/p&gt;

&lt;p&gt;Two things worth being precise about. Every one of these lived in the payment layer, which was six days old and had never been runnable against a real transaction until that morning. And the one with direct guideline exposure, the restore gap, was found by testing the reviewer's exact account state rather than by reading the guideline again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eighteen test areas, three of them not applicable
&lt;/h2&gt;

&lt;p&gt;The rest of the plan ran over the following two days: fresh install and cold launch, onboarding end to end, every tab in both empty and populated states, session handling and cross-user leakage, network failure, offline degradation, loading and error and partial-failure states, crash and memory under sustained use, background and battery, subscription gating, AI quota exhaustion and provider failure, the consent gate, domain edge cases, and a German-language sweep across every legal, consent and disclaimer surface.&lt;/p&gt;

&lt;p&gt;Three areas came back not applicable, and that is worth saying out loud. There is no Sign in with Apple, because there is no third-party login at all, so Guideline 4.8 is not triggered. Nothing routes into the app by deep link. There is no push token at launch. The temptation with a test plan is to write tests you can pass and mark the awkward ones green. Marking them not applicable, with the reason recorded, is the version that survives someone checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The worst things I found were not the ones Apple checks
&lt;/h2&gt;

&lt;p&gt;Testing that hard turns up things the store does not care about, and several of them were worse than the ones it does.&lt;/p&gt;

&lt;p&gt;The API client's retry policy did not distinguish reads from writes, so a network failure on a write could be retried and duplicated. Server errors still retry; network failures on writes no longer do. Offline requests now short-circuit to an offline state in about ten seconds, instead of spinning for a minute towards a timeout that was already certain. Error states had drifted into three different vocabularies across the app and were consolidated into one component and one label.&lt;/p&gt;

&lt;p&gt;On the backend, a sweep turned up the standard FastAPI trap: handlers declared async while the database code inside them is synchronous, which blocks the event loop. Meal-plan generation was the worst case: one user generating a plan froze every other request for seconds. Converting them freed the event loop and surfaced two further bug classes during verification, including domain errors being re-wrapped as server errors, which the client then dutifully retried.&lt;/p&gt;

&lt;p&gt;And two iOS rendering bugs got their own investigations, both of which are written up separately: &lt;a href="https://pragathijayaram.com/articles/react-native-scrollview-recycling-bug/" rel="noopener noreferrer"&gt;a ScrollView that scrolled into empty space&lt;/a&gt; and traced to a framework-level view-recycling defect, and &lt;a href="https://pragathijayaram.com/articles/react-native-keyboard-insets-leaking/" rel="noopener noreferrer"&gt;the keyboard-inset leak behind it&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;None of this was on Apple's list. All of it is the difference between an app that passes review and an app that survives its first thousand users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metadata is where a good product gets rejected
&lt;/h2&gt;

&lt;p&gt;This is the part I underestimated, and the part that took longest. Budget a fortnight rather than an afternoon, because the listing is not a form. It is two workstreams: a design deliverable with hard specifications, and a compliance surface where the rules are not only Apple's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every language you ship is a complete listing.&lt;/strong&gt; Name, subtitle, keywords, description, screenshots, each one written rather than translated. Anovi ships English and German, so the German is written natively: informal register, German legal vocabulary, and the official phrasing for health claims under &lt;a href="https://eur-lex.europa.eu/eli/reg/2012/432/oj" rel="noopener noreferrer"&gt;Regulation (EU) No 432/2012&lt;/a&gt;. Machine translation is not available to you if your app makes any claim about health, because the permitted wording is specified in law rather than chosen for tone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshots are a design job with a spec attached.&lt;/strong&gt; Two device sizes across two languages is four sets, and every one has to survive rules that have nothing to do with how it looks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No alpha channel&lt;/strong&gt;, per Apple's &lt;a href="https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications/" rel="noopener noreferrer"&gt;screenshot specifications&lt;/a&gt;. The check is per pixel, so an image that looks entirely opaque but carries transparency still fails. Design tools export with alpha by default, so flatten deliberately. The same applies to the 1024×1024 icon. Mine carried one, and all four sets had to be re-exported.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They have to show the app in use&lt;/strong&gt; (2.3.3), not title art, a login screen or a splash. Overlaid text is allowed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything has to suit a 4+ audience&lt;/strong&gt; (2.3.8) regardless of the app's own rating, which constrains the imagery as much as the copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No references to other platforms&lt;/strong&gt; (2.3.10). Scrub mentions of Android from screenshots and copy both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The compliance surfaces are the ones with dependencies you do not control.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Legal URLs have to be live at the moment of submission&lt;/strong&gt;, and if you ship more than one language, make them locale-explicit rather than asking a single URL to guess at the reader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy labels have to agree with your privacy manifest.&lt;/strong&gt; Build both from a written inventory of what your app and every SDK inside it actually send, so the two declarations cannot contradict each other later. Working out what your analytics, crash reporting and payment SDKs collect is the real task. The form itself takes an hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The age-rating questionnaire has to be answered against what the app does.&lt;/strong&gt; One trap worth naming: the mitigation checkbox about social media being disabled for under-13s asserts an age-range integration you may not have, and it only applies if you declared a social surface in the first place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The review notes are a product document&lt;/strong&gt;, not a formality. Where each gated feature is, which account reaches it, what to tap, with every label checked against the actual strings in the build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Availability is a decision.&lt;/strong&gt; Anovi ships to twenty-nine EU and EEA countries with automatic addition of new territories switched off, which is a choice about which legal regimes you have agreed to operate under.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;A good product gets rejected for reasons that have nothing to do with the product. Metadata is where that happens.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Account deletion is easy to claim and easy to half-build
&lt;/h2&gt;

&lt;p&gt;Guideline 5.1.1(v) requires that an app supporting account creation must also offer account deletion within the app. That is easy to claim and easy to half build, particularly once a payment processor is holding a record of the user too.&lt;/p&gt;

&lt;p&gt;So it was run for real before submission, on a live account, through the in-app flow rather than through a script. Afterwards: the authentication record gone, twenty user-scoped tables at zero rows for that user, and the RevenueCat subscriber deleted. The app also warns before deletion that removing the account does not cancel an Apple subscription, because it cannot, and tells the user where to do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-verified on the exact binary that ships
&lt;/h2&gt;

&lt;p&gt;Production signing and production purchase configuration are not the same as development. A test that passed on a development build is evidence about that build and nothing else.&lt;/p&gt;

&lt;p&gt;So the gate before submission was narrow and specific: cold launch, and a real sandbox purchase, on the exact binary going to Apple. Release hygiene was checked on that binary too, that console output is stripped, that the development-only tier switch is absent, that no environment file rides along inside the package. The git tag went on afterwards, not before, so the tag means the thing that was verified rather than the thing that was intended.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I shipped while waiting, because a reviewer would hit it
&lt;/h2&gt;

&lt;p&gt;Submission was 27 July at 17:09. Four items go into review together: the binary, the subscription group, and both subscription products. After that, hands off App Store Connect entirely, because editing a submitted item pulls it back out of the queue, and all three demo accounts frozen so nothing changes underneath a reviewer.&lt;/p&gt;

&lt;p&gt;Hands off the store is not hands off the work. The day after submitting, I finished root-causing a logout bug that had been surfacing intermittently. The cause was that signing out was revoking every session for that user rather than the one on the device, so a sign-out in one place logged the user out everywhere, including sessions that had nothing to do with it. The fix scopes the revocation to the calling session.&lt;/p&gt;

&lt;p&gt;That went to production immediately rather than waiting for the verdict. A reviewer moving between a free demo account and a paid one is exactly the pattern that triggers it, and a reviewer being mysteriously signed out mid-review is not a thing you want to explain afterwards. A second contributing cause, a keychain accessibility setting that made tokens unreadable when the app launched into the background before first unlock, was fixed on the client shortly after.&lt;/p&gt;

&lt;h2&gt;
  
  
  One session, and an approval
&lt;/h2&gt;

&lt;p&gt;The app sat in "Waiting for Review" for ten days, and for nine of them nothing happened at all.&lt;/p&gt;

&lt;p&gt;On the morning of 6 August a reviewer signed in on the free demo account at 07:01 UTC and worked through the app in that single session. That it took one session is the part the preparation paid for: the accounts were seeded so every screen had real data in it, and the review notes routed a reader to each gated surface in turn, so there was no need to come back for a second look at something that had not been reachable the first time. The approval email arrived the same morning.&lt;/p&gt;

&lt;p&gt;What I have is the server's view, not the reviewer's. I know when they signed in and that the verdict followed within hours, and the backend traced every endpoint that session called. What it cannot show is what happened on screen between those calls: what they tapped, what they lingered on, what they were trying to break. I would rather say that than turn request logs into an itinerary. Nothing came back through Resolution Center either: no rejection, no questions, no request for clarification, no second build.&lt;/p&gt;

&lt;p&gt;Ten days of waiting, and a review that began and finished inside one morning. That ratio is the whole point. Almost none of the time an app spends in review is review, which means almost none of the outcome is decided there. It is decided in the weeks before, in a checklist you keep current, a purchase flow you test on the first day you are allowed to, and a listing you check character by character because the form will not tell you which of the four thousand is wrong.&lt;/p&gt;

&lt;p&gt;If you want the same material as a working checklist rather than a story, the &lt;a href="https://pragathijayaram.com/articles/app-store-submission-checklist-health-app/" rel="noopener noreferrer"&gt;companion piece&lt;/a&gt; has it, sourced to Apple's rules rather than to mine.&lt;/p&gt;

&lt;p&gt;If you are heading into a submission yourself, you can write to me at &lt;a href="mailto:hello@pragathijayaram.com"&gt;hello@pragathijayaram.com&lt;/a&gt; and describe what you are shipping. I am happy to go through it with you, whether you want a checklist of your own or a second pair of eyes before you submit.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>appstore</category>
      <category>mobile</category>
      <category>indiedev</category>
    </item>
    <item>
      <title>The bug that survived being recycled: automaticallyAdjustKeyboardInsets and Fabric view recycling on iOS</title>
      <dc:creator>Pragathi Jayaram</dc:creator>
      <pubDate>Wed, 05 Aug 2026 14:08:17 +0000</pubDate>
      <link>https://dev.to/pragathijayaram/the-bug-that-survived-being-recycled-automaticallyadjustkeyboardinsets-and-fabric-view-recycling-5e1e</link>
      <guid>https://dev.to/pragathijayaram/the-bug-that-survived-being-recycled-automaticallyadjustkeyboardinsets-and-fabric-view-recycling-5e1e</guid>
      <description>&lt;p&gt;After logging a meal in &lt;a href="https://pragathijayaram.com/portfolio/" rel="noopener noreferrer"&gt;Anovi&lt;/a&gt;, the nutrition app I build, the next screen opened normally. The content was there. Then the first scroll ran past the end of it, into empty space that should not have existed, and stopped.&lt;/p&gt;

&lt;p&gt;Dragging in that space did nothing. Not a slow scroll, not a bounce. Nothing moved, because there was nothing under my finger. The content was somewhere above me and there was no way to reach it. The only way out was to kill the app.&lt;/p&gt;

&lt;p&gt;It was not every screen and not every time, which is what kept it alive through earlier rounds of testing. It produced no crash, no error, and no failed request.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;In short.&lt;/strong&gt; On iOS with the New Architecture, a scroll view that opted into keyboard inset handling can leave that behaviour switched on when React Native recycles it. The next screen to inherit that view gets a phantom inset it never asked for. Filed as &lt;a href="https://github.com/facebook/react-native/issues/57755" rel="noopener noreferrer"&gt;react-native#57755&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;If you just want to check and fix your own app, that is &lt;a href="https://pragathijayaram.com/articles/react-native-keyboard-insets-leaking/" rel="noopener noreferrer"&gt;a separate, shorter piece&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The evidence that was missing
&lt;/h2&gt;

&lt;p&gt;Every request in the sequence returned what it should, in the time it should. Nothing failed, nothing timed out, nothing appeared in the logs at the moment things went wrong. The app had not crashed and was not in an error state.&lt;/p&gt;

&lt;p&gt;Healthy logs are usually read as the absence of a finding. Here they were the finding, because they eliminated the entire category I had been searching in. That is what promoted the next detail from background noise to the only lead.&lt;/p&gt;

&lt;p&gt;The next detail was the keyboard. On the broken screens it appeared, briefly, and went away again. On a screen with no text input on it and nothing focused.&lt;/p&gt;

&lt;p&gt;A keyboard reacting where there is nothing to type into is not a rendering bug. Something was still listening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The screen that broke was not the screen at fault
&lt;/h2&gt;

&lt;p&gt;React Native's new architecture recycles native views. Rather than destroying a component's native view and building a fresh one, it resets the existing view and hands it to the next component that needs the same kind. On iOS, that reset happens in a method called &lt;code&gt;prepareForRecycle&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Recycling is only safe if the reset is complete. Anything left behind belongs to whoever inherits the view next.&lt;/p&gt;

&lt;p&gt;A scroll view can opt into having iOS adjust its insets when the keyboard moves, with a prop called &lt;code&gt;automaticallyAdjustKeyboardInsets&lt;/code&gt;. Underneath, that sets a flag on the native view and gates a keyboard observer that lives for as long as the view does. &lt;code&gt;prepareForRecycle&lt;/code&gt; does not clear that flag.&lt;/p&gt;

&lt;p&gt;So the broken screen was innocent. It had no text input, no keyboard props, and nothing to do with any of this. It had simply been handed a view that came out of the pool still holding someone else's settings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the keyboard came from
&lt;/h3&gt;

&lt;p&gt;The view came from the meal logging modal, which does have a scroll view with the prop set and a search field that focuses itself.&lt;/p&gt;

&lt;p&gt;The interesting part is what happened when I closed it. Tapping Done dismissed the keyboard, reset the modal's internal flow back to its search step, and hid the modal, all in the same render batch. Resetting the flow remounted the search field, its &lt;code&gt;autoFocus&lt;/code&gt; fired, and &lt;strong&gt;the app opened a keyboard inside a modal that was already closing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the flicker. Not a leftover from typing, but a keyboard my own code summoned into a dying screen. Its scroll view then went into the recycle pool while that keyboard was still up.&lt;/p&gt;

&lt;p&gt;None of that is a bug. A keyboard being up while a screen goes away is ordinary, and the framework is meant to survive it. What it did instead was hand the next screen a view that was still holding the last one's settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building something that fails on demand
&lt;/h2&gt;

&lt;p&gt;An explanation that only reproduces inside a nutrition app is not much use to anyone, and it is not something a framework maintainer can act on. So the next step was the smallest thing that still fails: a modal with the prop and a focused input that kills itself mid-animation, then a plain scroll view that mounts straight afterwards and reports its own geometry.&lt;/p&gt;

&lt;h3&gt;
  
  
  The detector that cried wolf
&lt;/h3&gt;

&lt;p&gt;Getting the measurement right took longer than finding the bug.&lt;/p&gt;

&lt;p&gt;The first version reported failures that were not failures. A scroll view settling after a bounce moves on its own, and sampling at the wrong moment records that jitter as displacement. Several iterations went into separating "this view is still settling" from "this view has been moved by something else".&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A detector that reports a bug which is not there is worse than no detector, because it makes the real signal unfalsifiable.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Everything downstream of it, including the controls, would have been noise interpreted as evidence.&lt;/p&gt;

&lt;p&gt;Once it was honest, the numbers were unambiguous. A scroll view that never asked for keyboard insets, with the keyboard hidden, reporting a bottom inset of 217.3 points. Its content was 840 points tall in a 234 point window, so the furthest it should ever scroll is 606. It went to 823.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8y3ucqizqpgebpwt90w8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8y3ucqizqpgebpwt90w8.png" alt="The reproducer on React Native 0.86.2 showing a victim scroll view reporting a bottom inset of 217.3 with the keyboard hidden, content 840 and viewport 234, alongside counters for cycles, rogue keyboard events and corrupt readings." width="800" height="1739"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Physical iPhone, iOS 26.5.2, Release build, React Native 0.86.2, New Architecture. The victim sets no keyboard props of any kind.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The controls are the argument
&lt;/h2&gt;

&lt;p&gt;A reproducer that fails is a claim. A reproducer that fails, and stops failing when you remove exactly one ingredient, is evidence.&lt;/p&gt;

&lt;p&gt;Two controls, each from a freshly killed process. Turn off the inset prop: nothing. Dismiss the keyboard and let it settle before the modal closes: nothing. Each ingredient is necessary and neither is sufficient. The failure needs a view configured for keyboard insets to be recycled while its observer is still live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring it instead of reasoning about it
&lt;/h2&gt;

&lt;p&gt;Everything so far was inference, from reading source and watching behaviour. The mechanism deserved a measurement, so I instrumented the framework itself: every keyboard delivery logged with the view's address and whether it was on screen or in the recycle pool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rec-in  view=0x108118c00 ivar=1          entering prepareForRecycle
rec-out view=0x108118c00 ivar=1          leaving it, flag still set
kbd     view=0x108118c00 ivar=1 POOLED
write   view=0x108118c00 bottom=217.3 POOLED    ‚Üê inset written while pooled
props   view=0x108118c00 old=1 new=0 ivar=0     ‚Üê next screen mounts, flag cleared
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The write lands &lt;strong&gt;while the view is sitting in the pool&lt;/strong&gt;, 105ms before anything mounts on it. When a screen does mount, the flag is cleared immediately, so the screen you can see is never written to at all.&lt;/p&gt;

&lt;p&gt;That ruled out the explanation I had been carrying. An earlier test, where a poisoned view's scroll position moved after I focused an unrelated text field, had looked like proof that the recycled view was still listening while mounted. It was not. The keyboard changed the layout, the window grew from 234 points to 299, the furthest legal scroll position dropped by 65, and iOS pulled the offset back to the new limit. Arithmetic, not a live listener.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually happening
&lt;/h2&gt;

&lt;p&gt;The pooled view keeps receiving keyboard notifications and keeps writing insets onto itself. It is not stale; it is current. It tracks the keyboard continuously, taking a real value when a keyboard rises and zero when it falls.&lt;/p&gt;

&lt;p&gt;Whatever value it happens to be holding at the moment someone inherits it is what that screen is stuck with, because the inset is only reassigned when a component's props change it, and a component that never set the prop has nothing to reassign.&lt;/p&gt;

&lt;p&gt;Which means the whole thing turns on timing. Sweeping the delay before the modal tears itself down, ten cycles each from a fresh process:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;modal torn down after&lt;/th&gt;
&lt;th&gt;corrupt readings&lt;/th&gt;
&lt;th&gt;phantom inset&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;~120ms&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;217.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~220ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~320ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~520ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;keyboard dismissed first&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the earliest teardown reproduces. Wait longer and the keyboard's own disappearance reaches the pooled view first, zeroes the inset, and the pool quietly cleans itself. The bug needs a screen to mount during the narrow window while a keyboard is up.&lt;/p&gt;

&lt;p&gt;That is also why it was intermittent in production, and why it survived earlier testing rounds. Most of the time nothing mounts inside that window.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it could not be scrolled back
&lt;/h3&gt;

&lt;p&gt;The phantom inset extends the scrollable range past the end of the content, and that extra region has no view underneath it. So it takes no touches. A drag there produces no scroll events at all.&lt;/p&gt;

&lt;p&gt;In the reproducer a sliver of the last row stays visible, so you can grab that and pull yourself back. In the app the scroll views were full screen. Once the content had left the viewport there was nothing to grab, and no gesture in the app could recover it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A screen recording of the reproducer on a physical device is on the &lt;a href="https://pragathijayaram.com/articles/react-native-scrollview-recycling-bug/" rel="noopener noreferrer"&gt;original article&lt;/a&gt;: several poison cycles, a scroll up into the space that should not exist, then taps on an unrelated input.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the difference between a layout glitch and a screen that is finished until you kill the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened after filing
&lt;/h2&gt;

&lt;p&gt;I reproduced it unchanged on React Native 0.86.2, the latest release at the time, and filed it as &lt;a href="https://github.com/facebook/react-native/issues/57755" rel="noopener noreferrer"&gt;react-native#57755&lt;/a&gt; with a &lt;a href="https://github.com/PragathiJ/rn-aaki-recycle-repro" rel="noopener noreferrer"&gt;public reproducer&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two pull requests followed, from people I have never met. The second carries a regression test covering the whole lifecycle, and a Meta engineer imported it for internal review the same morning it appeared.&lt;/p&gt;

&lt;p&gt;Its two lines are small: clear the flag when the view is recycled, and stop guarding the assignment behind a props comparison so a recycled view can be re-armed properly. I applied them to the reproducer and ran it on the phone that found the bug in the first place. Baseline immediately before: 36 corrupt readings in 10 cycles. With the patch: 49 cycles, zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The healthy logs were the finding.&lt;/strong&gt; They eliminated a whole category, which is what turned a flicker into the only lead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Controls before claims.&lt;/strong&gt; Any bug can be explained by a plausible story. The question is which single ingredient you can remove to make it stop, and whether you have actually tried removing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A detector that lies is worse than no detector.&lt;/strong&gt; Most of the reproducer work went into eliminating false positives, not into finding the failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instrument the explanation, not just the bug.&lt;/strong&gt; A story that fits every observation is not the same as a correct one. Mine was in the right place and the wrong moment, and only logging the framework showed the difference.&lt;/p&gt;




&lt;p&gt;If you think your own app has this, the checks and the three ways to fix it are in &lt;a href="https://pragathijayaram.com/articles/react-native-keyboard-insets-leaking/" rel="noopener noreferrer"&gt;a shorter companion piece&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;The reproducer is at &lt;a href="https://github.com/PragathiJ/rn-aaki-recycle-repro" rel="noopener noreferrer"&gt;github.com/PragathiJ/rn-aaki-recycle-repro&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;I write about production engineering and applied AI at &lt;a href="https://pragathijayaram.com/articles/" rel="noopener noreferrer"&gt;pragathijayaram.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ios</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>automaticallyAdjustKeyboardInsets is leaking into your other screens: how to check and fix it</title>
      <dc:creator>Pragathi Jayaram</dc:creator>
      <pubDate>Wed, 05 Aug 2026 13:53:30 +0000</pubDate>
      <link>https://dev.to/pragathijayaram/automaticallyadjustkeyboardinsets-is-leaking-into-your-other-screens-how-to-check-and-fix-it-2pm7</link>
      <guid>https://dev.to/pragathijayaram/automaticallyadjustkeyboardinsets-is-leaking-into-your-other-screens-how-to-check-and-fix-it-2pm7</guid>
      <description>&lt;p&gt;A screen opens normally, then scrolls past the end of its content into empty&lt;br&gt;
space, and dragging in that space does nothing at all. Restarting the app is the&lt;br&gt;
only way out.&lt;/p&gt;

&lt;p&gt;If that sounds familiar, this is a React Native framework defect, not your code.&lt;br&gt;
This page is the operational version: how to confirm it, how to find every source&lt;br&gt;
in your project, and what to do. The investigation behind it, including why it&lt;br&gt;
took a while to understand, &lt;a href="https://pragathijayaram.com/articles/react-native-scrollview-recycling-bug/" rel="noopener noreferrer"&gt;is here&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Scope.&lt;/strong&gt; iOS only, New Architecture only. The code involved is the Fabric iOS&lt;br&gt;
scroll view, so the old architecture and Android take different paths.&lt;br&gt;
Reproduced on React Native 0.81.4 and 0.86.2. Filed as&lt;br&gt;
&lt;a href="https://github.com/facebook/react-native/issues/57755" rel="noopener noreferrer"&gt;react-native#57755&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  What is going wrong
&lt;/h2&gt;

&lt;p&gt;A scroll view that sets &lt;code&gt;automaticallyAdjustKeyboardInsets&lt;/code&gt; keeps that behaviour&lt;br&gt;
switched on when React Native recycles its native view. While the view sits in the&lt;br&gt;
recycle pool it carries on receiving keyboard notifications and writing insets&lt;br&gt;
onto itself. The next component to be handed that view inherits whatever value was&lt;br&gt;
there, and nothing puts it back, because a component that never set the prop has&lt;br&gt;
no inset of its own to assign.&lt;/p&gt;

&lt;p&gt;The result is a scroll view with extra scrollable space below its content. That&lt;br&gt;
space has no view underneath it, so it takes no touches, which is why you cannot&lt;br&gt;
drag your way back out of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Confirm it
&lt;/h2&gt;

&lt;p&gt;The first sign is a keyboard appearing on a screen with nothing focused. That is&lt;br&gt;
the tell, not the bug.&lt;/p&gt;

&lt;p&gt;The test is to read the inset on a scroll view that should not have one. Put this&lt;br&gt;
on a screen that sets no insets of its own, scroll it with the keyboard hidden,&lt;br&gt;
and watch the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt;
  &lt;span class="na"&gt;scrollEventThrottle&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onScroll&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// contentInset is iOS-only; on Android nativeEvent has no such field.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nativeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentInset&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;reported&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;reported&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;phantom bottom inset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details decide whether this test is worth anything. The scroll view needs&lt;br&gt;
content taller than itself, or it never scrolls, &lt;code&gt;onScroll&lt;/code&gt; never fires, and you&lt;br&gt;
will conclude you are clean without having tested anything. And the reading has to&lt;br&gt;
be gated, because at a 16ms throttle an ungated &lt;code&gt;console.warn&lt;/code&gt; buries your log.&lt;/p&gt;

&lt;p&gt;A scroll view that never opted in should report zero. Anything else means it is&lt;br&gt;
sitting on a view that came out of the pool still armed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Find every source
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"automaticallyAdjustKeyboardInsets"&lt;/span&gt; src/
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"contentInset"&lt;/span&gt; src/   &lt;span class="c"&gt;# also catches contentInsetAdjustmentBehavior&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The second search matters more than it looks. Any prop that writes native insets&lt;br&gt;
belongs to the native view, and therefore to the recycling pool, so a static&lt;br&gt;
&lt;code&gt;contentInset&lt;/code&gt; combined with an automatic &lt;code&gt;contentInsetAdjustmentBehavior&lt;/code&gt; is the&lt;br&gt;
same class of risk even though it has nothing to do with keyboards. In my own&lt;br&gt;
sweep that turned up a grid component the first search had missed.&lt;/p&gt;

&lt;p&gt;Then run both again against &lt;code&gt;node_modules&lt;/code&gt;. The pool is per process, not per&lt;br&gt;
module, so a scroll view inside a dependency arms it exactly as well as one of&lt;br&gt;
yours. You can clean your own code completely and still be handed a poisoned view by a library you did not write.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fix it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option 1, remove the prop.&lt;/strong&gt; Handle keyboard layout explicitly instead. This is&lt;br&gt;
what I shipped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: the flag does the layout, and the native view keeps listening&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Modal&lt;/span&gt; &lt;span class="na"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt; &lt;span class="na"&gt;automaticallyAdjustKeyboardInsets&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextInput&lt;/span&gt; &lt;span class="na"&gt;autoFocus&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Modal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: explicit layout, nothing left armed on the native view&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Modal&lt;/span&gt; &lt;span class="na"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KeyboardAvoidingView&lt;/span&gt; &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"padding"&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt; &lt;span class="na"&gt;contentContainerStyle&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;paddingBottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextInput&lt;/span&gt; &lt;span class="na"&gt;autoFocus&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;KeyboardAvoidingView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Modal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;autoFocus={visible}&lt;/code&gt; change matters more than it looks. Focusing on mount is&lt;br&gt;
what starts a keyboard animation during modal presentation, which is exactly the&lt;br&gt;
window the defect needs. Watch for the same shape anywhere a modal resets its&lt;br&gt;
internal state as it closes: that can remount a focused field and open a keyboard&lt;br&gt;
inside a screen that is already going away.&lt;/p&gt;

&lt;p&gt;One caveat: &lt;code&gt;KeyboardAvoidingView&lt;/code&gt; with &lt;code&gt;behavior="padding"&lt;/code&gt; inside a &lt;code&gt;Modal&lt;/code&gt;&lt;br&gt;
behaves differently depending on presentation style and often wants a&lt;br&gt;
&lt;code&gt;keyboardVerticalOffset&lt;/code&gt;. Treat it as a shape to adapt, not a drop-in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2, keep the prop but dismiss the keyboard first.&lt;/strong&gt; Dismiss it and let it&lt;br&gt;
settle before the screen unmounts. This came back at zero in the reproducer. It is&lt;br&gt;
narrower than option 1, because it depends on every exit path from that screen&lt;br&gt;
doing the same thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 3, patch the framework locally.&lt;/strong&gt; Until the upstream fix ships you can&lt;br&gt;
carry it as a &lt;code&gt;patch-package&lt;/code&gt; patch. In &lt;code&gt;RCTScrollViewComponentView.mm&lt;/code&gt;, clear the&lt;br&gt;
flag on recycle and assign it on every update instead of only when it changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// prepareForRecycle: disarm before the view goes back in the pool&lt;/span&gt;
&lt;span class="n"&gt;_automaticallyAdjustKeyboardInsets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;NO&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// updateProps: drop the old-vs-new condition around the assignment&lt;/span&gt;
&lt;span class="n"&gt;_automaticallyAdjustKeyboardInsets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;newScrollViewProps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;automaticallyAdjustKeyboardInsets&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both lines are needed. The first stops the leak. The second matters because&lt;br&gt;
&lt;code&gt;prepareForRecycle&lt;/code&gt; does not reset the stored props, so once the flag is cleared a&lt;br&gt;
view being reused by another component that &lt;em&gt;also&lt;/em&gt; wants keyboard insets would&lt;br&gt;
skip the assignment and silently lose the behaviour.&lt;/p&gt;

&lt;p&gt;This is the substance of &lt;a href="https://github.com/facebook/react-native/pull/57811" rel="noopener noreferrer"&gt;react-native#57811&lt;/a&gt;, which a Meta engineer has imported for internal review. I verified those changes against the reproducer on a physical device: 49 cycles, zero failures, where the unpatched build produced 36 failures in 10 cycles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you are on 0.86.2 and patching the framework&lt;/strong&gt;, note that React core ships&lt;br&gt;
prebuilt, so edits to &lt;code&gt;RCTScrollViewComponentView.mm&lt;/code&gt; in &lt;code&gt;node_modules&lt;/code&gt; never&lt;br&gt;
reach the binary. You need &lt;code&gt;RCT_USE_PREBUILT_RNCORE=0&lt;/code&gt; and a fresh &lt;code&gt;pod install&lt;/code&gt;,&lt;br&gt;
or the patch silently does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does not work
&lt;/h2&gt;

&lt;p&gt;Scrolling the screen back to the top when it opens. I shipped that early and it&lt;br&gt;
does clear the wrong opening position, which is enough to make it look like a&lt;br&gt;
cure. It does not remove the phantom inset, so the dead space is still waiting at&lt;br&gt;
the bottom.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell you have fixed it
&lt;/h2&gt;

&lt;p&gt;Run the confirmation check again after the flow that used to break things. Zero&lt;br&gt;
inset, and a scroll to the bottom that stops at the end of your content with&lt;br&gt;
nothing beneath it.&lt;/p&gt;

&lt;p&gt;The reproducer is at&lt;br&gt;
&lt;a href="https://github.com/PragathiJ/rn-aaki-recycle-repro" rel="noopener noreferrer"&gt;github.com/PragathiJ/rn-aaki-recycle-repro&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I write about production engineering and applied AI at&lt;br&gt;
&lt;a href="https://pragathijayaram.com/articles/" rel="noopener noreferrer"&gt;pragathijayaram.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ios</category>
      <category>mobile</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
