Skip to content

Android restart() silently downgraded from a process restart to a soft React-context reload in 0.0.28 #292

Description

@mvrty

In react-native-restart 0.0.27, calling RNRestart.restart() on Android performed a true process restart via JakeWharton's ProcessPhoenix.triggerRebirth(...): the Android process was killed and relaunched, all native state was reinitialized.

In 0.0.28 (released as part of the RN 0.85 support PR, #287), the Android implementation was changed to call loadBundle() instead, which does instanceManager.recreateReactContextInBackground() — a soft reload equivalent to dev-mode "R, R". The JS bundle reruns, but the native process keeps running and native state (locale, native singletons, ContentProvider-initialised modules, etc.) is preserved.

This is a silent behavioural breaking change on Android. The API signature, package name and JS surface didn't change, so it is not flagged by typechecking or release notes, and iOS behaviour is unchanged. Any caller that depended on the hard-restart semantics (e.g. apply a runtime language change so native locale-dependent modules re-init) will silently stop working on Android while continuing to work on iOS.

The process-phoenix:2.2.0 dependency is still declared in android/build.gradle but is no longer referenced from the Java sources.

Diff that introduced the regression

android/src/main/java/com/reactnativerestart/RestartModule.java:

 @ReactMethod
-public void Restart() {
-  ProcessPhoenix.triggerRebirth(getReactApplicationContext());
+public void Restart(String reason) {
+    restartReason = reason;
+    loadBundle();
 }

 @ReactMethod
-public void restart() {
-  ProcessPhoenix.triggerRebirth(getReactApplicationContext());
+public void restart(String reason) {
+    restartReason = reason;
+    loadBundle();
 }

where loadBundle() is:

private void loadBundle() {
    clearLifecycleEventListener();
    try {
        final ReactInstanceManager instanceManager = resolveInstanceManager();
        if (instanceManager == null) return;
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override public void run() {
                try {
                    instanceManager.recreateReactContextInBackground();
                } catch (Throwable t) {
                    loadBundleLegacy(); // currentActivity.recreate()
                }
            }
        });
    } catch (Throwable t) {
        loadBundleLegacy();
    }
}

i.e. at worst it just calls Activity.recreate(), never a process kill.

Reproduction

  1. Any app on RN 0.80+ that uses react-native-restart to apply a runtime change that requires native re-init (typical case: i18n language switch where the native side caches the locale at startup).
  2. Bump react-native-restart from 0.0.270.0.28.
  3. Rebuild the Android app.
  4. Trigger RNRestart.restart() from JS.

Expected (0.0.27): the Android process dies, the bootsplash flashes, the app re-launches cleanly, the new locale is picked up everywhere including native modules.

Actual (0.0.28): the JS bundle reloads in place, the bootsplash does not appear, the process PID is unchanged, and any native-side state from the previous run survives. To the user it looks like "the restart button did nothing meaningful".

Root cause

The PR titled "feat: react native 0.85 support" (avishayil/react-native-restart#287, commit 9312b24) replaced the Android implementation in order to unify it with the iOS soft-reload semantics. There is no mention in the changelog or README that the Android behaviour was being downgraded from a process restart to a context reload. The README still describes the package as "reload your app bundle during app runtime", which is technically accurate but glosses over the change.

The two behaviours are not equivalent:

0.0.27 Android 0.0.28 Android iOS (unchanged)
Underlying call ProcessPhoenix.triggerRebirth recreateReactContextInBackground RCTTriggerReloadCommandListeners
Process killed yes no no
Application.onCreate re-runs yes no n/a
Native singletons reset yes no no
ContentProvider-initialised modules re-init yes no n/a
Bootsplash shown again yes no depends

For consumers who knowingly chose this library because it gave them the hard-restart behaviour, 0.0.28 is a regression.

Proposed fix

Either:

Option A — preserve the old Android semantics by default

Restore ProcessPhoenix.triggerRebirth(getReactApplicationContext()) as the Android implementation. The new getReason() API can still be supported by writing restartReason to SharedPreferences (or any persistent store) before the kill, and reading it back on next launch.

Option B — make it explicit and let the caller choose

Add an option to the JS API, e.g.:

RNRestart.restart({ reason?: string; hard?: boolean });

…where hard: true (the default, or at minimum supported) maps to ProcessPhoenix.triggerRebirth on Android. Document the difference between the two modes.

Either way, the changelog and README should explicitly call out that Android behaviour changed in 0.0.28.

Workaround for users who need the 0.0.27 behaviour today

Two options:

  1. Pin to 0.0.27. Works on RN ≤ 0.84. Won't work on RN 0.85+ because of API changes the rest of 0.0.28 also brings in.

  2. Yarn-patch 0.0.28 to swap loadBundle() back to ProcessPhoenix.triggerRebirth(...). The dependency on process-phoenix:2.2.0 is still declared in android/build.gradle, so no extra setup is needed. Minimal patch:

    --- a/android/src/main/java/com/reactnativerestart/RestartModule.java
    +++ b/android/src/main/java/com/reactnativerestart/RestartModule.java
    @@ -100,13 +100,13 @@ public class RestartModule extends ReactContextBaseJavaModule {
         @ReactMethod
         public void Restart(String reason) {
             restartReason = reason;
    -        loadBundle();
    +        ProcessPhoenix.triggerRebirth(getReactApplicationContext());
         }
    
         @ReactMethod
         public void restart(String reason) {
             restartReason = reason;
    -        loadBundle();
    +        ProcessPhoenix.triggerRebirth(getReactApplicationContext());
         }

    Caveat: because the process is killed, the static restartReason field is wiped, so RNRestart.getReason() will resolve to null on Android after a hard restart. If you need the reason to survive, persist it to disk before triggering the restart (or implement Option A above).

    With Yarn Berry, this persists as a yarn patch; with npm, patch-package works.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions