From d4a648835a41d0d05208c44d1bd7af1750f5ef15 Mon Sep 17 00:00:00 2001
From: Tomas Bulva <tbulva@gitlab.com>
Date: Wed, 12 Mar 2025 19:56:17 +0100
Subject: [PATCH] Fixed not updating properly other scopes when multimatch
 search term changes

---
 .../javascripts/search/store/actions.js       |  95 +++++-----
 .../javascripts/search/store/constants.js     |   1 +
 .../javascripts/search/store/mutations.js     |   2 +-
 app/assets/javascripts/search/store/utils.js  |  21 ++-
 .../search/topbar/components/app.vue          |   8 +-
 .../search/user_searches_for_code_spec.rb     |   8 +-
 spec/frontend/search/mock_data.js             |   2 +-
 spec/frontend/search/store/actions_spec.js    | 178 ++++++++++++++----
 spec/frontend/search/store/utils_spec.js      |  44 +++++
 .../search/topbar/components/app_spec.js      |  44 +++++
 spec/support/helpers/search_helpers.rb        |   5 +-
 11 files changed, 319 insertions(+), 89 deletions(-)

diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index afc08045afaae..c7a7e33889881 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -1,4 +1,5 @@
 import { omitBy } from 'lodash';
+import { nextTick } from 'vue';
 import Api from '~/api';
 import { createAlert } from '~/alert';
 import axios from '~/lib/utils/axios_utils';
@@ -23,6 +24,7 @@ import {
   prepareSearchAggregations,
   setDataToLS,
   skipBlobESCount,
+  buildDocumentTitle,
 } from './utils';
 
 export const fetchGroups = ({ commit }, search) => {
@@ -103,7 +105,45 @@ export const setFrequentProject = ({ state, commit }, item) => {
   commit(types.LOAD_FREQUENT_ITEMS, { key: PROJECTS_LOCAL_STORAGE_KEY, data: frequentItems });
 };
 
-export const setQuery = ({ state, commit, getters }, { key, value }) => {
+export const fetchSidebarCount = ({ commit, state }) => {
+  const items = Object.values(state.navigation)
+    .filter(
+      (navigationItem) =>
+        !navigationItem.active &&
+        navigationItem.count_link &&
+        skipBlobESCount(state, navigationItem.scope),
+    )
+    .map((navItem) => {
+      const navigationItem = { ...navItem };
+
+      const modifications = {
+        search: state.query?.search || '*',
+      };
+
+      if (navigationItem.scope === SCOPE_BLOB && loadDataFromLS(LS_REGEX_HANDLE)) {
+        modifications[REGEX_PARAM] = true;
+      }
+
+      navigationItem.count_link = setUrlParams(
+        modifications,
+        getNormalizedURL(navigationItem.count_link),
+      );
+      return navigationItem;
+    });
+
+  const promises = items.map((navigationItem) =>
+    axios
+      .get(navigationItem.count_link)
+      .then(({ data: { count } }) => {
+        commit(types.RECEIVE_NAVIGATION_COUNT, { key: navigationItem.scope, count });
+      })
+      .catch((e) => logError(e)),
+  );
+
+  return Promise.all(promises);
+};
+
+export const setQuery = async ({ state, commit, getters }, { key, value }) => {
   commit(types.SET_QUERY, { key, value });
 
   if (SIDEBAR_PARAMS.includes(key)) {
@@ -117,10 +157,14 @@ export const setQuery = ({ state, commit, getters }, { key, value }) => {
   if (
     state.searchType === SEARCH_TYPE_ZOEKT &&
     getters.currentScope === SCOPE_BLOB &&
-    gon.features.zoektMultimatchFrontend
+    gon.features?.zoektMultimatchFrontend
   ) {
     const newUrl = setUrlParams({ ...state.query }, window.location.href, false, true);
-    updateHistory({ state: state.query, url: newUrl, replace: true });
+    document.title = buildDocumentTitle(state.query.search);
+    updateHistory({ state: state.query, title: state.query.search, url: newUrl, replace: false });
+
+    await nextTick();
+    fetchSidebarCount({ state, commit });
   }
 };
 
@@ -148,53 +192,16 @@ export const resetQuery = ({ state }) => {
   );
 };
 
-export const closeLabel = ({ state, commit }, { title }) => {
-  const labels = state?.query?.[LABEL_FILTER_PARAM].filter((labelName) => labelName !== title);
-  setQuery({ state, commit }, { key: LABEL_FILTER_PARAM, value: labels });
+export const closeLabel = ({ state, commit, getters }, { title }) => {
+  const labels =
+    state?.query?.[LABEL_FILTER_PARAM]?.filter((labelName) => labelName !== title) || [];
+  setQuery({ state, commit, getters }, { key: LABEL_FILTER_PARAM, value: labels });
 };
 
 export const setLabelFilterSearch = ({ commit }, { value }) => {
   commit(types.SET_LABEL_SEARCH_STRING, value);
 };
 
-export const fetchSidebarCount = ({ commit, state }) => {
-  const items = Object.values(state.navigation)
-    .filter(
-      (navigationItem) =>
-        !navigationItem.active &&
-        navigationItem.count_link &&
-        skipBlobESCount(state, navigationItem.scope),
-    )
-    .map((navItem) => {
-      const navigationItem = { ...navItem };
-      const modifications = {
-        search: state.query?.search || '*',
-      };
-
-      if (navigationItem.scope === SCOPE_BLOB && loadDataFromLS(LS_REGEX_HANDLE)) {
-        modifications[REGEX_PARAM] = true;
-      }
-
-      navigationItem.count_link = setUrlParams(
-        modifications,
-        getNormalizedURL(navigationItem.count_link),
-      );
-
-      return navigationItem;
-    });
-
-  const promises = items.map((navigationItem) =>
-    axios
-      .get(navigationItem.count_link)
-      .then(({ data: { count } }) => {
-        commit(types.RECEIVE_NAVIGATION_COUNT, { key: navigationItem.scope, count });
-      })
-      .catch((e) => logError(e)),
-  );
-
-  return Promise.all(promises);
-};
-
 export const fetchAllAggregation = ({ commit, state }) => {
   commit(types.REQUEST_AGGREGATIONS);
   return axios
diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js
index dbb2ce7f11205..fa9270ec5ff3a 100644
--- a/app/assets/javascripts/search/store/constants.js
+++ b/app/assets/javascripts/search/store/constants.js
@@ -84,3 +84,4 @@ export const SEARCH_LEVEL_PROJECT = 'project';
 export const SEARCH_LEVEL_GROUP = 'group';
 
 export const LS_REGEX_HANDLE = `${REGEX_PARAM}_advanced_search`;
+export const SEARCH_WINDOW_TITLE = `${s__('GlobalSearch|Search')} · GitLab`;
diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js
index 7627b2e0e08a7..b67f7d2843b77 100644
--- a/app/assets/javascripts/search/store/mutations.js
+++ b/app/assets/javascripts/search/store/mutations.js
@@ -33,7 +33,7 @@ export default {
     state.frequentItems[key] = data;
   },
   [types.RECEIVE_NAVIGATION_COUNT](state, { key, count }) {
-    const item = { ...state.navigation[key], count, count_link: null };
+    const item = { ...state.navigation[key], count };
     state.navigation = { ...state.navigation, [key]: item };
   },
   [types.REQUEST_AGGREGATIONS](state) {
diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js
index 4ef2d91c057df..aa203c0ba0485 100644
--- a/app/assets/javascripts/search/store/utils.js
+++ b/app/assets/javascripts/search/store/utils.js
@@ -14,6 +14,7 @@ import {
   NUMBER_FORMATING_OPTIONS,
   REGEX_PARAM,
   LS_REGEX_HANDLE,
+  SEARCH_WINDOW_TITLE,
 } from './constants';
 
 function extractKeys(object, keyList) {
@@ -114,7 +115,6 @@ export const mergeById = (inflatedData, storedData) => {
 
 export const isSidebarDirty = (currentQuery, urlQuery) => {
   return SIDEBAR_PARAMS.some((param) => {
-    // userAddParam ensures we don't get a false dirty from null !== undefined
     const userAddedParam = !urlQuery[param] && currentQuery[param];
     const userChangedExistingParam = urlQuery[param] && urlQuery[param] !== currentQuery[param];
 
@@ -219,3 +219,22 @@ export const skipBlobESCount = (state, itemScope) =>
     state.zoektAvailable &&
     itemScope === SCOPE_BLOB
   );
+
+export const buildDocumentTitle = (title) => {
+  const prevTitle = document.title;
+
+  if (prevTitle.includes(SEARCH_WINDOW_TITLE)) {
+    if (prevTitle.startsWith(SEARCH_WINDOW_TITLE)) {
+      return `${title} · ${SEARCH_WINDOW_TITLE}`;
+    }
+
+    if (prevTitle.trim().startsWith(` · ${SEARCH_WINDOW_TITLE}`.trim())) {
+      return `${title} · ${SEARCH_WINDOW_TITLE}`;
+    }
+
+    const pattern = new RegExp(`^.*?(?= · ${SEARCH_WINDOW_TITLE})`);
+    return prevTitle.replace(pattern, title);
+  }
+  // If pattern not found, return the original
+  return title;
+};
diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue
index 8e04bda01ae78..1811a608d7c2c 100644
--- a/app/assets/javascripts/search/topbar/components/app.vue
+++ b/app/assets/javascripts/search/topbar/components/app.vue
@@ -1,8 +1,9 @@
 <script>
 import { GlButton } from '@gitlab/ui';
-import { isEmpty } from 'lodash';
+import { isEmpty, debounce } from 'lodash';
 // eslint-disable-next-line no-restricted-imports
 import { mapState, mapActions, mapGetters } from 'vuex';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
 import { InternalEvents } from '~/tracking';
 import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
 import { s__ } from '~/locale';
@@ -51,6 +52,10 @@ export default {
         return this.query ? this.query.search : '';
       },
       set(value) {
+        if (this.isMultiMatch) {
+          this.debouncedSetQuery({ key: 'search', value });
+          return;
+        }
         this.setQuery({ key: 'search', value });
       },
     },
@@ -86,6 +91,7 @@ export default {
   created() {
     this.preloadStoredFrequentItems();
     this.regexEnabled = loadDataFromLS(LS_REGEX_HANDLE);
+    this.debouncedSetQuery = debounce(this.setQuery, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
   },
   methods: {
     ...mapActions(['applyQuery', 'setQuery', 'preloadStoredFrequentItems']),
diff --git a/spec/features/search/user_searches_for_code_spec.rb b/spec/features/search/user_searches_for_code_spec.rb
index 48161e675dd84..ae861817234fe 100644
--- a/spec/features/search/user_searches_for_code_spec.rb
+++ b/spec/features/search/user_searches_for_code_spec.rb
@@ -7,7 +7,13 @@
   include ListboxHelpers
 
   let_it_be(:user) { create(:user) }
-  let_it_be_with_reload(:project) { create(:project, :repository, namespace: user.namespace) }
+  let_it_be_with_reload(:project) do
+    # This helps with some of the test flakiness.
+    project = create(:project, :repository, namespace: user.namespace)
+    project.repository.root_ref
+    project.repository.ls_files('master')
+    project
+  end
 
   context 'when signed in' do
     before do
diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js
index 4ec1e01e55326..558959b38e24c 100644
--- a/spec/frontend/search/mock_data.js
+++ b/spec/frontend/search/mock_data.js
@@ -220,7 +220,7 @@ export const MOCK_DATA_FOR_NAVIGATION_ACTION_MUTATION = {
     label: 'Projects',
     scope: 'projects',
     link: '/search?scope=projects&search=et',
-    count_link: null,
+    count_link: '/search/count?scope=projects&search=et',
   },
 };
 
diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js
index c92600e50aafd..ba98fc85298b4 100644
--- a/spec/frontend/search/store/actions_spec.js
+++ b/spec/frontend/search/store/actions_spec.js
@@ -1,6 +1,9 @@
 import MockAdapter from 'axios-mock-adapter';
 import { mapValues } from 'lodash';
+// rspec spec/frontend/fixtures/search_navigation.rb to generate this file
+import noActiveItems from 'test_fixtures/search_navigation/no_active_items.json';
 import testAction from 'helpers/vuex_action_helper';
+import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
 import Api from '~/api';
 import { createAlert } from '~/alert';
 import * as logger from '~/lib/logger';
@@ -45,6 +48,17 @@ jest.mock('~/lib/logger', () => ({
   logError: jest.fn(),
 }));
 
+jest.mock('~/lib/utils/url_utility', () => {
+  const urlUtility = jest.requireActual('~/lib/utils/url_utility');
+
+  return {
+    __esModule: true,
+    ...urlUtility,
+    setUrlParams: jest.fn(() => 'mocked-new-url'),
+    updateHistory: jest.fn(),
+  };
+});
+
 describe('Global Search Store Actions', () => {
   let mock;
   let state;
@@ -159,41 +173,112 @@ describe('Global Search Store Actions', () => {
     });
   });
 
-  describe.each`
-    payload                                      | isDirty  | isDirtyMutation
-    ${{ key: SIDEBAR_PARAMS[0], value: 'test' }} | ${false} | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: false }]}
-    ${{ key: SIDEBAR_PARAMS[0], value: 'test' }} | ${true}  | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: true }]}
-    ${{ key: SIDEBAR_PARAMS[1], value: 'test' }} | ${false} | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: false }]}
-    ${{ key: SIDEBAR_PARAMS[1], value: 'test' }} | ${true}  | ${[{ type: types.SET_SIDEBAR_DIRTY, payload: true }]}
-    ${{ key: 'non-sidebar', value: 'test' }}     | ${false} | ${[]}
-    ${{ key: 'non-sidebar', value: 'test' }}     | ${true}  | ${[]}
-  `('setQuery', ({ payload, isDirty, isDirtyMutation }) => {
-    describe(`when filter param is ${payload.key} and utils.isSidebarDirty returns ${isDirty}`, () => {
-      const expectedMutations = [{ type: types.SET_QUERY, payload }].concat(isDirtyMutation);
+  describe('setQuery', () => {
+    describe('when search type is zoekt and scope is blob with zoektMultimatchFrontend feature enabled', () => {
+      const payload = { key: 'some-key', value: 'some-value' };
+      let originalGon;
+      let commit;
+      let fetchSidebarCountSpy;
 
       beforeEach(() => {
-        storeUtils.isSidebarDirty = jest.fn().mockReturnValue(isDirty);
+        originalGon = window.gon;
+        commit = jest.fn();
+
+        fetchSidebarCountSpy = jest
+          .spyOn(actions, 'fetchSidebarCount')
+          .mockImplementation(() => Promise.resolve());
+
+        window.gon = { features: { zoektMultimatchFrontend: true } };
+        storeUtils.isSidebarDirty = jest.fn().mockReturnValue(false);
+
+        state = createState({
+          query: { ...MOCK_QUERY, search: 'test-search' },
+          navigation: { ...MOCK_NAVIGATION },
+          searchType: 'zoekt',
+        });
       });
 
-      it(`should dispatch the correct mutations`, () => {
-        return testAction({ action: actions.setQuery, payload, state, expectedMutations });
+      afterEach(() => {
+        window.gon = originalGon;
+        fetchSidebarCountSpy.mockRestore();
       });
-    });
-  });
 
-  describe.each`
-    payload
-    ${{ key: REGEX_PARAM, value: true }}
-    ${{ key: REGEX_PARAM, value: { random: 'test' } }}
-  `('setQuery', ({ payload }) => {
-    describe(`when query param is ${payload.key}`, () => {
-      beforeEach(() => {
-        storeUtils.setDataToLS = jest.fn();
-        actions.setQuery({ state, commit: jest.fn() }, payload);
+      it('should update URL, document title, and history', async () => {
+        const getters = { currentScope: 'blobs' };
+
+        await actions.setQuery({ state, commit, getters }, payload);
+
+        expect(setUrlParams).toHaveBeenCalledWith(
+          { ...state.query },
+          window.location.href,
+          false,
+          true,
+        );
+
+        expect(document.title).toBe(state.query.search);
+
+        expect(updateHistory).toHaveBeenCalledWith({
+          state: state.query,
+          title: state.query.search,
+          url: 'mocked-new-url',
+          replace: false,
+        });
+      });
+
+      it('does not update URL or fetch sidebar counts when conditions are not met', async () => {
+        let getters = { currentScope: 'blobs' };
+        state.searchType = 'not-zoekt';
+
+        await actions.setQuery({ state, commit, getters }, payload);
+
+        expect(setUrlParams).not.toHaveBeenCalled();
+        expect(updateHistory).not.toHaveBeenCalled();
+        expect(fetchSidebarCountSpy).not.toHaveBeenCalled();
+
+        setUrlParams.mockClear();
+        updateHistory.mockClear();
+        fetchSidebarCountSpy.mockClear();
+
+        state.searchType = 'zoekt';
+        getters = { currentScope: 'not-blobs' };
+
+        await actions.setQuery({ state, commit, getters }, payload);
+
+        expect(setUrlParams).not.toHaveBeenCalled();
+        expect(updateHistory).not.toHaveBeenCalled();
+        expect(fetchSidebarCountSpy).not.toHaveBeenCalled();
+
+        setUrlParams.mockClear();
+        updateHistory.mockClear();
+        fetchSidebarCountSpy.mockClear();
+
+        getters = { currentScope: 'blobs' };
+        window.gon.features.zoektMultimatchFrontend = false;
+
+        await actions.setQuery({ state, commit, getters }, payload);
+
+        expect(setUrlParams).not.toHaveBeenCalled();
+        expect(updateHistory).not.toHaveBeenCalled();
+        expect(fetchSidebarCountSpy).not.toHaveBeenCalled();
       });
+    });
+
+    describe.each`
+      payload
+      ${{ key: REGEX_PARAM, value: true }}
+      ${{ key: REGEX_PARAM, value: { random: 'test' } }}
+    `('setQuery with REGEX_PARAM', ({ payload }) => {
+      describe(`when query param is ${payload.key}`, () => {
+        beforeEach(() => {
+          storeUtils.setDataToLS = jest.fn();
+          window.gon = { features: { zoektMultimatchFrontend: false } };
+          const getters = { currentScope: 'not-blobs' };
+          actions.setQuery({ state, commit: jest.fn(), getters }, payload);
+        });
 
-      it(`setsItem in local storage`, () => {
-        expect(storeUtils.setDataToLS).toHaveBeenCalledWith(LS_REGEX_HANDLE, expect.anything());
+        it(`setsItem in local storage`, () => {
+          expect(storeUtils.setDataToLS).toHaveBeenCalledWith(LS_REGEX_HANDLE, expect.anything());
+        });
       });
     });
   });
@@ -201,7 +286,12 @@ describe('Global Search Store Actions', () => {
   describe('applyQuery', () => {
     beforeEach(() => {
       setWindowLocation('https://test/');
-      jest.spyOn(urlUtils, 'visitUrl').mockReturnValue({});
+      jest.spyOn(urlUtils, 'visitUrl').mockImplementation(() => {});
+      jest
+        .spyOn(urlUtils, 'setUrlParams')
+        .mockReturnValue(
+          'https://test/?scope=issues&state=all&group_id=1&language%5B%5D=C&language%5B%5D=JavaScript&label_name%5B%5D=Aftersync&label_name%5B%5D=Brist&search=*',
+        );
     });
 
     it('calls visitUrl and setParams with the state.query', async () => {
@@ -355,17 +445,29 @@ describe('Global Search Store Actions', () => {
     });
   });
 
-  describe('fetchSidebarCount uses wild card seach', () => {
+  describe('fetchSidebarCount uses wild card search', () => {
     beforeEach(() => {
-      state.navigation = MOCK_NAVIGATION;
-      state.urlQuery.search = '';
+      state.navigation = noActiveItems;
+      state.query = { search: '' };
+      state.urlQuery = { search: '' };
+
+      jest.spyOn(urlUtils, 'setUrlParams').mockImplementation((params) => {
+        return `http://test.host/search/count?search=${params.search || '*'}`;
+      });
+
+      storeUtils.skipBlobESCount = jest.fn().mockReturnValue(true);
+
+      mock.onGet().reply(HTTP_STATUS_OK, MOCK_ENDPOINT_RESPONSE);
     });
 
     it('should use wild card', async () => {
-      await testAction({ action: actions.fetchSidebarCount, state, expectedMutations: [] });
-      expect(mock.history.get[0].url).toBe('http://test.host/search/count?scope=projects&search=*');
-      expect(mock.history.get[3].url).toBe(
-        'http://test.host/search/count?scope=merge_requests&search=*',
+      const commit = jest.fn();
+
+      await actions.fetchSidebarCount({ commit, state });
+
+      expect(urlUtils.setUrlParams).toHaveBeenCalledWith(
+        expect.objectContaining({ search: '*' }),
+        expect.anything(),
       );
     });
   });
@@ -409,16 +511,16 @@ describe('Global Search Store Actions', () => {
         {
           payload: {
             key: 'label_name',
-            value: ['Aftersync', 'Brist'],
+            value: ['Aftersync'],
           },
           type: 'SET_QUERY',
         },
         {
-          payload: true,
+          payload: false,
           type: 'SET_SIDEBAR_DIRTY',
         },
       ];
-      return testAction(actions.closeLabel, { key: '60' }, state, expectedResult, []);
+      return testAction(actions.closeLabel, { title: 'Brist' }, state, expectedResult, []);
     });
   });
 
diff --git a/spec/frontend/search/store/utils_spec.js b/spec/frontend/search/store/utils_spec.js
index 014f963002e1c..ffb0c80ed06e0 100644
--- a/spec/frontend/search/store/utils_spec.js
+++ b/spec/frontend/search/store/utils_spec.js
@@ -1,3 +1,4 @@
+// rspec spec/frontend/fixtures/search_navigation.rb to generate these files
 import subItemActive from 'test_fixtures/search_navigation/sub_item_active.json';
 import noActiveItems from 'test_fixtures/search_navigation/no_active_items.json';
 import partialNavigationActive from 'test_fixtures/search_navigation/partial_navigation_active.json';
@@ -17,6 +18,7 @@ import {
   injectRegexSearch,
   scopeCrawler,
   skipBlobESCount,
+  buildDocumentTitle,
 } from '~/search/store/utils';
 import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
 import { TEST_HOST } from 'helpers/test_constants';
@@ -447,4 +449,46 @@ describe('Global Search Store Utils', () => {
       expect(skipBlobESCount(state, SCOPE_BLOB)).toBe(false);
     });
   });
+
+  describe('buildDocumentTitle', () => {
+    const SEARCH_WINDOW_TITLE = `Search`; // Make sure this matches your actual constant
+    let originalTitle;
+
+    beforeEach(() => {
+      originalTitle = document.title;
+    });
+
+    afterEach(() => {
+      document.title = originalTitle;
+    });
+
+    it('returns original title when document title does not include search title', () => {
+      document.title = 'GitLab';
+      expect(buildDocumentTitle('test')).toBe('test');
+    });
+
+    it('prepends new title when document title starts with search title', () => {
+      document.title = `${SEARCH_WINDOW_TITLE} · GitLab`;
+      const result = buildDocumentTitle('test');
+      expect(result).toBe(`test · ${SEARCH_WINDOW_TITLE} · GitLab`);
+    });
+
+    it('prepends new title when document title starts with dot and search title', () => {
+      document.title = ` · ${SEARCH_WINDOW_TITLE} · GitLab`;
+      const result = buildDocumentTitle('test');
+      expect(result).toBe(`test · ${SEARCH_WINDOW_TITLE} · GitLab`);
+    });
+
+    it('replaces title before search title with new title', () => {
+      document.title = `Issues · ${SEARCH_WINDOW_TITLE} · GitLab`;
+      const result = buildDocumentTitle('test');
+      expect(result).toBe(`test · ${SEARCH_WINDOW_TITLE} · GitLab`);
+    });
+
+    it('handles complex titles correctly', () => {
+      document.title = `Something · With · Dots · ${SEARCH_WINDOW_TITLE} · GitLab`;
+      const result = buildDocumentTitle('test');
+      expect(result).toBe(`test · ${SEARCH_WINDOW_TITLE} · GitLab`);
+    });
+  });
 });
diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js
index ca4023086db30..da2d3e12ffc66 100644
--- a/spec/frontend/search/topbar/components/app_spec.js
+++ b/spec/frontend/search/topbar/components/app_spec.js
@@ -229,4 +229,48 @@ describe('GlobalSearchTopbar', () => {
       });
     });
   });
+
+  describe('search computed property setter', () => {
+    describe.each`
+      FF                                    | scope       | searchType    | debounced
+      ${{ zoektMultimatchFrontend: true }}  | ${'blobs'}  | ${'zoekt'}    | ${true}
+      ${{ zoektMultimatchFrontend: false }} | ${'blobs'}  | ${'zoekt'}    | ${false}
+      ${{ zoektMultimatchFrontend: true }}  | ${'issues'} | ${'zoekt'}    | ${false}
+      ${{ zoektMultimatchFrontend: true }}  | ${'blobs'}  | ${'advanced'} | ${false}
+    `(
+      'when isMultiMatch is $debounced (FF: $FF, scope: $scope, searchType: $searchType)',
+      ({ FF, scope, searchType, debounced }) => {
+        beforeEach(() => {
+          getterSpies.currentScope = jest.fn(() => scope);
+          actionSpies.setQuery.mockClear();
+
+          createComponent({
+            featureFlag: FF,
+            initialState: { searchType },
+          });
+
+          wrapper.vm.debouncedSetQuery = jest.fn();
+        });
+
+        it(`${debounced ? 'calls debouncedSetQuery' : 'calls setQuery directly'}`, () => {
+          findGlSearchBox().vm.$emit('input', 'new search value');
+
+          if (debounced) {
+            expect(actionSpies.setQuery).not.toHaveBeenCalled();
+          } else {
+            expect(actionSpies.setQuery).toHaveBeenCalled();
+
+            const lastCallArgs = actionSpies.setQuery.mock.calls[0];
+            const payload = lastCallArgs[lastCallArgs.length - 1];
+            expect(payload).toEqual(
+              expect.objectContaining({
+                key: 'search',
+                value: 'new search value',
+              }),
+            );
+          }
+        });
+      },
+    );
+  });
 });
diff --git a/spec/support/helpers/search_helpers.rb b/spec/support/helpers/search_helpers.rb
index b915196462825..d4bf82f536a67 100644
--- a/spec/support/helpers/search_helpers.rb
+++ b/spec/support/helpers/search_helpers.rb
@@ -9,15 +9,16 @@ def fill_in_search(text)
   end
 
   def submit_search(query)
-    # Forms directly on the search page
     if page.has_css?('.search-page-form')
       search_form = '.search-page-form'
-    # Open search modal from super sidebar
+
     else
       find_by_testid('super-sidebar-search-button').click
       search_form = '#super-sidebar-search-modal'
     end
 
+    wait_for_all_requests
+
     page.within(search_form) do
       field = find_field('search')
       field.click
-- 
GitLab