diff --git a/ReactAndroid/src/main/java/com/facebook/react/cxxbridge/FallbackJSBundleLoader.java b/ReactAndroid/src/main/java/com/facebook/react/cxxbridge/FallbackJSBundleLoader.java new file mode 100644 index 000000000..ee86feedc --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/cxxbridge/FallbackJSBundleLoader.java @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.cxxbridge; + +import java.util.ArrayList; +import java.util.List; +import java.util.ListIterator; +import java.util.Stack; + +/** + * FallbackJSBundleLoader + * + * An implementation of {@link JSBundleLoader} that will try to load from + * multiple sources, falling back from one source to the next at load time + * when an exception is thrown for a recoverable error. + */ +public final class FallbackJSBundleLoader extends JSBundleLoader { + + /* package */ static final String RECOVERABLE = "facebook::react::Recoverable"; + + // Loaders to delegate to, with the preferred one at the top. + private Stack mLoaders; + + // Reasons why we fell-back on previous loaders, in order of occurrence. + private final ArrayList mRecoveredErrors = new ArrayList<>(); + + /** + * @param loaders Loaders for the sources to try, in descending order of + * preference. + */ + public FallbackJSBundleLoader(List loaders) { + mLoaders = new Stack(); + ListIterator it = loaders.listIterator(loaders.size()); + while (it.hasPrevious()) { + mLoaders.push(it.previous()); + } + } + + /** + * This loader delegates to (and so behaves like) the currently preferred + * loader. If that loader fails in a recoverable way and we fall back from it, + * it is replaced by the next most preferred loader. + */ + @Override + public String loadScript(CatalystInstanceImpl instance) { + while (true) { + try { + return getDelegateLoader().loadScript(instance); + } catch (Exception e) { + if (!e.getMessage().startsWith(RECOVERABLE)) { + throw e; + } + + mLoaders.pop(); + mRecoveredErrors.add(e); + // TODO (t14839302): Report a soft error for each swallowed exception. + } + } + } + + private JSBundleLoader getDelegateLoader() { + if (!mLoaders.empty()) { + return mLoaders.peek(); + } + + RuntimeException fallbackException = + new RuntimeException("No fallback options available"); + + // Invariant: tail.getCause() == null + Throwable tail = fallbackException; + for (Exception e : mRecoveredErrors) { + tail.initCause(e); + while (tail.getCause() != null) { + tail = tail.getCause(); + } + } + + throw fallbackException; + } +} diff --git a/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/BUCK b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/BUCK new file mode 100644 index 000000000..0a5a55086 --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/BUCK @@ -0,0 +1,18 @@ +include_defs('//ReactAndroid/DEFS') + +rn_robolectric_test( + name = 'cxxbridge', + # Please change the contact to the oncall of your team + contacts = ['oncall+fbandroid_sheriff@xmail.facebook.com'], + srcs = glob(['*Test.java']), + deps = [ + react_native_dep('third-party/java/fest:fest'), + react_native_dep('third-party/java/junit:junit'), + react_native_dep('third-party/java/mockito:mockito'), + react_native_dep('third-party/java/robolectric3/robolectric:robolectric'), + react_native_target('java/com/facebook/react/cxxbridge:bridge'), + ], + visibility = [ + 'PUBLIC' + ], +) diff --git a/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/FallbackJSBundleLoaderTest.java b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/FallbackJSBundleLoaderTest.java new file mode 100644 index 000000000..45c0fd1e8 --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/cxxbridge/FallbackJSBundleLoaderTest.java @@ -0,0 +1,154 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.cxxbridge; + +import java.util.ArrayList; +import java.util.Arrays; + +import org.junit.Before; +import org.junit.Test; + +import static org.fest.assertions.api.Assertions.assertThat; +import static org.fest.assertions.api.Assertions.fail; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class FallbackJSBundleLoaderTest { + + private static final String UNRECOVERABLE; + static { + String prefix = FallbackJSBundleLoader.RECOVERABLE; + char first = prefix.charAt(0); + + UNRECOVERABLE = prefix.replace(first, (char) (first + 1)); + } + + @Test + public void firstLoaderSucceeds() { + JSBundleLoader delegates[] = new JSBundleLoader[] { + successfulLoader("url1"), + successfulLoader("url2") + }; + + FallbackJSBundleLoader fallbackLoader = + new FallbackJSBundleLoader(new ArrayList<>(Arrays.asList(delegates))); + + assertThat(fallbackLoader.loadScript(null)).isEqualTo("url1"); + + verify(delegates[0], times(1)).loadScript(null); + verify(delegates[1], never()).loadScript(null); + } + + @Test + public void fallingBackSuccessfully() { + JSBundleLoader delegates[] = new JSBundleLoader[] { + recoverableLoader("url1", "error1"), + successfulLoader("url2"), + successfulLoader("url3") + }; + + FallbackJSBundleLoader fallbackLoader = + new FallbackJSBundleLoader(new ArrayList<>(Arrays.asList(delegates))); + + assertThat(fallbackLoader.loadScript(null)).isEqualTo("url2"); + + verify(delegates[0], times(1)).loadScript(null); + verify(delegates[1], times(1)).loadScript(null); + verify(delegates[2], never()).loadScript(null); + } + + @Test + public void fallingbackUnsuccessfully() { + JSBundleLoader delegates[] = new JSBundleLoader[] { + recoverableLoader("url1", "error1"), + recoverableLoader("url2", "error2") + }; + + FallbackJSBundleLoader fallbackLoader = + new FallbackJSBundleLoader(new ArrayList<>(Arrays.asList(delegates))); + + try { + fallbackLoader.loadScript(null); + fail("expect throw"); + } catch (Exception e) { + assertThat(e).isInstanceOf(RuntimeException.class); + + Throwable cause = e.getCause(); + ArrayList msgs = new ArrayList<>(); + while (cause != null) { + msgs.add(cause.getMessage()); + cause = cause.getCause(); + } + + assertThat(msgs).containsExactly( + recoverableMsg("error1"), + recoverableMsg("error2")); + } + + verify(delegates[0], times(1)).loadScript(null); + verify(delegates[1], times(1)).loadScript(null); + } + + @Test + public void unrecoverable() { + JSBundleLoader delegates[] = new JSBundleLoader[] { + fatalLoader("url1", "error1"), + recoverableLoader("url2", "error2") + }; + + FallbackJSBundleLoader fallbackLoader = + new FallbackJSBundleLoader(new ArrayList(Arrays.asList(delegates))); + + try { + fallbackLoader.loadScript(null); + fail("expect throw"); + } catch (Exception e) { + assertThat(e.getMessage()).isEqualTo(fatalMsg("error1")); + } + + verify(delegates[0], times(1)).loadScript(null); + verify(delegates[1], never()).loadScript(null); + } + + private static JSBundleLoader successfulLoader(String url) { + JSBundleLoader loader = mock(JSBundleLoader.class); + when(loader.loadScript(null)).thenReturn(url); + + return loader; + } + + private static String recoverableMsg(String errMsg) { + return FallbackJSBundleLoader.RECOVERABLE + errMsg; + } + + private static JSBundleLoader recoverableLoader(String url, String errMsg) { + JSBundleLoader loader = mock(JSBundleLoader.class); + when(loader.loadScript(null)) + .thenThrow(new RuntimeException(FallbackJSBundleLoader.RECOVERABLE + errMsg)); + + return loader; + } + + private static String fatalMsg(String errMsg) { + return UNRECOVERABLE + errMsg; + } + + private static JSBundleLoader fatalLoader(String url, String errMsg) { + JSBundleLoader loader = mock(JSBundleLoader.class); + when(loader.loadScript(null)) + .thenThrow(new RuntimeException(UNRECOVERABLE + errMsg)); + + return loader; + } +}