Skip to content

fix: make useCountries share in-flight requests #2555

Description

Problem

useCountries() currently uses local Vue provide/inject state directly inside the composable:

const _sharedCountries = inject("swCountries", ref());
provide("swCountries", _sharedCountries);

This is inconsistent with the shared-composable pattern already used elsewhere in the repo, for example useCart, usePrice, and modal composables via createSharedComposable.

It also means concurrent initial consumers can still trigger duplicate requests before the shared countries ref is populated.

On pages like templates/vue-starter-template/app/pages/account/login.vue, multiple consumers can mount at roughly the same time:

  • SharedCountryStateInput.vue calls useCountries() for country/state options.
  • registrationFormRules.ts calls useCountries() for country-state validation.
  • Other address-related components can do the same on checkout/account pages.

Because each caller sees countries as empty before the first request resolves, duplicate Store API requests can be issued for the same criteria.

Proposal

Refactor useCountries() to follow the existing createSharedComposable pattern rather than managing sharing directly with provide/inject.

The expected shape should be similar to patterns already present in the repo:

import { createSharedComposable } from "@vueuse/core";

function _useCountries(criteria?: Schemas["Criteria"]): UseCountriesReturn {
  // existing state/fetch/computed logic
}

export const useCountries = createSharedComposable(_useCountries);

While doing this, make sure concurrent initial fetches are deduplicated by storing an in-flight promise in the shared composable state:

const countriesRequest = ref<
  Promise<operations["readCountry post /country"]["response"]> | null
>(null);

async function fetchCountries() {
  if (countriesRequest.value) {
    return countriesRequest.value;
  }

  const queryCriteria = defu(searchCriteria.value, {
    associations: {
      states: {},
    },
  } as Schemas["Criteria"]);

  countriesRequest.value = (
    cacheableReads
      ? apiClient.invoke("readCountryGet get /country", {
          query: { _criteria: encodeForQuery(queryCriteria) },
        })
      : apiClient.invoke("readCountry post /country", {
          body: queryCriteria,
        })
  ).then((result) => {
    countries.value = result.data.elements;
    return result.data;
  });

  try {
    return await countriesRequest.value;
  } finally {
    countriesRequest.value = null;
  }
}

Avoid adding new provide/inject keys as the primary sharing mechanism. The goal is to align this composable with the shared-composable convention already used in the package.

Suggested test coverage

Add a regression test to packages/composables/src/useCountries/useCountries.test.ts verifying concurrent calls only invoke the API once:

it("deduplicates concurrent country fetches", async () => {
  const { vm, injections } = await useSetup(() => useCountries(), {
    apiClient: {
      invoke: vi.fn().mockResolvedValue({ data: CountryMock }),
    },
  });

  const [firstResult, secondResult] = await Promise.all([
    vm.fetchCountries(),
    vm.fetchCountries(),
  ]);

  expect(injections.apiClient.invoke).toHaveBeenCalledTimes(1);
  expect(firstResult).toStrictEqual(CountryMock);
  expect(secondResult).toStrictEqual(CountryMock);
  expect(vm.getCountries).toStrictEqual(CountryMock.elements);
});

Also consider a test that two separate components calling useCountries() receive the same shared state, matching the behavior expected from createSharedComposable.

Acceptance criteria

  • useCountries() uses createSharedComposable instead of direct provide/inject sharing.
  • Multiple concurrent fetchCountries() calls share one Store API request.
  • Existing cacheableReads behavior remains unchanged: GET when enabled, POST otherwise.
  • Existing public return shape of useCountries() remains unchanged.
  • Add focused regression test coverage.

Related

This is separate from the deterministic _criteria encoding issue: #2553

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions