<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ivan — Mobile Engineering Notes]]></title><description><![CDATA[React Native and Expo upgrades, iOS debugging, mobile CI/CD, and Maestro E2E case studies by ivan. Available for part-time, async technical support.]]></description><link>https://ivanbuilds.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a9cf26c838ede1fd18872b6/3f2f4c86-4c39-48de-971f-8cb77c6a7798.png</url><title>ivan — Mobile Engineering Notes</title><link>https://ivanbuilds.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 11 Sep 2026 08:33:53 GMT</lastBuildDate><atom:link href="https://ivanbuilds.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Design Proposal: Mobile Release Orchestration Across iOS and Android]]></title><description><![CDATA[Build and upload completion can precede testing approval, store review, release decisions, and several days of rollout. That lifecycle needs a persistent release record.
This is my architecture propos]]></description><link>https://ivanbuilds.hashnode.dev/mobile-release-orchestration-design-proposal</link><guid isPermaLink="true">https://ivanbuilds.hashnode.dev/mobile-release-orchestration-design-proposal</guid><category><![CDATA[software architecture]]></category><category><![CDATA[GitHub Actions]]></category><dc:creator><![CDATA[ivan]]></dc:creator><pubDate>Sun, 06 Sep 2026 06:41:29 GMT</pubDate><content:encoded><![CDATA[<p>Build and upload completion can precede testing approval, store review, release decisions, and several days of rollout. That lifecycle needs a persistent release record.</p>
<p>This is my architecture proposal extending an existing <a href="https://ivanbuilds.hashnode.dev/react-native-github-actions-fastlane-builds">build-and-upload pipeline</a>. Review, monitoring, and rollout orchestration remain unimplemented and unverified end to end. The models below are design deliverables, not production results.</p>
<h2>Connect short executions through a Release Task</h2>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a9cf26c838ede1fd18872b6/ed3e7670-ed9b-497c-ac0b-4b13b0774ae5.png" alt="Connect short executions through a Release Task" /></p>
<p>Technical flow; implementation and validation boundaries are explained below.</p>
<p>A GitHub-hosted job currently has a six-hour execution limit. It should not sleep through a multi-day review. Each proposed workflow performs one action, persists its result, and exits. <a href="https://docs.github.com/en/actions/reference/limits">GitHub Actions limits</a>.</p>
<p>The record carries the release ID, application, source SHA, configuration revision, platform build IDs, runtime, store version or track, raw states, observation times, artifact-bound approvals, executions, and pause reasons. Persistent state must outlive short-lived artifacts; artifact retention must support recovery.</p>
<h2>Reconcile uncertain outcomes before retrying</h2>
<p>A timeout does not prove an upload or submission failed. Query the identified application and build before deciding to repeat an operation.</p>
<table>
<thead>
<tr>
<th>Failure</th>
<th>Recovery</th>
</tr>
</thead>
<tbody><tr>
<td>Build fails</td>
<td>Correct the source or toolchain and create a new build identity</td>
</tr>
<tr>
<td>Upload response is lost</td>
<td>Check whether the same build already exists</td>
</tr>
<tr>
<td>Submission outcome is uncertain</td>
<td>Inspect lifecycle state before resubmitting</td>
</tr>
<tr>
<td>Status query fails</td>
<td>Preserve the error and last successful observation</td>
</tr>
<tr>
<td>Notification fails</td>
<td>Retry the notification, not the store mutation</td>
</tr>
</tbody></table>
<p>State updates also require version checks or locking. A stale scheduled run must not overwrite a newer pause decision. Workflow concurrency reduces conflicts but does not replace idempotency and conditional writes.</p>
<h2>Update the earlier Google state model</h2>
<p><code>edits.tracks</code> alone does not represent every review stage. The current <code>applications.tracks.releases</code> resource additionally exposes <code>releaseLifecycleState</code>, including in-review, approved-but-unpublished, rejected, and published states. <a href="https://developers.google.com/android-publisher/api-ref/rest/v3/applications.tracks.releases">Google release lifecycle API</a>.</p>
<p>The revised design combines lifecycle observations with track and rollout information. Its published state can include partial and resumable halted releases, so it does not alone prove full rollout. Access and behavior for the actual account still require MVP validation.</p>
<p>For Apple, an approved version awaiting developer release is distinct from one ready for distribution. <code>Pending Developer Release</code> still requires the release action. <a href="https://developer.apple.com/help/app-store-connect/reference/app-information/app-and-submission-statuses">Apple application states</a>.</p>
<p>An internal status model can simplify presentation while retaining the original platform state, build identity, and observation time. Unknown states require inspection, not an inferred success.</p>
<h2>Separate reading status from controlling publication</h2>
<p>Fastlane's App Store tooling supports formal submission parameters, subject to the application's metadata, review information, credentials, and integration validation. <a href="https://docs.fastlane.tools/actions/upload_to_app_store/">Fastlane App Store submission</a>.</p>
<p>Google's lifecycle resource currently lists a read operation. The Managed publishing, supply, and publishing API documentation checked for this design does not establish a standard equivalent of the Console's final “Publish changes” action. The proposal therefore retains two explicit choices:</p>
<table>
<thead>
<tr>
<th>Strategy</th>
<th>Consequence</th>
</tr>
</thead>
<tbody><tr>
<td>Managed publishing for an eligible existing app</td>
<td>An authorized operator performs and records the final Console action</td>
</tr>
<tr>
<td>Automatic staged publication after review</td>
<td>Android may reach real users before iOS</td>
</tr>
</tbody></table>
<p>Observing approval does not implement publication. <code>changesNotSentForReview</code> is not a replacement for the final release action. <a href="https://support.google.com/googleplay/android-developer/answer/9859654?hl=en">Google publishing controls</a>, <a href="https://developers.google.com/android-publisher/api-ref/rest/v3/edits/commit">edit commit</a>, <a href="https://docs.fastlane.tools/actions/upload_to_play_store/">Fastlane supply</a>.</p>
<p>The orchestration coordinates a launch window, not simultaneous propagation across stores and devices. First publication also has different eligibility from an update.</p>
<h2>Treat a one-percent rollout as real exposure</h2>
<p>An earlier option proposed releasing Android to one percent while waiting for iOS. That requires the backend, runtime, OTA bundle, and feature configuration to support those users already. It is not an unpublished state.</p>
<p>Apple's phased updates primarily govern eligible automatic updates; users can still download manually. Android uses its own eligibility and rollout mechanism. Matching percentages does not create matching cohorts or identical exposure. <a href="https://developer.apple.com/help/app-store-connect/update-your-app/release-a-version-update-in-phases/">Apple phased updates</a>, <a href="https://developers.google.com/android-publisher/api-ref/rest/v3/edits.tracks">Google track rollout</a>.</p>
<p>Pausing does not remove installed builds. Full availability also does not mean every user has upgraded. Runtime changes still need compatible services and bundles for older clients, or an independently designed forced-update and support-retirement policy.</p>
<h2>Bind approval to artifacts and actions</h2>
<h3>Make readiness a checkable condition</h3>
<p>This pure function is a preflight policy for <strong>manual publication after review</strong>. Both platforms must be approved but unpublished, QA and publication approvals must bind the same artifacts, and observations must be fresh. It deliberately refuses to treat Google's <code>PUBLISHED</code> state as permission to publish again or evidence of full rollout.</p>
<p><code>targetKey</code> identifies an immutable persisted target. A real implementation must bind it uniquely to the app, source SHA, both build IDs, runtime, configuration revision, track and intended action. Query adapters must verify the actual version identities returned by the platforms rather than stamp the current task's key onto arbitrary results. Fifteen minutes is an example policy, not an API guarantee.</p>
<pre><code class="language-js">// Proposed policy model. No network, store mutation, lock or persistence.
export function evaluateReadiness(task, nowMs) {
  const reasons = [];
  const expected = task.targetKey;
  if (!expected) reasons.push('missing-target');
  if (task.paused !== false) reasons.push('paused-or-unknown');
  for (const kind of ['qa', 'publication']) {
    const approval = task.approvals?.[kind];
    if (approval?.decision !== 'approved' || approval.targetKey !== expected) {
      reasons.push(kind + '-approval-mismatch');
    }
  }

  const requiredStates = {
    ios: 'PENDING_DEVELOPER_RELEASE',
    android: 'RELEASE_LIFECYCLE_STATE_APPROVED_NOT_PUBLISHED',
  };
  for (const platform of ['ios', 'android']) {
    const observation = task.observations?.[platform];
    if (observation?.targetKey !== expected) {
      reasons.push(platform + '-target-mismatch');
    }
    const age = nowMs - observation?.observedAtMs;
    if (observation?.querySucceeded !== true || !Number.isFinite(age)
        || age &lt; 0 || age &gt; 15 * 60_000) {
      reasons.push(platform + '-observation-unusable');
    }
    if (observation?.state !== requiredStates[platform]) {
      reasons.push(platform + '-not-waiting-for-publication');
    }
  }
  return {
    stage: reasons.length ? 'blocked' : 'ready_for_execution_checks',
    reasons,
  };
}
</code></pre>
<p>The function above is a self-contained policy model. It evaluates local inputs and performs no store operations. <code>ready_for_execution_checks</code> only permits further execution checks. This function does not authorize an operator, acquire locks or call a store API. Execution still needs revision-conditional writes, renewed pause and approval checks, and reconciliation of uncertain outcomes. Google Managed publishing retains its authorized human action.</p>
<table>
<thead>
<tr>
<th>Changed input</th>
<th>Model outcome</th>
<th>Mistake prevented</th>
</tr>
</thead>
<tbody><tr>
<td>QA approved an earlier build</td>
<td>blocked</td>
<td>Reusing old approval for new artifacts</td>
</tr>
<tr>
<td>Query fails while cached state is approved</td>
<td>blocked</td>
<td>Treating unknown as current success</td>
</tr>
<tr>
<td>Google is already PUBLISHED</td>
<td>blocked</td>
<td>Repeated publication or inferring full rollout</td>
</tr>
<tr>
<td>Manual pause with both stores approved</td>
<td>blocked</td>
<td>Advancing past a pause decision</td>
</tr>
<tr>
<td>Fresh states, matching targets and approvals</td>
<td>ready_for_execution_checks</td>
<td>Continue execution checks, not publication</td>
</tr>
</tbody></table>
<p>Nine local model tests passed during preparation, covering coordinate comparison and release-readiness counterexamples. These checks do not establish store integration or a completed release. The table above lists representative release cases that can be reproduced with synthetic task inputs.</p>
<p>The proposal separates QA confirmation from the production release decision. Approvals bind the source SHA, both builds, and intended action. A subsequent PR change cannot inherit an old build's approval, and an older completed release cannot approve a new PR head.</p>
<p>Runtime-changing releases and compatible patches can share executors with different completion policies. A version label does not itself prove compatibility between old binaries, JS, and backend behavior.</p>
<p>GitHub Environments can provide protected credentials and approval controls, but capabilities depend on repository visibility and plan. Private repositories must not assume required reviewers are available. An alternative approval system must still validate authority, bind artifacts, and record decisions. <a href="https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments">GitHub Environment availability</a>.</p>
<h2>Define evidence for the MVP</h2>
<p>Slack notifications carry release identity, observed platform states, failure reasons, and next-action links. A click is not evidence that a store mutation succeeded.</p>
<p>The MVP should exercise asymmetric approvals, submission timeouts, duplicate actions, polling interruptions, pause races, expired artifacts, changed PR heads, and the manual Android publication path.</p>
<p>Only after those exercises should the proposal become an implementation case study with identified applications, builds, and results. Its present contribution is API research, state modeling, recovery, and permission design, without claims of completed automation or measured labor reduction.</p>
<hr />
<p>ivan provides part-time remote support for mobile delivery workflows, including audits, orchestration design, and staged implementation through written, asynchronous collaboration.</p>
<p><a href="https://ivanbuilds.hashnode.dev/page/about">Work with me</a></p>
]]></content:encoded></item><item><title><![CDATA[Launching an iOS and Android App: The Work Beyond Uploading a Binary]]></title><description><![CDATA[Finished application code can still be blocked by developer enrollment, signing, store assets, reviewer access, or third-party configuration. Useful launch support makes these dependencies explicit, a]]></description><link>https://ivanbuilds.hashnode.dev/ios-android-app-store-launch-checklist</link><guid isPermaLink="true">https://ivanbuilds.hashnode.dev/ios-android-app-store-launch-checklist</guid><category><![CDATA[iOS]]></category><category><![CDATA[Android]]></category><dc:creator><![CDATA[ivan]]></dc:creator><pubDate>Sun, 06 Sep 2026 06:41:02 GMT</pubDate><content:encoded><![CDATA[<p>Finished application code can still be blocked by developer enrollment, signing, store assets, reviewer access, or third-party configuration. Useful launch support makes these dependencies explicit, assigns ownership, and defines evidence for completion.</p>
<p>This article organizes my existing application-onboarding and store-submission notes into a reusable delivery method, checked against current official guidance. Those records do not establish a consolidated launch count, approval rate, or complete review-outcome history, so none is claimed.</p>
<h2>Establish ownership and access before building</h2>
<p>Determine whether the app belongs to an individual or organization and who completes verification, agreements, and payment. Organization enrollment involves legal-entity and authorization requirements, including applicable D-U-N-S information. <a href="https://developer.apple.com/programs/enroll/">Apple enrollment requirements</a>.</p>
<p>Use member invitations and permissions appropriate to the task. Store administration, CI identities, and signing materials are separate concerns. Creating an app, uploading, submitting, and releasing can require different access. Secrets belong in controlled credential storage; handoff documents describe their purpose and location.</p>
<p>Historical templates also need correction. Google Play currently charges a US$25 one-time registration fee. Applicable new personal accounts must complete a closed test with at least 12 continuously opted-in testers for 14 days before applying for production access; meeting that condition still requires the access review. <a href="https://support.google.com/googleplay/android-developer/answer/6112435?hl=en">Registration</a>, <a href="https://support.google.com/googleplay/android-developer/answer/14151465?hl=en">testing requirements</a>.</p>
<h2>Align application identity across services</h2>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a9cf26c838ede1fd18872b6/e5bf9e93-8be8-4d1f-b197-97ae6fefb6ef.png" alt="Align application identity across services" /></p>
<p>Technical flow; implementation and validation boundaries are explained below.</p>
<table>
<thead>
<tr>
<th>Area</th>
<th>Values that must agree</th>
</tr>
</thead>
<tbody><tr>
<td>iOS</td>
<td>Team, Bundle ID, store record, signing, entitlements</td>
</tr>
<tr>
<td>Android</td>
<td>Application ID, Play app, upload certificate, app-signing certificate</td>
</tr>
<tr>
<td>Third-party services</td>
<td>Environment-specific Firebase, push, maps, login, and payment configuration</td>
</tr>
<tr>
<td>Version</td>
<td>Store version, build identifier, source commit, binary</td>
</tr>
</tbody></table>
<p>My Android onboarding records connect package identity and certificate fingerprints to service configuration and a notification check after installation from Google Play.</p>
<p>The upload key and Play App Signing key are different roles. Services restricted by certificate fingerprint must recognize the certificate used for the distributed application. Push, maps, and sign-in need independent checks; one notification does not validate all integrations. <a href="https://developer.android.com/studio/publish/app-signing">Android app signing</a>.</p>
<h2>Tie store materials to the submitted build</h2>
<h3>An identity manifest and artifact inspection</h3>
<p>I organize application identity and signing references into a reviewable handoff. The generic JSON below is a design example, not a store API payload or an existing platform schema. It contains references rather than private keys, reviewer passwords or service credentials. Capabilities must match the actual app.</p>
<pre><code class="language-json">{
  "app": "example-mobile",
  "environment": "production",
  "ios": {
    "bundleId": "com.example.mobile",
    "distribution": "app-store",
    "teamIdReference": "signing/ios/team",
    "capabilities": ["push-notifications", "associated-domains"]
  },
  "android": {
    "applicationId": "com.example.mobile",
    "track": "internal",
    "uploadCertificateReference": "signing/android/upload-certificate",
    "appSigningCertificateReference": "signing/android/play-app-signing-certificate"
  },
  "servicesToVerifyFromStore": ["maps", "login", "push"]
}
</code></pre>
<p>Next, inspect the binary instead of relying only on configuration. These read-only IPA checks require macOS developer tools and an exported <code>Example.ipa</code>. They were not run against an app during this writing task.</p>
<pre><code class="language-bash"># macOS; an IPA already exported for inspection. No upload or signing occurs.
# Use a fresh temporary directory to avoid mixing builds.
inspection_dir="$(mktemp -d)"
unzip -q ./Example.ipa -d "$inspection_dir"
find "$inspection_dir/Payload" -maxdepth 1 -name '*.app' -print
# Replace Example.app below with the actual application bundle name.
plutil -extract CFBundleIdentifier raw "$inspection_dir/Payload/Example.app/Info.plist"
plutil -extract CFBundleVersion raw "$inspection_dir/Payload/Example.app/Info.plist"
codesign -d --entitlements :- "$inspection_dir/Payload/Example.app"
</code></pre>
<p>Compare <code>CFBundleIdentifier</code>, <code>CFBundleVersion</code>, and the signed entitlements' application identifier, Team, push environment and associated domains, then check that the store selects this build. On Android, confirm the app-signing certificate in Play's App integrity page and compare package names and fingerprints registered with maps or login services. Play App Signing can use a different key from the upload key, a useful starting point for a local-build success followed by a store-build failure. <a href="https://developer.android.com/studio/publish/app-signing">Android app signing</a>.</p>
<p>The handoff includes the app name, icon, localization, description, classification or keywords, screenshots, support URL, privacy policy, review contact, and access notes. Screenshot requirements should be checked in the current Console, and images should represent actual submitted functionality.</p>
<p>iOS submission selects a specific build and requires completed metadata. Google Play setup covers applicable access, advertising, rating, audience, and Data safety declarations. <a href="https://developer.apple.com/help/app-store-connect/manage-submissions-to-app-review/submit-an-app">Apple submission guidance</a>.</p>
<p>Reuse a form's structure, not another application's answers. Statements about advertising, audience, and collected data must reflect the actual product and SDKs.</p>
<h2>Make the core experience accessible to reviewers</h2>
<p>My operational notes cover review accounts, access instructions, and handling test data afterward. A reusable review-access package includes a dedicated account, stable access, clear English instructions, suitable demonstration data, and accurate explanations of limitations.</p>
<p>Credentials belong in the designated store fields, not public documents. Arrange testing that avoids real charges or irreversible business actions. Reviewers should not depend on a developer being online to relay an OTP or reveal an unexplained hidden switch.</p>
<p>A demonstration video can provide context but does not replace functionality in the submitted build. Payment testing also depends on the purchase type: third-party payment integration is not evidence of StoreKit subscription experience.</p>
<h2>Derive privacy declarations from actual behavior</h2>
<p>Map features and SDKs to data types, purposes, recipients, and deletion behavior before completing disclosures. Both stores require consideration of third-party data practices. <a href="https://developer.apple.com/app-store/app-privacy-details/">Apple privacy details</a>, <a href="https://support.google.com/googleplay/android-developer/answer/10787469?hl=en">Google Data safety</a>.</p>
<p>The policy URL, deletion entry point, and implemented behavior must agree. An ordinary privacy-policy page does not establish an account-deletion capability.</p>
<h2>Separate first launch from an update</h2>
<p>Initial publication includes account and application setup that an established app has already completed. Google explicitly states that Managed publishing cannot be used for an application's first publication, so an update-control plan cannot automatically promise the same launch timing. <a href="https://support.google.com/googleplay/android-developer/answer/9859654?hl=en">Google publishing controls</a>.</p>
<p>Track actual milestones: buildable, uploaded, testable, submitted, rejected, approved, published, and available in the intended region. Each needs different evidence.</p>
<p>For a rejection, I would map each finding to code, configuration, metadata, or reviewer access, document corrections, and identify the resubmitted version. Engineering support can deliver diagnosis, changes, and a clear response; the store makes the review decision. <a href="https://developer.apple.com/help/app-store-connect/manage-submissions-to-app-review/reply-to-app-review-messages">Responding to Apple review messages</a>.</p>
<h2>Leave a repeatable handoff</h2>
<p>Delivery includes ownership, the application identity map, store materials, build identifiers, installation checks, review correspondence, and instructions for the next update. Together with a <a href="https://ivanbuilds.hashnode.dev/react-native-github-actions-fastlane-builds">build-and-upload pipeline</a>, the initial setup becomes reusable operational knowledge.</p>
<p>This article uses three historical onboarding and submission documents plus current official guidance. No store publication or verification of historical review accounts was performed during writing.</p>
<hr />
<p>ivan provides part-time remote support for iOS / Android onboarding, launch preparation, and release troubleshooting through written, asynchronous collaboration.</p>
<p><a href="https://ivanbuilds.hashnode.dev/page/about">Work with me</a></p>
]]></content:encoded></item><item><title><![CDATA[Making Maestro E2E Part of Delivery: Two Platforms, Version Matching, and Evidence]]></title><description><![CDATA[A mobile E2E test often starts with a simple flow: open the app, tap a few controls, and assert that a screen appears. Integrating that flow into team delivery introduces different questions. Which na]]></description><link>https://ivanbuilds.hashnode.dev/maestro-e2e-react-native-ci-evidence</link><guid isPermaLink="true">https://ivanbuilds.hashnode.dev/maestro-e2e-react-native-ci-evidence</guid><category><![CDATA[React Native]]></category><category><![CDATA[Testing]]></category><dc:creator><![CDATA[ivan]]></dc:creator><pubDate>Sun, 06 Sep 2026 06:40:41 GMT</pubDate><content:encoded><![CDATA[<p>A mobile E2E test often starts with a simple flow: open the app, tap a few controls, and assert that a screen appears. Integrating that flow into team delivery introduces different questions. Which native binary and JavaScript bundle were tested? Did a locally discovered case actually run in CI? Did a green workflow execute any tests? Can a remote teammate inspect what happened after a failure?</p>
<p>I introduced Maestro into an existing React Native application and connected the app repository, shared GitHub Actions workflows, and a separate E2E repository. The work covered Android and iOS execution, version selection, change-based suite selection, case conventions, and artifact retention.</p>
<p>This article explains those decisions using generic names and examples. It is an implementation case study, not a complete CI template.</p>
<h2>Give every run an explicit identity</h2>
<p>For an RN application that can update its JavaScript bundle, “the current branch” is not a complete test identity. I separated four inputs:</p>
<table>
<thead>
<tr>
<th>Version</th>
<th>What it determines</th>
</tr>
</thead>
<tbody><tr>
<td>App source and target commit</td>
<td>The changes we intend to validate</td>
</tr>
<tr>
<td>Native binary</td>
<td>Available modules, native permissions, and native runtime</td>
</tr>
<tr>
<td>JavaScript bundle</td>
<td>Executed business logic and test selectors</td>
</tr>
<tr>
<td>E2E code</td>
<td>Test steps, suite selection, and assertions</td>
</tr>
</tbody></table>
<p>The workflow definition has its own version as well. Changing its ref does not necessarily change the downloaded binary. Selecting a test branch does not mean the app contains the selectors added on that branch.</p>
<p>I recorded these values as explicit inputs or resolved outputs. When an existing binary was used as a fallback, each platform's artifact availability and actual source needed recording. Native code or dependency changes required a matching new binary; switching bundles could not replace that build.</p>
<p>The artifact also had to match the device. An iOS Simulator needs a simulator-compatible app, rather than a device IPA. Android checks included the application ID, build environment, and emulator ABI. Performing these checks before business tests made failures easier to classify.</p>
<h2>Separate infrastructure from test policy</h2>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a9cf26c838ede1fd18872b6/33e7a114-82b6-4a3d-b6b3-6ba55320ab80.png" alt="Separate infrastructure from test policy" /></p>
<p>Technical flow; implementation and validation boundaries are explained below.</p>
<p>I avoided maintaining duplicate business-suite lists in the app repository and shared workflows, using the ownership boundaries shown above.</p>
<p>The E2E repository owned suite definitions, path mappings, ordering, and platform runners. Shared workflows handled invocation and environment preparation. Maintainers could find the test rules together when adding a case.</p>
<p>One important detail: local discovery of a YAML file does not prove CI executes it. An earlier CI implementation listed suites explicitly. Adding a case therefore required checking discovery, registration, and the actual remote execution plan.</p>
<h2>Treat unavailable change information differently from an empty list</h2>
<p>We mapped business paths to suites in E2E configuration and generated a plan from the changed files. Common preparation ran first; selected business suites followed their configured order.</p>
<table>
<thead>
<tr>
<th>Input</th>
<th>Behavior</th>
</tr>
</thead>
<tbody><tr>
<td>Explicit full run</td>
<td>Run all configured suites</td>
</tr>
<tr>
<td>Changes match a business path</td>
<td>Run the matching suites and common preparation</td>
</tr>
<tr>
<td>Changes match a shared-impact path</td>
<td>Run all configured business suites</td>
</tr>
<tr>
<td>Change retrieval succeeds without business matches</td>
<td>Still run the configured preparation flow</td>
</tr>
<tr>
<td>Change retrieval is unavailable</td>
<td>Fall back to all configured business suites and record why</td>
</tr>
</tbody></table>
<p>“No relevant changes” and “change information unavailable” are different states. Converting an API failure to <code>[]</code> could silently reduce test scope while producing an apparently normal run.</p>
<p>We also handled empty cross-repository refs explicitly. A caller can pass an empty string, so an input default alone is insufficient. Normalizing it before checkout prevents the E2E code from drifting to an unintended default branch.</p>
<h2>Make business state observable</h2>
<p>Each case had a small contract: known preconditions, one primary business behavior, cleanup limited to its own records, and a known final screen on success. Failures preserved the scene for diagnosis.</p>
<p>Suites owned shared preparation. Cases did not depend on business results left by earlier cases. Cleanup used exact IDs from the current case, rather than deleting unrelated records from a shared account.</p>
<p>We preferred stable <code>testID</code> selectors and checked them against the actual UI hierarchy. A selector in source code does not prove the installed app or loaded bundle contains it. Additional test-state nodes also needed an explicit test-environment boundary.</p>
<p>After a tap, the flow should wait for an observable result. This simplified navigation fragment uses illustrative selectors; the timeout needs adjustment for the real application:</p>
<pre><code class="language-yaml">- tapOn:
    id: "example-open-detail"
