← Blog

React Native Force Update vs OTA: Can Your Fix Run?

6 min read
Watch the walkthrough on YouTube

The companion video becomes viewable when its YouTube release goes public.

Your JavaScript fix calls a native module the installed binary does not contain. Downloading a new bundle cannot supply that native implementation. Choose delivery from installed native capabilities; choose the gate from the minimum supported app version.

Two separate contracts

A store gate asks users for a new binary. An OTA restart prompt applies an eligible JavaScript release. Algarawi's shared core contains both interfaces; the Podium native entry points select Metro under iOS DEBUG and Stallion otherwise, with Stallion's bundle file supplied on Android. Installed release behavior across all three branded apps remains unverified. The package and lockfile specify Stallion 2.3.2; installed dependencies were not verified.

For sample versions, current 1.4.0 with minimum 1.3.0 and latest 1.5.0 produces an optional offer. Raising minimum to 1.5.0 produces a forced offer. It installs nothing. Confirm store availability for the actual platform and audience before withdrawing support.

Read the decision table

Config / version Outcome
Missing or query error No gate
Kill switch configured Maintenance before other checks
Below nonblank minimum Forced store update
Below latest, offer not dismissed Optional store update
Offered version already dismissed No optional offer
Blank version field That check disabled

The source comment about comparing against zero blocking everyone is inaccurate: ordinary positive versions are above zero. The explicit blank-field guard still defines product behavior.

Comparator limits

The source strips non-digits per dotted segment then retains three numeric fields. It maps 1.2.0-beta to [1,2,0], but 1.2.0-beta1 to [1,2,1]; a fourth segment is ignored. This is not full semantic-version precedence. Build numbers are separate. The teaching comparator below intentionally accepts only numeric two- or three-segment release versions, rejecting other formats visibly. This adaptation differs from the source and requires an explicit config schema.

Reviewed teaching adaptation

The following integration example takes a trusted native version, validated platform-specific config, durable dismissal function, and SDK restart status. Mount it under the app's Stallion root wrapper. No new app was scaffolded or run. Source precedence is retained; visible store-link feedback, error reporting for malformed version config, and priority between store and restart modals are teaching additions. A missing or rejected URL shows an error but does not repair remote config; support must supply a valid reachable release. The host application must provide appropriate modal layout, localization, support contact, and native integration.

/** Reviewed teaching adaptation; not device-tested.
 * Caller supplies trusted native version, validated config, durable dismissal,
 * SDK restart state and restart function under withStallion.
 * Restricted numeric release versions replace the source's lossy comparator.
 * Missing config fails open; server must enforce backend support separately.
 */
import React, { useState } from 'react';
import { Button, Linking, Modal, Text, View } from 'react-native';
type Config = { killed: boolean; message: string; minimum: string; latest: string; storeUrl: string };
type Gate = 'none' | 'killed' | 'forced' | 'optional';
export function compareRelease(a: string, b: string): number {
  const parse = (v: string) => {
    if (!/^\d+\.\d+(?:\.\d+)?$/.test(v)) throw new Error('Unsupported release version');
    const parts = v.split('.').map(Number);
    if (parts.some(n => !Number.isSafeInteger(n))) throw new Error('Version segment too large');
    return [parts[0], parts[1], parts[2] ?? 0];
  };
  const left = parse(a), right = parse(b);
  for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1;
  return 0;
}
export function gateFor(current: string, config: Config | undefined, dismissed?: string): Gate {
  if (!config) return 'none';
  if (config.killed) return 'killed';
  if (config.minimum && compareRelease(current, config.minimum) < 0) return 'forced';
  if (config.latest && compareRelease(current, config.latest) < 0) {
    return dismissed && compareRelease(dismissed, config.latest) >= 0 ? 'none' : 'optional';
  }
  return 'none';
}
type Props = { current: string; config?: Config; dismissed?: string;
  rememberDismissal: (version: string) => Promise<void>;
  restartRequired: boolean; releaseNote: string; restart: () => void };
