Преглед изворни кода

Always use mockable location methods (#11929)

* Always use mockable location methods
* Add eslint rules for window.location calls/assignment
* Add useLocation hook
* Update tests

GitOrigin-RevId: eafb846db89f884a7a9a8570cce7745be605152c
Alf Eaton пре 3 година
родитељ
комит
f375362894
48 измењених фајлова са 381 додато и 265 уклоњено
  1. 26 0
      services/web/.eslintrc
  2. 2 1
      services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js
  3. 4 3
      services/web/frontend/js/features/editor-left-menu/components/actions-copy-project.tsx
  4. 4 3
      services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.js
  5. 3 5
      services/web/frontend/js/features/file-tree/components/file-tree-error.js
  6. 7 5
      services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js
  7. 3 1
      services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx
  8. 3 1
      services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx
  9. 3 1
      services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx
  10. 4 2
      services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx
  11. 4 4
      services/web/frontend/js/features/project-list/components/table/project-tools/buttons/download-projects-button.tsx
  12. 5 1
      services/web/frontend/js/features/settings/components/emails/add-email/sso-linking-info.tsx
  13. 3 1
      services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx
  14. 5 1
      services/web/frontend/js/features/settings/components/emails/row.tsx
  15. 3 1
      services/web/frontend/js/features/settings/components/leave/modal-form.tsx
  16. 3 3
      services/web/frontend/js/features/settings/context/sso-context.tsx
  17. 3 2
      services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.js
  18. 4 2
      services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx
  19. 3 2
      services/web/frontend/js/features/subscription/components/dashboard/reactivate-subscription.tsx
  20. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx
  21. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/downgrade-plan-button.tsx
  22. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/extend-trial-button.tsx
  23. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx
  24. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx
  25. 3 1
      services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx
  26. 4 2
      services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx
  27. 3 4
      services/web/frontend/js/main/new-subscription.js
  28. 2 0
      services/web/frontend/js/shared/components/location.js
  29. 25 0
      services/web/frontend/js/shared/hooks/use-location.ts
  30. 1 0
      services/web/modules/user-activate/test/frontend/js/components/user-activate-register.test.js
  31. 7 3
      services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.js
  32. 10 12
      services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx
  33. 11 14
      services/web/test/frontend/features/project-list/components/new-project-button/upload-project-modal.test.tsx
  34. 10 9
      services/web/test/frontend/features/project-list/components/notifications.test.tsx
  35. 9 10
      services/web/test/frontend/features/project-list/components/project-list-root.test.tsx
  36. 10 11
      services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx
  37. 5 0
      services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx
  38. 80 84
      services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx
  39. 7 2
      services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx
  40. 19 19
      services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx
  41. 9 12
      services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx
  42. 9 0
      services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx
  43. 6 5
      services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js
  44. 6 7
      services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
  45. 17 7
      services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx
  46. 6 3
      services/web/test/frontend/features/subscription/components/dashboard/premium-features-link.test.tsx
  47. 8 9
      services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx
  48. 7 7
      services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx

+ 26 - 0
services/web/.eslintrc

@@ -111,6 +111,32 @@
         "plugin:cypress/recommended"
       ]
     },
+    {
+      // React component specific rules
+      //
+      "files": ["**/frontend/js/**/components/**/*.{js,ts,tsx}", "**/frontend/js/**/hooks/**/*.{js,ts,tsx}"],
+      "rules": {
+        // https://astexplorer.net/
+        "no-restricted-syntax": [
+          "error",
+          // prohibit direct calls to methods of window.location
+          {
+            "selector": "CallExpression[callee.object.object.name='window'][callee.object.property.name='location']",
+            "message": "Modify location via useLocation instead of calling window.location methods directly"
+          },
+          // prohibit assignment to window.location
+          {
+            "selector": "AssignmentExpression[left.object.name='window'][left.property.name='location']",
+            "message": "Modify location via useLocation instead of calling window.location methods directly"
+          },
+          // prohibit assignment to window.location.href
+          {
+            "selector": "AssignmentExpression[left.object.object.name='window'][left.object.property.name='location'][left.property.name='href']",
+            "message": "Modify location via useLocation instead of calling window.location methods directly"
+          }
+        ]
+      }
+    },
     {
       // Frontend specific rules
       "files": ["**/frontend/js/**/*.{js,ts,tsx}", "**/frontend/stories/**/*.{js,ts,tsx}", "**/*.stories.{js,ts,tsx}", "**/test/frontend/**/*.{js,ts,tsx}", "**/test/frontend/components/**/*.spec.{js,ts,tsx}"],

+ 2 - 1
services/web/frontend/js/features/clone-project-modal/controllers/left-menu-clone-project-modal-controller.js

@@ -2,6 +2,7 @@ import App from '../../../base'
 import { react2angular } from 'react2angular'
 import EditorCloneProjectModalWrapper from '../components/editor-clone-project-modal-wrapper'
 import { rootContext } from '../../../shared/context/root-context'
+import { assign } from '../../../shared/components/location'
 
 export default App.controller(
   'LeftMenuCloneProjectModalController',
@@ -21,7 +22,7 @@ export default App.controller(
     }
 
     $scope.openProject = project => {
-      window.location.assign(`/project/${project.project_id}`)
+      assign(`/project/${project.project_id}`)
     }
   }
 )

+ 4 - 3
services/web/frontend/js/features/editor-left-menu/components/actions-copy-project.tsx

@@ -1,8 +1,8 @@
 import { useCallback, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import { assign } from '../../../shared/components/location'
 import EditorCloneProjectModalWrapper from '../../clone-project-modal/components/editor-clone-project-modal-wrapper'
 import LeftMenuButton from './left-menu-button'
+import { useLocation } from '../../../shared/hooks/use-location'
 
 type ProjectCopyResponse = {
   project_id: string
@@ -11,12 +11,13 @@ type ProjectCopyResponse = {
 export default function ActionsCopyProject() {
   const [showModal, setShowModal] = useState(false)
   const { t } = useTranslation()
+  const location = useLocation()
 
   const openProject = useCallback(
     ({ project_id: projectId }: ProjectCopyResponse) => {
-      assign(`/project/${projectId}`)
+      location.assign(`/project/${projectId}`)
     },
-    []
+    [location]
   )
 
   return (

+ 4 - 3
services/web/frontend/js/features/file-tree/components/file-tree-create/redirect-to-login.js

@@ -2,12 +2,13 @@ import { useState, useEffect } from 'react'
 import PropTypes from 'prop-types'
 import { Trans } from 'react-i18next'
 import { useProjectContext } from '../../../../shared/context/project-context'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 // handle "not-logged-in" errors by redirecting to the login page
 export default function RedirectToLogin() {
   const { _id: projectId } = useProjectContext(projectContextPropTypes)
-
   const [secondsToRedirect, setSecondsToRedirect] = useState(10)
+  const location = useLocation()
 
   useEffect(() => {
     setSecondsToRedirect(10)
@@ -16,7 +17,7 @@ export default function RedirectToLogin() {
       setSecondsToRedirect(value => {
         if (value === 0) {
           window.clearInterval(timer)
-          window.location.assign(`/login?redir=/project/${projectId}`)
+          location.assign(`/login?redir=/project/${projectId}`)
           return 0
         }
 
@@ -27,7 +28,7 @@ export default function RedirectToLogin() {
     return () => {
       window.clearInterval(timer)
     }
-  }, [projectId])
+  }, [projectId, location])
 
   return (
     <Trans

+ 3 - 5
services/web/frontend/js/features/file-tree/components/file-tree-error.js

@@ -1,18 +1,16 @@
 import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
+import { useLocation } from '../../../shared/hooks/use-location'
 
 function FileTreeError() {
   const { t } = useTranslation()
-
-  function reload() {
-    location.reload()
-  }
+  const { reload: handleClick } = useLocation()
 
   return (
     <div className="file-tree-error">
       <p>{t('generic_something_went_wrong')}</p>
       <p>{t('please_refresh')}</p>
-      <Button bsStyle="primary" onClick={reload}>
+      <Button bsStyle="primary" onClick={handleClick}>
         {t('refresh')}
       </Button>
     </div>

+ 7 - 5
services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js

@@ -1,14 +1,16 @@
 import { Button } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
-import { memo } from 'react'
+import { memo, useCallback } from 'react'
 import { buildUrlWithDetachRole } from '../../../shared/utils/url-helper'
-
-const redirect = function () {
-  window.location = buildUrlWithDetachRole(null).toString()
-}
+import { useLocation } from '../../../shared/hooks/use-location'
 
 function PdfOrphanRefreshButton() {
   const { t } = useTranslation()
+  const location = useLocation()
+
+  const redirect = useCallback(() => {
+    location.assign(buildUrlWithDetachRole(null).toString())
+  }, [location])
 
   return (
     <Button

+ 3 - 1
services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx

@@ -7,6 +7,7 @@ import {
   postJSON,
 } from '../../../../infrastructure/fetch-json'
 import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 type NewProjectData = {
   project_id: string
@@ -29,6 +30,7 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
   const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
   const [projectName, setProjectName] = useState('')
   const { isLoading, isError, error, runAsync } = useAsync<NewProjectData>()
+  const location = useLocation()
 
   const createNewProject = () => {
     runAsync(
@@ -42,7 +44,7 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
     )
       .then(data => {
         if (data.project_id) {
-          window.location.assign(`/project/${data.project_id}`)
+          location.assign(`/project/${data.project_id}`)
         }
       })
       .catch(() => {})

+ 3 - 1
services/web/frontend/js/features/project-list/components/new-project-button/upload-project-modal.tsx

@@ -10,6 +10,7 @@ import { ExposedSettings } from '../../../../../../types/exposed-settings'
 
 import '@uppy/core/dist/style.css'
 import '@uppy/dashboard/dist/style.css'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 type UploadResponse = {
   project_id: string
@@ -24,6 +25,7 @@ function UploadProjectModal({ onHide }: UploadProjectModalProps) {
   const { maxUploadSize } = getMeta('ol-ExposedSettings') as ExposedSettings
   const [ableToUpload, setAbleToUpload] = useState(true)
   const [correctfileAdded, setCorrectFileAdded] = useState(false)
+  const location = useLocation()
 
   const uppy: Uppy.Uppy<Uppy.StrictTypes> = useUppy(() => {
     return Uppy({
@@ -55,7 +57,7 @@ function UploadProjectModal({ onHide }: UploadProjectModalProps) {
         const { project_id: projectId }: UploadResponse = response.body
 
         if (projectId) {
-          window.location.assign(`/project/${projectId}`)
+          location.assign(`/project/${projectId}`)
         }
       })
       .on('restriction-failed', () => {

+ 3 - 1
services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx

@@ -8,6 +8,7 @@ import { postJSON } from '../../../../../../infrastructure/fetch-json'
 import { UserEmailData } from '../../../../../../../../types/user-email'
 import { ExposedSettings } from '../../../../../../../../types/exposed-settings'
 import { Institution } from '../../../../../../../../types/institution'
+import { useLocation } from '../../../../../../shared/hooks/use-location'
 
 type ReconfirmAffiliationProps = {
   email: UserEmailData['email']
@@ -24,6 +25,7 @@ function ReconfirmAffiliation({
   const [hasSent, setHasSent] = useState(false)
   const [isPending, setIsPending] = useState(false)
   const ssoEnabled = institution.ssoEnabled
+  const location = useLocation()
 
   useEffect(() => {
     if (isSuccess) {
@@ -34,7 +36,7 @@ function ReconfirmAffiliation({
   const handleRequestReconfirmation = () => {
     if (ssoEnabled) {
       setIsPending(true)
-      window.location.assign(
+      location.assign(
         `${samlInitPath}?university_id=${institution.id}&reconfirm=/project`
       )
     } else {

+ 4 - 2
services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx

@@ -4,6 +4,7 @@ import { Project } from '../../../../../../../../types/project/dashboard/api'
 import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
+import { useLocation } from '../../../../../../shared/hooks/use-location'
 
 type DownloadProjectButtonProps = {
   project: Project
@@ -16,6 +17,7 @@ function DownloadProjectButton({
 }: DownloadProjectButtonProps) {
   const { t } = useTranslation()
   const text = t('download')
+  const location = useLocation()
 
   const downloadProject = useCallback(() => {
     eventTracking.send(
@@ -23,8 +25,8 @@ function DownloadProjectButton({
       'project action',
       'Download Zip'
     )
-    window.location.assign(`/project/${project.id}/download/zip`)
-  }, [project])
+    location.assign(`/project/${project.id}/download/zip`)
+  }, [project, location])
 
   return children(text, downloadProject)
 }

+ 4 - 4
services/web/frontend/js/features/project-list/components/table/project-tools/buttons/download-projects-button.tsx

@@ -4,12 +4,14 @@ import Icon from '../../../../../../shared/components/icon'
 import Tooltip from '../../../../../../shared/components/tooltip'
 import * as eventTracking from '../../../../../../infrastructure/event-tracking'
 import { useProjectListContext } from '../../../../context/project-list-context'
+import { useLocation } from '../../../../../../shared/hooks/use-location'
 
 function DownloadProjectsButton() {
   const { selectedProjects, selectOrUnselectAllProjects } =
     useProjectListContext()
   const { t } = useTranslation()
   const text = t('download')
+  const location = useLocation()
 
   const projectIds = selectedProjects.map(p => p.id)
 
@@ -20,13 +22,11 @@ function DownloadProjectsButton() {
       'Download Zip'
     )
 
-    window.location.assign(
-      `/project/download/zip?project_ids=${projectIds.join(',')}`
-    )
+    location.assign(`/project/download/zip?project_ids=${projectIds.join(',')}`)
 
     const selected = false
     selectOrUnselectAllProjects(selected)
-  }, [projectIds, selectOrUnselectAllProjects])
+  }, [projectIds, selectOrUnselectAllProjects, location])
 
   return (
     <Tooltip

+ 5 - 1
services/web/frontend/js/features/settings/components/emails/add-email/sso-linking-info.tsx

@@ -4,6 +4,7 @@ import { Trans, useTranslation } from 'react-i18next'
 import { DomainInfo } from './input'
 import { ExposedSettings } from '../../../../../../../types/exposed-settings'
 import getMeta from '../../../../../utils/meta'
+import { useLocation } from '../../../../../shared/hooks/use-location'
 
 type SSOLinkingInfoProps = {
   domainInfo: DomainInfo
@@ -13,13 +14,16 @@ type SSOLinkingInfoProps = {
 function SsoLinkingInfo({ domainInfo, email }: SSOLinkingInfoProps) {
   const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings
   const { t } = useTranslation()
+  const location = useLocation()
 
   const [linkAccountsButtonDisabled, setLinkAccountsButtonDisabled] =
     useState(false)
 
   function handleLinkAccountsButtonClick() {
     setLinkAccountsButtonDisabled(true)
-    window.location.href = `${samlInitPath}?university_id=${domainInfo.university.id}&auto=/user/settings&email=${email}`
+    location.assign(
+      `${samlInitPath}?university_id=${domainInfo.university.id}&auto=/user/settings&email=${email}`
+    )
   }
 
   return (

+ 3 - 1
services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx

@@ -9,6 +9,7 @@ import getMeta from '../../../../../utils/meta'
 import { ExposedSettings } from '../../../../../../../types/exposed-settings'
 import { ssoAvailableForInstitution } from '../../../utils/sso'
 import Icon from '../../../../../shared/components/icon'
+import { useLocation } from '../../../../../shared/hooks/use-location'
 
 type ReconfirmationInfoPromptProps = {
   email: string
@@ -29,6 +30,7 @@ function ReconfirmationInfoPrompt({
   const [isPending, setIsPending] = useState(false)
   const [hasSent, setHasSent] = useState(false)
   const ssoAvailable = Boolean(ssoAvailableForInstitution(institution))
+  const location = useLocation()
 
   useEffect(() => {
     setUserEmailsContextLoading(isLoading)
@@ -43,7 +45,7 @@ function ReconfirmationInfoPrompt({
   const handleRequestReconfirmation = () => {
     if (ssoAvailable) {
       setIsPending(true)
-      window.location.assign(
+      location.assign(
         `${samlInitPath}?university_id=${institution.id}&reconfirm=/user/settings`
       )
     } else {

+ 5 - 1
services/web/frontend/js/features/settings/components/emails/row.tsx

@@ -12,6 +12,7 @@ import getMeta from '../../../../utils/meta'
 import { ExposedSettings } from '../../../../../../types/exposed-settings'
 import { ssoAvailableForInstitution } from '../../utils/sso'
 import ReconfirmationInfo from './reconfirmation-info'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 type EmailsRowProps = {
   userEmailData: UserEmailData
@@ -61,13 +62,16 @@ function SSOAffiliationInfo({ userEmailData }: SSOAffiliationInfoProps) {
   const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings
   const { t } = useTranslation()
   const { state } = useUserEmailsContext()
+  const location = useLocation()
 
   const [linkAccountsButtonDisabled, setLinkAccountsButtonDisabled] =
     useState(false)
 
   function handleLinkAccountsButtonClick() {
     setLinkAccountsButtonDisabled(true)
-    window.location.href = `${samlInitPath}?university_id=${userEmailData.affiliation?.institution?.id}&auto=/user/settings&email=${userEmailData.email}`
+    location.assign(
+      `${samlInitPath}?university_id=${userEmailData.affiliation?.institution?.id}&auto=/user/settings&email=${userEmailData.email}`
+    )
   }
 
   if (

+ 3 - 1
services/web/frontend/js/features/settings/components/leave/modal-form.tsx

@@ -4,6 +4,7 @@ import { useTranslation, Trans } from 'react-i18next'
 import { postJSON, FetchError } from '../../../../infrastructure/fetch-json'
 import getMeta from '../../../../utils/meta'
 import LeaveModalFormError from './modal-form-error'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 export type LeaveModalFormProps = {
   setInFlight: Dispatch<SetStateAction<boolean>>
@@ -18,6 +19,7 @@ function LeaveModalForm({
 }: LeaveModalFormProps) {
   const { t } = useTranslation()
   const userDefaultEmail = getMeta('ol-usersEmail') as string
+  const location = useLocation()
 
   const [email, setEmail] = useState('')
   const [password, setPassword] = useState('')
@@ -53,7 +55,7 @@ function LeaveModalForm({
       },
     })
       .then(() => {
-        window.location.assign('/login')
+        location.assign('/login')
       })
       .catch(setError)
       .finally(() => {

+ 3 - 3
services/web/frontend/js/features/settings/context/sso-context.tsx

@@ -34,7 +34,7 @@ type SSOProviderProps = {
 }
 
 export function SSOProvider({ children }: SSOProviderProps) {
-  const isMountedRef = useIsMounted()
+  const isMounted = useIsMounted()
   const oauthProviders = getMeta('ol-oauthProviders', {}) as OAuthProviders
   const thirdPartyIds = getMeta('ol-thirdPartyIds') as ThirdPartyIds
 
@@ -66,14 +66,14 @@ export function SSOProvider({ children }: SSOProviderProps) {
       }
 
       return postJSON('/user/oauth-unlink', { body, signal }).then(() => {
-        if (isMountedRef.current) {
+        if (isMounted.current) {
           setSubscriptions(subs =>
             set(cloneDeep(subs), `${providerId}.linked`, false)
           )
         }
       })
     },
-    [isMountedRef, subscriptions]
+    [isMounted, subscriptions]
   )
 
   const value = useMemo<SSOContextValue>(

+ 3 - 2
services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.js

@@ -5,12 +5,13 @@ import PropTypes from 'prop-types'
 import Icon from '../../../shared/components/icon'
 import { transferProjectOwnership } from '../utils/api'
 import AccessibleModal from '../../../shared/components/accessible-modal'
-import { reload } from '../../../shared/components/location'
 import { useProjectContext } from '../../../shared/context/project-context'
+import { useLocation } from '../../../shared/hooks/use-location'
 
 export default function TransferOwnershipModal({ member, cancel }) {
   const [inflight, setInflight] = useState(false)
   const [error, setError] = useState(false)
+  const location = useLocation()
 
   const { _id: projectId, name: projectName } = useProjectContext()
 
@@ -20,7 +21,7 @@ export default function TransferOwnershipModal({ member, cancel }) {
 
     transferProjectOwnership(projectId, member)
       .then(() => {
-        reload()
+        location.reload()
       })
       .catch(() => {
         setError(true)

+ 4 - 2
services/web/frontend/js/features/subscription/components/dashboard/leave-group-modal.tsx

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
 import { deleteJSON } from '../../../../infrastructure/fetch-json'
 import AccessibleModal from '../../../../shared/components/accessible-modal'
 import { useSubscriptionDashboardContext } from '../../context/subscription-dashboard-context'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 export const LEAVE_GROUP_MODAL_ID = 'leave-group'
 
@@ -12,6 +13,7 @@ export default function LeaveGroupModal() {
   const { handleCloseModal, modalIdShown, leavingGroupId } =
     useSubscriptionDashboardContext()
   const [inflight, setInflight] = useState(false)
+  const location = useLocation()
 
   const handleConfirmLeaveGroup = useCallback(async () => {
     if (!leavingGroupId) {
@@ -22,12 +24,12 @@ export default function LeaveGroupModal() {
       const params = new URLSearchParams()
       params.set('subscriptionId', leavingGroupId)
       await deleteJSON(`/subscription/group/user?${params}`)
-      window.location.reload()
+      location.reload()
     } catch (error) {
       console.log('something went wrong', error)
       setInflight(false)
     }
-  }, [leavingGroupId])
+  }, [location, leavingGroupId])
 
   if (modalIdShown !== LEAVE_GROUP_MODAL_ID || !leavingGroupId) {
     return null

+ 3 - 2
services/web/frontend/js/features/subscription/components/dashboard/reactivate-subscription.tsx

@@ -1,19 +1,20 @@
 import { useTranslation } from 'react-i18next'
 import { postJSON } from '../../../../infrastructure/fetch-json'
 import { reactivateSubscriptionUrl } from '../../data/subscription-url'
-import { reload } from '../../../../shared/components/location'
 import useAsync from '../../../../shared/hooks/use-async'
+import { useLocation } from '../../../../shared/hooks/use-location'
 
 function ReactivateSubscription() {
   const { t } = useTranslation()
   const { isLoading, isSuccess, runAsync } = useAsync()
+  const location = useLocation()
 
   const handleReactivate = () => {
     runAsync(postJSON(reactivateSubscriptionUrl)).catch(console.error)
   }
 
   if (isSuccess) {
-    reload()
+    location.reload()
   }
 
   return (

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/cancel-subscription.tsx

@@ -14,6 +14,7 @@ import ActionButtonText from '../../../action-button-text'
 import GenericErrorAlert from '../../../generic-error-alert'
 import DowngradePlanButton from './downgrade-plan-button'
 import ExtendTrialButton from './extend-trial-button'
+import { useLocation } from '../../../../../../../shared/hooks/use-location'
 
 const planCodeToDowngradeTo = 'paid-personal'
 
@@ -140,6 +141,7 @@ function NotCancelOption({
 
 export function CancelSubscription() {
   const { t } = useTranslation()
+  const location = useLocation()
   const { personalSubscription, plans } = useSubscriptionDashboardContext()
   const {
     isLoading: isLoadingCancel,
@@ -176,7 +178,7 @@ export function CancelSubscription() {
   async function handleCancelSubscription() {
     try {
       await runAsyncCancel(postJSON(cancelSubscriptionUrl))
-      window.location.assign(redirectAfterCancelSubscriptionUrl)
+      location.assign(redirectAfterCancelSubscriptionUrl)
     } catch (e) {
       console.error(e)
     }

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/downgrade-plan-button.tsx

@@ -3,6 +3,7 @@ import { Plan } from '../../../../../../../../../types/subscription/plan'
 import { postJSON } from '../../../../../../../infrastructure/fetch-json'
 import { subscriptionUpdateUrl } from '../../../../../data/subscription-url'
 import ActionButtonText from '../../../action-button-text'
+import { useLocation } from '../../../../../../../shared/hooks/use-location'
 
 export default function DowngradePlanButton({
   isButtonDisabled,
@@ -18,6 +19,7 @@ export default function DowngradePlanButton({
   runAsyncSecondaryAction: (promise: Promise<unknown>) => Promise<unknown>
 }) {
   const { t } = useTranslation()
+  const location = useLocation()
   const buttonText = t('yes_move_me_to_personal_plan')
 
   async function handleDowngradePlan() {
@@ -27,7 +29,7 @@ export default function DowngradePlanButton({
           body: { plan_code: planToDowngradeTo.planCode },
         })
       )
-      window.location.reload()
+      location.reload()
     } catch (e) {
       console.error(e)
     }

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/cancel-plan/extend-trial-button.tsx

@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
 import { putJSON } from '../../../../../../../infrastructure/fetch-json'
 import { extendTrialUrl } from '../../../../../data/subscription-url'
 import ActionButtonText from '../../../action-button-text'
+import { useLocation } from '../../../../../../../shared/hooks/use-location'
 
 export default function ExtendTrialButton({
   isButtonDisabled,
@@ -16,11 +17,12 @@ export default function ExtendTrialButton({
 }) {
   const { t } = useTranslation()
   const buttonText = t('ill_take_it')
+  const location = useLocation()
 
   async function handleExtendTrial() {
     try {
       await runAsyncSecondaryAction(putJSON(extendTrialUrl))
-      window.location.reload()
+      location.reload()
     } catch (e) {
       console.error(e)
     }

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/change-to-group-modal.tsx

@@ -11,6 +11,7 @@ import { useSubscriptionDashboardContext } from '../../../../../../context/subsc
 import GenericErrorAlert from '../../../../generic-error-alert'
 import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
 import { getRecurlyGroupPlanCode } from '../../../../../../util/recurly-group-plan-code'
+import { useLocation } from '../../../../../../../../shared/hooks/use-location'
 
 const educationalPercentDiscount = 40
 const groupSizeForEducationalDiscount = 10
@@ -143,6 +144,7 @@ export function ChangeToGroupModal() {
   const personalSubscription: Subscription = getMeta('ol-subscription')
   const [error, setError] = useState(false)
   const [inflight, setInflight] = useState(false)
+  const location = useLocation()
 
   async function upgrade() {
     setError(false)
@@ -158,7 +160,7 @@ export function ChangeToGroupModal() {
           ),
         },
       })
-      window.location.reload()
+      location.reload()
     } catch (e) {
       setError(true)
       setInflight(false)

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/confirm-change-plan-modal.tsx

@@ -7,6 +7,7 @@ import AccessibleModal from '../../../../../../../../shared/components/accessibl
 import getMeta from '../../../../../../../../utils/meta'
 import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
 import { subscriptionUpdateUrl } from '../../../../../../data/subscription-url'
+import { useLocation } from '../../../../../../../../shared/hooks/use-location'
 
 export function ConfirmChangePlanModal() {
   const modalId: SubscriptionDashModalIds = 'change-to-plan'
@@ -16,6 +17,7 @@ export function ConfirmChangePlanModal() {
   const { handleCloseModal, modalIdShown, plans, planCodeToChangeTo } =
     useSubscriptionDashboardContext()
   const planCodesChangingAtTermEnd = getMeta('ol-planCodesChangingAtTermEnd')
+  const location = useLocation()
 
   async function handleConfirmChange() {
     setError(false)
@@ -27,7 +29,7 @@ export function ConfirmChangePlanModal() {
           plan_code: planCodeToChangeTo,
         },
       })
-      window.location.reload()
+      location.reload()
     } catch (e) {
       setError(true)
       setInflight(false)

+ 3 - 1
services/web/frontend/js/features/subscription/components/dashboard/states/active/change-plan/modals/keep-current-plan-modal.tsx

@@ -6,12 +6,14 @@ import { postJSON } from '../../../../../../../../infrastructure/fetch-json'
 import AccessibleModal from '../../../../../../../../shared/components/accessible-modal'
 import { useSubscriptionDashboardContext } from '../../../../../../context/subscription-dashboard-context'
 import { cancelPendingSubscriptionChangeUrl } from '../../../../../../data/subscription-url'
+import { useLocation } from '../../../../../../../../shared/hooks/use-location'
 
 export function KeepCurrentPlanModal() {
   const modalId: SubscriptionDashModalIds = 'keep-current-plan'
   const [error, setError] = useState(false)
   const [inflight, setInflight] = useState(false)
   const { t } = useTranslation()
+  const location = useLocation()
   const { modalIdShown, handleCloseModal, personalSubscription } =
     useSubscriptionDashboardContext()
 
@@ -21,7 +23,7 @@ export function KeepCurrentPlanModal() {
 
     try {
       await postJSON(cancelPendingSubscriptionChangeUrl)
-      window.location.reload()
+      location.reload()
     } catch (e) {
       setError(true)
       setInflight(false)

+ 4 - 2
services/web/frontend/js/features/subscription/components/new/checkout/checkout-panel.tsx

@@ -18,7 +18,6 @@ import SubmitButton from './submit-button'
 import ThreeDSecure from './three-d-secure'
 import getMeta from '../../../../../utils/meta'
 import { postJSON } from '../../../../../infrastructure/fetch-json'
-import { assign } from '../../../../../shared/components/location'
 import * as eventTracking from '../../../../../infrastructure/event-tracking'
 import classnames from 'classnames'
 import {
@@ -30,6 +29,7 @@ import {
 import { PricingFormState } from '../../../context/types/payment-context-value'
 import { CreateError } from '../../../../../../../types/subscription/api'
 import { CardElementChangeState } from '../../../../../../../types/recurly/elements'
+import { useLocation } from '../../../../../shared/hooks/use-location'
 
 function CheckoutPanel() {
   const { t } = useTranslation()
@@ -59,6 +59,7 @@ function CheckoutPanel() {
   const [formIsValid, setFormIsValid] = useState<boolean>()
   const [threeDSecureActionTokenId, setThreeDSecureActionTokenId] =
     useState<string>()
+  const location = useLocation()
 
   const isCreditCardPaymentMethod = paymentMethod === 'credit_card'
   const isPayPalPaymentMethod = paymentMethod === 'paypal'
@@ -154,7 +155,7 @@ function CheckoutPanel() {
             'subscription-submission-success',
             planCode
           )
-          assign('/user/subscription/thank-you')
+          location.assign('/user/subscription/thank-you')
         } catch (error) {
           setIsProcessing(false)
 
@@ -175,6 +176,7 @@ function CheckoutPanel() {
       ITMReferrer,
       isAddCompanyDetailsChecked,
       isPayPalPaymentMethod,
+      location,
       planCode,
       pricing,
       pricingFormState,

+ 3 - 4
services/web/frontend/js/main/new-subscription.js

@@ -7,6 +7,7 @@ import _ from 'lodash'
 /* global recurly */
 import App from '../base'
 import getMeta from '../utils/meta'
+import { assign } from '../shared/components/location'
 
 export default App.controller(
   'NewSubscriptionController',
@@ -775,14 +776,12 @@ App.controller(
     $scope.browsePlans = () => {
       if (document.referrer?.includes('/user/subscription/choose-your-plan')) {
         // redirect to interstitial page with `itm_referrer` param
-        window.location.assign(
+        assign(
           '/user/subscription/choose-your-plan?itm_referrer=student-status-declined'
         )
       } else {
         // redirect to plans page with `itm_referrer` param
-        window.location.assign(
-          '/user/subscription/plans?itm_referrer=student-status-declined'
-        )
+        assign('/user/subscription/plans?itm_referrer=student-status-declined')
       }
     }
 

+ 2 - 0
services/web/frontend/js/shared/components/location.js

@@ -1,9 +1,11 @@
 // window location-related functions in a separate module so they can be mocked/stubbed in tests
 
 export function reload() {
+  // eslint-disable-next-line no-restricted-syntax
   window.location.reload()
 }
 
 export function assign(url) {
+  // eslint-disable-next-line no-restricted-syntax
   window.location.assign(url)
 }

+ 25 - 0
services/web/frontend/js/shared/hooks/use-location.ts

@@ -0,0 +1,25 @@
+import { useCallback, useMemo } from 'react'
+import useIsMounted from './use-is-mounted'
+
+export const useLocation = () => {
+  const isMounted = useIsMounted()
+
+  const assign = useCallback(
+    url => {
+      if (isMounted.current) {
+        // eslint-disable-next-line no-restricted-syntax
+        window.location.assign(url)
+      }
+    },
+    [isMounted]
+  )
+
+  const reload = useCallback(() => {
+    if (isMounted.current) {
+      // eslint-disable-next-line no-restricted-syntax
+      window.location.reload()
+    }
+  }, [isMounted])
+
+  return useMemo(() => ({ assign, reload }), [assign, reload])
+}

+ 1 - 0
services/web/modules/user-activate/test/frontend/js/components/user-activate-register.test.js

@@ -125,6 +125,7 @@ describe('UserActivateRegister', function () {
       const body = JSON.parse(req.body)
       if (body.email === 'abc@gmail.com') return endPointResponse1
       else if (body.email === 'def@gmail.com') return endPointResponse2
+      else return 500
     })
     const registerInput = screen.getByLabelText('emails to register')
     const registerButton = screen.getByRole('button', { name: /register/i })

+ 7 - 3
services/web/test/frontend/features/editor-left-menu/components/actions-copy-project.test.js

@@ -4,18 +4,22 @@ import sinon from 'sinon'
 import { expect } from 'chai'
 import ActionsCopyProject from '../../../../../frontend/js/features/editor-left-menu/components/actions-copy-project'
 import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import * as locationModule from '../../../../../frontend/js/shared/components/location'
 import { waitFor } from '@testing-library/react'
+import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
 
 describe('<ActionsCopyProject />', function () {
   let assignStub
 
   beforeEach(function () {
-    assignStub = sinon.stub(locationModule, 'assign')
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
+    })
   })
 
   afterEach(function () {
-    assignStub.restore()
+    this.locationStub.restore()
     fetchMock.reset()
   })
 

+ 10 - 12
services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx

@@ -3,23 +3,21 @@ import { expect } from 'chai'
 import fetchMock from 'fetch-mock'
 import sinon from 'sinon'
 import ModalContentNewProjectForm from '../../../../../../frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 describe('<ModalContentNewProjectForm />', function () {
-  const locationStub = sinon.stub()
-  const originalLocation = window.location
+  let assignStub: sinon.SinonStub
 
   beforeEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: {
-        assign: locationStub,
-      },
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
     })
   })
 
   afterEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
     fetchMock.reset()
   })
 
@@ -52,8 +50,8 @@ describe('<ModalContentNewProjectForm />', function () {
     expect(newProjectMock.called()).to.be.true
 
     await waitFor(() => {
-      sinon.assert.calledOnce(locationStub)
-      sinon.assert.calledWith(locationStub, `/project/${projectId}`)
+      sinon.assert.calledOnce(assignStub)
+      sinon.assert.calledWith(assignStub, `/project/${projectId}`)
     })
   })
 
@@ -131,7 +129,7 @@ describe('<ModalContentNewProjectForm />', function () {
       Feugiat in fermentum posuere urna nec. Elementum eu facilisis sed odio morbi quis commodo. Vel fringilla est ullamcorper eget nulla facilisi. Nunc sed blandit libero volutpat sed cras ornare arcu dui. Tortor id aliquet lectus proin nibh nisl condimentum id venenatis. Sapien pellentesque habitant morbi tristique senectus et. Quam elementum pulvinar etiam non quam lacus suspendisse faucibus. Sem nulla pharetra diam sit amet nisl suscipit adipiscing bibendum. Porttitor leo a diam sollicitudin tempor id. In iaculis nunc sed augue.
       Velit euismod in pellentesque massa placerat duis ultricies lacus sed. Dictum fusce ut placerat orci nulla pellentesque dignissim enim. Dui id ornare arcu odio. Dignissim cras tincidunt lobortis feugiat vivamus at augue. Non tellus orci ac auctor. Egestas fringilla phasellus faucibus scelerisque eleifend donec. Nisi vitae suscipit tellus mauris a diam maecenas. Orci dapibus ultrices in iaculis nunc sed. Facilisi morbi tempus iaculis urna id volutpat lacus laoreet non. Aliquam etiam erat velit scelerisque in dictum. Sed enim ut sem viverra. Eleifend donec pretium vulputate sapien nec sagittis. Quisque egestas diam in arcu cursus euismod quis. Faucibus a pellentesque sit amet porttitor eget dolor. Elementum facilisis leo vel fringilla. Pellentesque habitant morbi tristique senectus et netus. Viverra tellus in hac habitasse platea dictumst vestibulum. Tincidunt nunc pulvinar sapien et ligula ullamcorper malesuada proin. Sit amet porttitor eget dolor morbi non. Neque egestas congue quisque egestas.
       Convallis posuere morbi leo urna molestie at. Posuere sollicitudin aliquam ultrices sagittis orci. Lacus vestibulum sed arcu non odio. Sit amet dictum sit amet. Nunc scelerisque viverra mauris in aliquam sem fringilla ut morbi. Vestibulum morbi blandit cursus risus at ultrices mi. Purus gravida quis blandit turpis cursus. Diam maecenas sed enim ut. Senectus et netus et malesuada fames ac turpis. Massa tempor nec feugiat nisl pretium fusce id velit. Mollis nunc sed id semper. Elit sed vulputate mi sit. Vitae et leo duis ut diam. Pellentesque sit amet porttitor eget dolor morbi non arcu risus.
-      Mi quis hendrerit dolor magna eget est lorem. Quam vulputate dignissim suspendisse in est ante in nibh. Nisi porta lorem mollis aliquam. Duis tristique sollicitudin nibh sit amet commodo nulla facilisi nullam. Euismod nisi porta lorem mollis aliquam ut porttitor leo a. Tempus imperdiet nulla malesuada pellentesque elit eget. Amet nisl purus in mollis nunc sed id. Id velit ut tortor pretium viverra suspendisse. Integer quis auctor elit sed. Tortor at risus viverra adipiscing. Ac auctor augue mauris augue neque gravida in. Lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis. A diam sollicitudin tempor id eu nisl nunc mi. Tellus id interdum velit laoreet id donec. Lacus vestibulum sed arcu non odio euismod lacinia. Tellus at urna condimentum mattis.      
+      Mi quis hendrerit dolor magna eget est lorem. Quam vulputate dignissim suspendisse in est ante in nibh. Nisi porta lorem mollis aliquam. Duis tristique sollicitudin nibh sit amet commodo nulla facilisi nullam. Euismod nisi porta lorem mollis aliquam ut porttitor leo a. Tempus imperdiet nulla malesuada pellentesque elit eget. Amet nisl purus in mollis nunc sed id. Id velit ut tortor pretium viverra suspendisse. Integer quis auctor elit sed. Tortor at risus viverra adipiscing. Ac auctor augue mauris augue neque gravida in. Lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis. A diam sollicitudin tempor id eu nisl nunc mi. Tellus id interdum velit laoreet id donec. Lacus vestibulum sed arcu non odio euismod lacinia. Tellus at urna condimentum mattis.
       `,
       },
     })

+ 11 - 14
services/web/test/frontend/features/project-list/components/new-project-button/upload-project-modal.test.tsx

@@ -2,18 +2,19 @@ import sinon from 'sinon'
 import { render, screen, fireEvent, waitFor } from '@testing-library/react'
 import UploadProjectModal from '../../../../../../frontend/js/features/project-list/components/new-project-button/upload-project-modal'
 import { expect } from 'chai'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 describe('<UploadProjectModal />', function () {
   const originalWindowCSRFToken = window.csrfToken
-  const locationStub = sinon.stub()
-  const originalLocation = window.location
   const maxUploadSize = 10 * 1024 * 1024 // 10 MB
 
+  let assignStub: sinon.SinonStub
+
   beforeEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: {
-        assign: locationStub,
-      },
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
     })
     window.metaAttributesCache.set('ol-ExposedSettings', {
       maxUploadSize,
@@ -22,13 +23,9 @@ describe('<UploadProjectModal />', function () {
   })
 
   afterEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
     window.metaAttributesCache = new Map()
     window.csrfToken = originalWindowCSRFToken
-
-    locationStub.reset()
   })
 
   it('uploads a dropped file', async function () {
@@ -66,8 +63,8 @@ describe('<UploadProjectModal />', function () {
     )
 
     await waitFor(() => {
-      sinon.assert.calledOnce(locationStub)
-      sinon.assert.calledWith(locationStub, `/project/${projectId}`)
+      sinon.assert.calledOnce(assignStub)
+      sinon.assert.calledWith(assignStub, `/project/${projectId}`)
     })
 
     xhr.restore()
@@ -146,7 +143,7 @@ describe('<UploadProjectModal />', function () {
     )
 
     await waitFor(() => {
-      sinon.assert.notCalled(locationStub)
+      sinon.assert.notCalled(assignStub)
       screen.getByText('Upload failed')
     })
 

+ 10 - 9
services/web/test/frontend/features/project-list/components/notifications.test.tsx

@@ -29,6 +29,7 @@ import { DeepPartial } from '../../../../../types/utils'
 import { Project } from '../../../../../types/project/dashboard/api'
 import GroupsAndEnterpriseBanner from '../../../../../frontend/js/features/project-list/components/notifications/groups-and-enterprise-banner'
 import localStorage from '../../../../../frontend/js/infrastructure/local-storage'
+import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
 
 const renderWithinProjectListProvider = (Component: React.ComponentType) => {
   render(<Component />, {
@@ -548,22 +549,22 @@ describe('<UserNotifications />', function () {
   })
 
   describe('<Affiliation/>', function () {
-    const locationStub = sinon.stub()
-    const originalLocation = window.location
+    let assignStub: sinon.SinonStub
+
     beforeEach(function () {
       window.metaAttributesCache = window.metaAttributesCache || new Map()
       window.metaAttributesCache.set('ol-ExposedSettings', exposedSettings)
-      Object.defineProperty(window, 'location', {
-        value: { assign: locationStub },
+      assignStub = sinon.stub()
+      this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+        assign: assignStub,
+        reload: sinon.stub(),
       })
       fetchMock.reset()
     })
 
     afterEach(function () {
       window.metaAttributesCache = new Map()
-      Object.defineProperty(window, 'location', {
-        value: originalLocation,
-      })
+      this.locationStub.restore()
       fetchMock.reset()
     })
 
@@ -619,9 +620,9 @@ describe('<UserNotifications />', function () {
       fireEvent.click(
         screen.getByRole('button', { name: /confirm affiliation/i })
       )
-      sinon.assert.calledOnce(locationStub)
+      sinon.assert.calledOnce(assignStub)
       sinon.assert.calledWithMatch(
-        locationStub,
+        assignStub,
         `${exposedSettings.samlInitPath}?university_id=${professionalUserData.affiliation.institution.id}&reconfirm=/project`
       )
     })

+ 9 - 10
services/web/test/frontend/features/project-list/components/project-list-root.test.tsx

@@ -11,6 +11,7 @@ import {
   archivedProjects,
   makeLongProjectList,
 } from '../fixtures/projects-data'
+import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
 
 const {
   fullList,
@@ -24,9 +25,8 @@ const {
 const userId = owner.id
 
 describe('<ProjectListRoot />', function () {
-  const originalLocation = window.location
-  const locationStub = sinon.stub()
   let sendSpy: sinon.SinonSpy
+  let assignStub: sinon.SinonStub
 
   beforeEach(async function () {
     global.localStorage.clear()
@@ -48,9 +48,10 @@ describe('<ProjectListRoot />', function () {
       { email: 'test@overleaf.com', default: true },
     ])
     window.user_id = userId
-
-    Object.defineProperty(window, 'location', {
-      value: { assign: locationStub },
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
     })
   })
 
@@ -58,9 +59,7 @@ describe('<ProjectListRoot />', function () {
     sendSpy.restore()
     window.user_id = undefined
     fetchMock.reset()
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
   })
 
   describe('welcome page', function () {
@@ -117,11 +116,11 @@ describe('<ProjectListRoot />', function () {
           fireEvent.click(downloadButton)
 
           await waitFor(() => {
-            expect(locationStub).to.have.been.called
+            expect(assignStub).to.have.been.called
           })
 
           sinon.assert.calledWithMatch(
-            locationStub,
+            assignStub,
             `/project/download/zip?project_ids=${project1Id},${project2Id}`
           )
 

+ 10 - 11
services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx

@@ -3,23 +3,22 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import sinon from 'sinon'
 import { DownloadProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button'
 import { projectsData } from '../../../../fixtures/projects-data'
+import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location'
 
 describe('<DownloadProjectButton />', function () {
-  const originalLocation = window.location
-  const locationStub = sinon.stub()
+  let assignStub: sinon.SinonStub
 
   beforeEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: { assign: locationStub },
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
     })
-
     render(<DownloadProjectButtonTooltip project={projectsData[0]} />)
   })
 
   afterEach(function () {
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
   })
 
   it('renders tooltip for button', function () {
@@ -33,13 +32,13 @@ describe('<DownloadProjectButton />', function () {
     fireEvent.click(btn)
 
     await waitFor(() => {
-      expect(locationStub).to.have.been.called
+      expect(assignStub).to.have.been.called
     })
 
-    sinon.assert.calledOnce(locationStub)
+    sinon.assert.calledOnce(assignStub)
 
     sinon.assert.calledWithMatch(
-      locationStub,
+      assignStub,
       `/project/${projectsData[0].id}/download/zip`
     )
   })

+ 5 - 0
services/web/test/frontend/features/settings/components/emails/emails-row.test.tsx

@@ -33,6 +33,11 @@ describe('<EmailsRow/>', function () {
       samlInitPath: '/saml',
       hasSamlBeta: true,
     })
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
+  })
+
+  afterEach(function () {
+    fetchMock.reset()
   })
 
   describe('with unaffiliated email data', function () {

+ 80 - 84
services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx

@@ -76,21 +76,20 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
 
-    await fetchMock.flush(true)
-
-    screen.getByRole('button', { name: /add another email/i })
+    await screen.findByRole('button', { name: /add another email/i })
   })
 
   it('renders input', async function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
+    await fetchMock.flush(true)
 
-    const addAnotherEmailBtn = (await screen.findByRole('button', {
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
       name: /add another email/i,
-    })) as HTMLButtonElement
-    fireEvent.click(addAnotherEmailBtn)
+    })
+    fireEvent.click(button)
 
-    screen.getByLabelText(/email/i)
+    await screen.findByLabelText(/email/i)
   })
 
   it('renders "Start adding your address" until a valid email is typed', async function () {
@@ -98,22 +97,21 @@ describe('<EmailsSection />', function () {
     fetchMock.get(`/institutions/domains?hostname=email.com&limit=1`, 200)
     fetchMock.get(`/institutions/domains?hostname=email&limit=1`, 200)
     render(<EmailsSection />)
+    await fetchMock.flush(true)
 
-    const addAnotherEmailBtn = (await screen.findByRole('button', {
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
       name: /add another email/i,
-    })) as HTMLButtonElement
-    fireEvent.click(addAnotherEmailBtn)
+    })
+    fireEvent.click(button)
 
     const input = screen.getByLabelText(/email/i)
 
     // initially the text is displayed and the "add email" button disabled
     screen.getByText('Start by adding your email address.')
     expect(
-      (
-        screen.getByRole('button', {
-          name: /add new email/i,
-        }) as HTMLButtonElement
-      ).disabled
+      screen.getByRole<HTMLButtonElement>('button', {
+        name: /add new email/i,
+      }).disabled
     ).to.be.true
 
     // no changes while writing the email address
@@ -122,11 +120,9 @@ describe('<EmailsSection />', function () {
     })
     screen.getByText('Start by adding your email address.')
     expect(
-      (
-        screen.getByRole('button', {
-          name: /add new email/i,
-        }) as HTMLButtonElement
-      ).disabled
+      screen.getByRole<HTMLButtonElement>('button', {
+        name: /add new email/i,
+      }).disabled
     ).to.be.true
 
     // the text is removed when the complete email address is typed, and the "add button" is reenabled
@@ -135,11 +131,9 @@ describe('<EmailsSection />', function () {
     })
     expect(screen.queryByText('Start by adding your email address.')).to.be.null
     expect(
-      (
-        screen.getByRole('button', {
-          name: /add new email/i,
-        }) as HTMLButtonElement
-      ).disabled
+      screen.getByRole<HTMLButtonElement>('button', {
+        name: /add new email/i,
+      }).disabled
     ).to.be.false
   })
 
@@ -147,10 +141,10 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
 
-    const addAnotherEmailBtn = (await screen.findByRole('button', {
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
       name: /add another email/i,
-    })) as HTMLButtonElement
-    fireEvent.click(addAnotherEmailBtn)
+    })
+    fireEvent.click(button)
 
     screen.getByRole('button', { name: /add new email/i })
   })
@@ -159,16 +153,17 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
 
+    const addAnotherEmailBtn = await screen.findByRole<HTMLButtonElement>(
+      'button',
+      { name: /add another email/i }
+    )
+
     await fetchMock.flush(true)
     resetFetchMock()
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [userEmailData])
       .post('/user/emails', 200)
 
-    const addAnotherEmailBtn = await screen.findByRole('button', {
-      name: /add another email/i,
-    })
-
     fireEvent.click(addAnotherEmailBtn)
     const input = screen.getByLabelText(/email/i)
 
@@ -176,9 +171,9 @@ describe('<EmailsSection />', function () {
       target: { value: userEmailData.email },
     })
 
-    const submitBtn = screen.getByRole('button', {
+    const submitBtn = screen.getByRole<HTMLButtonElement>('button', {
       name: /add new email/i,
-    }) as HTMLButtonElement
+    })
 
     expect(submitBtn.disabled).to.be.false
 
@@ -199,16 +194,17 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
 
+    const addAnotherEmailBtn = await screen.findByRole<HTMLButtonElement>(
+      'button',
+      { name: /add another email/i }
+    )
+
     await fetchMock.flush(true)
     resetFetchMock()
     fetchMock
       .get('/user/emails?ensureAffiliation=true', [])
       .post('/user/emails', 400)
 
-    const addAnotherEmailBtn = screen.getByRole('button', {
-      name: /add another email/i,
-    })
-
     fireEvent.click(addAnotherEmailBtn)
     const input = screen.getByLabelText(/email/i)
 
@@ -216,9 +212,9 @@ describe('<EmailsSection />', function () {
       target: { value: userEmailData.email },
     })
 
-    const submitBtn = screen.getByRole('button', {
+    const submitBtn = screen.getByRole<HTMLButtonElement>('button', {
       name: /add new email/i,
-    }) as HTMLButtonElement
+    })
 
     expect(submitBtn.disabled).to.be.false
 
@@ -237,15 +233,15 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     fetchMock.reset()
     fetchMock.get('express:/institutions/domains', institutionDomainData)
 
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
 
     const input = screen.getByLabelText(/email/i)
     fireEvent.change(input, {
@@ -261,26 +257,26 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     resetFetchMock()
 
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
 
     await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
 
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
 
-    const universityInput = screen.getByRole('textbox', {
+    const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
       name: /university/i,
-    }) as HTMLInputElement
+    })
 
     expect(universityInput.disabled).to.be.true
 
-    fetchMock.get(/\/institutions\/list/, [
+    fetchMock.get('/institutions/list?country_code=de', [
       {
         id: userEmailData.affiliation.institution.id,
         name: userEmailData.affiliation.institution.name,
@@ -306,7 +302,7 @@ describe('<EmailsSection />', function () {
     // Select the university from dropdown
     await userEvent.click(universityInput)
     await userEvent.click(
-      screen.getByText(userEmailData.affiliation.institution.name)
+      await screen.findByText(userEmailData.affiliation.institution.name)
     )
 
     const roleInput = screen.getByRole('textbox', { name: /role/i })
@@ -354,10 +350,14 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     resetFetchMock()
 
-    fetchMock.get(/\/institutions\/list/, [
+    fetchMock.get('/institutions/list?country_code=de', [
       {
         id: 1,
         name: 'University of Bonn',
@@ -369,34 +369,30 @@ describe('<EmailsSection />', function () {
     ])
 
     // open "add new email" section and click "let us know" to open the Country/University form
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
     await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
 
     // select a country
-    const countryInput = screen.getByRole('textbox', {
+    const countryInput = screen.getByRole<HTMLInputElement>('textbox', {
       name: /country/i,
-    }) as HTMLInputElement
+    })
     await userEvent.click(countryInput)
     await userEvent.type(countryInput, 'Germ')
     await userEvent.click(await screen.findByText('Germany'))
 
     // match several universities on initial typing
-    const universityInput = screen.getByRole('textbox', {
+    const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
       name: /university/i,
-    }) as HTMLInputElement
+    })
     await userEvent.click(universityInput)
     await userEvent.type(universityInput, 'bo')
-    screen.getByText('University of Bonn')
-    screen.getByText('Bochum institute of Science')
+    await screen.findByText('University of Bonn')
+    await screen.findByText('Bochum institute of Science')
 
     // match a single university when typing to refine the search
     await userEvent.type(universityInput, 'nn')
-    screen.getByText('University of Bonn')
+    await screen.findByText('University of Bonn')
     expect(screen.queryByText('Bochum institute of Science')).to.be.null
   })
 
@@ -407,22 +403,22 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     resetFetchMock()
 
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
 
     await userEvent.type(screen.getByLabelText(/email/i), userEmailData.email)
 
     await userEvent.click(screen.getByRole('button', { name: /let us know/i }))
 
-    const universityInput = screen.getByRole('textbox', {
+    const universityInput = screen.getByRole<HTMLInputElement>('textbox', {
       name: /university/i,
-    }) as HTMLInputElement
+    })
 
     expect(universityInput.disabled).to.be.true
 
@@ -510,6 +506,10 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     fetchMock.reset()
     fetchMock.get(
@@ -517,11 +517,7 @@ describe('<EmailsSection />', function () {
       institutionDomainDataCopy
     )
 
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
 
     await userEvent.type(
       screen.getByLabelText(/email/i),
@@ -583,6 +579,10 @@ describe('<EmailsSection />', function () {
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     render(<EmailsSection />)
 
+    const button = await screen.findByRole<HTMLButtonElement>('button', {
+      name: /add another email/i,
+    })
+
     await fetchMock.flush(true)
     fetchMock.reset()
     fetchMock.get(
@@ -590,11 +590,7 @@ describe('<EmailsSection />', function () {
       institutionDomainDataCopy
     )
 
-    await userEvent.click(
-      screen.getByRole('button', {
-        name: /add another email/i,
-      })
-    )
+    await userEvent.click(button)
 
     await userEvent.type(
       screen.getByLabelText(/email/i),

+ 7 - 2
services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx

@@ -78,6 +78,7 @@ describe('user role and institution', function () {
       hasAffiliationsFeature: true,
     })
     fetchMock.reset()
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
   })
 
   afterEach(function () {
@@ -120,7 +121,9 @@ describe('user role and institution', function () {
 
   it('fetches institution data and replaces departments dropdown on add/change', async function () {
     const userEmailData = userData1
-    fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailData])
+    fetchMock.get('/user/emails?ensureAffiliation=true', [userEmailData], {
+      overwriteRoutes: true,
+    })
     render(<EmailsSection />)
 
     await fetchMock.flush(true)
@@ -149,7 +152,9 @@ describe('user role and institution', function () {
 
   it('adds new role and department', async function () {
     fetchMock
-      .get('/user/emails?ensureAffiliation=true', [userData1])
+      .get('/user/emails?ensureAffiliation=true', [userData1], {
+        overwriteRoutes: true,
+      })
       .get(/\/institutions\/list/, { departments: [] })
       .post('/user/emails/endorse', 200)
     render(<EmailsSection />)

+ 19 - 19
services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx

@@ -13,6 +13,7 @@ import ReconfirmationInfo from '../../../../../../frontend/js/features/settings/
 import { ssoUserData } from '../../fixtures/test-user-email-data'
 import { UserEmailData } from '../../../../../../types/user-email'
 import { UserEmailsProvider } from '../../../../../../frontend/js/features/settings/context/user-email-context'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 function renderReconfirmationInfo(data: UserEmailData) {
   return render(
@@ -23,13 +24,21 @@ function renderReconfirmationInfo(data: UserEmailData) {
 }
 
 describe('<ReconfirmationInfo/>', function () {
+  let assignStub: sinon.SinonStub
+
   beforeEach(function () {
     window.metaAttributesCache = window.metaAttributesCache || new Map()
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
+    assignStub = sinon.stub()
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: assignStub,
+      reload: sinon.stub(),
+    })
   })
 
   afterEach(function () {
     fetchMock.reset()
+    this.locationStub.restore()
   })
 
   describe('reconfirmed via SAML', function () {
@@ -54,18 +63,11 @@ describe('<ReconfirmationInfo/>', function () {
 
   describe('in reconfirm notification period', function () {
     let inReconfirmUserData: UserEmailData
-    const locationStub = sinon.stub()
-    const originalLocation = window.location
 
     beforeEach(function () {
       window.metaAttributesCache.set('ol-ExposedSettings', {
         samlInitPath: '/saml',
       })
-      Object.defineProperty(window, 'location', {
-        value: {
-          assign: locationStub,
-        },
-      })
 
       inReconfirmUserData = cloneDeep(ssoUserData)
       if (inReconfirmUserData.affiliation) {
@@ -75,9 +77,6 @@ describe('<ReconfirmationInfo/>', function () {
 
     afterEach(function () {
       window.metaAttributesCache = new Map()
-      Object.defineProperty(window, 'location', {
-        value: originalLocation,
-      })
     })
 
     it('renders prompt', function () {
@@ -119,9 +118,9 @@ describe('<ReconfirmationInfo/>', function () {
         await waitFor(() => {
           expect(confirmButton.disabled).to.be.true
         })
-        sinon.assert.calledOnce(locationStub)
+        sinon.assert.calledOnce(assignStub)
         sinon.assert.calledWithMatch(
-          locationStub,
+          assignStub,
           '/saml/init?university_id=2&reconfirm=/user/settings'
         )
       })
@@ -137,9 +136,9 @@ describe('<ReconfirmationInfo/>', function () {
 
       it('sends and resends confirmation email', async function () {
         renderReconfirmationInfo(inReconfirmUserData)
-        const confirmButton = screen.getByRole('button', {
+        const confirmButton = (await screen.findByRole('button', {
           name: 'Confirm Affiliation',
-        }) as HTMLButtonElement
+        })) as HTMLButtonElement
 
         await waitFor(() => {
           expect(confirmButton.disabled).to.be.false
@@ -152,20 +151,21 @@ describe('<ReconfirmationInfo/>', function () {
         expect(fetchMock.called()).to.be.true
 
         // the confirmation text should now be displayed
-        screen.getByText(/Please check your email inbox to confirm/)
+        await screen.findByText(/Please check your email inbox to confirm/)
 
         // try the resend button
         fetchMock.resetHistory()
-        const resendButton = screen.getByRole('button', {
+        const resendButton = await screen.findByRole('button', {
           name: 'Resend confirmation email',
-        }) as HTMLButtonElement
+        })
 
         fireEvent.click(resendButton)
 
-        screen.getByText(/Sending/)
+        // commented out as it's already gone by this point
+        // await screen.findByText(/Sending/)
         expect(fetchMock.called()).to.be.true
         await waitForElementToBeRemoved(() => screen.getByText(/Sending/))
-        screen.getByRole('button', {
+        await screen.findByRole('button', {
           name: 'Resend confirmation email',
         })
       })

+ 9 - 12
services/web/test/frontend/features/settings/components/leave/modal-form.test.tsx

@@ -4,6 +4,7 @@ import { fireEvent, screen, render, waitFor } from '@testing-library/react'
 import fetchMock, { FetchMockStatic } from 'fetch-mock'
 
 import LeaveModalForm from '../../../../../../frontend/js/features/settings/components/leave/modal-form'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 describe('<LeaveModalForm />', function () {
   beforeEach(function () {
@@ -51,27 +52,23 @@ describe('<LeaveModalForm />', function () {
     let setInFlight: sinon.SinonStub
     let setIsFormValid: sinon.SinonStub
     let deleteMock: FetchMockStatic
-    let locationStub: sinon.SinonStub
-    const originalLocation = window.location
+    let assignStub: sinon.SinonStub
 
     beforeEach(function () {
       setInFlight = sinon.stub()
       setIsFormValid = sinon.stub()
       deleteMock = fetchMock.post('/user/delete', 200)
-      locationStub = sinon.stub()
-      Object.defineProperty(window, 'location', {
-        value: {
-          assign: locationStub,
-        },
+      assignStub = sinon.stub()
+      this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+        assign: assignStub,
+        reload: sinon.stub(),
       })
       window.metaAttributesCache.set('ol-ExposedSettings', { isOverleaf: true })
     })
 
     afterEach(function () {
       fetchMock.reset()
-      Object.defineProperty(window, 'location', {
-        value: originalLocation,
-      })
+      this.locationStub.restore()
     })
 
     it('with valid form', async function () {
@@ -91,8 +88,8 @@ describe('<LeaveModalForm />', function () {
       await waitFor(() => {
         sinon.assert.calledTwice(setInFlight)
         sinon.assert.calledWithMatch(setInFlight, false)
-        sinon.assert.calledOnce(locationStub)
-        sinon.assert.calledWithMatch(locationStub, '/login')
+        sinon.assert.calledOnce(assignStub)
+        sinon.assert.calledWith(assignStub, '/login')
       })
     })
 

+ 9 - 0
services/web/test/frontend/features/settings/components/leavers-survey-alert.test.tsx

@@ -5,6 +5,7 @@ import { UserEmailsProvider } from '../../../../../frontend/js/features/settings
 import { LeaversSurveyAlert } from '../../../../../frontend/js/features/settings/components/leavers-survey-alert'
 import * as eventTracking from '../../../../../frontend/js/infrastructure/event-tracking'
 import localStorage from '../../../../../frontend/js/infrastructure/local-storage'
+import fetchMock from 'fetch-mock'
 
 function renderWithProvider() {
   render(<LeaversSurveyAlert />, {
@@ -15,6 +16,14 @@ function renderWithProvider() {
 }
 
 describe('<LeaversSurveyAlert/>', function () {
+  beforeEach(function () {
+    fetchMock.get('/user/emails?ensureAffiliation=true', [])
+  })
+
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
   it('should render before the expiration date', function () {
     const tomorrow = Date.now() + 1000 * 60 * 60 * 24
     localStorage.setItem('showInstitutionalLeaversSurveyUntil', tomorrow)

+ 6 - 5
services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js

@@ -14,12 +14,12 @@ import {
   renderWithEditorContext,
   cleanUpContext,
 } from '../../../helpers/render-with-context'
-import * as locationModule from '../../../../../frontend/js/shared/components/location'
 import {
   EditorProviders,
   USER_EMAIL,
   USER_ID,
 } from '../../../helpers/editor-providers'
+import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
 
 describe('<ShareProjectModal/>', function () {
   const project = {
@@ -85,6 +85,10 @@ describe('<ShareProjectModal/>', function () {
   }
 
   beforeEach(function () {
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: sinon.stub(),
+      reload: sinon.stub(),
+    })
     fetchMock.get('/user/contacts', { contacts })
     window.metaAttributesCache = new Map()
     window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true })
@@ -92,6 +96,7 @@ describe('<ShareProjectModal/>', function () {
   })
 
   afterEach(function () {
+    this.locationStub.restore()
     fetchMock.restore()
     cleanUpContext()
     window.metaAttributesCache = new Map()
@@ -525,8 +530,6 @@ describe('<ShareProjectModal/>', function () {
       )
     })
 
-    const reloadStub = sinon.stub(locationModule, 'reload')
-
     const confirmButton = screen.getByRole('button', {
       name: 'Change owner',
     })
@@ -537,8 +540,6 @@ describe('<ShareProjectModal/>', function () {
     expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
 
     expect(fetchMock.done()).to.be.true
-    expect(reloadStub.calledOnce).to.be.true
-    reloadStub.restore()
   })
 
   it('sends invites to input email addresses', async function () {

+ 6 - 7
services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx

@@ -15,6 +15,7 @@ import {
   groupActiveSubscription,
   groupActiveSubscriptionWithPendingLicenseChange,
 } from '../../fixtures/subscriptions'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 const userId = 'fff999fff999'
 const memberGroupSubscriptions: MemberGroupSubscription[] = [
@@ -71,13 +72,13 @@ describe('<GroupSubscriptionMemberships />', function () {
   })
 
   describe('opens leave group modal when button is clicked', function () {
-    let reloadStub: () => void
-    const originalLocation = window.location
+    let reloadStub: sinon.SinonStub
 
     beforeEach(function () {
       reloadStub = sinon.stub()
-      Object.defineProperty(window, 'location', {
-        value: { reload: reloadStub },
+      this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+        assign: sinon.stub(),
+        reload: reloadStub,
       })
 
       render(
@@ -99,9 +100,7 @@ describe('<GroupSubscriptionMemberships />', function () {
     })
 
     afterEach(function () {
-      Object.defineProperty(window, 'location', {
-        value: originalLocation,
-      })
+      this.locationStub.restore()
     })
 
     it('close the modal', function () {

+ 17 - 7
services/web/test/frontend/features/subscription/components/dashboard/personal-subscription.test.tsx

@@ -17,9 +17,9 @@ import {
   renderWithSubscriptionDashContext,
 } from '../../helpers/render-with-subscription-dash-context'
 import { reactivateSubscriptionUrl } from '../../../../../../frontend/js/features/subscription/data/subscription-url'
-import * as locationModule from '../../../../../../frontend/js/shared/components/location'
 import fetchMock from 'fetch-mock'
 import sinon from 'sinon'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 describe('<PersonalSubscription />', function () {
   afterEach(function () {
@@ -48,6 +48,20 @@ describe('<PersonalSubscription />', function () {
   })
 
   describe('subscription states  ', function () {
+    let reloadStub: sinon.SinonStub
+
+    beforeEach(function () {
+      reloadStub = sinon.stub()
+      this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+        assign: sinon.stub(),
+        reload: reloadStub,
+      })
+    })
+
+    afterEach(function () {
+      this.locationStub.restore()
+    })
+
     it('renders the active dash', function () {
       renderWithSubscriptionDashContext(<PersonalSubscription />, {
         metaTags: [
@@ -82,8 +96,6 @@ describe('<PersonalSubscription />', function () {
     })
 
     it('reactivates canceled plan', async function () {
-      const reload = sinon.stub(locationModule, 'reload')
-
       renderWithSubscriptionDashContext(<PersonalSubscription />, {
         metaTags: [{ name: 'ol-subscription', value: canceledSubscription }],
       })
@@ -98,18 +110,16 @@ describe('<PersonalSubscription />', function () {
       expect(reactivateBtn.disabled).to.be.true
       await fetchMock.flush(true)
       expect(reactivateBtn.disabled).to.be.false
-      expect(reload).not.to.have.been.called
+      expect(reloadStub).not.to.have.been.called
       fetchMock.reset()
 
       // 2nd click - success
       fetchMock.postOnce(reactivateSubscriptionUrl, 200)
       fireEvent.click(reactivateBtn)
       await fetchMock.flush(true)
-      expect(reload).to.have.been.calledOnce
+      expect(reloadStub).to.have.been.calledOnce
       expect(reactivateBtn.disabled).to.be.true
       fetchMock.reset()
-
-      reload.restore()
     })
 
     it('renders the expired dash', function () {

+ 6 - 3
services/web/test/frontend/features/subscription/components/dashboard/premium-features-link.test.tsx

@@ -3,6 +3,7 @@ import sinon from 'sinon'
 import { fireEvent, render, screen, within } from '@testing-library/react'
 import * as eventTracking from '../../../../../../frontend/js/infrastructure/event-tracking'
 import PremiumFeaturesLink from '../../../../../../frontend/js/features/subscription/components/dashboard/premium-features-link'
+import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
 
 describe('<PremiumFeaturesLink />', function () {
   const originalLocation = window.location
@@ -17,14 +18,16 @@ describe('<PremiumFeaturesLink />', function () {
   beforeEach(function () {
     window.metaAttributesCache = new Map()
     sendMBSpy = sinon.spy(eventTracking, 'sendMB')
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: sinon.stub(),
+      reload: sinon.stub(),
+    })
   })
 
   afterEach(function () {
     window.metaAttributesCache = new Map()
     sendMBSpy.restore()
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
   })
 
   for (const variant of variants) {

+ 8 - 9
services/web/test/frontend/features/subscription/components/dashboard/states/active/active.test.tsx

@@ -21,6 +21,7 @@ import {
   extendTrialUrl,
   subscriptionUpdateUrl,
 } from '../../../../../../../../frontend/js/features/subscription/data/subscription-url'
+import * as useLocationModule from '../../../../../../../../frontend/js/shared/hooks/use-location'
 
 describe('<ActiveSubscription />', function () {
   let sendMBSpy: sinon.SinonSpy
@@ -195,20 +196,18 @@ describe('<ActiveSubscription />', function () {
   })
 
   describe('cancel plan', function () {
-    const locationStub = sinon.stub()
+    const assignStub = sinon.stub()
     const reloadStub = sinon.stub()
-    const originalLocation = window.location
 
     beforeEach(function () {
-      Object.defineProperty(window, 'location', {
-        value: { assign: locationStub, reload: reloadStub },
+      this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+        assign: assignStub,
+        reload: reloadStub,
       })
     })
 
     afterEach(function () {
-      Object.defineProperty(window, 'location', {
-        value: originalLocation,
-      })
+      this.locationStub.restore()
       fetchMock.reset()
     })
 
@@ -256,9 +255,9 @@ describe('<ActiveSubscription />', function () {
       })
       fireEvent.click(button)
       await waitFor(() => {
-        expect(locationStub).to.have.been.called
+        expect(assignStub).to.have.been.called
       })
-      sinon.assert.calledWithMatch(locationStub, '/user/subscription/canceled')
+      sinon.assert.calledWithMatch(assignStub, '/user/subscription/canceled')
     })
 
     it('shows an error message if canceling subscription failed', async function () {

+ 7 - 7
services/web/test/frontend/features/subscription/components/dashboard/states/active/change-plan/change-plan.test.tsx

@@ -20,25 +20,25 @@ import {
   subscriptionUpdateUrl,
 } from '../../../../../../../../../frontend/js/features/subscription/data/subscription-url'
 import { renderActiveSubscription } from '../../../../../helpers/render-active-subscription'
+import * as useLocationModule from '../../../../../../../../../frontend/js/shared/hooks/use-location'
 
 describe('<ChangePlanModal />', function () {
-  let reloadStub: () => void
-  const originalLocation = window.location
   const plansMetaTag = { name: 'ol-plans', value: plans }
 
+  let reloadStub: sinon.SinonStub
+
   beforeEach(function () {
     reloadStub = sinon.stub()
-    Object.defineProperty(window, 'location', {
-      value: { reload: reloadStub },
+    this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
+      assign: sinon.stub(),
+      reload: reloadStub,
     })
   })
 
   afterEach(function () {
     cleanUpContext()
     fetchMock.reset()
-    Object.defineProperty(window, 'location', {
-      value: originalLocation,
-    })
+    this.locationStub.restore()
   })
 
   it('renders the individual plans table and group plans UI', async function () {