- extendedWaitUntil:
    visible:
      id: "example-detail-ready"
    timeout: 10000
</code></pre>
<p>This verifies screen readiness. A test intended to create an order or complete a payment must assert that business outcome as well. <code>extendedWaitUntil</code> proceeds when its condition is met; the timeout is an upper bound. <a href="https://docs.maestro.dev/api-reference/commands/extendedwaituntil">Maestro wait command</a></p>
<p>Optional dialogs need state-aware handling. A dialog that may not appear should not become a mandatory assertion. Dismissing a dialog also does not prove the flow succeeded. If several asynchronous branches can appear, the bounded waiting logic must recognize those states and still finish with the intended business assertion.</p>
<p>Retries should be narrow. Observation and navigation can support bounded retries, but operations that create, charge, or delete should not simply be wrapped in a retry of the entire flow. Test retries do not provide business idempotency. <a href="https://docs.maestro.dev/reference/commands-available/retry">Maestro retry documentation</a></p>
<h2>Preserve evidence from passing runs too</h2>
<h3>Separate test status from evidence delivery</h3>
<p>This job fragment distills the implementation principle. The illustrative runner must export diagnostics to <code>runner-output/</code> and leave them there before returning. An outer <code>always()</code> cannot recover files the runner has already deleted. The script name is an integration contract, not a runner shipped in this repository.</p>
<pre><code class="language-yaml"># Fragment inside an existing job after device / App preparation.
- name: Execute tests and preserve the result
  id: e2e
  shell: bash
  run: |
    mkdir -p evidence
    set +e
    bash scripts/run-selected-suites.sh &gt; evidence/console.log 2&gt;&amp;1
    test_exit=$?
    set -e
    printf '%s\n' "$test_exit" &gt; evidence/test-exit-code.txt
    copy_exit=0
    cp -R runner-output/. evidence/ || copy_exit=$?
    printf '%s\n' "$copy_exit" &gt; evidence/evidence-copy-exit-code.txt
    if [ "$test_exit" -ne 0 ]; then
      exit "$test_exit"
    fi
    exit "$copy_exit"