export function UpdateSurfaces(props: Props) {
  const [later, setLater] = useState(false);
  const [localDismissal, setLocalDismissal] = useState<string>();
  const [busy, setBusy] = useState(false), [error, setError] = useState('');
  let gate: Gate = 'none';
  let configError = '';
  try { gate = gateFor(props.current, props.config, localDismissal ?? props.dismissed); }
  catch { configError = 'Update configuration is invalid. Contact support.'; }
  // Explicit adaptation: invalid version config reports an error and fails open.
  const openStore = async () => {
    setBusy(true); setError('');
    try {
      const url = props.config?.storeUrl;
      if (!url || !/^https:\/\/(apps\.apple\.com|play\.google\.com)\//.test(url)) {
        throw new Error('Store link unavailable. Contact support.');
      }
      await Linking.openURL(url);
    } catch { setError('Cannot open the store. Try again or contact support.'); }
    finally { setBusy(false); }
  };
  const dismiss = async () => {
    if (gate !== 'optional' || !props.config) return;
    setBusy(true); setError('');
    try {
      await props.rememberDismissal(props.config.latest);
      setLocalDismissal(props.config.latest);
    } catch { setError('Could not save your choice. Please try again.'); }
    finally { setBusy(false); }
  };
  const storeVisible = gate !== 'none';
  const otaVisible = !__DEV__ && !storeVisible && props.restartRequired && !later;
  return <View>
    {!!configError && <Text accessibilityRole="alert">{configError}</Text>}
    <Modal visible={storeVisible} onRequestClose={() => { if (gate === 'optional') void dismiss(); }}>
      <Text>{gate === 'killed' ? props.config?.message : 'A store update is available.'}</Text>
      {gate !== 'killed' && <Button title={busy ? 'Opening...' : 'Open store'} disabled={busy} onPress={() => void openStore()} />}
      {gate === 'optional' && <Button title="Later" disabled={busy} onPress={() => void dismiss()} />}
      {!!error && <Text accessibilityRole="alert">{error}</Text>}
    </Modal>
    <Modal visible={otaVisible} onRequestClose={() => setLater(true)}>
      <Text>{props.releaseNote}</Text>
      <Button title="Restart to apply downloaded release" onPress={props.restart} />
      <Button title="Later" onPress={() => setLater(true)} />
    </Modal>
  </View>;
}

Boundaries to verify

The source config hook fails open while loading or errored. That keeps a config outage from locking all users out, but backend support policy still belongs on the backend. Test offline startup and recovery. Optional dismissal is persisted per offered version; the OTA Later choice lasts only in component state and may return after remount or relaunch.

The source store handler silently returns on missing URL and swallows openURL rejection. A forced modal remains blocking, creating a reviewed failure path. We did not reproduce it on a device or modify the source application.

A release being available, downloading, and running are separate facts. Record currentlyRunningBundle and newReleaseBundle before restart and the running identity afterward. Vendor rollback still requires a project integration test.

Verification checklist

  • Check absent config, kill-switch precedence, minimum equality and below-minimum cases, latest equality, optional dismissal, newer offers, both platforms and blank fields.
  • Validate numeric release format and compare segment boundaries, including 1.10.0 against 1.9.0. Original gate tests were inspected. Their execution remains outstanding.
  • Check store URLs on devices, missing URLs, rejected opening, visible feedback and dismissal storage failure.
  • Use an installed release build for compatible OTA targeting; Metro cannot verify OTA application.
  • Verify bundle identity after restart, incompatible native targets excluded, and rollback/recovery in a staged test environment.

Sources and context

Source review: Algarawi monorepo packages/core remote-config gate.ts, gate.test.ts, config/version hooks, UpdateGate and OtaUpdateModal; Podium native entry points. Source history includes 04679d7 credited to Ibrahim Fathi. This establishes source history. The production incident remains unverified.

Stallion installation, SDK API reference, and vendor migration compatibility guide, checked October 10, 2026. Vendor documentation is general guidance; match exact SDK and compiled host behavior before release.

Previous lesson: Offline queue and idempotency. Next: WebView one-time login tickets. Links will be added when published.

Synthetic English narration uses macOS Daniel. Visuals are source-backed illustrations with sample versions. No device was recorded. No native builds, live backend, deployed OTA or rollback were tested for this lesson.

Comments

No account needed.

  1. Loading comments…