diff --git a/README.md b/README.md index 2d93d4a1e..be87dd153 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ More importantly, the client is designed to be incorporated into an existing Rea - @react-native-picker/picker >= 2.9.0 - @react-navigation/bottom-tabs >= 6.5.7 - @react-navigation/native >= 6.1.6 +- @react-navigation/native-stack >= 6.11.0 - @react-navigation/stack >= 6.3.16 - @types/react >= 18.3.12 - react >= 18.3.1 diff --git a/demo/package.json b/demo/package.json index 1e30ed265..e14a49709 100644 --- a/demo/package.json +++ b/demo/package.json @@ -22,6 +22,7 @@ "@react-native-picker/picker": "2.11.1", "@react-navigation/bottom-tabs": "6.5.7", "@react-navigation/native": "6.1.6", + "@react-navigation/native-stack": "6.11.0", "@react-navigation/stack": "6.3.16", "expo": "~53.0.17", "expo-constants": "~17.1.7", diff --git a/demo/yarn.lock b/demo/yarn.lock index 2a873da51..f9d08f3c8 100644 --- a/demo/yarn.lock +++ b/demo/yarn.lock @@ -2014,7 +2014,7 @@ "@react-navigation/bottom-tabs@6.5.7": version "6.5.7" - resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-6.5.7.tgz#08470c96e0d11481422214bb98f0ff034038856c" + resolved "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-6.5.7.tgz#08470c96e0d11481422214bb98f0ff034038856c" integrity sha512-9oZYyRu2z7+1pr2dX5V54rHFPmlj4ztwQxFe85zwpnGcPtGIsXj7VCIdlHnjRHJBBFCszvJGQpYY6/G2+DfD+A== dependencies: "@react-navigation/elements" "^1.3.17" @@ -2023,7 +2023,7 @@ "@react-navigation/core@^6.4.8": version "6.4.17" - resolved "https://registry.yarnpkg.com/@react-navigation/core/-/core-6.4.17.tgz#f277a196b578c8a456efcc563d1c9bd87eb4ab04" + resolved "https://registry.npmjs.org/@react-navigation/core/-/core-6.4.17.tgz#f277a196b578c8a456efcc563d1c9bd87eb4ab04" integrity sha512-Nd76EpomzChWAosGqWOYE3ItayhDzIEzzZsT7PfGcRFDgW5miHV2t4MZcq9YIK4tzxZjVVpYbIynOOQQd1e0Cg== dependencies: "@react-navigation/routers" "^6.1.9" @@ -2033,14 +2033,22 @@ react-is "^16.13.0" use-latest-callback "^0.2.1" -"@react-navigation/elements@^1.3.17": +"@react-navigation/elements@^1.3.17", "@react-navigation/elements@^1.3.31": version "1.3.31" - resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-1.3.31.tgz#28dd802a0787bb03fc0e5be296daf1804dbebbcf" + resolved "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.31.tgz#28dd802a0787bb03fc0e5be296daf1804dbebbcf" integrity sha512-bUzP4Awlljx5RKEExw8WYtif8EuQni2glDaieYROKTnaxsu9kEIA515sXQgUDZU4Ob12VoL7+z70uO3qrlfXcQ== +"@react-navigation/native-stack@6.11.0": + version "6.11.0" + resolved "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-6.11.0.tgz#a33f92cbd55dfe28fb0ba67df99aaa95240eb87c" + integrity sha512-U5EcUB9Q2NQspCFwYGGNJm0h6wBCOv7T30QjndmvlawLkNt7S7KWbpWyxS9XBHSIKF57RgWjfxuJNTgTstpXxw== + dependencies: + "@react-navigation/elements" "^1.3.31" + warn-once "^0.1.0" + "@react-navigation/native@6.1.6": version "6.1.6" - resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-6.1.6.tgz#84ff5cf85b91f660470fa9407c06c8ee393d5792" + resolved "https://registry.npmjs.org/@react-navigation/native/-/native-6.1.6.tgz#84ff5cf85b91f660470fa9407c06c8ee393d5792" integrity sha512-14PmSy4JR8HHEk04QkxQ0ZLuqtiQfb4BV9kkMXD2/jI4TZ+yc43OnO6fQ2o9wm+Bq8pY3DxyerC2AjNUz+oH7Q== dependencies: "@react-navigation/core" "^6.4.8" @@ -2050,14 +2058,14 @@ "@react-navigation/routers@^6.1.9": version "6.1.9" - resolved "https://registry.yarnpkg.com/@react-navigation/routers/-/routers-6.1.9.tgz#73f5481a15a38e36592a0afa13c3c064b9f90bed" + resolved "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz#73f5481a15a38e36592a0afa13c3c064b9f90bed" integrity sha512-lTM8gSFHSfkJvQkxacGM6VJtBt61ip2XO54aNfswD+KMw6eeZ4oehl7m0me3CR9hnDE4+60iAZR8sAhvCiI3NA== dependencies: nanoid "^3.1.23" "@react-navigation/stack@6.3.16": version "6.3.16" - resolved "https://registry.yarnpkg.com/@react-navigation/stack/-/stack-6.3.16.tgz#cf94e3c8c1587455515743e91d328beef722e0ab" + resolved "https://registry.npmjs.org/@react-navigation/stack/-/stack-6.3.16.tgz#cf94e3c8c1587455515743e91d328beef722e0ab" integrity sha512-KTOn9cNuZ6p154Htbl2DiR95Wl+c7niLPRiGs7gjOkyVDGiaGQF9ODNQTYBDE1OxZGHe/EyYc6T2CbmiItLWDg== dependencies: "@react-navigation/elements" "^1.3.17" @@ -7617,7 +7625,12 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@^3.1.23, nanoid@^3.3.11, nanoid@^3.3.7: +nanoid@^3.1.23: + version "3.3.18" + resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== + +nanoid@^3.3.11, nanoid@^3.3.7: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== @@ -8753,7 +8766,7 @@ react-freeze@^1.0.0: react-is@^16.13.0, react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== react-is@^18.0.0, react-is@^18.3.1: @@ -10510,9 +10523,9 @@ urlpattern-polyfill@^10.0.0: integrity sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw== use-latest-callback@^0.2.1: - version "0.2.4" - resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.2.4.tgz#35c0f028f85a3f4cf025b06011110e87cc18f57e" - integrity sha512-LS2s2n1usUUnDq4oVh1ca6JFX9uSqUncTfAm44WMg0v6TxL7POUTk1B044NH8TeLkFbNajIsgDHcgNpNzZucdg== + version "0.2.6" + resolved "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz#e5ea752808c86219acc179ace0ae3c1203255e77" + integrity sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg== util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" diff --git a/docs/reference_hyperview_component.md b/docs/reference_hyperview_component.md index 8129d9794..07a6755e6 100644 --- a/docs/reference_hyperview_component.md +++ b/docs/reference_hyperview_component.md @@ -17,6 +17,8 @@ function Screen({ url }) { ``` - `entrypointUrl` is the URL for the screen. `Hyperview` will make a `GET` request to fetch the URL. The response should be an HXML doc, that will be parsed and rendered on-screen. +- `enableNativeRoutes` enables native-stack navigation on iOS and Android. It defaults to `false` and is always disabled on web. - `fetch` is the fetch method used to make requests in the screen. Since it is injected, the app can modify the method. This is commonly done to add headers for authentication, like an API header or cookie. +- `enableModalDismissGesture` enables swipe-down dismissal for native modal routes on iOS and uses the native `modal` presentation instead of `fullScreenModal`. It defaults to `false` and has no effect when native routes are disabled or on Android. To see more examples of `Hyperview` features, check out the [demo app code](https://github.com/Instawork/hyperview/blob/2bd2cce6c381d9bb83983024fb7dc42045ad4261/demo/App.tsx#L33-L43). diff --git a/package.json b/package.json index c4c70f9e3..f00d57adb 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@react-native-picker/picker": "^2.9.0", "@react-navigation/bottom-tabs": "^6.5.7", "@react-navigation/native": "^6.1.6", + "@react-navigation/native-stack": "^6.11.0", "@react-navigation/stack": "^6.3.16", "@types/react": "~18.3.12", "react": "^18.3.1", @@ -65,6 +66,7 @@ "@react-native-picker/picker": "2.9.0", "@react-navigation/bottom-tabs": "6.5.7", "@react-navigation/native": "6.1.6", + "@react-navigation/native-stack": "6.11.0", "@react-navigation/stack": "6.3.16", "@testing-library/react-native": "13.2.0", "@tsconfig/react-native": "3.0.2", diff --git a/scripts/update-demo.sh b/scripts/update-demo.sh index 0e011811f..148386560 100755 --- a/scripts/update-demo.sh +++ b/scripts/update-demo.sh @@ -47,6 +47,7 @@ yarn add \ yarn add --exact \ @react-navigation/bottom-tabs@6.5.7 \ @react-navigation/native@6.1.6 \ + @react-navigation/native-stack@6.11.0 \ @react-navigation/stack@6.3.16 # Make Hyperview symlinkable diff --git a/src/components/navigator-stack/index.tsx b/src/components/navigator-stack/index.tsx index 67a7b850b..00c6e8527 100644 --- a/src/components/navigator-stack/index.tsx +++ b/src/components/navigator-stack/index.tsx @@ -2,10 +2,13 @@ import * as CustomStackRouter from 'hyperview/src/components/navigator-stack/rou import * as NavigatorService from 'hyperview/src/services/navigator'; import * as React from 'react'; import type { - CompatibleStackViewProps, + NativeViewComponent, NavigationBuilderWithDescribe, + NavigationEventMap, + NavigationOptions, Props, StackOptions, + StackViewComponent, } from './types'; import { StackActionHelpers, @@ -13,50 +16,54 @@ import { createNavigatorFactory, useNavigationBuilder, } from '@react-navigation/native'; -import { - StackNavigationEventMap, - StackNavigationOptions, - StackView, -} from '@react-navigation/stack'; +import { NativeStackView } from '@react-navigation/native-stack'; import type { ParamListBase } from '@react-navigation/routers'; +import { StackView } from '@react-navigation/stack'; import { useHvDocContext } from 'hyperview/src/elements/hv-doc'; import { useHyperview } from 'hyperview/src/contexts/hyperview'; -const CompatibleStackView = StackView as React.ComponentType; +const CompatibleNativeStackView = NativeStackView as NativeViewComponent; +const CompatibleStackView = StackView as StackViewComponent; -const CustomStackNavigator = (props: Props) => { +const Navigator = (props: Props) => { const { getSourceDoc } = useHvDocContext(); - const { entrypointUrl } = useHyperview(); + const { enableNativeRoutes, entrypointUrl } = useHyperview(); const { direction } = NavigatorService.useCompatibleLocale(); - const builder = useNavigationBuilder< StackNavigationState, StackOptions, StackActionHelpers, - StackNavigationOptions, - StackNavigationEventMap + NavigationOptions, + NavigationEventMap >(CustomStackRouter.Router, { children: props.children, entrypointUrl, getDoc: () => getSourceDoc(), id: props.id, initialRouteName: props.initialRouteName, - screenOptions: props.screenOptions, + screenOptions: props.screenOptions as NavigationOptions, }); const { state, descriptors, navigation, NavigationContent } = builder; const { describe } = (builder as unknown) as NavigationBuilderWithDescribe; - return ( - - - + const stackView = enableNativeRoutes ? ( + + ) : ( + ); + + return {stackView}; }; -export default createNavigatorFactory(CustomStackNavigator); +export default createNavigatorFactory(Navigator); diff --git a/src/components/navigator-stack/router.rn6.test.tsx b/src/components/navigator-stack/router.rn6.test.tsx new file mode 100644 index 000000000..48048cc42 --- /dev/null +++ b/src/components/navigator-stack/router.rn6.test.tsx @@ -0,0 +1,120 @@ +import * as DomErrors from 'hyperview/src/services/dom/errors'; +import * as NavigatorService from 'hyperview/src/services/navigator'; +import { CommonActions } from '@react-navigation/native'; +import { DOMParser } from '@instawork/xmldom'; +import { Router } from './router'; +import { createStackNavigationState } from 'hyperview/test/helpers/navigation'; + +// Deliberately unmocked, unlike router.test.tsx: that file stubs `useLocale` +// so `isReactNavigation7` reads true while the installed router is still +// React Navigation 6, which cannot exercise the version-gated paths below. + +const parser = new DOMParser({ + errorHandler: { + error: (error: string) => { + throw new DomErrors.XMLParserError(error); + }, + fatalError: (error: string) => { + throw new DomErrors.XMLParserFatalError(error); + }, + warning: (error: string) => { + throw new DomErrors.XMLParserWarning(error); + }, + }, + locator: {}, +}); + +const routerOptions = { + routeGetIdList: { + card: undefined, + modal: undefined, + 'tabs-route': undefined, + }, + routeNames: ['tabs-route', 'card', 'modal'], + routeParamList: { + card: undefined, + modal: undefined, + 'tabs-route': { + id: 'tabs-route', + }, + }, +}; + +const doc = parser.parseFromString(` + + + + + + + + + + +`); + +const router = Router({ + entrypointUrl: 'https://example.com', + getDoc: () => doc, + id: 'root-navigator', +}); + +describe('stack router under React Navigation 6', () => { + it('detects the installed React Navigation 6 dependency', () => { + expect(NavigatorService.isReactNavigation7).toBe(false); + }); + + describe('with a route stacked above the target navigator', () => { + // The shape behind the storybook regression: a deep-linked card sits on + // top of the tabs, and a bare navigate to a tab route must not discard it. + const state = createStackNavigationState({ + index: 1, + key: 'root-stack', + routeNames: routerOptions.routeNames, + routes: [ + { + key: 'tabs-route-key', + name: 'tabs-route', + params: { id: 'tabs-route' }, + }, + { + key: 'card-key', + name: 'card', + }, + ], + stale: false, + type: 'stack', + }); + const action = CommonActions.navigate('messages-route'); + + it('leaves the action for the mounted child without dropping the card', () => { + const result = router.getStateForAction(state, action, routerOptions); + + expect(result).toBeNull(); + }); + }); + + describe('with the target navigator on top', () => { + const state = createStackNavigationState({ + index: 0, + key: 'root-stack', + routeNames: routerOptions.routeNames, + routes: [ + { + key: 'tabs-route-key', + name: 'tabs-route', + params: { id: 'tabs-route' }, + }, + ], + stale: false, + type: 'stack', + }); + const action = CommonActions.navigate('messages-route'); + + it('matches master by leaving the action for the mounted child', () => { + const result = router.getStateForAction(state, action, routerOptions); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/components/navigator-stack/types.ts b/src/components/navigator-stack/types.ts index f1fd9b9f4..f1b145a3f 100644 --- a/src/components/navigator-stack/types.ts +++ b/src/components/navigator-stack/types.ts @@ -1,6 +1,12 @@ import type * as NavigatorService from 'hyperview/src/services/navigator'; import * as React from 'react'; import type { + NativeStackNavigationEventMap, + NativeStackNavigationOptions, + NativeStackView, +} from '@react-navigation/native-stack'; +import type { + StackNavigationEventMap, StackNavigationOptions, StackView, } from '@react-navigation/stack'; @@ -10,7 +16,7 @@ export type Props = { id: string; children?: React.ReactNode; initialRouteName?: string; - screenOptions?: StackNavigationOptions; + screenOptions?: NativeStackNavigationOptions | StackNavigationOptions; }; export type RouterRenameOptions = RouterConfigOptions & { @@ -28,6 +34,18 @@ export type StackOptions = { initialRouteName?: string; }; +export type NavigationEventMap = NativeStackNavigationEventMap & + StackNavigationEventMap; +export type NavigationOptions = NativeStackNavigationOptions & + StackNavigationOptions; + +export type CompatibleNativeStackViewProps = Omit< + React.ComponentProps, + 'describe' +> & { + describe?: unknown; +}; + export type CompatibleStackViewProps = Omit< React.ComponentProps, 'describe' | 'direction' @@ -39,3 +57,9 @@ export type CompatibleStackViewProps = Omit< export type NavigationBuilderWithDescribe = { describe?: unknown; }; + +/** + * Component types for stack views with compatible React Navigation props + */ +export type NativeViewComponent = React.ComponentType; +export type StackViewComponent = React.ComponentType; diff --git a/src/contexts/back-behaviors/context.tsx b/src/contexts/back-behaviors/context.tsx index 4e40b8bb8..2db03f0cc 100644 --- a/src/contexts/back-behaviors/context.tsx +++ b/src/contexts/back-behaviors/context.tsx @@ -3,7 +3,6 @@ import React, { createContext, useCallback, useContext, - useRef, useState, } from 'react'; import type { HvComponentOnUpdate } from 'hyperview/src/types'; @@ -29,7 +28,7 @@ export function removeElements( } export const Provider = (props: { children: ReactNode }) => { - const registry = useRef([]); + const [registry, setRegistry] = useState([]); const [onUpdate, setOnUpdate] = useState(() => null); const add = useCallback( @@ -37,16 +36,16 @@ export const Provider = (props: { children: ReactNode }) => { if (elements.length === 0) { return; } - registry.current.push(...elements); + setRegistry(currentRegistry => [...currentRegistry, ...elements]); setOnUpdate(() => update); }, [], ); - const get = useCallback((): Element[] => registry.current, []); + const get = useCallback((): Element[] => registry, [registry]); const remove = useCallback((elements: Element[]): void => { - registry.current = removeElements(registry.current, elements); + setRegistry(currentRegistry => removeElements(currentRegistry, elements)); }, []); return ( diff --git a/src/elements/hv-navigator/index.test.tsx b/src/elements/hv-navigator/index.test.tsx new file mode 100644 index 000000000..c1928288c --- /dev/null +++ b/src/elements/hv-navigator/index.test.tsx @@ -0,0 +1,15 @@ +import { getStackPresentation } from './index'; + +describe('getStackPresentation', () => { + it('uses full-screen presentation by default for modals', () => { + expect(getStackPresentation(true, false)).toBe('fullScreenModal'); + }); + + it('uses modal presentation when dismissal gestures are enabled', () => { + expect(getStackPresentation(true, true)).toBe('modal'); + }); + + it('uses card presentation for non-modal routes', () => { + expect(getStackPresentation(false, false)).toBe('card'); + }); +}); diff --git a/src/elements/hv-navigator/index.tsx b/src/elements/hv-navigator/index.tsx index f3cf5f8b0..4e396a03a 100644 --- a/src/elements/hv-navigator/index.tsx +++ b/src/elements/hv-navigator/index.tsx @@ -16,10 +16,6 @@ import { EventMissingNameError, EventTriggerError, } from 'hyperview/src/errors'; -import { - CardStyleInterpolators, - StackNavigationOptions, -} from '@react-navigation/stack'; import { Props, SHOW_DEFAULT_FOOTER_UI, @@ -31,7 +27,10 @@ import { } from './types'; import React, { useCallback, useEffect, useRef } from 'react'; import type { BottomTabBarProps } from '@react-navigation/bottom-tabs'; +import { CardStyleInterpolators } from '@react-navigation/stack'; import { HvDocContext } from 'hyperview/src/elements/hv-doc'; +import type { NativeStackNavigationOptions } from '@react-navigation/native-stack'; +import type { NavigationOptions } from 'hyperview/src/components/navigator-stack/types'; import NavigatorStack from 'hyperview/src/components/navigator-stack'; import NavigatorTab from 'hyperview/src/components/navigator-tab'; import { Platform } from 'react-native'; @@ -41,11 +40,26 @@ import { useHyperview } from 'hyperview/src/contexts/hyperview'; export const Stack = NavigatorStack(); export const BottomTab = NavigatorTab(); +export const getStackPresentation = ( + isModalPresentation: boolean, + modalDismissGestureEnabled: boolean, +): NativeStackNavigationOptions['presentation'] => { + if (!isModalPresentation) { + return 'card'; + } + return modalDismissGestureEnabled ? 'modal' : 'fullScreenModal'; +}; + export default function HvNavigator(props: Props) { // eslint-disable-next-line react/destructuring-assignment const { element, onUpdate, params, routeComponent } = props; const behaviorElements = useRef([]); - const { navigationComponents } = useHyperview(); + const { + enableModalDismissGesture = false, + enableNativeRoutes = false, + entrypointUrl, + navigationComponents, + } = useHyperview(); const prevProps = useRef(undefined); const onEventDispatch = useCallback( @@ -122,30 +136,33 @@ export default function HvNavigator(props: Props) { /** * Logic to determine the nav route id */ - const getId = useCallback((p: RouteParams | undefined) => { - if (!p) { - throw new NavigatorService.HvNavigatorError('No params found for route'); - } - if (p.id) { - if (NavigatorService.isDynamicRoute(p.id)) { - // Dynamic routes use their url as id - return p.url || p.id; + const getId = useCallback( + (p: RouteParams | undefined) => { + if (!p) { + throw new NavigatorService.HvNavigatorError( + 'No params found for route', + ); } - return p.id; - } - return p.url || undefined; - }, []); + if (p.id && !NavigatorService.isDynamicRoute(p.id)) { + return p.id; + } + return p.url + ? NavigatorService.getUrlFromHref(p.url, entrypointUrl) + : p.id; + }, + [entrypointUrl], + ); /** * Encapsulated options for the stack screenOptions */ const stackScreenOptions = useCallback( ({ route }: ScreenOptionsProps): StackScreenOptions => ({ - headerMode: 'screen', + ...(!enableNativeRoutes && { headerMode: 'screen' as const }), headerShown: SHOW_DEFAULT_HEADER_UI, title: getId(route.params), }), - [getId], + [enableNativeRoutes, getId], ); /** @@ -175,8 +192,10 @@ export default function HvNavigator(props: Props) { routeId?: string | undefined, isModal = false, ): React.ReactElement => { + const dynamicInitialParams = + enableNativeRoutes && needsSubStack ? { needsSubStack: true } : {}; const initialParams = NavigatorService.isDynamicRoute(id) - ? {} + ? dynamicInitialParams : { id, isModal, needsSubStack, routeId, url: href }; if (type === NAVIGATOR_TYPE.TAB) { return ( @@ -189,14 +208,51 @@ export default function HvNavigator(props: Props) { ); } if (type === NAVIGATOR_TYPE.STACK) { - const animation = isFirstScreen ? 'none' : 'default'; - const gestureEnabled = Platform.OS === 'ios' ? !needsSubStack : false; - let cardStyleInterpolator; - if (needsSubStack) { - cardStyleInterpolator = - Platform.OS === 'android' - ? CardStyleInterpolators.forBottomSheetAndroid - : CardStyleInterpolators.forVerticalIOS; + const isModalPresentation = needsSubStack; + const modalDismissGestureEnabled = + enableNativeRoutes && + isModalPresentation && + Platform.OS === 'ios' && + enableModalDismissGesture; + const gestureEnabled = + Platform.OS === 'ios' + ? !isModalPresentation || modalDismissGestureEnabled + : false; + let options: NavigationOptions; + if (!enableNativeRoutes) { + let cardStyleInterpolator; + if (isModalPresentation) { + cardStyleInterpolator = + Platform.OS === 'android' + ? CardStyleInterpolators.forBottomSheetAndroid + : CardStyleInterpolators.forVerticalIOS; + } + options = { + animation: isFirstScreen ? 'none' : 'default', + animationEnabled: !isFirstScreen, + cardStyleInterpolator, + gestureEnabled, + presentation: isModalPresentation + ? NavigatorService.ID_MODAL + : NavigatorService.ID_CARD, + } as NavigationOptions; + } else { + const presentation = getStackPresentation( + isModalPresentation, + modalDismissGestureEnabled, + ); + let animation: NativeStackNavigationOptions['animation'] = + Platform.OS === 'ios' ? 'default' : 'slide_from_right'; + if (isFirstScreen) { + animation = 'none'; + } else if (isModalPresentation && Platform.OS === 'android') { + animation = 'slide_from_bottom'; + } + options = { + animation, + gestureEnabled, + presentation, + } as NavigationOptions; } return ( getId(p)} initialParams={initialParams} name={id} - options={ - { - animation, - animationEnabled: !isFirstScreen, - cardStyleInterpolator, - gestureEnabled, - presentation: needsSubStack - ? NavigatorService.ID_MODAL - : NavigatorService.ID_CARD, - } as StackNavigationOptions - } + options={options} /> ); } @@ -223,7 +269,7 @@ export default function HvNavigator(props: Props) { `No navigator found for type '${type}'`, ); }, - [getId, routeComponent], + [enableModalDismissGesture, enableNativeRoutes, getId, routeComponent], ); /** diff --git a/src/elements/hv-navigator/types.ts b/src/elements/hv-navigator/types.ts index d4b7dc5fd..adc0e27cc 100644 --- a/src/elements/hv-navigator/types.ts +++ b/src/elements/hv-navigator/types.ts @@ -40,7 +40,7 @@ export type NavigatorProps = { * Options used for a stack navigator's screenOptions */ export type StackScreenOptions = { - headerMode: 'float' | 'screen' | undefined; + headerMode?: 'float' | 'screen'; headerShown: boolean; title: string | undefined; }; diff --git a/src/elements/hv-route/hv-route.tsx b/src/elements/hv-route/hv-route.tsx index 6bc76cfec..9992ad40b 100644 --- a/src/elements/hv-route/hv-route.tsx +++ b/src/elements/hv-route/hv-route.tsx @@ -15,6 +15,10 @@ import type { NavigationProps, ScreenState, } from 'hyperview/src/types'; +import { + NavigationContainerRefContext, + useIsFocused, +} from '@react-navigation/native'; import React, { PureComponent, useCallback, @@ -26,7 +30,6 @@ import React, { import HvNavigator from 'hyperview/src/elements/hv-navigator'; import HvScreen from 'hyperview/src/elements/hv-screen'; import { LOCAL_NAME } from 'hyperview/src/types'; -import { NavigationContainerRefContext } from '@react-navigation/native'; import { useElementCache } from 'hyperview/src/contexts/element-cache'; import { useHyperview } from 'hyperview/src/contexts/hyperview'; @@ -175,6 +178,27 @@ const getNestedNavigator = ( return undefined; }; +const BackBehaviorGuard = (props: Types.BackBehaviorGuardProps) => { + const isFocused = useIsFocused(); + NavigatorService.useCompatiblePreventRemove( + isFocused && props.preventRemove, + props.onPreventRemove, + ); + return <>{props.children}; +}; + +const getVisibleBackBehaviors = (elements: Element[]): Element[] => + elements.filter(el => { + let node: Node | null = el; + while (node && node.nodeType !== 9) { + if ((node as Element).getAttribute?.('hide') === 'true') { + return false; + } + node = (node as Element).parentNode; + } + return true; + }); + /** * Functional component wrapper around HvRouteInner * NOTE: The reason for this approach is to allow accessing @@ -269,38 +293,47 @@ function HvRouteFC(props: Types.Props) { }); }, [entrypointUrl, getDoc, id, nav, onRouteFocus, props.route, setDoc]); - const handleBeforeRemove = useCallback( - (event: { preventDefault: () => void }) => { - // Check for elements registered to interrupt back action via a trigger of BACK - const elements: Element[] = (get && get()) || []; - // Filter to only elements that are not hidden (or whose ancestors are not hidden). - const visibleElements: Element[] = elements.filter(el => { - let node: Node | null = el; - while (node && node.nodeType !== 9) { - if ((node as Element).getAttribute?.('hide') === 'true') { - return false; - } - node = (node as Element).parentNode; - } - return true; - }); + // Check for elements registered to interrupt back action via a trigger of BACK + const elements: Element[] = (get && get()) || []; + // Filter to only elements that are not hidden (or whose ancestors are not hidden). + const visibleElements = getVisibleBackBehaviors(elements); + const handlePreventRemove = useCallback( + ({ + data: { action }, + }: Parameters[0]) => { + const currentVisibleElements = getVisibleBackBehaviors( + (get && get()) || [], + ); if ( - visibleElements.length > 0 && - onUpdateRef.current && - isFocusedRef.current + currentVisibleElements.length > 0 && + isFocusedRef.current && + onUpdateRef.current ) { - event.preventDefault(); - visibleElements.forEach(behaviorElement => { + currentVisibleElements.forEach(behaviorElement => { const href = behaviorElement.getAttribute('href'); - const action = behaviorElement.getAttribute('action'); - onUpdateRef.current?.(href, action, behaviorElement, { + const behaviorAction = behaviorElement.getAttribute('action'); + onUpdateRef.current?.(href, behaviorAction, behaviorElement, { behaviorElement, showIndicatorId: behaviorElement.getAttribute('show-during-load'), targetId: behaviorElement.getAttribute('target'), }); }); - } else { - // Perform cleanup of the associated route (retrieved from parent document state) + return; + } + NavigatorService.removeStackRoute( + getDoc?.(), + routeUrl, + entrypointUrl, + setDoc, + ); + nav.dispatch(action); + }, + [entrypointUrl, get, getDoc, nav, routeUrl, setDoc], + ); + + const handleBeforeRemove = useCallback( + (event: ListenerEvent) => { + if (!event.defaultPrevented) { NavigatorService.removeStackRoute( getDoc?.(), routeUrl, @@ -309,7 +342,7 @@ function HvRouteFC(props: Types.Props) { ); } }, - [entrypointUrl, get, getDoc, routeUrl, setDoc], + [entrypointUrl, getDoc, routeUrl, setDoc], ); const handleState = useCallback( @@ -380,21 +413,33 @@ function HvRouteFC(props: Types.Props) { onUpdateCallbacks, reload, setScreenState, - }) => ( - - )} + }) => { + const routeContent = ( + + ); + return props.route ? ( + 0} + > + {routeContent} + + ) : ( + routeContent + ); + }} ); diff --git a/src/elements/hv-route/types.ts b/src/elements/hv-route/types.ts index 9bf496362..5606c043e 100644 --- a/src/elements/hv-route/types.ts +++ b/src/elements/hv-route/types.ts @@ -1,4 +1,5 @@ import * as Components from 'hyperview/src/services/components'; +import type { ComponentType, ReactNode } from 'react'; import type { ElementErrorComponentProps, HvComponentOnUpdate, @@ -7,7 +8,7 @@ import type { RouteProps, ScreenState, } from 'hyperview/src/types'; -import type { ComponentType } from 'react'; +import type { NavigationAction } from '@react-navigation/native'; /** * The props used by inner components of hv-route @@ -33,3 +34,12 @@ export type Props = { navigation?: NavigationProps; route?: RouteProps; }; + +/** + * The props used to guard route removal when back behaviors are registered + */ +export type BackBehaviorGuardProps = Readonly<{ + children: ReactNode; + onPreventRemove: (options: { data: { action: NavigationAction } }) => void; + preventRemove: boolean; +}>; diff --git a/src/hyperview.test.tsx b/src/hyperview.test.tsx index 73ab993b1..aa41079a0 100644 --- a/src/hyperview.test.tsx +++ b/src/hyperview.test.tsx @@ -1,6 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react-native'; import Hyperview from './hyperview'; import { NavigationContainer } from '@react-navigation/native'; +import { Platform } from 'react-native'; import React from 'react'; import { fetchFactory } from 'hyperview/test/helpers/fetch'; @@ -12,6 +13,40 @@ describe('Hyperview', () => { const formatDate = jest.fn(); + describe('enableNativeRoutes', () => { + test.each([ + [undefined, false], + [false, false], + [true, true], + ])('resolves %s to %s on native platforms', (value, expected) => { + const hyperview = new Hyperview({ + enableNativeRoutes: value, + entrypointUrl: 'http://myapp.com', + fetch: jest.fn(), + formatDate, + }); + + expect(hyperview.render().props.value.enableNativeRoutes).toBe(expected); + }); + + it('is always disabled on web', () => { + const platform = Platform.OS; + Object.defineProperty(Platform, 'OS', { value: 'web' }); + const hyperview = new Hyperview({ + enableNativeRoutes: true, + entrypointUrl: 'http://myapp.com', + fetch: jest.fn(), + formatDate, + }); + + try { + expect(hyperview.render().props.value.enableNativeRoutes).toBe(false); + } finally { + Object.defineProperty(Platform, 'OS', { value: platform }); + } + }); + }); + /** * Test for a view which has two behaviors which originally caused a bug. * Uses the `behaviorView` test content. diff --git a/src/hyperview.tsx b/src/hyperview.tsx index 82b8562aa..7a8fa644c 100644 --- a/src/hyperview.tsx +++ b/src/hyperview.tsx @@ -32,11 +32,11 @@ import { UpdateMissingBehaviorError, UpdateMissingTargetError, } from 'hyperview/src/errors'; +import { Linking, Platform } from 'react-native'; import React, { PureComponent } from 'react'; import { ElementCacheProvider } from 'hyperview/src/contexts/element-cache'; import HvRoute from 'hyperview/src/elements/hv-route'; import { HyperviewContext } from 'hyperview/src/contexts/hyperview'; -import { Linking } from 'react-native'; /** * Provides routing to the correct path based on the state passed in @@ -587,6 +587,9 @@ export default class Hyperview extends PureComponent { value={{ componentRegistry: this.componentRegistry, elementErrorComponent: this.props.elementErrorComponent, + enableModalDismissGesture: this.props.enableModalDismissGesture, + enableNativeRoutes: + Platform.OS !== 'web' && this.props.enableNativeRoutes === true, entrypointUrl: this.props.entrypointUrl, errorScreen: this.props.errorScreen, experimentalFeatures: this.props.experimentalFeatures, diff --git a/src/services/navigator/helpers.test.ts b/src/services/navigator/helpers.test.ts index 7b90d5e2b..5d6e13b12 100644 --- a/src/services/navigator/helpers.test.ts +++ b/src/services/navigator/helpers.test.ts @@ -428,12 +428,52 @@ describe('expandNestedNavigate', () => { expect(isReactNavigation7).toBe(false); }); - it('preserves bare nested navigation under React Navigation 6', () => { + it('leaves a bare nested navigate unchanged under React Navigation 6', () => { const state = StateSource as NavigationState; const action = CommonActions.navigate('performance_2'); expect(expandNestedNavigate(state, action)).toBe(action); }); + + it('leaves a navigate to a route of the current navigator untouched', () => { + const state = StateSource as NavigationState; + const action = CommonActions.navigate(state.routeNames[0]); + + expect(expandNestedNavigate(state, action)).toBe(action); + }); + + it('leaves the action bare when expanding would unwind the stack', () => { + const state = ({ + index: 1, + key: 'stack-root', + routeNames: ['tabs-route', 'card'], + routes: [ + { + key: 'tabs-route-key', + name: 'tabs-route', + state: { + index: 0, + key: 'tabs', + routeNames: ['shifts-route', 'messages-route'], + routes: [ + { key: 'shifts-key', name: 'shifts-route' }, + { key: 'messages-key', name: 'messages-route' }, + ], + stale: false, + type: 'tab', + }, + }, + { key: 'card-key', name: 'card' }, + ], + stale: false, + type: 'stack', + } as unknown) as NavigationState; + const action = CommonActions.navigate('messages-route'); + + // React Navigation 6 pops to an existing route regardless of `pop`, so + // expanding here would discard `card`. + expect(expandNestedNavigate(state, action)).toBe(action); + }); }); // TODO getNavigatorAndPath @@ -674,7 +714,10 @@ describe('buildRequest', () => { const params = { url: 'url', // New actions are always modal - ...(action === NAV_ACTIONS.NEW && { isModal: true }), + ...(action === NAV_ACTIONS.NEW && { + isModal: true, + needsSubStack: true, + }), }; it('should ignore object without params', () => { expect(buildRequest(undefined, action, undefined)).toEqual([ @@ -706,10 +749,47 @@ describe('buildRequest', () => { ]); }); }); + describe('new', () => { + it('should present a new modal outside the sub-stack it was opened from', () => { + const root = ({ + getParent: () => undefined, + getState: () => ({ + index: 1, + routes: [{ name: 'home' }, { name: `${ID_MODAL}-first` }], + }), + } as unknown) as NonNullable; + const modalSubStack = ({ + getParent: () => root, + getState: () => ({ index: 0, routes: [{ name: 'modal-screen' }] }), + } as unknown) as NonNullable; + + const [, navigation] = buildRequest(modalSubStack, NAV_ACTIONS.NEW, { + url: 'url', + }); + + expect(navigation).toBe(root); + }); + + it('should present a new modal on the current navigator outside a modal', () => { + const root = ({ + getParent: () => undefined, + getState: () => ({ + index: 1, + routes: [{ name: 'home' }, { name: ID_CARD }], + }), + } as unknown) as NonNullable; + + const [, navigation] = buildRequest(root, NAV_ACTIONS.NEW, { + url: 'url', + }); + + expect(navigation).toBe(root); + }); + }); + // TODO buildRequest tests // - invalid navigator // - invalid path - // - success }); describe('mergeDocuments', () => { diff --git a/src/services/navigator/helpers.ts b/src/services/navigator/helpers.ts index 17567923b..cd7b7b269 100644 --- a/src/services/navigator/helpers.ts +++ b/src/services/navigator/helpers.ts @@ -30,6 +30,10 @@ export const isReactNavigation7 = navigationWithLocale.useLocale !== undefined; export const useCompatibleLocale = navigationWithLocale.useLocale ?? (() => ({ direction: 'ltr' as const })); +export const useCompatiblePreventRemove = + navigationWithLocale.usePreventRemove ?? + navigationWithLocale.UNSTABLE_usePreventRemove; + /** * Card and modal routes are not defined in the document */ @@ -184,6 +188,26 @@ export const findNavigatorKeyForRoute = ( ); }; +/** + * Determine whether a navigator is still present in the navigation state + */ +export const isNavigatorMounted = ( + state: NavigationState | undefined, + navigatorKey: string | undefined, +): boolean => { + if (!state?.routes || !navigatorKey) { + return false; + } + + if (state.key === navigatorKey) { + return true; + } + + return state.routes.some(route => + isNavigatorMounted(route.state as NavigationState, navigatorKey), + ); +}; + /** * Convert a Hyperview-style bare nested navigate into explicit RN7 nested params * Uses mounted navigation state first, then an optional HXML path fallback @@ -213,6 +237,12 @@ export const expandNestedNavigate = ( return action; } + // Retargeting a parent below the top of a stack must preserve the screens + // above it while delivering the action to the nested navigator. + const parentIndex = state.routes.findIndex(route => route.name === parentId); + const unwinds = + state.type === 'stack' && parentIndex > -1 && parentIndex < state.index; + const params = path.reduceRight( (childParams, screen) => ({ params: childParams, @@ -227,7 +257,7 @@ export const expandNestedNavigate = ( ...payload, name: parentId, params, - pop: payload.pop ?? true, + pop: unwinds ? false : payload.pop ?? true, }, } as Action; }; @@ -467,6 +497,29 @@ const buildCloseRequest = ( return buildCloseRequest(parent, routeParams); }; +/** + * Resolve the navigator which presents a new modal, skipping the sub-stack of + * the modal it was opened from + */ +const getModalHostNavigator = ( + navigation?: NavigationProps, +): NavigationProps | undefined => { + let current = navigation; + while (current) { + const parent = current.getParent(); + if (!parent) { + return current; + } + const parentState = parent.getState(); + const focused = parentState.routes[parentState.index]; + if (!focused || !isModalRouteName(focused.name)) { + return current; + } + current = parent; + } + return navigation; +}; + /** * Build the request structure including finding the navigation, * building params, and determining screen id @@ -499,13 +552,18 @@ export const buildRequest = ( const [navigation, path] = getNavigatorAndPath( routeParams.targetId || '', - nav, + navAction === NAV_ACTIONS.NEW && !routeParams.targetId + ? getModalHostNavigator(nav) + : nav, ); const cleanedParams: RouteParams = { ...routeParams, // New actions are always modal - ...(action === NAV_ACTIONS.NEW && { isModal: true }), + ...(action === NAV_ACTIONS.NEW && { + isModal: true, + needsSubStack: true, + }), }; if (cleanedParams.url && isUrlFragment(cleanedParams.url)) { diff --git a/src/services/navigator/index.ts b/src/services/navigator/index.ts index 48a2e0fc6..8bce00d93 100644 --- a/src/services/navigator/index.ts +++ b/src/services/navigator/index.ts @@ -19,6 +19,7 @@ export { setSelected, updateRouteUrlFromState, useCompatibleLocale, + useCompatiblePreventRemove, } from './helpers'; export { ANCHOR_ID_SEPARATOR, ID_CARD, ID_MODAL, KEY_MODAL } from './types'; export type { Locale } from './types'; diff --git a/src/services/navigator/navigator.test.ts b/src/services/navigator/navigator.test.ts index 147c8552b..b87658355 100644 --- a/src/services/navigator/navigator.test.ts +++ b/src/services/navigator/navigator.test.ts @@ -103,6 +103,46 @@ describe('Navigator.routeBackRequest', () => { target: 'tab-root', }); }); + + it('removes a background source route at index 0 instead of popping the focused route', () => { + const selectionRoute = { + key: 'selection-key', + name: 'card', + }; + const confirmationRoute = { + key: 'confirmation-key', + name: 'modal', + }; + const state = { + index: 1, + key: 'stack-root', + routeNames: ['card', 'modal'], + routes: [selectionRoute, confirmationRoute], + stale: false, + type: 'stack', + } as NavigationState; + const navigation = createNavigation(state); + const navigator = new Navigator({ + entrypointUrl: 'https://example.com/index.xml', + navigation, + route: { + key: 'selection-key', + name: 'card', + } as RouteProps, + setElement: jest.fn(), + }); + + navigator.routeBackRequest(navigation, NAV_ACTIONS.BACK, 'selection-key'); + + expect(navigation.goBack).not.toHaveBeenCalled(); + expect(navigation.dispatch).toHaveBeenCalledWith({ + ...CommonActions.reset({ + ...state, + index: 0, + routes: [confirmationRoute], + }), + }); + }); }); describe('Navigator.sendRequest', () => { @@ -215,7 +255,7 @@ describe('Navigator.sendRequest', () => { expect(parentNavigation.goBack).not.toHaveBeenCalled(); }); - it('continues navigation from the parent after its route is removed', () => { + it('continues navigation from the root after its route is removed', () => { const parentState = { index: 0, key: 'stack-root', @@ -229,7 +269,19 @@ describe('Navigator.sendRequest', () => { stale: false, type: 'stack', } as NavigationState; - const parentNavigation = createNavigation(parentState); + const staleParentNavigation = createNavigation({ + index: 0, + key: 'stale-parent-stack', + routeNames: ['modal-screen'], + routes: [ + { + key: 'stale-parent-screen-key', + name: 'modal-screen', + }, + ], + stale: false, + type: 'stack', + } as NavigationState); const navigation = createNavigation({ index: 0, key: 'modal-stack', @@ -243,10 +295,15 @@ describe('Navigator.sendRequest', () => { stale: false, type: 'stack', } as NavigationState); - (navigation.getParent as jest.Mock).mockReturnValue(parentNavigation); - const rootNavigation = ({ + (navigation.isFocused as jest.Mock).mockReturnValue(false); + (navigation.getParent as jest.Mock).mockReturnValue(staleParentNavigation); + const rootNavigation = (Object.assign(createNavigation(parentState), { + getCurrentRoute: jest.fn(() => ({ + key: 'home-key', + name: 'home', + })), getRootState: jest.fn(() => parentState), - } as unknown) as NonNullable; + }) as unknown) as NonNullable; const navigator = new Navigator({ entrypointUrl: 'https://example.com/index.xml', navigation, @@ -263,12 +320,74 @@ describe('Navigator.sendRequest', () => { navigator.sendRequest(NAV_ACTIONS.PUSH, routeParams); - expect(parentNavigation.dispatch).toHaveBeenCalledWith( + expect(rootNavigation.dispatch).toHaveBeenCalledWith( StackActions.push('card', routeParams), ); + expect(staleParentNavigation.dispatch).not.toHaveBeenCalled(); expect(navigation.dispatch).not.toHaveBeenCalled(); }); + test.each([NAV_ACTIONS.BACK, NAV_ACTIONS.CLOSE])( + 'does not %s the revealed route after its source route is removed', + action => { + const revealedState = { + index: 1, + key: 'stack-root', + routeNames: ['home', 'card'], + routes: [ + { + key: 'home-key', + name: 'home', + }, + { + key: 'revealed-card-key', + name: 'card', + }, + ], + stale: false, + type: 'stack', + } as NavigationState; + const navigation = createNavigation({ + index: 0, + key: 'removed-stack', + routeNames: ['removed-screen'], + routes: [ + { + key: 'removed-screen-key', + name: 'removed-screen', + }, + ], + stale: false, + type: 'stack', + } as NavigationState); + (navigation.isFocused as jest.Mock).mockReturnValue(false); + const rootNavigation = (Object.assign(createNavigation(revealedState), { + getCurrentRoute: jest.fn(() => ({ + key: 'revealed-card-key', + name: 'card', + })), + getRootState: jest.fn(() => revealedState), + }) as unknown) as NonNullable; + const navigator = new Navigator({ + entrypointUrl: 'https://example.com/index.xml', + navigation, + rootNavigation, + route: { + key: 'removed-screen-key', + name: 'removed-screen', + } as RouteProps, + setElement: jest.fn(), + }); + + navigator.sendRequest(action); + + expect(rootNavigation.goBack).not.toHaveBeenCalled(); + expect(rootNavigation.dispatch).not.toHaveBeenCalled(); + expect(navigation.goBack).not.toHaveBeenCalled(); + expect(navigation.dispatch).not.toHaveBeenCalled(); + }, + ); + it('keeps navigation within a modal while its route is active', () => { const modalState = { index: 0, @@ -307,7 +426,9 @@ describe('Navigator.sendRequest', () => { ), } as unknown) as NonNullable; const navigation = createNavigation(modalState); - const parentNavigation = createNavigation(rootNavigation.getRootState()); + const parentNavigation = createNavigation( + rootNavigation.getRootState() as NavigationState, + ); (navigation.getParent as jest.Mock).mockReturnValue(parentNavigation); const navigator = new Navigator({ entrypointUrl: 'https://example.com/index.xml', @@ -330,6 +451,263 @@ describe('Navigator.sendRequest', () => { ); expect(parentNavigation.dispatch).not.toHaveBeenCalled(); }); + + it('keeps navigation within a modal when root state omits nested native state', () => { + const modalState = { + index: 0, + key: 'modal-stack', + routeNames: ['modal-screen'], + routes: [ + { + key: 'modal-screen-key', + name: 'modal-screen', + }, + ], + stale: false, + type: 'stack', + } as NavigationState; + const nativeRootState = { + index: 1, + key: 'stack-root', + routeNames: ['home', 'modal'], + routes: [ + { + key: 'home-key', + name: 'home', + }, + { + key: 'modal-key', + name: 'modal', + }, + ], + stale: false, + type: 'stack', + } as NavigationState; + const rootNavigation = (Object.assign(createNavigation(nativeRootState), { + getCurrentRoute: jest.fn(() => ({ + key: 'modal-key', + name: 'modal', + })), + getRootState: jest.fn(() => nativeRootState), + }) as unknown) as NonNullable; + const navigation = createNavigation(modalState); + (navigation.getParent as jest.Mock).mockReturnValue(rootNavigation); + const navigator = new Navigator({ + entrypointUrl: 'https://example.com/index.xml', + navigation, + rootNavigation, + route: { + key: 'modal-screen-key', + name: 'modal-screen', + } as RouteProps, + setElement: jest.fn(), + }); + const routeParams = { + url: 'https://example.com/destination.xml', + }; + + navigator.sendRequest(NAV_ACTIONS.PUSH, routeParams); + + expect(navigation.dispatch).toHaveBeenCalledWith( + StackActions.push('card', routeParams), + ); + expect(rootNavigation.dispatch).not.toHaveBeenCalled(); + + navigator.sendRequest(NAV_ACTIONS.CLOSE); + + expect(navigation.goBack).toHaveBeenCalled(); + expect(rootNavigation.goBack).not.toHaveBeenCalled(); + }); + + it('stacks a new modal from the root instead of reusing the presented one', () => { + const modalState = { + index: 0, + key: 'modal-stack', + routeNames: ['modal-screen'], + routes: [ + { + key: 'modal-screen-key', + name: 'modal-screen', + }, + ], + stale: false, + type: 'stack', + } as NavigationState; + const rootStateOmittingNested = { + index: 1, + key: 'stack-root', + routeNames: ['home', 'modal'], + routes: [ + { + key: 'home-key', + name: 'home', + }, + { + key: 'modal-key', + name: 'modal', + }, + ], + stale: false, + type: 'stack', + } as NavigationState; + const rootNavigation = (Object.assign( + createNavigation(rootStateOmittingNested), + { + getRootState: jest.fn(() => rootStateOmittingNested), + }, + ) as unknown) as NonNullable; + const navigation = createNavigation(modalState); + (navigation.isFocused as jest.Mock).mockReturnValue(false); + const navigator = new Navigator({ + entrypointUrl: 'https://example.com/index.xml', + navigation, + rootNavigation, + route: { + key: 'modal-screen-key', + name: 'modal-screen', + } as RouteProps, + setElement: jest.fn(), + }); + const routeParams = { + url: 'https://example.com/confirm.xml', + }; + + navigator.sendRequest(NAV_ACTIONS.NEW, routeParams); + + const params = { + ...routeParams, + isModal: true, + needsSubStack: true, + }; + expect(rootNavigation.dispatch).toHaveBeenCalledWith({ + ...CommonActions.navigate('modal', params), + payload: { + name: 'modal', + params, + pop: false, + }, + }); + expect(navigation.dispatch).not.toHaveBeenCalled(); + }); + + it('replaces a presented modal when a removed nested route opens a new modal', () => { + const revealedState = { + index: 0, + key: 'modal-stack', + routeNames: ['modal-screen'], + routes: [ + { + key: 'revealed-screen-key', + name: 'modal-screen', + }, + ], + stale: false, + type: 'stack', + } as NavigationState; + const rootStateOmittingNested = { + index: 1, + key: 'stack-root', + routeNames: ['home', 'modal'], + routes: [ + { + key: 'home-key', + name: 'home', + }, + { + key: 'modal-key', + name: 'modal', + }, + ], + stale: false, + type: 'stack', + } as NavigationState; + const rootNavigation = (Object.assign( + createNavigation(rootStateOmittingNested), + { + getRootState: jest.fn(() => rootStateOmittingNested), + }, + ) as unknown) as NonNullable; + const navigation = createNavigation(revealedState); + (navigation.isFocused as jest.Mock).mockReturnValue(false); + const navigator = new Navigator({ + entrypointUrl: 'https://example.com/index.xml', + navigation, + rootNavigation, + route: { + key: 'removed-screen-key', + name: 'card', + } as RouteProps, + setElement: jest.fn(), + }); + const routeParams = { + url: 'https://example.com/destination.xml', + }; + + navigator.sendRequest(NAV_ACTIONS.NEW, routeParams); + + const params = { + ...routeParams, + isModal: true, + needsSubStack: true, + }; + expect(rootNavigation.dispatch).toHaveBeenCalledWith({ + ...StackActions.replace('modal', params), + target: 'stack-root', + }); + expect(navigation.dispatch).not.toHaveBeenCalled(); + }); + + it('unwinds to an existing route when navigating within the root stack', () => { + const unwindState = { + index: 2, + key: 'stack-root', + routeNames: ['step-1', 'step-2', 'step-3'], + routes: [ + { + key: 'step-1-key', + name: 'step-1', + }, + { + key: 'step-2-key', + name: 'step-2', + }, + { + key: 'step-3-key', + name: 'step-3', + }, + ], + stale: false, + type: 'stack', + } as NavigationState; + const navigation = Object.assign(createNavigation(unwindState), { + getRootState: jest.fn(() => unwindState), + }); + const rootNavigation = (navigation as unknown) as NonNullable< + Types.Props['rootNavigation'] + >; + const navigator = new Navigator({ + entrypointUrl: 'https://example.com/index.xml', + navigation, + rootNavigation, + route: { + key: 'step-3-key', + name: 'step-3', + } as RouteProps, + setElement: jest.fn(), + }); + const routeParams = { + targetId: 'step-1', + url: 'https://example.com/step-1.xml', + }; + + navigator.sendRequest(NAV_ACTIONS.NAVIGATE, routeParams); + + expect(navigation.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ pop: true }), + }), + ); + }); }); describe('Navigator.updateRouteUrl', () => { diff --git a/src/services/navigator/navigator.ts b/src/services/navigator/navigator.ts index 0e640d8aa..b61ed22d3 100644 --- a/src/services/navigator/navigator.ts +++ b/src/services/navigator/navigator.ts @@ -43,7 +43,7 @@ export class Navigator implements NavigationProvider { if ( action === NAV_ACTIONS.BACK && - sourceIndex && + sourceIndex >= 0 && sourceIndex < state.index ) { // Back request from a non-focused route @@ -82,29 +82,60 @@ export class Navigator implements NavigationProvider { } /** - * Resolve the navigator which owns the route. + * Determine if the route is absent from the navigation state */ - getActiveNavigation = (): NavigationProps | undefined => { + isRouteUnowned = (): boolean => { const { navigation, rootNavigation, route } = this.props; + if (navigation?.isFocused()) { + return false; + } const rootState = rootNavigation?.getRootState(); + if (!route?.key || !rootState) { + return false; + } + const ownerKey = Helpers.findNavigatorKeyForRoute(rootState, route.key); + return typeof ownerKey !== 'string'; + }; + /** + * Resolve the navigator which owns the route. + */ + getActiveNavigation = (action: NavAction): NavigationProps | undefined => { + const { navigation, rootNavigation } = this.props; + if (!this.isRouteUnowned()) { + return navigation; + } + if (action === NAV_ACTIONS.BACK) { + return undefined; + } + // A detached route can have a detached parent, and dispatching there goes + // nowhere, so only continue from the parent while it is still mounted. + const parent = navigation?.getParent(); if ( - !navigation || - !route?.key || - !rootState || - Helpers.findNavigatorKeyForRoute(rootState, route.key) + Helpers.isNavigatorMounted( + rootNavigation?.getRootState(), + parent?.getState().key, + ) ) { - return navigation; + return parent; } - - return navigation.getParent() || navigation; + return rootNavigation as NavigationProps; }; /** * Prepare and send the request */ sendRequest = (action: NavAction, routeParams?: RouteParams) => { - const activeNavigation = this.getActiveNavigation(); + const routeIsUnowned = this.isRouteUnowned(); + const routeKey = this.props.route?.key; + const routeWasRemoved = + routeKey && + this.props.navigation && + typeof Helpers.findNavigatorKeyForRoute( + this.props.navigation.getState(), + routeKey, + ) !== 'string'; + const activeNavigation = this.getActiveNavigation(action); const [navAction, navigation, routeId, params] = Helpers.buildRequest( activeNavigation, action, @@ -132,12 +163,28 @@ export class Navigator implements NavigationProvider { case NAV_ACTIONS.NAVIGATE: case NAV_ACTIONS.NEW: if (routeId) { + const navigationState = navigation.getState(); + const currentRoute = navigationState.routes[navigationState.index]; + if ( + navAction === NAV_ACTIONS.NEW && + routeIsUnowned && + routeWasRemoved && + Helpers.isModalRouteName(currentRoute?.name) + ) { + navigation.dispatch({ + ...StackActions.replace(routeId, params), + target: navigationState.key, + }); + break; + } + // Unwind only when the route belongs to the target navigator + const pop = !routeIsUnowned; navigation.dispatch({ ...CommonActions.navigate(routeId, params), payload: { name: routeId, params, - pop: true, + pop, }, }); } diff --git a/src/services/navigator/types.ts b/src/services/navigator/types.ts index 927b69741..993fc55f0 100644 --- a/src/services/navigator/types.ts +++ b/src/services/navigator/types.ts @@ -52,6 +52,18 @@ export type Locale = { direction: 'ltr' | 'rtl'; }; +export type UsePreventRemove = ( + preventRemove: boolean, + callback: (options: { + data: { action: ReactNavigation.NavigationAction }; + }) => void, +) => void; + +type ReactNavigation6PreventRemove = { + [key in 'UNSTABLE_usePreventRemove']?: UsePreventRemove; +}; + export type ReactNavigationWithLocale = typeof ReactNavigation & { useLocale?: () => Locale; -}; + usePreventRemove?: UsePreventRemove; +} & ReactNavigation6PreventRemove; diff --git a/src/types.ts b/src/types.ts index 6611dff27..ba94860d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -258,7 +258,7 @@ export type UpdateAction = typeof UPDATE_ACTIONS[keyof typeof UPDATE_ACTIONS]; export type ListenerEvent = { data: { state: NavigationState | undefined } | undefined; - preventDefault: () => void; + defaultPrevented?: boolean; }; type ListenerCallback = (event: ListenerEvent) => void; @@ -380,6 +380,8 @@ export type Props = { behaviors?: HvBehavior[]; components?: HvComponent[]; elementErrorComponent?: ComponentType; + enableModalDismissGesture?: boolean; + enableNativeRoutes?: boolean; entrypointUrl: string; errorScreen?: ComponentType; experimentalFeatures?: ExperimentalFeatures; diff --git a/test/integration/navigation.test.tsx b/test/integration/navigation.test.tsx index d63e80b9a..772e9f927 100644 --- a/test/integration/navigation.test.tsx +++ b/test/integration/navigation.test.tsx @@ -1,8 +1,10 @@ import { + CommonActions, NavigationContainer, createNavigationContainerRef, } from '@react-navigation/native'; import { + act, fireEvent, render, screen, @@ -13,16 +15,207 @@ import { Pressable } from 'react-native'; import React from 'react'; import { fetchFactory } from 'hyperview/test/helpers/fetch'; -describe('Hyperview navigation sequences', () => { - const formatDate = jest.fn(); +describe.each([false, true])( + 'Hyperview navigation sequences (native routes: %s)', + enableNativeRoutes => { + const formatDate = jest.fn(); - test.each(['new', 'push'])( - 'runs adjacent close and %s load behaviors as one transition', - async action => { + test('blocks route removal while a visible back behavior is registered', async () => { + const navigationRef = createNavigationContainerRef(); const mockFetch = fetchFactory([ [ - 'http://myapp.com/navigation-sequence-document', + 'http://myapp.com/back-behavior-document', ` + + + + + + `, + ], + [ + 'http://myapp.com/back-behavior-home', + ` + + + + + Open guarded screen + + Home + + + + `, + ], + [ + 'http://myapp.com/back-behavior-guarded', + ` + + + + + + Back behavior + + Guarded screen + + + + `, + ], + ]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('open-guarded')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-guarded')); + await waitFor(() => { + expect(screen.getByTestId('guarded')).toBeOnTheScreen(); + }); + + act(() => navigationRef.goBack()); + + await waitFor(() => { + expect(screen.getByTestId('guarded')).toBeOnTheScreen(); + expect(screen.queryByTestId('back-behavior')).not.toBeOnTheScreen(); + }); + + act(() => navigationRef.goBack()); + + await waitFor(() => { + expect(screen.getByTestId('home')).toBeOnTheScreen(); + expect(screen.queryByTestId('guarded')).not.toBeOnTheScreen(); + }); + }); + + test('continues a back removal through a registered close behavior', async () => { + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/back-close-document', + ` + + + + + + `, + ], + [ + 'http://myapp.com/back-close-home', + ` + + + + + Open guarded screen + + Home + + + + `, + ], + [ + 'http://myapp.com/back-close-guarded', + ` + + + + + + + + + + + + Close + + + + + `, + ], + ]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('open-guarded')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-guarded')); + await waitFor(() => { + expect(screen.getByTestId('close')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('close')); + + await waitFor(() => { + expect(screen.getByTestId('home')).toBeOnTheScreen(); + expect(screen.queryByTestId('close')).not.toBeOnTheScreen(); + }); + + fireEvent.press(screen.getByTestId('open-guarded')); + await waitFor(() => { + expect(screen.getByTestId('close')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('close')); + await waitFor(() => { + expect(screen.getByTestId('home')).toBeOnTheScreen(); + expect(screen.queryByTestId('close')).not.toBeOnTheScreen(); + }); + }); + + test.each(['new', 'push'])( + 'runs adjacent close and %s load behaviors as one transition', + async action => { + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/navigation-sequence-document', + ` { `, - ], - [ - 'http://myapp.com/navigation-sequence-home', - ` + ], + [ + 'http://myapp.com/navigation-sequence-home', + ` @@ -50,10 +243,10 @@ describe('Hyperview navigation sequences', () => { `, - ], - [ - 'http://myapp.com/navigation-sequence-proxy', - ` + ], + [ + 'http://myapp.com/navigation-sequence-proxy', + ` @@ -69,10 +262,10 @@ describe('Hyperview navigation sequences', () => { `, - ], - [ - 'http://myapp.com/navigation-sequence-destination', - ` + ], + [ + 'http://myapp.com/navigation-sequence-destination', + ` @@ -81,44 +274,46 @@ describe('Hyperview navigation sequences', () => { `, - ], - ]); + ], + ]); - render( - - - , - ); + render( + + + , + ); - await waitFor( - () => { - expect(screen.getByTestId('destination')).toBeOnTheScreen(); - expect(screen.queryByTestId('proxy')).not.toBeOnTheScreen(); - }, - { timeout: 2000 }, - ); - }, - ); + await waitFor( + () => { + expect(screen.getByTestId('destination')).toBeOnTheScreen(); + expect(screen.queryByTestId('proxy')).not.toBeOnTheScreen(); + }, + { timeout: 2000 }, + ); + }, + ); - test('runs adjacent close and push press behaviors as one transition', async () => { - const mockFetch = fetchFactory([ - [ - 'http://myapp.com/community-document', - ` + test('runs adjacent close and push press behaviors as one transition', async () => { + const navigationRef = createNavigationContainerRef(); + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/community-document', + ` `, - ], - [ - 'http://myapp.com/community-home', - ` + ], + [ + 'http://myapp.com/community-home', + ` @@ -133,10 +328,10 @@ describe('Hyperview navigation sequences', () => { `, - ], - [ - 'http://myapp.com/community-intro', - ` + ], + [ + 'http://myapp.com/community-intro', + ` @@ -150,10 +345,10 @@ describe('Hyperview navigation sequences', () => { `, - ], - [ - 'http://myapp.com/community-form', - ` + ], + [ + 'http://myapp.com/community-form', + ` @@ -162,532 +357,1423 @@ describe('Hyperview navigation sequences', () => { `, - ], - ]); + ], + ]); - render( - - - , - ); + render( + + + , + ); - await waitFor(() => { - expect(screen.getByTestId('get-started')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('get-started')); + await waitFor(() => { + expect(screen.getByTestId('get-started')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('get-started')); - await waitFor(() => { - expect(screen.getByTestId('community-form')).toBeOnTheScreen(); - expect(screen.queryByTestId('intro')).not.toBeOnTheScreen(); + await waitFor(() => { + expect(screen.getByTestId('community-form')).toBeOnTheScreen(); + expect(screen.queryByTestId('intro')).not.toBeOnTheScreen(); + }); + const rootState = navigationRef.getRootState(); + expect(rootState.routes.map(route => route.name)).toEqual([ + 'home', + 'card', + ]); + expect(rootState.index).toBe(1); }); - }); - test('closes a named route marked as a modal', async () => { - const mockFetch = fetchFactory([ - [ - 'http://myapp.com/named-modal-document', - ` + test('runs adjacent back and new load behaviors after a modal push as one transition', async () => { + const navigationRef = createNavigationContainerRef(); + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/shift-transfer-document', + ` - - + `, - ], - [ - 'http://myapp.com/named-modal-home', - ` + ], + [ + 'http://myapp.com/shift-transfer-home', + ` - Home + + Open connections + `, - ], - [ - 'http://myapp.com/named-modal-welcome', - ` + ], + [ + 'http://myapp.com/shift-transfer-connections', + ` - - Close welcome + + Open confirmation + + Connections + + + + `, + ], + [ + 'http://myapp.com/shift-transfer-confirmation', + ` + + + + + + + Confirm + + Confirmation `, - ], - ]); + ], + [ + 'http://myapp.com/shift-transfer-success', + ` + + + + + + `, + ], + [ + 'http://myapp.com/shift-transfer-destination', + ` + + + + Shift details + + + + `, + ], + ]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('open-connections')).toBeOnTheScreen(); + }); + if (enableNativeRoutes) { + act(() => + navigationRef.dispatch( + CommonActions.navigate('modal', { + url: 'http://myapp.com/shift-transfer-connections', + }), + ), + ); + } else { + fireEvent.press(screen.getByTestId('open-connections')); + } + await waitFor(() => { + expect(screen.getByTestId('connections')).toBeOnTheScreen(); + }); + if (enableNativeRoutes) { + const modalParams = navigationRef.getRootState().routes[1]?.params as + | { needsSubStack?: boolean } + | undefined; + expect(modalParams?.needsSubStack).toBe(true); + } + fireEvent.press(screen.getByTestId('open-confirmation')); + await waitFor(() => { + expect(screen.getByTestId('confirm-transfer')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('confirm-transfer')); + + await waitFor(() => { + expect( + screen.getByTestId('shift-transfer-destination'), + ).toBeOnTheScreen(); + expect(screen.queryByTestId('confirmation')).not.toBeOnTheScreen(); + }); + const rootState = navigationRef.getRootState(); + expect(rootState.routes.map(route => route.name)).toEqual([ + 'home', + 'modal', + ]); + expect(rootState.index).toBe(1); + }); + + test.each(['back', 'close'])( + 'reloads the underlying route while a modal performs %s', + async action => { + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/event-reload-document', + ` + + + + + + `, + ], + [ + 'http://myapp.com/event-reload-home', + ` + + + + + Open details + + Home + + + + `, + ], + [ + 'http://myapp.com/event-reload-details', + ` + + + + + + Details + + + + `, + ], + [ + 'http://myapp.com/event-reload-modal', + ` + + + + + + Modal + + + + `, + ], + [ + 'http://myapp.com/event-reload-refreshed', + ` + + + + Refreshed details + + + + `, + ], + ]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('open-details')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-details')); + + await waitFor(() => { + expect(screen.getByTestId('refreshed-details')).toBeOnTheScreen(); + expect(screen.queryByTestId('modal')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('home')).not.toBeOnTheScreen(); + }); + }, + ); + + test('closes a named route marked as a modal', async () => { + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/named-modal-document', + ` + + + + + + + `, + ], + [ + 'http://myapp.com/named-modal-home', + ` + + + + Home + + + + `, + ], + [ + 'http://myapp.com/named-modal-welcome', + ` + + + + + Close welcome + + + + + `, + ], + ]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('close-welcome')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('close-welcome')); + + await waitFor(() => { + expect(screen.getByTestId('home')).toBeOnTheScreen(); + expect(screen.queryByTestId('close-welcome')).not.toBeOnTheScreen(); + }); + }); + + test('retires a welcome modal when navigating into a tab route', async () => { + // Mirrors backend/templates/worker/app/base.xml: the tabs sit at the + // bottom of the root stack and a welcome modal is stacked above them, + // so navigating to a tab route retargets a parent below the top. On + // device the modal stayed up, covering the tab bar, and the run + // stranded looking for a tab it could no longer reach. + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/welcome-tabs-document', + ` + + + + + + + + + + + + `, + ], + [ + 'http://myapp.com/welcome-tabs-welcome', + ` + + + + Congrats + + Book your first shift + + + + + `, + ], + [ + 'http://myapp.com/welcome-tabs-shifts', + ` + + + + Open shifts + + Messages + + + + + `, + ], + [ + 'http://myapp.com/welcome-tabs-messages', + ` + + + + Messages + + + + `, + ], + ]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('book-first')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('book-first')); + + await waitFor(() => { + expect(screen.getByTestId('shifts')).toBeOnTheScreen(); + }); + // The device symptom: the shifts screen rendered underneath a modal that + // was never dismissed, which is why the tab bar had gone missing. + expect(screen.queryByTestId('welcome')).not.toBeOnTheScreen(); + + fireEvent.press(screen.getByTestId('go-messages')); + + await waitFor(() => { + expect(screen.getByTestId('messages')).toBeOnTheScreen(); + }); + expect(screen.queryByTestId('welcome')).not.toBeOnTheScreen(); + }); + + test('closes an entire flow containing nested modals', async () => { + const navigationRef = createNavigationContainerRef(); + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/nested-modal-document', + ` + + + + + + `, + ], + [ + 'http://myapp.com/nested-modal-home', + ` + + + + + Open details + + Home + + + + `, + ], + [ + 'http://myapp.com/nested-modal-details', + ` + + + + + Open first modal + + Details + + + + `, + ], + [ + 'http://myapp.com/nested-modal-first', + ` + + + + + + Open second modal + + + + + `, + ], + [ + 'http://myapp.com/nested-modal-second', + ` + + + + + + Open third modal + + + + + `, + ], + [ + 'http://myapp.com/nested-modal-third', + ` + + + + + + + Close modal flow + + + + + `, + ], + ]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('open-details')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-details')); + await waitFor(() => { + expect(screen.getByTestId('open-first-modal')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-first-modal')); + await waitFor(() => { + expect(screen.getByTestId('open-second-modal')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-second-modal')); + await waitFor(() => { + expect(screen.getByTestId('open-third-modal')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-third-modal')); + await waitFor(() => { + expect(screen.getByTestId('close-modal-flow')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('close-modal-flow')); + + // Each modal closes itself, so the route which presented the first modal + // has to survive the cascade + await waitFor(() => { + expect(screen.getByTestId('details')).toBeOnTheScreen(); + expect(screen.queryByTestId('close-modal-flow')).not.toBeOnTheScreen(); + }); + const rootState = navigationRef.getRootState(); + expect(rootState.routes.map(route => route.name)).toEqual([ + 'home', + 'card', + ]); + expect(rootState.index).toBe(1); + }); + + test('closes a modal after pushing within it', async () => { + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/push-in-modal-document', + ` + + + + + + `, + ], + [ + 'http://myapp.com/push-in-modal-home', + ` + + + + + Open modal + + Home + + + + `, + ], + [ + 'http://myapp.com/push-in-modal-step-one', + ` + + + + + Push + + + + + `, + ], + [ + 'http://myapp.com/push-in-modal-step-two', + ` + + + + + Close + + + + + `, + ], + ]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('open-modal')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-modal')); + await waitFor(() => { + expect(screen.getByTestId('push')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('push')); + await waitFor(() => { + expect(screen.getByTestId('close')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('close')); + + await waitFor(() => { + expect(screen.getByTestId('home')).toBeOnTheScreen(); + expect(screen.queryByTestId('close')).not.toBeOnTheScreen(); + }); + }); + + test('unwinds a pushed screen and modal through an event', async () => { + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/push-then-modal-document', + ` + + + + + + `, + ], + [ + 'http://myapp.com/push-then-modal-home', + ` + + + + + Push + + Home + + + + `, + ], + [ + 'http://myapp.com/push-then-modal-card', + ` + + + + + + Open modal + + + + + `, + ], + [ + 'http://myapp.com/push-then-modal-done', + ` + + + + + + + Finish + + Done + + + + `, + ], + ]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('push')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('push')); + await waitFor(() => { + expect(screen.getByTestId('open-modal')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-modal')); + await waitFor(() => { + expect(screen.getByTestId('finish')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('finish')); + + await waitFor(() => { + expect(screen.getByTestId('home')).toBeOnTheScreen(); + expect(screen.queryByTestId('done')).not.toBeOnTheScreen(); + }); + }); + + test('keeps a focused confirmation when a background route at index 0 goes back', async () => { + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/index-zero-back-document', + ` + + + + + + `, + ], + [ + 'http://myapp.com/index-zero-back-selection', + ` + + + + + + Open confirmation + + Selection + + + + `, + ], + [ + 'http://myapp.com/index-zero-back-confirmation', + ` + + + + + + Finish + + Confirmation + + + + `, + ], + ]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('open-confirmation')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-confirmation')); + await waitFor(() => { + expect(screen.getByTestId('confirmation')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('finish')); + + await waitFor(() => { + expect(screen.getByTestId('confirmation')).toBeOnTheScreen(); + expect(screen.queryByTestId('selection')).not.toBeOnTheScreen(); + }); + }); + + test('loads a replacement URL once before updating its route', async () => { + const navigationRef = createNavigationContainerRef(); + const getCurrentUrl = () => + (navigationRef.getCurrentRoute() as + | { params?: { url?: string } } + | undefined)?.params?.url; + let resolveDestination: (body: string) => void = () => undefined; + const destinationBody = new Promise(resolve => { + resolveDestination = resolve; + }); + const destinationUrl = 'http://myapp.com/replacement-destination'; + const destinationXml = ` + + + + Destination + + + + `; + const destinationRouteUrls: Array = []; + const mockFetch = jest.fn(async url => { + if (url.includes('replacement-document')) { + return new Response( + ` + + + + + + `, + { status: 200 }, + ); + } + if (url.includes('replacement-home')) { + return new Response( + ` + + + + + + + + `, + { status: 200 }, + ); + } + if (url.includes(destinationUrl)) { + destinationRouteUrls.push(getCurrentUrl()); + return new Response(await destinationBody, { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }); - render( - - - , - ); + render( + <> + + + + resolveDestination(destinationXml)} + testID="resolve-destination" + /> + , + ); - await waitFor(() => { - expect(screen.getByTestId('close-welcome')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('close-welcome')); + await waitFor(() => { + expect(screen.getByTestId('load-destination')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('load-destination')); + + await waitFor(() => { + expect( + mockFetch.mock.calls.filter(([url]) => url === destinationUrl), + ).toHaveLength(1); + }); + expect(getCurrentUrl()).not.toBe(destinationUrl); - await waitFor(() => { - expect(screen.getByTestId('home')).toBeOnTheScreen(); - expect(screen.queryByTestId('close-welcome')).not.toBeOnTheScreen(); + fireEvent.press(screen.getByTestId('resolve-destination')); + + await waitFor(() => { + expect(screen.getByTestId('destination')).toBeOnTheScreen(); + expect(getCurrentUrl()).toBe(destinationUrl); + }); + expect(destinationRouteUrls).toEqual([ + 'http://myapp.com/replacement-home', + ]); + expect( + mockFetch.mock.calls.filter(([url]) => url === destinationUrl), + ).toHaveLength(1); }); - }); - test('closes an entire flow containing nested modals', async () => { - const mockFetch = fetchFactory([ - [ - 'http://myapp.com/nested-modal-document', - ` + test('keeps the screen under a modal when a pushed child close races an event close', async () => { + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/leave-flow-document', + ` - + `, - ], - [ - 'http://myapp.com/nested-modal-home', - ` + ], + [ + 'http://myapp.com/leave-flow-home', + ` - Open first modal + Open details Home `, - ], - [ - 'http://myapp.com/nested-modal-first', - ` + ], + [ + 'http://myapp.com/leave-flow-details', + ` - + Gig details - Open second modal + Open options `, - ], - [ - 'http://myapp.com/nested-modal-second', - ` + ], + [ + 'http://myapp.com/leave-flow-options', + ` - Open third modal + Open form `, - ], - [ - 'http://myapp.com/nested-modal-third', - ` + ], + [ + 'http://myapp.com/leave-flow-form', + ` - - - Close modal flow - + Leave form `, - ], - ]); + ], + ]); - render( - - - , - ); + render( + + + , + ); - await waitFor(() => { - expect(screen.getByTestId('open-first-modal')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('open-first-modal')); - await waitFor(() => { - expect(screen.getByTestId('open-second-modal')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('open-second-modal')); - await waitFor(() => { - expect(screen.getByTestId('open-third-modal')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('open-third-modal')); - await waitFor(() => { - expect(screen.getByTestId('close-modal-flow')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('close-modal-flow')); + await waitFor(() => { + expect(screen.getByTestId('open-details')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-details')); + await waitFor(() => { + expect(screen.getByTestId('details')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-options')); + await waitFor(() => { + expect(screen.getByTestId('open-form')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-form')); - await waitFor(() => { - expect(screen.getByTestId('home')).toBeOnTheScreen(); - expect(screen.queryByTestId('close-modal-flow')).not.toBeOnTheScreen(); + await waitFor(() => { + expect(screen.getByTestId('details')).toBeOnTheScreen(); + expect(screen.queryByTestId('home')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('form')).not.toBeOnTheScreen(); + }); }); - }); - test('closes a modal after pushing within it', async () => { - const mockFetch = fetchFactory([ - [ - 'http://myapp.com/push-in-modal-document', - ` + test('triggers a load behavior once when a route is first shown', async () => { + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/load-once-document', + ` - + `, - ], - [ - 'http://myapp.com/push-in-modal-home', - ` + ], + [ + 'http://myapp.com/load-once-home', + ` - - Open modal + + Home `, - ], - [ - 'http://myapp.com/push-in-modal-step-one', - ` + ], + [ + 'http://myapp.com/load-once-fragment', + ` + + Fragment + + `, + ], + ]); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('fragment')).toBeOnTheScreen(); + }); + + const fragmentRequests = mockFetch.mock.calls.filter(([url]) => + String(url).includes('load-once-fragment'), + ); + expect(fragmentRequests).toHaveLength(1); + }); + + test('does not re-trigger a load behavior when returning to a route', async () => { + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/load-return-document', + ` + + + + + + `, + ], + [ + 'http://myapp.com/load-return-home', + ` + + + - Push + Open detail `, - ], - [ - 'http://myapp.com/push-in-modal-step-two', - ` + ], + [ + 'http://myapp.com/load-return-detail', + ` - - Close + + Go back + Detail `, - ], - ]); + ], + [ + 'http://myapp.com/load-return-fragment', + ` + + Fragment + + `, + ], + ]); - render( - - - , - ); + render( + + + , + ); - await waitFor(() => { - expect(screen.getByTestId('open-modal')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('open-modal')); - await waitFor(() => { - expect(screen.getByTestId('push')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('push')); - await waitFor(() => { - expect(screen.getByTestId('close')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('close')); + await waitFor(() => { + expect(screen.getByTestId('open-detail')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-detail')); + await waitFor(() => { + expect(screen.getByTestId('detail')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('go-back')); + await waitFor(() => { + expect(screen.getByTestId('open-detail')).toBeOnTheScreen(); + }); - await waitFor(() => { - expect(screen.getByTestId('home')).toBeOnTheScreen(); - expect(screen.queryByTestId('close')).not.toBeOnTheScreen(); + const fragmentRequests = mockFetch.mock.calls.filter(([url]) => + String(url).includes('load-return-fragment'), + ); + expect(fragmentRequests).toHaveLength(1); }); - }); - test('unwinds a pushed screen and modal through an event', async () => { - const mockFetch = fetchFactory([ - [ - 'http://myapp.com/push-then-modal-document', - ` + test('keeps a pushed screen when navigating to a tab route', async () => { + const mockFetch = fetchFactory([ + [ + 'http://myapp.com/tabs-document', + ` - + + + + + + `, - ], - [ - 'http://myapp.com/push-then-modal-home', - ` + ], + [ + 'http://myapp.com/tabs-shifts', + ` - Push + Open card - Home `, - ], - [ - 'http://myapp.com/push-then-modal-card', - ` + ], + [ + 'http://myapp.com/tabs-messages', + ` - - - Open modal - + Messages `, - ], - [ - 'http://myapp.com/push-then-modal-done', - ` + ], + [ + 'http://myapp.com/tabs-card', + ` - - - Done + Card + + Go to messages + `, - ], - ]); - - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByTestId('push')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('push')); - await waitFor(() => { - expect(screen.getByTestId('open-modal')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('open-modal')); - - await waitFor(() => { - expect(screen.getByTestId('home')).toBeOnTheScreen(); - expect(screen.queryByTestId('done')).not.toBeOnTheScreen(); - }); - }); - - test('loads a replacement URL once before updating its route', async () => { - const navigationRef = createNavigationContainerRef(); - const getCurrentUrl = () => - (navigationRef.getCurrentRoute() as - | { params?: { url?: string } } - | undefined)?.params?.url; - let resolveDestination: (body: string) => void = () => undefined; - const destinationBody = new Promise(resolve => { - resolveDestination = resolve; - }); - const destinationUrl = 'http://myapp.com/replacement-destination'; - const destinationXml = ` - - - - Destination - - - - `; - const destinationRouteUrls: Array = []; - const mockFetch = jest.fn(async url => { - if (url.includes('replacement-document')) { - return new Response( - ` - - - - - - `, - { status: 200 }, - ); - } - if (url.includes('replacement-home')) { - return new Response( - ` - - - - - - - - `, - { status: 200 }, - ); - } - if (url.includes(destinationUrl)) { - destinationRouteUrls.push(getCurrentUrl()); - return new Response(await destinationBody, { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }); + ], + ]); - render( - <> - + render( + - - resolveDestination(destinationXml)} - testID="resolve-destination" - /> - , - ); - - await waitFor(() => { - expect(screen.getByTestId('load-destination')).toBeOnTheScreen(); - }); - fireEvent.press(screen.getByTestId('load-destination')); + , + ); - await waitFor(() => { - expect( - mockFetch.mock.calls.filter(([url]) => url === destinationUrl), - ).toHaveLength(1); - }); - expect(getCurrentUrl()).not.toBe(destinationUrl); + await waitFor(() => { + expect(screen.getByTestId('open-card')).toBeOnTheScreen(); + }); + fireEvent.press(screen.getByTestId('open-card')); + await waitFor(() => { + expect(screen.getByTestId('card')).toBeOnTheScreen(); + }); - fireEvent.press(screen.getByTestId('resolve-destination')); + // The storybook regression: a bare navigate to a tab route retargeted + // the tabs and popped the pushed screen off the stack behind it. + fireEvent.press(screen.getByTestId('go-messages')); - await waitFor(() => { - expect(screen.getByTestId('destination')).toBeOnTheScreen(); - expect(getCurrentUrl()).toBe(destinationUrl); + await waitFor(() => { + expect(screen.getByTestId('card')).toBeOnTheScreen(); + }); }); - expect(destinationRouteUrls).toEqual(['http://myapp.com/replacement-home']); - expect( - mockFetch.mock.calls.filter(([url]) => url === destinationUrl), - ).toHaveLength(1); - }); -}); + }, +); diff --git a/yarn.lock b/yarn.lock index c04661f1e..68c8ee8ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2304,7 +2304,7 @@ "@react-navigation/bottom-tabs@6.5.7": version "6.5.7" - resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-6.5.7.tgz#08470c96e0d11481422214bb98f0ff034038856c" + resolved "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-6.5.7.tgz#08470c96e0d11481422214bb98f0ff034038856c" integrity sha512-9oZYyRu2z7+1pr2dX5V54rHFPmlj4ztwQxFe85zwpnGcPtGIsXj7VCIdlHnjRHJBBFCszvJGQpYY6/G2+DfD+A== dependencies: "@react-navigation/elements" "^1.3.17" @@ -2312,25 +2312,33 @@ warn-once "^0.1.0" "@react-navigation/core@^6.4.8": - version "6.4.8" - resolved "https://registry.yarnpkg.com/@react-navigation/core/-/core-6.4.8.tgz#a18e106d3c59cdcfc4ce53f7344e219ed35c88ed" - integrity sha512-klZ9Mcf/P2j+5cHMoGyIeurEzyBM2Uq9+NoSFrF6sdV5iCWHLFhrCXuhbBiQ5wVLCKf4lavlkd/DDs47PXs9RQ== + version "6.4.17" + resolved "https://registry.npmjs.org/@react-navigation/core/-/core-6.4.17.tgz#f277a196b578c8a456efcc563d1c9bd87eb4ab04" + integrity sha512-Nd76EpomzChWAosGqWOYE3ItayhDzIEzzZsT7PfGcRFDgW5miHV2t4MZcq9YIK4tzxZjVVpYbIynOOQQd1e0Cg== dependencies: - "@react-navigation/routers" "^6.1.8" + "@react-navigation/routers" "^6.1.9" escape-string-regexp "^4.0.0" nanoid "^3.1.23" query-string "^7.1.3" react-is "^16.13.0" - use-latest-callback "^0.1.5" + use-latest-callback "^0.2.1" -"@react-navigation/elements@^1.3.17": - version "1.3.17" - resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-1.3.17.tgz#9cb95765940f2841916fc71686598c22a3e4067e" - integrity sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA== +"@react-navigation/elements@^1.3.17", "@react-navigation/elements@^1.3.31": + version "1.3.31" + resolved "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.31.tgz#28dd802a0787bb03fc0e5be296daf1804dbebbcf" + integrity sha512-bUzP4Awlljx5RKEExw8WYtif8EuQni2glDaieYROKTnaxsu9kEIA515sXQgUDZU4Ob12VoL7+z70uO3qrlfXcQ== + +"@react-navigation/native-stack@6.11.0": + version "6.11.0" + resolved "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-6.11.0.tgz#a33f92cbd55dfe28fb0ba67df99aaa95240eb87c" + integrity sha512-U5EcUB9Q2NQspCFwYGGNJm0h6wBCOv7T30QjndmvlawLkNt7S7KWbpWyxS9XBHSIKF57RgWjfxuJNTgTstpXxw== + dependencies: + "@react-navigation/elements" "^1.3.31" + warn-once "^0.1.0" "@react-navigation/native@6.1.6": version "6.1.6" - resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-6.1.6.tgz#84ff5cf85b91f660470fa9407c06c8ee393d5792" + resolved "https://registry.npmjs.org/@react-navigation/native/-/native-6.1.6.tgz#84ff5cf85b91f660470fa9407c06c8ee393d5792" integrity sha512-14PmSy4JR8HHEk04QkxQ0ZLuqtiQfb4BV9kkMXD2/jI4TZ+yc43OnO6fQ2o9wm+Bq8pY3DxyerC2AjNUz+oH7Q== dependencies: "@react-navigation/core" "^6.4.8" @@ -2338,16 +2346,16 @@ fast-deep-equal "^3.1.3" nanoid "^3.1.23" -"@react-navigation/routers@^6.1.8": - version "6.1.8" - resolved "https://registry.yarnpkg.com/@react-navigation/routers/-/routers-6.1.8.tgz#ae56b2678dbb5abca5bd7c95d6a8d1abc767cba2" - integrity sha512-CEge+ZLhb1HBrSvv4RwOol7EKLW1QoqVIQlE9TN5MpxS/+VoQvP+cLbuz0Op53/iJfYhtXRFd1ZAd3RTRqto9w== +"@react-navigation/routers@^6.1.9": + version "6.1.9" + resolved "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz#73f5481a15a38e36592a0afa13c3c064b9f90bed" + integrity sha512-lTM8gSFHSfkJvQkxacGM6VJtBt61ip2XO54aNfswD+KMw6eeZ4oehl7m0me3CR9hnDE4+60iAZR8sAhvCiI3NA== dependencies: nanoid "^3.1.23" "@react-navigation/stack@6.3.16": version "6.3.16" - resolved "https://registry.yarnpkg.com/@react-navigation/stack/-/stack-6.3.16.tgz#cf94e3c8c1587455515743e91d328beef722e0ab" + resolved "https://registry.npmjs.org/@react-navigation/stack/-/stack-6.3.16.tgz#cf94e3c8c1587455515743e91d328beef722e0ab" integrity sha512-KTOn9cNuZ6p154Htbl2DiR95Wl+c7niLPRiGs7gjOkyVDGiaGQF9ODNQTYBDE1OxZGHe/EyYc6T2CbmiItLWDg== dependencies: "@react-navigation/elements" "^1.3.17" @@ -6636,9 +6644,9 @@ multimatch@^4.0.0: minimatch "^3.0.4" nanoid@^3.1.23: - version "3.3.6" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" - integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== + version "3.3.18" + resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== nanomatch@^1.2.9: version "1.2.13" @@ -7282,7 +7290,7 @@ react-freeze@^1.0.0: react-is@^16.13.0, react-is@^16.7.0, react-is@^16.8.1: version "16.13.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== react-is@^18.0.0: @@ -8634,10 +8642,10 @@ url-parse@1.5.10: querystringify "^2.1.1" requires-port "^1.0.0" -use-latest-callback@^0.1.5: - version "0.1.6" - resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.6.tgz#3fa6e7babbb5f9bfa24b5094b22939e1e92ebcf6" - integrity sha512-VO/P91A/PmKH9bcN9a7O3duSuxe6M14ZoYXgA6a8dab8doWNdhiIHzEkX/jFeTTRBsX0Ubk6nG4q2NIjNsj+bg== +use-latest-callback@^0.2.1: + version "0.2.6" + resolved "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz#e5ea752808c86219acc179ace0ae3c1203255e77" + integrity sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg== use@^3.1.0: version "3.1.1"