- name: Upload evidence on success or failure
  if: ${{ always() }}
  uses: actions/upload-artifact@v7
  with:
    name: e2e-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
    path: evidence/
    if-no-files-found: error
</code></pre>
<p>A failed test keeps its original exit code. If tests pass but copying evidence fails, delivery also fails, with both statuses recorded. Uploading in a separate step avoids losing that operation when the shell exits. An advisory wrapper must still report the recorded test outcome. Inputs follow <a href="https://github.com/actions/upload-artifact">upload-artifact</a>.</p>
<p>The earlier implementation uploaded diagnostic directories only on failure and cleaned up successful output. When we wanted engineers to inspect successful runs, changing the upload condition alone was insufficient.</p>
<p>Two changes were necessary: runners copied successful output before cleanup, and workflow upload steps included both outcomes. Otherwise, the upload step could run after its input files had disappeared.</p>
<p>The diagram preserves the critical order: save results and evidence before cleaning temporary output.</p>
<p>Workflow status conditions allowed collection after success or failure. They could not guarantee complete evidence when a runner never started or was forcibly terminated. We still checked upload outcomes and artifact contents. <a href="https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#status-check-functions">GitHub status conditions</a>, <a href="https://docs.github.com/en/actions/concepts/workflows-and-actions/workflow-artifacts">workflow artifacts</a></p>
<p>The retained evidence included target versions, the execution plan, result files, JUnit, console and debug logs, recordings, and relevant failure screenshots or UI hierarchy. This gave teammates enough context to review a run without attending it.</p>
<h2>Keep advisory execution separate from test truth</h2>
<p>The rollout used E2E as an advisory regression signal rather than a required merge gate. That was a delivery-policy decision; it did not change whether the test passed.</p>
<p>I kept the runner's real result, the workflow's continuation policy, and the summary or notification distinct. Allowing the workflow to continue should not turn a failed business assertion into a reported pass. A skipped test is not completed validation either.</p>
<p>A green top-level workflow therefore still required inspection: was the run eligible, did both platform jobs execute, did the selected suites run, and did the final assertions pass?</p>
<h2>Validate the app's caller chain</h2>
<p>Running the E2E repository directly proves that entry point. To demonstrate integration into app delivery, we also had to exercise the app's caller and inspect version resolution, suite selection, and both platform jobs.</p>
<p>The recorded validation covered distinct scenarios:</p>
<ul>
<li>The full configured suite sequence passed on Android and iOS.</li>
<li>An app-triggered integration run selected the expected scope from changed files and executed it on both platforms.</li>
<li>Runs with business failures still uploaded diagnostic evidence.</li>
<li>A passing preparation-only run produced downloadable results, reports, logs, and recordings on both platforms.</li>
</ul>
<p>These scenarios answered different questions. They demonstrated the corresponding execution and artifact paths, not complete business coverage or a permanently flake-free test system.</p>
<h2>Make the next case maintainable by the team</h2>
<p>To help product engineers extend coverage, I documented the information needed for a new case: objective, preconditions, steps, optional branches, success assertions, and cleanup. Engineers could describe the behavior in writing, with tools helping inspect components, locate selectors, and draft YAML.</p>
<p>Tools reduce implementation effort; the business owner still needs to confirm that an assertion represents real success. Delivery should include the runnable case, suite registration, execution results, and enough documentation to investigate failures.</p>
<p>This work moved E2E from a script on one engineer's machine into a regression capability the team could invoke, inspect, and maintain. Further work could then focus on useful assertions, better failure attribution, and keeping the tested binary and code versions aligned.</p>
<hr />
<p>ivan works on React Native, Expo, and iOS migrations, troubleshooting, and delivery workflows. Available for part-time remote technical support through written, asynchronous communication.</p>
<p><a href="https://ivanbuilds.hashnode.dev/page/about">Work with me</a></p>
]]></content:encoded></item><item><title><![CDATA[Why Taps Stopped Working After Scrolling: Tracing iOS Events into React Native Pressability]]></title><description><![CDATA[During a React Native New Architecture adaptation, I investigated a problem on an iPhone X running iOS 16.7.10. After scrolling a particular form, a visible TouchableOpacity button showed press feedba]]></description><link>https://ivanbuilds.hashnode.dev/react-native-ios-scroll-touch-pressability</link><guid isPermaLink="true">https://ivanbuilds.hashnode.dev/react-native-ios-scroll-touch-pressability</guid><category><![CDATA[React Native]]></category><category><![CDATA[iOS]]></category><dc:creator><![CDATA[ivan]]></dc:creator><pubDate>Sun, 06 Sep 2026 06:40:17 GMT</pubDate><content:encoded><![CDATA[<p>During a React Native New Architecture adaptation, I investigated a problem on an iPhone X running iOS 16.7.10. After scrolling a particular form, a visible <code>TouchableOpacity</code> button showed press feedback, but its <code>onPress</code> callback did not run.</p>
<p>The investigation connected touch coordinates, post-scroll measurement, and Pressability state transitions. The delivered page-level workaround wrapped the affected <code>ScrollView</code> in a <code>View</code>, supported by a record of the Native-to-JS investigation.</p>
<p>The code change was small. Explaining where the interaction lost its eligibility as a press was the harder part.</p>
<h2>Distinguish a received touch from a completed press</h2>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a9cf26c838ede1fd18872b6/e8e26a05-e6dc-48b3-8f6a-8e20a90fcffb.png" alt="Distinguish a received touch from a completed press" /></p>
<p>Technical flow; implementation and validation boundaries are explained below.</p>
<p>I compared native callbacks, JS responder callbacks, and the final business callback. In the recorded failing interaction, the device emitted <code>touchesMoved</code>, reaching the region check in <code>onResponderMove</code>. The normal comparison taps recorded only the beginning and end callbacks.</p>
<p>This does not establish a hardware defect or behavior shared by every iPhone X. It identifies the branch that needs investigation.</p>
<p>The diagram shows the failure path for the already activated touch.</p>
<p>This diagram omits other paths such as delays, long presses, and cancellation. Receiving <code>onPressIn</code> establishes that the touch entered the response chain; it does not guarantee eligibility at release.</p>
<h2>Connect the transition to coordinate evidence</h2>
<h3>Turn the coordinate mismatch into a checkable predicate</h3>
<p>Synthetic numbers below reproduce a visible hit that fails the region test. These are not device logs. The model isolates coordinate space and omits <code>hitSlop</code>, <code>pressRetentionOffset</code>, delays and long-press branches.</p>
<pre><code class="language-js">function insideRegion(touch, region) {
  return touch.pageX &gt; region.left &amp;&amp; touch.pageX &lt; region.right
    &amp;&amp; touch.pageY &gt; region.top &amp;&amp; touch.pageY &lt; region.bottom;
}

