@applicaster/quick-brick-core 16.0.0-alpha.3112220329 → 16.0.0-alpha.3202041045

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/App/ActionsProvider/LegacyActionsRegistryAdapter.tsx +16 -0
  2. package/App/ActionsProvider/__tests__/LegacyActionsRegistryAdapter.test.tsx +64 -0
  3. package/App/ModalProvider/ModalContent.tsx +3 -16
  4. package/App/ModalProvider/__tests__/ModalContent.test.tsx +4 -9
  5. package/App/ModalProvider/__tests__/index.test.tsx +24 -0
  6. package/App/ModalProvider/index.tsx +10 -3
  7. package/App/NavigationProvider/NavigationProvider.tsx +20 -9
  8. package/App/NavigationProvider/__tests__/navToolbox.test.ts +227 -0
  9. package/App/NavigationProvider/__tests__/navigationProvider.test.tsx +56 -0
  10. package/App/NavigationProvider/navToolbox.ts +224 -0
  11. package/App/NavigationProvider/utils.ts +21 -0
  12. package/App/NetworkStatusProvider/NetworkStatusProvider.tsx +32 -4
  13. package/App/NetworkStatusProvider/ResumeVerificationController.ts +282 -0
  14. package/App/NetworkStatusProvider/__tests__/NetworkStatusProvider.test.tsx +152 -1
  15. package/App/NetworkStatusProvider/__tests__/ResumeVerificationController.test.ts +471 -0
  16. package/App/NetworkStatusProvider/__tests__/resumeVerification.test.ts +126 -0
  17. package/App/NetworkStatusProvider/__tests__/useResumeVerifiedNetInfo.test.ts +189 -0
  18. package/App/NetworkStatusProvider/resumeVerification.ts +54 -0
  19. package/App/NetworkStatusProvider/useResumeVerifiedNetInfo.ts +33 -0
  20. package/App/index.tsx +6 -2
  21. package/App/uiActions/index.ts +13 -0
  22. package/App/uiActions/muteUnmute/__tests__/assetResolver.test.ts +226 -0
  23. package/App/uiActions/muteUnmute/__tests__/binding.test.ts +51 -0
  24. package/App/uiActions/muteUnmute/__tests__/defaultAssets.test.ts +47 -0
  25. package/App/uiActions/muteUnmute/__tests__/entryState.test.ts +57 -0
  26. package/App/uiActions/muteUnmute/__tests__/labelResolver.test.ts +35 -0
  27. package/App/uiActions/muteUnmute/assetResolver.ts +87 -0
  28. package/App/uiActions/muteUnmute/defaultAssets.ts +44 -0
  29. package/App/uiActions/muteUnmute/entryState.ts +28 -0
  30. package/App/uiActions/muteUnmute/index.ts +79 -0
  31. package/App/uiActions/muteUnmute/labelResolver.ts +15 -0
  32. package/App/uiActions/muteUnmute/types.ts +18 -0
  33. package/package.json +8 -8
@@ -66,6 +66,22 @@ function LegacyActionBridge({ plugin }: { plugin: LegacyActionPlugin }) {
66
66
  };
67
67
  }, [plugin.identifier]);
68
68
 
69
+ // Consumers resolve a snapshot of `value`; when the plugin publishes a new
70
+ // one (e.g. cast devices found), make them re-resolve so availability
71
+ // predicates are not read from a stale value. Registration already
72
+ // notifies, so the first render is skipped.
73
+ const isFirstValueRef = React.useRef(true);
74
+
75
+ React.useEffect(() => {
76
+ if (isFirstValueRef.current) {
77
+ isFirstValueRef.current = false;
78
+
79
+ return;
80
+ }
81
+
82
+ uiActionsRegistry.notifyProviderChanged(plugin.identifier);
83
+ }, [value, plugin.identifier]);
84
+
69
85
  return null;
70
86
  }
71
87
 
@@ -0,0 +1,64 @@
1
+ import React from "react";
2
+ import { render } from "@testing-library/react-native";
3
+ import { uiActionsRegistry } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
4
+ import { LegacyActionsRegistryAdapter } from "../LegacyActionsRegistryAdapter";
5
+
6
+ const CastContext = React.createContext<any>(null);
7
+
8
+ const plugin = {
9
+ identifier: "cast-action-test",
10
+ module: { context: CastContext },
11
+ };
12
+
13
+ const renderWithValue = (value) => (
14
+ <CastContext.Provider value={value}>
15
+ <LegacyActionsRegistryAdapter actionPlugins={{ cast: plugin } as any} />
16
+ </CastContext.Provider>
17
+ );
18
+
19
+ describe("LegacyActionsRegistryAdapter", () => {
20
+ it("makes consumers re-resolve when the plugin publishes a new value", () => {
21
+ const noDevices = { isActionAvailable: () => false };
22
+ const devicesFound = { isActionAvailable: () => true };
23
+
24
+ const { rerender, unmount } = render(renderWithValue(noDevices));
25
+
26
+ // what a consumer resolved at first paint
27
+ expect(
28
+ uiActionsRegistry
29
+ .getAction("cast-action-test")
30
+ ?.isActionAvailable?.({} as any)
31
+ ).toBe(false);
32
+
33
+ const listener = jest.fn();
34
+ const unsubscribe = uiActionsRegistry.subscribe(listener);
35
+
36
+ rerender(renderWithValue(devicesFound));
37
+
38
+ expect(listener).toHaveBeenCalledTimes(1);
39
+
40
+ expect(
41
+ uiActionsRegistry
42
+ .getAction("cast-action-test")
43
+ ?.isActionAvailable?.({} as any)
44
+ ).toBe(true);
45
+
46
+ unsubscribe();
47
+ unmount();
48
+ });
49
+
50
+ it("does not notify again when the value is unchanged", () => {
51
+ const stable = { isActionAvailable: () => true };
52
+ const { rerender, unmount } = render(renderWithValue(stable));
53
+
54
+ const listener = jest.fn();
55
+ const unsubscribe = uiActionsRegistry.subscribe(listener);
56
+
57
+ rerender(renderWithValue(stable));
58
+
59
+ expect(listener).not.toHaveBeenCalled();
60
+
61
+ unsubscribe();
62
+ unmount();
63
+ });
64
+ });
@@ -1,7 +1,6 @@
1
1
  import React from "react";
2
2
 
3
3
  import { View, StyleSheet } from "react-native";
4
- import { useSafeAreaInsets } from "react-native-safe-area-context";
5
4
 
6
5
  import { ScreenResolver } from "@applicaster/zapp-react-native-ui-components/Components/ScreenResolver";
7
6
 
@@ -18,8 +17,6 @@ const styles = StyleSheet.create({
18
17
  export function ModalContent(props: Props) {
19
18
  const { modalScreenProps } = props;
20
19
 
21
- const insets = useSafeAreaInsets();
22
-
23
20
  if (modalScreenProps?.screenData?.modalBottomSheetContentProps) {
24
21
  const { ModalBottomSheetContent, modalBottomSheetContentProps } =
25
22
  modalScreenProps?.screenData || {};
@@ -34,19 +31,9 @@ export function ModalContent(props: Props) {
34
31
 
35
32
  return (
36
33
  // A presented screen fills the window, including the status bar and home
37
- // indicator. Inset it so chrome it draws at its own edges — a close button,
38
- // for example — stays inside the tappable area.
39
- <View
40
- style={[
41
- styles.container,
42
- {
43
- paddingTop: insets.top,
44
- paddingBottom: insets.bottom,
45
- paddingLeft: insets.left,
46
- paddingRight: insets.right,
47
- },
48
- ]}
49
- >
34
+ // indicator. The modal wraps it in its own SafeAreaProvider, so the screen
35
+ // reads the real insets itself and places its own chrome inside them.
36
+ <View style={styles.container}>
50
37
  <ScreenResolver {...modalScreenProps} />
51
38
  </View>
52
39
  );
@@ -6,10 +6,6 @@ import { ModalContent } from "../ModalContent";
6
6
 
7
7
  jest.mock("../ModalBottomSheet", () => ({ ModalBottomSheet: () => null }));
8
8
 
9
- jest.mock("react-native-safe-area-context", () => ({
10
- useSafeAreaInsets: () => ({ top: 59, bottom: 34, left: 0, right: 0 }),
11
- }));
12
-
13
9
  jest.mock(
14
10
  "@applicaster/zapp-react-native-ui-components/Components/ScreenResolver",
15
11
  () => ({ ScreenResolver: () => null })
@@ -18,15 +14,14 @@ jest.mock(
18
14
  const flatStyle = (tree: any) => StyleSheet.flatten(tree.props.style);
19
15
 
20
16
  describe("ModalContent", () => {
21
- it("insets a presented screen by the safe area so its chrome stays tappable", () => {
17
+ it("lets a presented screen fill the modal, leaving the safe area to the screen", () => {
22
18
  const tree = render(
23
19
  <ModalContent modalScreenProps={{ screenId: "river-1" }} />
24
20
  ).toJSON();
25
21
 
26
- expect(flatStyle(tree)).toMatchObject({
27
- paddingTop: 59,
28
- paddingBottom: 34,
29
- });
22
+ // The modal provides a SafeAreaProvider instead, so the presented screen
23
+ // reads the real insets itself rather than being padded from the outside.
24
+ expect(flatStyle(tree)).toEqual({ flex: 1 });
30
25
  });
31
26
 
32
27
  it("leaves a bottom sheet full-bleed", () => {
@@ -11,6 +11,9 @@ import { ModalProvider } from "../";
11
11
  */
12
12
  const mockSeenScreenData: any[] = [];
13
13
 
14
+ /** Records the `initialMetrics` each SafeAreaProvider the modal renders got. */
15
+ const mockSafeAreaProviderMetrics: any[] = [];
16
+
14
17
  jest.mock(
15
18
  "@applicaster/zapp-react-native-ui-components/Components/ScreenResolver",
16
19
  () => {
@@ -43,6 +46,14 @@ jest.mock("../ModalBottomSheet", () => ({ ModalBottomSheet: () => null }));
43
46
 
44
47
  jest.mock("react-native-safe-area-context", () => ({
45
48
  useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
49
+ // A modal renders in its own native window, so it needs its own provider
50
+ // seeded with the window metrics; without them insets read as zero inside.
51
+ initialWindowMetrics: { insets: { top: 59, bottom: 34, left: 0, right: 0 } },
52
+ SafeAreaProvider: ({ children, initialMetrics }: any) => {
53
+ mockSafeAreaProviderMetrics.push(initialMetrics);
54
+
55
+ return children;
56
+ },
46
57
  }));
47
58
 
48
59
  const river = { id: "river-parent-lock", type: "parent_lock" } as any;
@@ -54,6 +65,7 @@ const seen = () => mockSeenScreenData[mockSeenScreenData.length - 1];
54
65
  describe("ModalProvider", () => {
55
66
  beforeEach(() => {
56
67
  mockSeenScreenData.length = 0;
68
+ mockSafeAreaProviderMetrics.length = 0;
57
69
  modalStore.getState().dismissModal();
58
70
  });
59
71
 
@@ -72,4 +84,16 @@ describe("ModalProvider", () => {
72
84
 
73
85
  expect(seen()).toMatchObject({ screen: river });
74
86
  });
87
+
88
+ it("seeds the presented screen's safe area with the window metrics", () => {
89
+ const { initialWindowMetrics } = require("react-native-safe-area-context");
90
+
91
+ modalStore.getState().openModal({ item: river });
92
+
93
+ render(<ModalProvider />);
94
+
95
+ expect(mockSafeAreaProviderMetrics).toEqual(
96
+ expect.arrayContaining([initialWindowMetrics])
97
+ );
98
+ });
75
99
  });
@@ -1,5 +1,10 @@
1
1
  import * as React from "react";
2
2
 
3
+ import {
4
+ initialWindowMetrics,
5
+ SafeAreaProvider,
6
+ } from "react-native-safe-area-context";
7
+
3
8
  import { ModalPresenter } from "./ModalPresenter";
4
9
  import { ModalContent } from "./ModalContent";
5
10
  import { ModalChildrenWrapper } from "./ModalChildrenWrapper";
@@ -62,9 +67,11 @@ export function ModalProvider() {
62
67
  {...modalState?.options}
63
68
  statusBarTranslucent
64
69
  >
65
- <ModalChildrenWrapper>
66
- <ModalContent modalScreenProps={modalScreenProps} />
67
- </ModalChildrenWrapper>
70
+ <SafeAreaProvider initialMetrics={initialWindowMetrics}>
71
+ <ModalChildrenWrapper>
72
+ <ModalContent modalScreenProps={modalScreenProps} />
73
+ </ModalChildrenWrapper>
74
+ </SafeAreaProvider>
68
75
  </ModalPresenter>
69
76
  </ScreenContextProvider>
70
77
  </ScreenDataContext.Provider>
@@ -13,6 +13,8 @@ import {
13
13
  useGetScreenOrientation,
14
14
  } from "@applicaster/zapp-react-native-utils/appUtils/orientationHelper";
15
15
  import { focusManager } from "@applicaster/zapp-react-native-utils/focusManager/FocusManager";
16
+ import { setXrayNavigator } from "@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator";
17
+ import { registerNavToolbox } from "./navToolbox";
16
18
 
17
19
  import reducer, {
18
20
  ACTIONS,
@@ -243,6 +245,10 @@ export function NavigationProvider({ children }: Props) {
243
245
  playNextOverlayState.current = state;
244
246
  };
245
247
 
248
+ // The play-next overlay owns remote focus while it is visible, so key
249
+ // handling (InteractionManager) needs to be able to ask for it.
250
+ const getPlayNextOverlay = () => playNextOverlayState.current ?? null;
251
+
246
252
  const setNestedScreenContent = (entry: ZappEntry, screenId: string) => {
247
253
  const screen = rivers[screenId];
248
254
  const parent = findParent(context, navigator.currentRoute);
@@ -349,10 +355,7 @@ export function NavigationProvider({ children }: Props) {
349
355
  state: firstRoute.state,
350
356
  };
351
357
 
352
- if (
353
- stateForBack?.state?.entry &&
354
- "home" in stateForBack?.state?.entry
355
- ) {
358
+ if (stateForBack?.state?.entry?.home) {
356
359
  goHome();
357
360
  } else {
358
361
  dispatch(back(stateForBack));
@@ -759,11 +762,11 @@ export function NavigationProvider({ children }: Props) {
759
762
  const getStackForPathname = (
760
763
  pathname: string
761
764
  ): NavigationScreenState | undefined => {
762
- const stack = [...state?.stack?.mainStack];
763
-
764
- const currentStack = stack.find(({ route }) => route === pathname);
765
-
766
- return currentStack;
765
+ // Topmost match: the active screen if a route is on the stack twice.
766
+ return R.findLast(
767
+ ({ route }) => route === pathname,
768
+ state?.stack?.mainStack ?? []
769
+ );
767
770
  };
768
771
 
769
772
  const navigator: QuickBrickAppNavigator = React.useMemo(
@@ -777,6 +780,7 @@ export function NavigationProvider({ children }: Props) {
777
780
  replace: replaceItem,
778
781
  setNestedScreenContent,
779
782
  setPlayNextOverlay,
783
+ getPlayNextOverlay,
780
784
  goHome,
781
785
  canGoBack,
782
786
  goBack,
@@ -827,6 +831,13 @@ export function NavigationProvider({ children }: Props) {
827
831
  navigator.state = state;
828
832
  }
829
833
 
834
+ React.useLayoutEffect(() => {
835
+ setXrayNavigator(navigator);
836
+ registerNavToolbox();
837
+
838
+ return () => setXrayNavigator(undefined);
839
+ }, [navigator]);
840
+
830
841
  React.useLayoutEffect(() => {
831
842
  if (pathname === "/" && appLaunched) {
832
843
  goHome(true);
@@ -0,0 +1,227 @@
1
+ const mockRegister = jest.fn().mockResolvedValue(undefined);
2
+
3
+ // The X-Ray mock (__mocks__/@applicaster/quick-brick-xray) has no toolbox API: give it one.
4
+ require("@applicaster/quick-brick-xray").registerReactToolbox = mockRegister;
5
+
6
+ // Required after the toolbox API is in place: loading them can register the player toolbox.
7
+ const {
8
+ modalStore,
9
+ } = require("@applicaster/zapp-react-native-utils/modalState");
10
+
11
+ const {
12
+ setXrayNavigator,
13
+ } = require("@applicaster/zapp-react-native-utils/navigationUtils/xrayNavigator");
14
+
15
+ const {
16
+ playerManager,
17
+ } = require("@applicaster/zapp-react-native-utils/appUtils/playerManager");
18
+
19
+ const { registerNavToolbox } = require("../navToolbox");
20
+
21
+ const navigator = (overrides = {}) => {
22
+ const nav = {
23
+ getPathname: () => "/river/a/river/b",
24
+ getStack: () => [{ route: "/river/a" }, { route: "/river/a/river/b" }],
25
+ canGoBack: () => true,
26
+ goBack: jest.fn(),
27
+ goHome: jest.fn(),
28
+ getPlayNextOverlay: () => null,
29
+ getModalState: () => modalStore.getState().modalState,
30
+ dismissModal: jest.fn(),
31
+ videoModalState: { visible: false },
32
+ minimiseVideoModal: jest.fn(),
33
+ maximiseVideoModal: jest.fn(),
34
+ closeVideoModal: jest.fn(),
35
+ ...overrides,
36
+ };
37
+
38
+ setXrayNavigator(nav as unknown as QuickBrickAppNavigator);
39
+
40
+ return nav;
41
+ };
42
+
43
+ const videoModal = (mode: string) => ({
44
+ videoModalState: { visible: true, mode, item: { id: "v1", title: "V" } },
45
+ });
46
+
47
+ const call = (name: string) => {
48
+ // Other toolboxes (player) may register first.
49
+ const { tools } = mockRegister.mock.calls.find(
50
+ ([toolbox]) => toolbox.name === "nav"
51
+ )[0];
52
+
53
+ return tools.find((tool) => tool.name === name).handler({});
54
+ };
55
+
56
+ describe("navToolbox", () => {
57
+ beforeAll(() => registerNavToolbox());
58
+
59
+ afterEach(() => {
60
+ modalStore.getState().setModalState({ visible: false, screen: null });
61
+ jest.restoreAllMocks();
62
+ });
63
+
64
+ it("throws until the NavigationProvider sets the navigator", () => {
65
+ setXrayNavigator(undefined);
66
+
67
+ expect(() => call("stack")).toThrow("Navigation is not available yet");
68
+ });
69
+
70
+ it("goes back one screen", () => {
71
+ const nav = navigator();
72
+
73
+ expect(call("back")).toEqual({ from: "/river/a/river/b", wentBack: true });
74
+ expect(nav.goBack).toHaveBeenCalledWith(false);
75
+ });
76
+
77
+ it("does not go back from the root", () => {
78
+ const nav = navigator({ canGoBack: () => false });
79
+
80
+ expect(call("back")).toMatchObject({ wentBack: false });
81
+ expect(nav.goBack).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("does not pop the screen under a modal or the video modal", () => {
85
+ const nav = navigator(videoModal("MAXIMIZED"));
86
+
87
+ expect(call("back")).toEqual({
88
+ from: "video-modal/v1",
89
+ wentBack: false,
90
+ blockedBy: "video-modal",
91
+ });
92
+
93
+ modalStore.getState().openModal({ item: { id: "m" } });
94
+
95
+ expect(call("back")).toMatchObject({ blockedBy: "modal" });
96
+ expect(nav.goBack).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it("only dismisses Play Next when it shows", () => {
100
+ const nav = navigator({ getPlayNextOverlay: () => ({}) });
101
+
102
+ expect(call("back")).toMatchObject({
103
+ wentBack: false,
104
+ dismissed: "playNext",
105
+ });
106
+
107
+ expect(nav.goBack).toHaveBeenCalledWith(false);
108
+ });
109
+
110
+ it("leaves a hook screen to cancel itself", () => {
111
+ const nav = navigator({ getPathname: () => "/river/a/hooks/login" });
112
+
113
+ expect(call("back")).toMatchObject({ wentBack: false, blockedBy: "hook" });
114
+ expect(nav.goBack).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("goes home", () => {
118
+ const nav = navigator();
119
+
120
+ expect(call("home")).toEqual({ from: "/river/a/river/b", wentHome: true });
121
+ expect(nav.goHome).toHaveBeenCalled();
122
+ });
123
+
124
+ it("does not go home under a modal or the video modal", () => {
125
+ const nav = navigator(videoModal("MAXIMIZED"));
126
+
127
+ expect(call("home")).toEqual({
128
+ from: "video-modal/v1",
129
+ wentHome: false,
130
+ blockedBy: "video-modal",
131
+ });
132
+
133
+ modalStore.getState().openModal({ item: { id: "m" } });
134
+
135
+ expect(call("home")).toMatchObject({ blockedBy: "modal" });
136
+ expect(nav.goHome).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it("goes home with the mini player docked", () => {
140
+ const nav = navigator(videoModal("MINIMIZED"));
141
+
142
+ expect(call("home")).toMatchObject({ wentHome: true });
143
+ expect(nav.goHome).toHaveBeenCalled();
144
+ });
145
+
146
+ it("dismisses the modal store's modal, not the video modal", () => {
147
+ const nav = navigator(videoModal("MAXIMIZED"));
148
+
149
+ expect(() => call("dismissModal")).toThrow("No modal is open");
150
+
151
+ modalStore.getState().openModal({ item: { id: "m" } });
152
+
153
+ expect(call("dismissModal")).toBe("OK");
154
+ expect(nav.dismissModal).toHaveBeenCalled();
155
+ });
156
+
157
+ it("minimises only a maximised video modal", () => {
158
+ const fullscreen = navigator(videoModal("FULLSCREEN"));
159
+
160
+ expect(() => call("minimizeVideoModal")).toThrow("mode is FULLSCREEN");
161
+ expect(fullscreen.minimiseVideoModal).not.toHaveBeenCalled();
162
+
163
+ const maximized = navigator(videoModal("MAXIMIZED"));
164
+
165
+ call("minimizeVideoModal");
166
+
167
+ expect(maximized.minimiseVideoModal).toHaveBeenCalled();
168
+ });
169
+
170
+ it("enters and leaves fullscreen through the player, which also rotates the phone", () => {
171
+ const invoke = jest.spyOn(playerManager, "invokeHandler");
172
+ const playerContainer = jest.fn();
173
+
174
+ playerManager.on("toggleFullscreen", playerContainer);
175
+
176
+ navigator(videoModal("MAXIMIZED"));
177
+ call("fullscreenVideoModal");
178
+
179
+ expect(invoke).toHaveBeenCalledWith("toggleFullscreen", {
180
+ fullscreen: true,
181
+ });
182
+
183
+ const fullscreen = navigator(videoModal("FULLSCREEN"));
184
+
185
+ call("maximizeVideoModal");
186
+
187
+ expect(invoke).toHaveBeenCalledWith("toggleFullscreen", {
188
+ fullscreen: false,
189
+ });
190
+
191
+ expect(fullscreen.maximiseVideoModal).not.toHaveBeenCalled();
192
+
193
+ playerManager.removeHandler("toggleFullscreen", playerContainer);
194
+ });
195
+
196
+ it("does not claim fullscreen without a player, or from the mini player", () => {
197
+ navigator(videoModal("MAXIMIZED"));
198
+
199
+ expect(() => call("fullscreenVideoModal")).toThrow("shows no player");
200
+
201
+ navigator(videoModal("MINIMIZED"));
202
+
203
+ expect(() => call("fullscreenVideoModal")).toThrow("mode is MINIMIZED");
204
+ });
205
+
206
+ it("expands the mini player", () => {
207
+ const nav = navigator(videoModal("MINIMIZED"));
208
+
209
+ call("maximizeVideoModal");
210
+
211
+ expect(nav.maximiseVideoModal).toHaveBeenCalled();
212
+ });
213
+
214
+ it("reports the video modal", () => {
215
+ navigator(videoModal("MINIMIZED"));
216
+
217
+ expect(call("videoModal")).toMatchObject({
218
+ visible: true,
219
+ mode: "MINIMIZED",
220
+ entry: { id: "v1", title: "V" },
221
+ });
222
+
223
+ navigator();
224
+
225
+ expect(call("videoModal")).toMatchObject({ visible: false });
226
+ });
227
+ });
@@ -410,6 +410,62 @@ describe("<NavigationProvider />", () => {
410
410
  });
411
411
  });
412
412
 
413
+ describe("when backToTop is requested from a non-home tab", () => {
414
+ const listenRiver = {
415
+ id: "LISTEN",
416
+ home: false,
417
+ navigations: [],
418
+ ui_components: [],
419
+ };
420
+
421
+ beforeEach(() => {
422
+ wrapper = renderContextConsumer({
423
+ ...store,
424
+ rivers: { ...rivers, LISTEN: listenRiver },
425
+ });
426
+
427
+ act(() => {
428
+ getNavigatorValues(wrapper).navigator.replace(listenRiver);
429
+ });
430
+ });
431
+
432
+ it("returns to the tab root from the second level", () => {
433
+ act(() => {
434
+ getNavigatorValues(wrapper).navigator.push(rivers.A1234);
435
+ });
436
+
437
+ act(() => {
438
+ getNavigatorValues(wrapper).navigator.goBack(false, false, true);
439
+ });
440
+
441
+ const { navigator, state } = getNavigatorValues(wrapper);
442
+
443
+ expect(navigator.currentRoute).toEqual("/river/LISTEN");
444
+ expect(navigator.activeRiver).toEqual(listenRiver);
445
+ expect(state.stack.mainStack).toHaveLength(1);
446
+ });
447
+
448
+ it("returns to the tab root from the third level", () => {
449
+ act(() => {
450
+ getNavigatorValues(wrapper).navigator.push(rivers.A1234);
451
+ });
452
+
453
+ act(() => {
454
+ getNavigatorValues(wrapper).navigator.push(rivers.C0987);
455
+ });
456
+
457
+ act(() => {
458
+ getNavigatorValues(wrapper).navigator.goBack(false, false, true);
459
+ });
460
+
461
+ const { navigator, state } = getNavigatorValues(wrapper);
462
+
463
+ expect(navigator.currentRoute).toEqual("/river/LISTEN");
464
+ expect(navigator.activeRiver).toEqual(listenRiver);
465
+ expect(state.stack.mainStack).toHaveLength(1);
466
+ });
467
+ });
468
+
413
469
  describe("when there is a previous route and there are hooks", () => {
414
470
  beforeEach(() => {
415
471
  wrapper = renderContextConsumer();