const touch = { pageX: 80, pageY: 120 };
const measured = { left: 20, right: 180, top: 820, bottom: 864 };
const visible = { left: 20, right: 180, top: 100, bottom: 144 };

console.log(insideRegion(touch, measured)); // false
console.log(insideRegion(touch, visible));  // true
</code></pre>
<p>Only the region's vertical position changes; the touch stays identical. In an investigation, capture <code>touch.pageY</code>, measured <code>pageY/height</code>, scroll offset and state transitions within the same gesture. Do not combine observations from different layout moments. A useful counterexample is a deliberate drag outside the visible region: it must still return false, preserving normal cancellation.</p>
<p>Pressability checks the touch against a responder region during movement and sends <code>LEAVE_PRESS_RECT</code> when it falls outside. Release handling also depends on the previous state and other conditions. I examined measurement, movement, and release together. <a href="https://github.com/react/react-native/blob/v0.77.3/packages/react-native/Libraries/Pressability/Pressability.js">RN 0.77.3 Pressability source reference</a>.</p>
<p>The investigation compared touch and measurement values between architectures. In the failing case, the measured <code>pageY</code> used to build the region did not reflect the ScrollView's scroll offset. The touch was still near the button's visible position, so the two could not be compared correctly.</p>
<p>This explanatory model is not a device log or a general coordinate-conversion formula:</p>
<pre><code class="language-text">Button position within scrolling content: Y_content
Scroll offset: offset
Visible position: Y_visible ≈ Y_content - offset (parent offsets omitted)

The touch is near Y_visible
The incorrect measurement remains near Y_content
  → A visually valid tap is classified as leaving the press region
</code></pre>
<p>The measurement callback constructs the region from page coordinates and size. The region check then uses the touch's <code>pageX / pageY</code>. Logging only a local coordinate such as <code>locationY</code>, or only the missing business callback, would not explain that mismatch.</p>
<p>The evidence identifies inconsistent measurement and the resulting state transition. It does not identify the exact Fabric layout or measurement function responsible for omitting the offset. Those are different levels of diagnosis.</p>
<h2>Apply a local page workaround</h2>
<p>The adaptation record resolves the affected page by adding a container outside the <code>ScrollView</code>. This rewritten example uses generic names:</p>
<pre><code class="language-tsx">import type { PropsWithChildren } from 'react';
import { ScrollView, View } from 'react-native';

export function FormScreen({ children }: PropsWithChildren) {
  return (
    &lt;View style={{ flex: 1 }}&gt;
      &lt;ScrollView style={{ flex: 1 }}&gt;{children}&lt;/ScrollView&gt;
    &lt;/View&gt;
  );
}
</code></pre>
<p>This was the documented page-level workaround. It was not a framework fix with a proven lower-level cause that could be applied universally.</p>
<p>Increasing <code>hitSlop</code> changes the allowed region without correcting its coordinate source. Ignoring all movement events would alter the intended cancellation when a user drags off a button. Neither is an equivalent replacement for understanding the observed failure.</p>
<p>Resolving the page issue and fully explaining the underlying framework mechanism can have different completion scopes. The useful deliverable preserves the failure evidence and makes the intervention's scope explicit.</p>
<h2>Make verification distinguish success from a lucky tap</h2>
<p>The historical notes contain the device and OS, callback differences, coordinate comparisons, state transitions, and workaround. They do not contain a complete post-fix device matrix or execution count. This writing session did not rerun the scenario, so no pass rate or all-device success claim is made.</p>
<p>A follow-up verification should cover:</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>What to observe</th>
</tr>
</thead>
<tbody><tr>
<td>Taps before scrolling, after scrolling, and after returning to the top</td>
<td>Whether the measured region follows the visible button</td>
</tr>
<tr>
<td>Slight movement between press and release</td>
<td>Whether the interaction incorrectly leaves the region</td>
</tr>
<tr>
<td>Deliberately dragging outside before release</td>
<td>Whether normal cancellation still works</td>
</tr>
<tr>
<td>Keyboard changes and screen re-entry</td>
<td>Whether coordinates remain correct after layout changes</td>
</tr>
<tr>
<td>The original device and comparison devices</td>
<td>Whether the observed device difference persists</td>
</tr>
</tbody></table>
<p>Evidence should include both touch state and the business result. For a submit button, verify the intended post-submit state; an animation or <code>onPressIn</code> alone does not complete the user task.</p>
<h2>What the investigation demonstrates</h2>
<p>The work narrowed a seemingly unresponsive button through native callbacks, RN event handling, Pressability state, and business callbacks. Coordinate evidence explained the failure path and supported a local intervention.</p>
<p>For remote support, that investigation is also a maintainable handoff: another engineer receives observation points, decision criteria, and a verification path without needing to rediscover the entire native event system.</p>
<hr />
<p>ivan provides part-time remote React Native / iOS support, focusing on native interactions, difficult debugging cases, and delivery through written, asynchronous collaboration.</p>
<p><a href="https://ivanbuilds.hashnode.dev/page/about">Work with me</a></p>
]]></content:encoded></item><item><title><![CDATA[Reusable React Native Builds and Uploads with GitHub Actions and Fastlane]]></title><description><![CDATA[A request for an installable app soon raises further questions: which environment does it use, how does another app join the pipeline, can an upload failure reuse the existing build, and can another e]]></description><link>https://ivanbuilds.hashnode.dev/react-native-github-actions-fastlane-builds</link><guid isPermaLink="true">https://ivanbuilds.hashnode.dev/react-native-github-actions-fastlane-builds</guid><category><![CDATA[GitHub Actions]]></category><category><![CDATA[React Native]]></category><dc:creator><![CDATA[ivan]]></dc:creator><pubDate>Sun, 06 Sep 2026 06:39:52 GMT</pubDate><content:encoded><![CDATA[<p>A request for an installable app soon raises further questions: which environment does it use, how does another app join the pipeline, can an upload failure reuse the existing build, and can another engineer repeat the process?</p>
<p>I helped implement and maintain a shared GitHub Actions and Fastlane pipeline covering builds, artifact transfer, and uploads. Here, submission means uploading to TestFlight or a configured Google Play track. Formal review submission, review monitoring, and rollout control are a separate <a href="https://ivanbuilds.hashnode.dev/mobile-release-orchestration-design-proposal">release orchestration design</a>, not a completed end-to-end implementation.</p>
<h2>Separate three configuration dimensions</h2>
<table>
<thead>
<tr>
<th>Dimension</th>
<th>Question</th>
<th>Examples</th>
</tr>
</thead>
<tbody><tr>
<td>Application environment</td>
<td>Which business services does the app use?</td>
<td>Test, production</td>
</tr>
<tr>
<td>Build and export type</td>
<td>Which native artifact is produced?</td>
<td>iOS device or simulator app; Android APK or AAB</td>
</tr>
<tr>
<td>Upload destination</td>
<td>Where does the artifact go?</td>
<td>Internal distribution, TestFlight, Google Play, build only</td>
</tr>
</tbody></table>
<p>A production-connected iOS build can be distributed through TestFlight before public release. A simulator app is not a device artifact for store upload.</p>
<p>The shared entry point validates the platform and profile combination before choosing the execution lane. Callers provide an explicit delivery target while platform implementations remain independently maintainable.</p>
<h2>Keep application policy separate from shared execution</h2>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a9cf26c838ede1fd18872b6/33813b20-a01d-408f-9405-5f5d625a3013.png" alt="Keep application policy separate from shared execution" /></p>
<p>Technical flow; implementation and validation boundaries are explained below.</p>
<p>My work included organizing application onboarding around stable metadata, signing configuration, and limited application hooks. Shared execution handles preparation, native builds, and artifact transfer; hooks own application-specific version and runtime rules.</p>
<p>Frequently changing build numbers do not belong in stable application metadata. Resolved iOS build numbers, Android version codes, and runtime identifiers travel through build metadata to the upload stage.</p>
<p>The upload identity must describe the artifact actually being uploaded. Recomputing a next build number during recovery can make the reported version differ from the binary.</p>
<h2>Make the build-to-upload handoff explicit</h2>
<h3>Express the handoff through a fixed artifact identity</h3>
<p>This iOS fragment omits dependency setup, signing, compilation and the upload lane. It keeps the existing build/post split and additionally illustrates artifact-ID binding and checksums; those additions are examples, not a claim about deployed behavior. Android can apply the same contract in its own build job.</p>
<pre><code class="language-yaml"># Handoff fragment for an iOS build. Project setup/build steps are omitted.
jobs:
  build:
    runs-on: macos-latest
    outputs:
      artifact_id: ${{ steps.archive.outputs.artifact-id }}
    steps:
      # Existing build steps must produce both files below.
      - name: Verify and fingerprint delivery
        run: |
          test -s delivery/app.ipa
          test -s delivery/build-info.json
          cd delivery
          shasum -a 256 app.ipa build-info.json &gt; SHA256SUMS
      - uses: actions/upload-artifact@v7
        id: archive
        with:
          name: ios-${{ github.run_id }}-${{ github.run_attempt }}
          path: delivery/
          if-no-files-found: error
          retention-days: 14

  post:
    needs: build
    runs-on: macos-latest
    steps:
      - uses: actions/download-artifact@v8
        with:
          artifact-ids: ${{ needs.build.outputs.artifact_id }}
          path: delivery/
      - name: Verify before upload
        run: |
          cd delivery
          shasum -a 256 -c SHA256SUMS
      # Restore pinned tooling/configuration, then run the existing upload lane.
      # Read version/build identity from build-info.json; do not allocate it again.
</code></pre>
<p>The build producing the binary must also produce <code>build-info.json</code>, recording the actual source SHA, Bundle ID, build number, runtime and configuration revision. The artifact ID fixes the handoff object for this run. Checksums check file consistency, not signing identity, review status or trusted provenance. Cross-run recovery also requires the run ID, artifact ID, authorization context and a check for prior store receipt.</p>
<p>The example's 14-day retention illustrates a configurable recovery window; the inspected implementation uses one day. Longer review cycles still need separate archival. Inputs and outputs are defined by <a href="https://github.com/actions/upload-artifact">upload-artifact</a> and <a href="https://github.com/actions/download-artifact">download-artifact</a>.</p>
<p>The existing pipeline separates build and post jobs. The first preserves native output and diagnostic material; the second restores its environment, downloads artifacts from that run, and executes upload and distribution steps.</p>
<p>This supports recovering the upload stage, provided artifacts remain available, metadata matches, and the destination has not already accepted the build. Two jobs alone do not establish unconditional retry safety.</p>
<p>A useful handoff records the application, source commit, configuration revision, platform, build identifiers, artifact location, destination track, and failure stage. It does not include secret values.</p>
<p>After an upload timeout, I would inspect the destination before retrying. A compiler failure requires correcting the source or toolchain and rebuilding. Those failures need different recovery actions.</p>
<h2>Preserve platform-specific behavior</h2>
<p>On iOS, the Team, Bundle ID, signing material, and entitlements must agree. The implemented upload lane loads build metadata, checks the IPA, and uploads to TestFlight.</p>
<p>An accepted upload, completed build processing, tester availability, and App Review approval are distinct. A lane configured not to wait for processing can finish before testers can install. Formal review requires selecting a build and completing the submission information. <a href="https://developer.apple.com/help/app-store-connect/manage-submissions-to-app-review/submit-an-app">Apple submission documentation</a>.</p>
<p>On Android, the lane resolves the package and track and checks for an AAB or APK. It distinguishes a draft-app path from ordinary uploads. A completed internal-track release is not a public production launch. First-time application setup also extends beyond what an upload tool does. <a href="https://docs.fastlane.tools/actions/upload_to_play_store/">Fastlane Google Play upload documentation</a>.</p>
<h2>Deliver diagnostic material with the binary</h2>
<p>The pipeline includes artifact paths and upload logic for debugging material. Whether sourcemaps, symbols, or obfuscation mappings are enabled and successfully delivered must be checked for each application and run.</p>
<p>These files must match the build they describe. A green workflow does not prove every optional upload executed, and a mapping from another build cannot establish reliable source attribution.</p>
<p>The inspected build artifacts have a one-day retention period. That supports short-term transfer but does not cover a multi-day review lifecycle. Durable release storage belongs to the next phase of the design.</p>
<h2>Scope a remote delivery engagement</h2>
<p>I start by identifying the repository, platform, environment, signing setup, destination, and failing stage. The deliverables are a repeatable entry point, configuration instructions, the identified artifact and actual upload status, any device verification performed, and stage-specific recovery notes.</p>
<p>This article was checked against the application caller, shared workflows, actions, and upload lanes. No new hosted build or store upload was triggered for the article. Source inspection does not prove every application or branch has passed, and no unmeasured speed or labor savings are claimed.</p>
<hr />
<p>ivan provides part-time remote support for React Native / iOS / Android builds, signing, and upload workflows through written, asynchronous collaboration.</p>
<p><a href="https://ivanbuilds.hashnode.dev/page/about">Work with me</a></p>
]]></content:encoded></item><item><title><![CDATA[Fixing iOS TextInput Cursor Drift in React Native's Legacy Architecture]]></title><description><![CDATA[An input can render correctly and accept keyboard events, yet insert subsequent characters into the middle of a word during fast typing. Investigating that behavior requires following UIKit autocorrec]]></description><link>https://ivanbuilds.hashnode.dev/react-native-ios-textinput-cursor-drift-paper</link><guid isPermaLink="true">https://ivanbuilds.hashnode.dev/react-native-ios-textinput-cursor-drift-paper</guid><category><![CDATA[React Native]]></category><category><![CDATA[iOS]]></category><dc:creator><![CDATA[ivan]]></dc:creator><pubDate>Sun, 06 Sep 2026 06:38:50 GMT</pubDate><content:encoded><![CDATA[<p>An input can render correctly and accept keyboard events, yet insert subsequent characters into the middle of a word during fast typing. Investigating that behavior requires following UIKit autocorrection, controlled-value updates, and selection state across their timing boundaries.</p>
<p>I encountered this in an application that still needed the Legacy Architecture. An upstream fix covered the New Architecture. I adapted its approach to Paper's native input implementation, shared an RN 0.77.3 patch publicly, and later maintained the fix during an RN 0.81.5 upgrade.</p>
<p>This case study distinguishes the upstream contribution, my adaptation, and the available validation.</p>
<h2>Identify the unnecessary update</h2>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a9cf26c838ede1fd18872b6/79a00a8a-1edb-485c-9b2a-6566914e0621.png" alt="Identify the unnecessary update" /></p>
<p>Technical flow; implementation and validation boundaries are explained below.</p>
<p>Issue #44157 describes cursor drift in controlled inputs with autocorrection and predictive text involved. PR #46970 explicitly addresses the New Architecture. <a href="https://github.com/react/react-native/issues/44157">Original issue</a>, <a href="https://github.com/react/react-native/pull/46970">upstream fix</a>.</p>
<p>The upstream investigation identified a timing problem: <code>UITextField</code> can update text and selection in separate stages during autocorrection. Replacing its complete <code>attributedText</code> in between can preserve an intermediate caret position.</p>
<p>The useful question was why a controlled-value round trip rewrote the input when the returned content was already effectively equivalent.</p>
<h2>Compare effective attributes in Paper</h2>
<p>Plain character equality ignores meaningful styling. Strict <code>NSAttributedString</code> equality can also reject equivalent input because UIKit's default typing attributes differ from RN-generated attributes.</p>
<p>Following the upstream approach, I added effective attribute comparison to Paper's <code>RCTBaseTextInputView.mm</code>. The implementation checks string content, compares attributes across character ranges, uses captured initial <code>typingAttributes</code> as a baseline for missing values, and normalizes natural paragraph alignment and writing direction.</p>
<p>Actual content, range, or effective style differences still have an update path. The work required adapting the principle to Paper's objects and lifecycle; Fabric's input component and attribute types could not simply be copied into a different file.</p>
<h2>Handle two distinct update boundaries</h2>
<h3>Single-line inputs</h3>
<p>This is the core guard from the RN 0.81.5 adaptation, with surrounding object preparation omitted. Variables belong to the <code>RCTBaseTextInputView</code> update path; <code>textOf:equals:</code> already uses the effective comparison described above. It is an excerpt, not a standalone patch.</p>
<pre><code class="language-objc">UITextRange *selection = self.backedTextInputView.selectedTextRange;
BOOL isSinglelineTextField =
    [self.backedTextInputView isKindOfClass:[UITextField class]];
BOOL stringsMatch =
    [attributedTextCopy.string isEqualToString:backedTextInputViewTextCopy.string];

if (eventLag == 0 &amp;&amp;
    isSinglelineTextField &amp;&amp;
    self.backedTextInputView.isFirstResponder &amp;&amp;
    (selection == nil || selection.empty) &amp;&amp;
    stringsMatch) {
  textNeedsUpdate = NO;
} else {
  textNeedsUpdate =
      ([self textOf:attributedTextCopy
            equals:backedTextInputViewTextCopy] == NO);
}
</code></pre>
<p>The review point is the final assignment decision: <code>textNeedsUpdate = NO</code> avoids the subsequent whole-<code>attributedText</code> reset. Other cases still pass through the comparator. It neither forces the caret to the end nor disables autocorrection. The public patch and upstream attribution are linked later in this article.</p>
<p>The legacy patch skips an identical-text rewrite only when all of these conditions hold:</p>
<table>
<thead>
<tr>
<th>Condition</th>
<th>Scope</th>
</tr>
</thead>
<tbody><tr>
<td><code>eventLag == 0</code></td>
<td>JS and native text event counts agree</td>
</tr>
<tr>
<td>The backing control is a <code>UITextField</code></td>
<td>Single-line input</td>
</tr>
<tr>
<td>The control is the first responder</td>
<td>Active editing</td>
</tr>
<tr>
<td>Selection is empty or absent</td>
<td>No selected text range</td>
</tr>
<tr>
<td>Old and new strings match</td>
<td>No new character content to apply</td>
</tr>
</tbody></table>
<p>This does not directly detect autocorrection. It protects an identical-text update that may occur while the caret is temporarily stale. Actual text changes retain the normal update path.</p>
<p>The guard is broader than effective attribute comparison: a style-only update during active editing may also skip that complete rewrite. Same-text style updates, selection changes, and composition therefore belong in regression testing. The patch should not be described as having no effect on every input scenario.</p>
<h3>Multiline inputs</h3>
<p>The patch also introduces a programmatic-update boundary between <code>RCTUITextView</code> and its delegate adapter. Relevant editing callbacks are suppressed during JS-driven writes, and string and selection snapshots are synchronized afterward.</p>
<p>This handles feedback from programmatic writes. It is a separate path from single-line autocorrection and needs separate review and validation.</p>
<h2>Maintain the patch across versions</h2>
<p>The public patch targets Expo 52 / RN 0.77.3. During the RN 0.81.5 migration, I inspected the legacy implementation again, identified the safeguards still needed, adapted the relevant changes, and regenerated the dependency patch.</p>
<p>Delivery included the native implementation, patch file, and corresponding lockfile identity. Keeping them aligned lets another developer or CI reproduce the modified dependency after installation.</p>
<p>The original patch and my report are available in the upstream discussion. The upstream authors implemented the Fabric fix; my contribution is the Paper adaptation and its subsequent maintenance. <a href="https://github.com/react/react-native/pull/46970#issuecomment-4331997918">My legacy patch and report</a>.</p>
<h2>Report the observed result accurately</h2>
<p>In that public report, I stated that we had not received further user reports after adopting the patch. The observation did not include a defined period, input count, or control group, so it does not establish a measured fix rate or permanent elimination.</p>
<p>Historical validation of the RN 0.81.5 adaptation included applying the patch against a clean dependency, installation with a frozen lockfile in offline mode, and an iOS Simulator build of the <code>React-RCTText</code> target.</p>
<p>Those checks establish patch reproducibility and native compilation. That validation record did not include real-device fast typing or predictive-keyboard regression. No device tests were rerun while writing this article.</p>
<p>For a similar engagement, I would test rapid typing, autocorrection, the double-space period shortcut, English and Chinese input methods, selection replacement, programmatic clearing, and dynamic styles across single-line and multiline inputs, recording both RN version and architecture.</p>
<h2>What the work delivers</h2>
<p>An application that cannot immediately switch architectures still needs usable input. This work connects upstream native analysis to a version-specific implementation, reproducible dependency changes, and an explicit validation scope that another maintainer can continue.</p>
<hr />
<p>ivan provides part-time remote React Native / iOS support, focusing on native debugging, upgrades, and patch maintenance through written, asynchronous collaboration.</p>
<p><a href="https://ivanbuilds.hashnode.dev/page/about">Work with me</a></p>
]]></content:encoded></item><item><title><![CDATA[Upgrading to Expo 54: From Passing Types to Runtime Compatibility]]></title><description><![CDATA[The difficult part of upgrading a shipped React Native application often starts after dependency installation succeeds. A screen opens, but a default callback becomes undefined. Type checking passes, ]]></description><link>https://ivanbuilds.hashnode.dev/expo-54-upgrade-runtime-compatibility</link><guid isPermaLink="true">https://ivanbuilds.hashnode.dev/expo-54-upgrade-runtime-compatibility</guid><category><![CDATA[React Native]]></category><category><![CDATA[Expo]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[ivan]]></dc:creator><pubDate>Sun, 06 Sep 2026 06:37:48 GMT</pubDate><content:encoded><![CDATA[<p>The difficult part of upgrading a shipped React Native application often starts after dependency installation succeeds. A screen opens, but a default callback becomes <code>undefined</code>. Type checking passes, but a file API fails at runtime. A debug build works, while release configuration and compatibility with older installed binaries remain untested.</p>
<p>I encountered these different layers while leading an Expo SDK 54 migration. The work covered React Native 0.81.5, React 19.1, native project changes, build tooling, and regression checks. This article focuses on the decisions that helped turn a version update into a migration with reviewable evidence.</p>
<p>The examples are simplified and use generic names. They explain this migration; they are not a universal upgrade script.</p>
<h2>Establish the migration boundary first</h2>
<p><img src="https://cdn.hashnode.com/uploads/covers/6a9cf26c838ede1fd18872b6/010e6e15-3787-4fb8-a7da-85d3acd10d48.png" alt="Establish the migration boundary first" /></p>
<p>Technical flow; implementation and validation boundaries are explained below.</p>
<p>The application already had native directories, custom initialization, third-party SDKs, and dependency patches. We retained the Legacy Architecture and Hermes for this migration, leaving the architecture transition as a separate task. SDK 54 still supports the Legacy Architecture, but it is the final SDK release to do so. That decision therefore carries future migration work. <a href="https://expo.dev/changelog/sdk-54">Expo SDK 54 release notes</a></p>
<p>Before editing, I established a baseline across five layers:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>What needed checking</th>
</tr>
</thead>
<tbody><tr>
<td>JavaScript</td>
<td>The compatible Expo, React Native, React, and dependency versions</td>
</tr>
<tr>
<td>Native projects</td>
<td>Initialization, custom modules, permissions, and SDK integrations</td>
</tr>
<tr>
<td>Toolchain</td>
<td>Node, Gradle, AGP, Kotlin, NDK, and the versions actually resolved</td>
</tr>
<tr>
<td>Runtime behavior</td>
<td>Component defaults, file operations, interactions, and existing patches</td>
</tr>
<tr>
<td>Delivery</td>
<td>Native binaries, JavaScript bundles, and runtime compatibility</td>
</tr>
</tbody></table>
<p>Declared configuration and actual build inputs can differ. Updating package versions while retaining an outdated native initialization path does not complete a migration.</p>
<h2>Compare templates against the application you actually maintain</h2>
<p>I compared the matching Expo template, the RN upgrade diff, and the application's implementation. A bare React Native diff needs interpretation when the project also uses Expo and maintains native code.</p>
<p>For example, we aligned Android's RN initialization with the corresponding <code>loadReactNative</code> entry point while preserving the ordering of application SDK initialization. Gradle, AGP, Kotlin, KSP, and NDK also needed review as a compatible toolchain, rather than as unrelated candidates for individual upgrades. <a href="https://github.com/expo/expo/tree/sdk-54/templates/expo-template-bare-minimum">Expo SDK 54 native template</a></p>
<p>Existing system-bar and inset handling required similar care. Keeping custom Activity behavior does not remove the need to handle platform edge-to-edge requirements. A framework configuration flag, application layout ownership, and OS-enforced behavior are separate concerns. We needed to document who owned the layout behavior and which screens and OS versions were checked.</p>
<p>I classified differences as confirmed compatibility gaps, intentional application choices, or risks needing further validation. That made both changes and deliberate omissions reviewable.</p>
<h2>React 19: a type fix cannot restore runtime defaults</h2>
<h3>Compare the layer that changes before and after the fix</h3>
<p>This example follows the clustering-dependency scenario and retains only parameter binding. Values and names are illustrative; <code>Props</code>, <code>noop</code> and rendering are omitted. The two same-named functions are a before/after comparison, not one executable module.</p>
<pre><code class="language-tsx">// Before: function-component defaults depended on React.
function ClusterLayer(props: Props) {
  const radius = props.radius;
  // ...the dependency's clustering and rendering logic...
}
ClusterLayer.defaultProps = { radius: 50, onClusterPress: noop };

// After: defaults are applied by JavaScript parameter binding.
function ClusterLayer({
  radius = 50,
  onClusterPress = noop,
  ...rest
}: Props) {
  // ...the same clustering and rendering logic...
}

// Call-site cases that must keep their meaning:
// omitted / undefined -&gt; default
// radius={0}          -&gt; 0, not 50
// explicit callback   -&gt; caller's callback, not noop
</code></pre>
<p>The change supplies values when the function executes; changing an optional type to a required type would not do that. <code>radius || 50</code> loses a valid zero, while <code>radius ?? 50</code> also treats explicit <code>null</code> as missing, unlike parameter defaults. Check the dependency's accepted inputs and regress the actual exported component.</p>
<p>One issue involved function-component <code>defaultProps</code>. React 19 no longer applies this mechanism to function components; class components retain support. <a href="https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-proptypes-and-defaultprops-for-functions">React 19 upgrade guide</a></p>
<p>A map-clustering dependency in our application used it for default configuration and callbacks. Omitting a prop could therefore leave a downstream operation with a value the library did not expect.</p>
<p>This generic button illustrates moving defaults into the function parameters. It is explanatory code, not copied application code:</p>
<pre><code class="language-tsx">import { Button } from 'react-native';

type RetryButtonProps = {
  label?: string;
  onRetry?: () =&gt; void;
};

const noop = () =&gt; {};

function RetryButton({
  label = 'Retry',
  onRetry = noop,
}: RetryButtonProps) {
  return &lt;Button title={label} onPress={onRetry} /&gt;;
}
</code></pre>
<p>Each migrated default still needs a semantic review, including callbacks, numeric calculations, and layout options. Parameter defaults apply only when the input is <code>undefined</code>; explicit values must retain priority. A truthiness-based fallback can incorrectly replace valid values such as <code>false</code> or <code>0</code>.</p>
<p>Our application also injected some application-wide component defaults during initialization, including font-scaling behavior. After the upgrade, omitted props could fall back to RN's own behavior, changing layouts at large system font sizes.</p>
<p>Changing a TypeScript declaration could not repair that behavior. We handled affected components according to their rendering paths: parameter defaults for functions we controlled, narrow patches for confirmed dependency issues, and render-time handling where dynamic application defaults were required. Explicit caller props retained priority. This compatibility work has maintenance costs and should not be treated as a default architecture for other applications.</p>
<p>A text search was only the start of the audit. A dependency containing <code>defaultProps</code> was not automatically a production defect: it might use a class, merge defaults itself, or receive explicit values from every relevant caller. We checked the actual call sites before deciding what to patch.</p>
<p>The regression checks also needed to exercise the modified runtime implementation. A test wrapper that supplies missing defaults only proves that the wrapper works.</p>
<h2>Preserve existing API behavior while migrating</h2>
<p>Existing file operations used string URIs, directory constants, and read, write, upload, and download APIs. SDK 54 provides the newer object API while retaining an official legacy entry point. <a href="https://docs.expo.dev/versions/v54.0.0/sdk/filesystem-legacy/">Expo FileSystem legacy documentation</a></p>
<p>We used that entry point to preserve the established calling model:</p>
<pre><code class="language-ts">import * as FileSystem from 'expo-file-system/legacy';
</code></pre>
<p>This separated the SDK upgrade from a future migration to <code>File</code> and <code>Directory</code>. It did not remove the need to inspect supported options, return values, and error handling. Separating the changes made potential regressions easier to attribute.</p>
<p>Localization calls moved to <code>getLocales()</code> and <code>getCalendars()</code>. The following shows the reading pattern only:</p>
<pre><code class="language-ts">import { getCalendars, getLocales } from 'expo-localization';

const regionCode = getLocales()[0]?.regionCode;
const timeZone = getCalendars()[0]?.timeZone;
</code></pre>
<p>Application logic still needs to handle missing values without overwriting an existing user selection. A one-time read also does not solve every lifecycle-related refresh requirement; that depends on the consuming screen. <a href="https://docs.expo.dev/versions/v54.0.0/sdk/localization/">Expo Localization documentation</a></p>
<p>We removed broad compatibility declarations that hid real API mismatches. Remaining type adaptations represented confirmed equivalent behavior. Declarations cannot create an API that no longer exists at runtime.</p>
<h2>Review dependency patches and OTA compatibility separately</h2>
<p>Existing patches often encode earlier production fixes. For each patch, I checked whether the newer dependency had already addressed the issue, whether the affected path remained reachable, whether lifecycle assumptions had changed, and which platform was affected.</p>
<p>Only the behavior still needed was carried forward. This preserved necessary fixes without importing obsolete implementation details wholesale.</p>
<p>A concrete example was the iOS controlled <code>TextInput</code> cursor issue. The upstream fix covered the New Architecture; I adapted its approach to Paper, including effective attribute comparison and input-update safeguards, then maintained the existing RN 0.77.3 patch for RN 0.81.5. The <a href="https://ivanbuilds.hashnode.dev/react-native-ios-textinput-cursor-drift-paper">legacy TextInput case study</a> explains the implementation, public patch, and validation scope.</p>
<p>A separate New Architecture adaptation involved taps failing after scrolling on an iPhone X running iOS 16.7.10. I traced native events into JS, identified inconsistent responder and touch coordinates, and applied a wrapper on the affected page. That case has its own version context and was not a newly introduced Expo 54 issue. See the <a href="https://ivanbuilds.hashnode.dev/react-native-ios-scroll-touch-pressability">iOS scrolling and touch investigation</a>.</p>
<p>Native changes also affect OTA compatibility. Producing a JavaScript bundle does not prove that an older installed binary can run it. That binary must contain the native capabilities the bundle expects. Native dependency changes therefore require a review of runtime versions and update targeting. <a href="https://docs.expo.dev/eas-update/runtime-versions/">Expo runtime versions</a></p>
<p>We treated a successful run on a newly built app and compatibility with an older app as separate questions. Delivery notes needed to identify the binary, compatibility boundary, and bundle used for validation.</p>
<h2>Report validation by what it actually proves</h2>
<p>The recorded checks for this migration included lockfile-constrained installation, type checking, relevant lint checks, 113 Jest suites with 509 tests, Android Kotlin compilation, and an Android JavaScript export. The project documentation also records self-tests for sending and saving images and uploading video after the file API adaptation.</p>
<p>Each result has a specific scope:</p>
<table>
<thead>
<tr>
<th>Check</th>
<th>Evidence provided</th>
<th>What it does not replace</th>
</tr>
</thead>
<tbody><tr>
<td>Installation and type checking</td>
<td>Reproducible dependencies and compilable types</td>
<td>Runtime behavior on devices</td>
</tr>
<tr>
<td>Jest</td>
<td>The executed assertions passed</td>
<td>Uncovered native lifecycles and business flows</td>
</tr>
<tr>
<td>Native compilation and JS export</td>
<td>Those compilation and packaging stages succeeded</td>
<td>Installation, execution, release settings, and external uploads</td>
</tr>
<tr>
<td>Targeted functional checks</td>
<td>Expected behavior on the recorded paths and devices</td>
<td>Other devices, OS versions, and untested paths</td>
</tr>
</tbody></table>
<p>The 113 suites and 509 tests describe the existing regression run, not newly added tests or complete business coverage. Release parameters, optimized artifacts, distribution, and device regression remain separate validation responsibilities.</p>
<h2>Leave a migration another engineer can maintain</h2>
<p>I organized the migration notes around symptoms, causes, changes, validation, and remaining boundaries. Type changes, runtime behavior, native compilation, and release risks had distinct evidence instead of sharing one broad “upgrade passed” label.</p>
<p>That record becomes useful again during the next upgrade. Another engineer can see why a template difference was retained, which patch still serves a purpose, and which checks should be repeated.</p>
<hr />
<p>ivan works on React Native, Expo, and iOS migrations, troubleshooting, and delivery workflows. Available for part-time remote technical support through written, asynchronous communication.</p>
<p><a href="https://ivanbuilds.hashnode.dev/page/about">Work with me</a></p>
]]></content:encoded></item></channel></rss>