VueNuxt.dev
Vue Insights

useGoogleTranslate

Detect if GoogleTranslate is used on your SPA

Intro

Join me in creating a composable to check if Google Translate is active while users visit your single-page application (SPA). This detection might seem trivial, but it silently wreaked havoc on the reactivity of a client's SPA with high international traffic. Even if this isn't directly relevant to your project, tag along as I share some interesting insights on the testing strategy of such a feature.


Problem

While working on a high-traffic international website, I learned the hard way that Google Translate wraps translated content in a new div element. This is a big deal because it decouples it from the virtual DOM, which drives our SPAs reactivity. While this issue affects only a small subset of users, it can significantly disrupt their entire user experience.

Here is what the injected div looks like:

<button><font style="vertical-align: inherit">total price</font></button>

Insight

You might think that we would have a deterministic way to verify that a translation is being applied to our site through web apis but sadly this is not the case. After some trial and error, I discovered that the only footprint left by Google Translate were a modified attribute and an added class to the html tag of our document. These mutations are sufficient though, as they indicate the use of an active translation with the translated-ltr class while also determining the target locale being used with lang="en". Which is crucial to suggesting a redirect to available localized content that would not require the use of Google Translate.

<html lang="en" class="lock-position translated-ltr ">...</html>

It's important to notice that users can enable automatic translations for specific languages in their browser settings, or they can manually activate translations at any time.

I selected this as our primary detection triggers because it effectively identifies both automatic and manual translations.

Observability

So far, so great. However, our plans depend heavily on specific implementation details from a third-party, which is far from ideal. From the beginning, I knew we would have to keep our eye on this because:

Google can change their implementation at any time without warning, creating a blind spot for our feature.

I understood that this situation was beyond my control, and that as a rule of thumb I should not test 3rd party functionality. However, in this case I needed to implement safeguards to monitor this; otherwise, it could fail silently, rendering the feature obsolete.

Given the constraints and the urgency of pushing a fix to production, we decided to rely on analytics to monitor this lagging metric. The process involved two steps. First, we measured how often we detected Google Translate usage, which was approximately 3% after the hotfix. Second, we set an alert to trigger if the detection dropped to less than 1%.

Through observability, we automated the process of maintaining confidence in our feature while giving ourselves the chance to be proactive in case Google changed their implementation.


POC

I began with a one-day timebox to develop a proof of concept for our technical solution. My initial idea was to listen for the identified mutations in our document. However, this approach involved monitoring the highest node in our DOM—the HTML tag—which can be inefficient and negatively impact performance. Therefore, I needed to ensure we were filtering out nested updates, aiming to improve the memory efficiency of our mechanism.

After some experiments, I decided to use the MutationObserver because it enabled us to watch attributes selectively and ignore childList updates.

Code-wise, we start by creating a new instance of MutationObserver, which we will assign to a const named observer. The MutationObserver takes a callback function that is executed after each mutation. We will add our logic to this function later.

const observer = new MutationObserver(() => {
  //...
})

We can configure the MutationObserver by calling its observe method and specifying the element we want to watch, which in our case is the documentElement. We can also pass a configuration object as a second argument that includes the options we need to fine-tune.

observer.observe(document.documentElement, {
  attributes: true,
  attributeFilter: ['class', 'lang'],
  childList: false,
  characterData: false
})

We will use four options for our settings. First, we need to monitor attributes, so we will set this option to true. Second, we will specify that only mutations related to class and lang should trigger our handler, filtering out all other attribute changes. Finally, to enhance our app's performance, we will set childList and characterData to false. This means we will ignore updates to the target nodes and their descendants, such as adding or removing child nodes, as well as changes to the character data within the node tree.

Mechanism

After solving how to detect mutations efficiently, our mechanism for handling language detection is straightforward. We compare the lang attribute, which is modified by Google Translate, with the locale prefix de in our route URL: https://high-traffic.eu/de/content, which is stable. If there is a mismatch, we check whether the target language is among our available locales. If it is, we can prompt the user to redirect to the localized content, eliminating the need for Google Translate. If the target language is not available, we warn the user about the potentital broken behavior due to the translation being enabled. In either case, we save the user's decision to avoid annoying them with repeated warnings.

  1. html.lang is en while route.lang is de
  2. We detect a mismatch and proceed
  3. Since en is an available locale, we prompt redirection to en
  4. We save the decision to avoid user annoyance

Design

This proof of concept validated our mechanism and reduced any uncertainty we had regarding performance. It also clarified the specifications needed for our testing strategy and justified the estimated effort with Product, resulting in the following code design:


TDD

Now that we have all the necessary pieces for our technical solution laid out, we can outline, at a high level, the tests that will drive our development. We expect that we will only need unit and integration tests to achieve the desired coverage for our feature.

Unit Tests

useGoogleTranslate.test.ts
describe('Unit Tests', async () => {
  test(`getPageLang: default`, () => {});
  test(`getPageLang: happy path`, () => {});
  test(`getRouteLang: happy path`, () => {});
});

Integration Tests

useGoogleTranslate.test.ts
describe('Integration Tests', async () => {
  test(`initObserver: initialization`, () => {});
  test(`initObserver: default handler`, () => {});
  test(`initObserver: custom handler`, () => {});
  test(`isDetected: default case`, () => {});
  test(`isDetected: ignores class footprint`, () => {});
  test(`isDetected: ignores lang mismatch`, () => {});
  test(`isDetected: happy path`, () => {});
  test(`hasMatchingLocale: default case`, () => {});
  test(`hasMatchingLocale: happy path`, () => {});
  test(`hasMatchingLocale: negative path`, () => {});
  test(`suggestLangSwitch: default case`, () => {});
  test(`suggestLangSwitch: happy path`, () => {});
  test(`suggestLangSwitch: negative path`, () => {});
  test(`redirectUrl: negative path`, () => {});
  test(`redirectUrl: happy path`, () => {});
});

Solution

Finally, it's time to get our hand dirty and start fleshing out some code for the new composable that will power and contain our detection mechanism.

import type { LangaugeCodes } from './useGoogleTranslate.types'

export const useGoogleTranslate = (availableLangs: LangaugeCodes) => {

  return {
    TEST: {
    }
  }
}

I added a TEST object to the return value of our composable to simplify testing in the upcoming steps. We can use it to include any internal elements that we want to manipulate during tests while isolating them from the public API of our composable. I prefer this approach because it allows us to test the wires in our composable without requiring a Vue instance or a DOM environment for vitest, significantly reducing our test execution times.

Lastly regarding types, our composable accepts an availableLangs parameter, which is an array of the language codes available in in our site. This parameter's type is an array of strings that conforms to the following format: xx-XX or ['en-US', 'de-DE'] for which a string type will not suffice.

export type LanguageCode = `${string}-${string}`;
export type LanguageCodes = LanguageCode[];

We use TypeScript (TS) to enhance the argument intake of our composable, as our logic relies on splitting the code by hyphens. By defining the character at the type level instead of using the general string type, we ensure greater accuracy and safety in our implementation.

This is how TS provides us with immediate feedback when we pass something that could break our code without having to run it, or worse wait for it to fail in production. It also saves us from writing a unit test that otherwise would be needed to account for this test case.

PageLang

Let's get started with the first section of our composable, which requires a method to retrieve the current language attribute in the HTML tag of our document and a ref for storing it. Both of these components are private and are accessible only through the TEST property in the public api of our composable.

Tests

We have two unit tests for this part. The first test checks that our initial lang attribute is set to the constant DE by default. The second test ensures we get a fresh value after mutating the lang attribute directly, confirming our getter method works as expected.

vi.stubGlobal('document', {});
mockDocumentElement(DE, '');

  test(`getPageLang: html lang defaults to '${DE}'`, () => {
    const {
      TEST: { getPageLang },
    } = useGoogleTranslate(AVAILABLE_LANGS);

    expect(getPageLang(DE)).toBe(DE);
    expect(getPageLang(DE)).not.toBe(EN);
  });
  test(`getPageLang: detects lang mutation to '${EN}'`, () => {
    const {
      TEST: { getPageLang },
    } = useGoogleTranslate(AVAILABLE_LANGS);

    document.documentElement.lang = EN;

    expect(getPageLang(DE)).toBe(EN);
    expect(getPageLang(DE)).not.toBe(DE);
  });

Our tests require use to mock the documentElement property from the document object in the runtime environment. I focus on the lang and className properties only because these are the ones we are watching for mutations. We initalize it in our tests like so:

useGoogleTranslate.test.ts
vi.stubGlobal('document', {});
mockDocumentElement(DE, '');

I created a new mockUtils file and placed it in the test folder of our project, as these are generic and highly reusable

export const mockDocumentElement = (lang: string, className: string) => {
  Object.defineProperty(document, 'documentElement', {
    value: {
      lang,
      className,
    },
  });
};

Code

Our required functionality is straightforward. The getter method getPageLang simply returns the value of LANG_ATTR, which we defined as a constant, from our documentElement or the default locale, which is also a constant and set to DE. The ref is initialized to the default locale DE.

  const LANG_ATTR = 'lang';
  const DE = 'de';

  export const CNST = {
    DE,
  };

  const pageLang = ref(DE);

  function getPageLang(): string {
    return document.documentElement[LANG_ATTR] || DE;
  }

RouteLang

We can now move on to the next section of our composable, which is responsible for extracting the language currently set in our route (which is not influenced by Google Translate). This step is essential for comparing it to our pageLang in order to determine if a redirection is possible.

In this section, we need two refs: one to store the route language and another to store the query string of our URL. This in order to rebuild a redirect link with the current route context.

For deep links, we might already have context data such as values for the date, filters, or other parameters, all of which we do not want to lose when redirecting users to a new locale.

Additionally, we will need a method to handle the route query and the processing of any parameters currently set in our route.

Tests

We only need one unit test for this part of our composable:

  vi.stubGlobal('window', {});
  mockWindowLocation(DE_LOCALE);

  test(`getRouteLang: extracts '${DE}' lang from route`, () => {
    const {
      TEST: { getRouteLang },
    } = useGoogleTranslate(AVAILABLE_LANGS);

    expect(getRouteLang(DE)).toBe(DE);
    expect(getRouteLang(DE)).not.toBe(EN);
  });

This test requires us to mock three things:

  1. the schema of our mockUrl to maintain consistency across tests.
  2. the window location object
  3. the URLSearchParams class from the runtime environment
export const mockUrl = (locale: string) =>
  `https://high-traffic.eu/de/deep-link/?destination=MUC&origin=BER&lng=${locale}#/category/select'`;

export const mockWindowLocation = (locale: string) => {
  Object.defineProperty(window, 'location', {
    value: {
      href: mockUrl(locale),
    },
  });
};

export class mockUrlSearchParams {
  qp: Record<string, string> = {};
  constructor(qs: string) {
    const params = qs.split('&');
    params.forEach((param) => {
      const [key, value] = param.split('=');
      this.qp[key] = value;
    });
  }
  get(key: keyof typeof this.qp) {
    return this.qp[key];
  }
  set(key: string, value: keyof typeof LOCALE_MAP) {
    this.qp[key] = LOCALE_MAP[value];
  }
  toString() {
    return Object.keys(this.qp)
      .map((key) => `${key}=${this.qp[key]}`)
      .join('&');
  }
}

Code

This code block introduces four new constants:

  • QP_LANG: The key for the language query parameter used in our URL.
  • QP_SEPARATOR: The separator used in language codes; es-MX.
  • QP_QUESTION and QP_HASH: These are implementation details to mimic how a specific project might choose to store data in the URL of the SPA for deep linking across services.

The getRouteLang method performs the following actions. First, it retrieves the url. If the url does not include our matching schema or if it is falsy, we return our default language, DE. Next, we extract the context portion of the URL and store it in our stateful ref called routeQueryParams. Finally, we use the value of routeQueryParams to access the QP_LANG param, splitting it's value using QP_SEPARATOR, and returning the first element [0] to match the two-letter language code returned by getPageLang from our HTML document.

We do not need exact matching of language codes because we do not support all variants. For example, someone visiting from en-US, which is not a locale we support, can be redirected to the en-UK locale, which we do support and would deactivate Google Translate.

Finally, we need to expose getRouteLang and routeLang through the TEST property of our composable.

onTranslated

The next step is to create a private helper method to colocate all the logic we need to run during the truthy case (activation) of the mutation observer toggle. We will call it onTranslated. This method will simply assign the return of each getters function to their corresponding refs values.

This part requires no tests as we have already tested its units (getter methods) and there is no need to test Vue (refs).

  function onTranslated(): void {
    pageLang.value = getPageLang();
    routeLang.value = getRouteLang();
  }

In terms of types, we just need to annotate that our method returns void.

initObserver

Next, we will wrap our mutation observer into a method called initObserver in our composable. This method will create a new instance of the observer, configure it, and return the instance for flexible execution outside the composable. For example, this can be done in the onMounted lifecycle hook of the component that uses it, where we know the client is hydrated, and we have a populated document object to work with.

While we might be tempted to abstract this initialization into the composable, I prefer to document this operation at the component level to enhance the readability, interoperability and comprehension of the component.

Component.vue
const { initObserver } = useGoogleTranslate()

onMounted(() => initObserver())

Tests

We need to evaluate three key aspects for this method. First, we should confirm that the method returns the instance of our observer and that it is initialized as expected. This serves as live documentation for our expected implementation. Second, we need to verify that the default event handler is triggered when a mutation is detected. Lastly, we must ensure that we can pass a custom event handler from outside the method.

  vi.stubGlobal('MutationObserver', mockMutationObserver);

  beforeEach(() => {
    document.documentElement.lang = DE;
  });

  describe('Integration Tests', async () => {
    test(`initObserver: returns observer and its configured as expected`, () => {
      const { initObserver } = useGoogleTranslate(AVAILABLE_LANGS);

      const observer = initObserver() as MutationObserverReturn;
      expect(observer).toBeInstanceOf(mockMutationObserver);
      expect(observer).toHaveProperty('observe');

      const { _state } = observer;
      expect(_state.el.lang).toBe(CNST.DE);
      expect(_state.el.className).toBe('');

      const { config } = _state;
      expect(config.attributes).toBeTruthy();
      expect(config.attributeFilter).toEqual(CNST.ATTRS);
      expect(config.childList).toBeFalsy();
      expect(config.characterData).toBeFalsy();
    });

    test.only(`initObserver: runs default handler on trigger`, () => {
      const {
        initObserver,
        TEST: { pageLang },
      } = useGoogleTranslate(AVAILABLE_LANGS);

      const observer = initObserver() as MutationObserverReturn;
      expect(toValue(pageLang)).toBe(DE);

      document.documentElement.lang = EN;
      observer.trigger();
      expect(toValue(pageLang)).toBe(EN);
    });

    test.only(`initObserver: runs custom handler on trigger`, () => {
      const {
        initObserver,
        TEST: { pageLang },
      } = useGoogleTranslate(AVAILABLE_LANGS);

      const handler = vi.fn();
      const observer = initObserver(handler) as MutationObserverReturn;
      observer.trigger();
      expect(handler).toHaveBeenCalled();
    });
  });

We do not need to test the functionality of the Mutation Observer itself, as it is third-party code. We can trust that the developers have tested their implementation.

However, what can potentially disrupt our functionality or negatively impact our performance is how we configure this observer. This is why we test the configuration parameters during initialization. Doing so provides us with sufficient coverage in case any of those parameters are accidentally altered.

Mocks

To thoroughly test this part of our code, we need to mock the Mutation Observer class. It's important to note that we use doubles to keep our tests running efficiently while also verifying implementation details. For this reason, we customize the mock to meet our testing needs. For example, the original class does not have a _state method; however, our test requires us to store the configuration within the instance, so we add it to the mock. Additionally, we manually create a trigger method since we do not have a runtime DOM environment to detect mutations.

  export class mockMutationObserver {
    _state: MockMutationObserverState = {
      el: undefined,
      handler: () => {},
      config: {
        attributes: false,
        attributeFilter: [],
        childList: false,
        characterData: false,
      },
    };
    constructor(handler: Function) {
      this._state.handler = handler;
    }
    observe(el: MockEl, config: MutationObserverConfig) {
      this._state.el = el;
      this._state.config.attributes = config.attributes;
      this._state.config.attributeFilter = config.attributeFilter;
      this._state.config.childList = config.childList;
      this._state.config.characterData = config.characterData;
    }
    trigger() {
      this._state.handler?.();
    }
  }

We’ve added a _state object to document how our code interacts with this third-party software. This object keeps track of the element being observed, the handler in use, and the configuration parameters used to initialize the observer. This information is essential for testing expected state of our feature.

Types

Our mock requires the following types to document the interfaces of our code and improve the developer experience devex while working with it.

  type MutationObserverConfig = {
    attributes: boolean;
    attributeFilter: string[];
    childList: boolean;
    characterData: boolean;
  };
  type MockEl = { lang: string; className: string };
  type MockMutationObserverState = {
    el: MockEl;
    handler: Function;
    config: MutationObserverConfig;
  };

  export type MutationObserverReturn = {
    observer: Function;
    trigger: Function;
    _state: MockMutationObserverState;
  };

Code

We will now implement what we learned from the POC. To start, we will create a new constant named ATTRS, which will be an array of attribute keys that we want to watch for triggering our handler, only what is specified here will make it past our filter. Next, we can initialize and configure our observer like so:

const ATTRS = [LANG_ATTR, 'class']

function initObserver<T>() {
  const observer = new MutationObserver(() => {
    // event handler logic goes here...
  });

  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ATTRS,
    childList: false,
    characterData: false,
  });

  return observer as T;
}

We return the instance of the obsever in case we need it outside the composable and expose this new method in the public api of our composable.

Decoupling

The observer's callback method will manage any necessary events. However, we have encapsulated this functionality within the initObserver method, which is inside of the useGoogleTranslate composable. This level of nesting limits our code design and makes evident the need for greater flexibility.

For example, the onTranslated method, which is responsible for mutating our composable's state when we detect Google Translate is active, would need to be hard coded two levels deep. useGoogleTranslate >> initObserver > onTranslated

One best practice I advocate is to always keep the option open for future changes while also providing a fallback that reflects our expected, most natural behavior.

We can accomplish this by transforming our initObserver into a pure function that takes an argument for the on event, allowing us to assign our onTranslated handler as the default. We can even pass internal context ctx to the outside handler by including it as an parameter during the callback execution, which is beautiful!

  function initObserver(onEvent: Function = onTranslated) {
    const observer = new MutationObserver(() => {
      onEvent(ctx); // decoupled with default
    });
  }

  function onTranslated() {
    // ...
  }

This change provides us with the best of both worlds: a default that functions seamlessly out of the box, while also enabling flexibility to extend or customize the behavior from the outside without loosing context.

isDetected

Next, we need to implement the reactive mechanism that indicates Google Translate is being used on our SPA. This will serve as our primary external signal, allowing other parts of our code to respond to this detection. For example, it can be used to toggle a modal or trigger analytical events in the data layer.

Tests

Our test coverage should include three integration tests. First, we need to ensure that the composable initializes correctly. Next, we must test two scenarios: one where there is a language mismatch and another where a class is detected, ensuring both cases keep the isDetected flag false. Finally, we should test the happy path of our feature.

  test(`isDetected: defaults to false`, () => {
    const {
      initObserver,
      isDetected,
      TEST: { hasLangMismatch, hasTranslatedClass },
    } = useGoogleTranslate(AVAILABLE_LANGS);

    const observer = initObserver() as MutationObserverReturn;
    observer.trigger();

    expect(toValue(isDetected)).toBeFalsy();
    expect(toValue(hasLangMismatch)).toBeFalsy();
    expect(toValue(hasTranslatedClass)).toBeFalsy();
  });

  test(`isDetected: ignores class footprint with matching langs`, () => {
    const {
      initObserver,
      isDetected,
      TEST: { hasLangMismatch, hasTranslatedClass },
    } = useGoogleTranslate(AVAILABLE_LANGS);

    const observer = initObserver() as MutationObserverReturn;

    document.documentElement.className = `${CLASS}-ltr`;
    observer.trigger();

    expect(toValue(isDetected)).toBeFalsy();
    expect(toValue(hasLangMismatch)).toBeFalsy();
    expect(toValue(hasTranslatedClass)).toBeTruthy();
  });

  test(`isDetected: ignore lang mismatch without class footprint`, () => {
    const {
      initObserver,
      isDetected,
      TEST: { hasLangMismatch, hasTranslatedClass },
    } = useGoogleTranslate(AVAILABLE_LANGS);

    const observer = initObserver() as MutationObserverReturn;

    document.documentElement.lang = EN;
    observer.trigger();

    expect(toValue(isDetected)).toBeFalsy();
    expect(toValue(hasLangMismatch)).toBeTruthy();
    expect(toValue(hasTranslatedClass)).toBeFalsy();
  });

  test(`isDetected: detects Google Translate footprint`, () => {
    const {
      initObserver,
      isDetected,
      TEST: { hasLangMismatch, hasTranslatedClass },
    } = useGoogleTranslate(AVAILABLE_LANGS);

    const observer = initObserver() as MutationObserverReturn;

    document.documentElement.lang = EN;
    document.documentElement.className = `${CLASS}-ltr`;
    observer.trigger();

    expect(toValue(isDetected)).toBeTruthy();
    expect(toValue(hasLangMismatch)).toBeTruthy();
    expect(toValue(hasTranslatedClass)).toBeTruthy();
  });

One of the reasons I appreciate the composition-api is that it simplifies testing significantly. Due to its functional nature, there’s no need to mock or stub anything. We can directly test reactivity without relying on a virtual DOM. It’s simply amazing!

Code

We start our code implementation by creating a new ref called hasTranslatedClass, and we add a constant named CLASS to our file, which contains the class string partial added by Google Translate.

useGoogleTranslate.ts
const CLASS = 'translated';

const hasTranslatedClass = ref(false);

This reference will store a boolean each time our mutation observer's handler is executed. We achieve this by adding the following logic to our onTranslated helper function.

hasTranslatedClass.value = !!document.documentElement.className.match(CLASS);

Every time we run our mutation handler, we check the className array of our documentElement and match it against our CLASS constant, using boolean coercion with !!.

Next, let's create our first computed property called hasLangMismatch. In this property, we simply need to compare our two references, pageLang and routeLang, to ensure they do not match (!==).

useGoogleTranslate.ts
const hasLangMismatch = computed(() => toValue(pageLang) !== toValue(routeLang));

Finally, we create our main computed flag called isDetected. This is where we ensure that both elements of Google's footprint are truthy before setting the flag to true, as follows:

useGoogleTranslate.ts
const isDetected = computed(() => toValue(hasTranslatedClass) && toValue(hasLangMismatch));

As this is our primary flag for the composable, we need to expose it in its public interface.

  return {
    initObserver,
    isDetected,
    TEST: {
      pageLang,
      getPageLang,
      routeLang,
      getRouteLang,
      hasLangMismatch,
      hasTranslatedClass,
    },
  };

hasMatchingLocale

We are almost finished. Next, we need to create a method to check if we have localized content available for the target language that Google Translate is using. It's important to note that this will involve fuzzy matching rather than exact matching. For instance, if en-US is used, we can redirect users to en-UK, which we do support, since we do not have content for en-US. Similarly, users from de-AT can be redirected to de-DE.

Tests

This is a straightforward helper function for which we only need to test three cases:

const IT = 'it';

  test(`hasMatchingLocale: returns true with defaults (without fn args)`, () => {
    const {
      TEST: { hasMatchingLocale },
    } = useGoogleTranslate(AVAILABLE_LANGS);

    expect(hasMatchingLocale()).toBeTruthy();
  });

  test(`hasMatchingLocale: returns true with matching fn args`, () => {
    const {
      TEST: { hasMatchingLocale },
    } = useGoogleTranslate(AVAILABLE_LANGS);

    expect(hasMatchingLocale(ES, AVAILABLE_LANGS)).toBeTruthy();
  });

  test(`hasMatchingLocale: returns false with unmatched args`, () => {
    const {
      TEST: { hasMatchingLocale },
    } = useGoogleTranslate(AVAILABLE_LANGS);

    expect(hasMatchingLocale(IT, AVAILABLE_LANGS)).toBeFalsy();
  });

This new function will return a boolean value. We also need to add a new constant, IT, at the top of our file to test a language code that is not included in our AVAILABLE_LANGS constant.

Code

In our composable, we introduce a new function called hasMatchingLocale. This function takes two parameters to maintain its purity. The first parameter is the target locale used by GT, which refers to the desired translation language. The second, is an array that contains all available language codes for our localized content.

Both parameters should have default values from the instance of the composable. This approach simplifies usage while enhancing flexibility, as we already discussed before.

  function hasMatchingLocale(
    detected: string = toValue(pageLang),
    available: LangaugeCodes = availableLangs
  ) {
    return available
      .map((lang: LanguageCode) => lang.split(QP_SEPARATOR)[0])
      .includes(detected);
  }

The logic of this function returns the result of a map operation on our available languages array. The map function splits the code using our QP_SEPARATOR constant and returns the first part of the iterable [0]. Finally, it uses includes to check if the detected language matches any of the elements in the array.

This method is private, so we only need to expose it under the TEST object in our composable's return.

Types

This function reuses our LanguageCodes type and utilizes our LanguageCode and a simple string type. However, I want to emphasize how reusing types can help maintain consistency in our implementation details while coding.

suggestLangSwitch

To connect everything together, we need to create a final computed flag called suggestLangSwitch, which returns a boolean. This will be our toggle to suggest redirection. It combines the output of both our isDetected and hasMatchingLocale values.

Tests

We need to test three specific aspects for this flag. First, we should ensure that it defaults to false. Second, we need to verify the successful scenario. Finally, we must confirm that it returns false when dealing with GT's footprint and no matching locale.

test(`suggestLangSwitch: defaults to false`, () => {
  const { initObserver, suggestLangSwitch } =
    useGoogleTranslate(AVAILABLE_LANGS);

  const observer = initObserver() as MutationObserverReturn;
  observer.trigger();

  expect(toValue(suggestLangSwitch)).toBeFalsy();
});

test(`suggestLangSwitch: returns true with truthy isDetected and hasMatchingLocale`, () => {
  const { initObserver, suggestLangSwitch } =
    useGoogleTranslate(AVAILABLE_LANGS);

  const observer = initObserver() as MutationObserverReturn;

  document.documentElement.lang = EN;
  document.documentElement.className = `${CLASS}-ltr`;
  observer.trigger();

  expect(toValue(suggestLangSwitch)).toBeTruthy();
});

test(`suggestLangSwitch: returns false with truthy isDetected and unmatched locale`, () => {
  const { initObserver, suggestLangSwitch } =
    useGoogleTranslate(AVAILABLE_LANGS);

  const observer = initObserver() as MutationObserverReturn;

  document.documentElement.lang = IT;
  document.documentElement.className = `${CLASS}-ltr`;
  observer.trigger();

  expect(toValue(suggestLangSwitch)).toBeFalsy();
});

Code

The implementation is quick and painless:

const suggestLangSwitch = computed(() => toValue(isDetected) && hasMatchingLocale());

Let's not forget to make this flag accessible through the public API of our composable.

One key aspect to highlight in this one-liner is the versatility of computed properties. We utilize isDetected as our reactive trigger to consistently run hasMatchingLocale, a pure function, without needing to introduce additional logic, such as a stateful ref or a watcher. Simply Vuesome!

redirectUrl

As an additional feature, let's create a method to reconstruct the redirection URL based on the context of the original route. While this might extend beyond merely detecting Google Translate, I believe that most cases would benefit from something like this.

Tests

We only need two tests for this method: one for the negative path and another for the successful path.

test(`redirectUrl: returns null when suggestLangSwitch is false`, () => {
  const { initObserver, redirectUrl } = useGoogleTranslate(AVAILABLE_LANGS);

  const observer = initObserver() as MutationObserverReturn;

  document.documentElement.lang = IT;
  document.documentElement.className = `${CLASS}-ltr`;
  observer.trigger();

  const url = redirectUrl('https://vuenuxt.dev/deep-link/');

  expect(url).toBeNull();
});

test(`redirectUrl: returns reconstructed url when suggestLangSwitch is true`, () => {
  const { QP_SEPARATOR, QP_QUESTION, QP_LANG } = CNST;
  const { initObserver, redirectUrl } = useGoogleTranslate(AVAILABLE_LANGS);

  const observer = initObserver() as MutationObserverReturn;

  const locale = ES_LOCALE;

  document.documentElement.lang = locale.split(QP_SEPARATOR)[0];
  document.documentElement.className = `${CLASS}-ltr`;
  observer.trigger();

  const newUrl = 'https://deep-link.vuenuxt.dev/';
  const url = redirectUrl(newUrl);
  const qr = mockUrl(DE).split(QP_QUESTION)[1].split(QP_LANG)[0];

  expect(url).toBe(`${newUrl}?${qr}${QP_LANG}=${locale}`);
});

One could argue that our happy path integration tests contain a significant amount of logic. I agree, but I believe that the trade-off of using dynamic assertions instead of hardcoded ones reduces maintenance costs, thereby justifying the added complexity. This approach ensures that if we make changes to the mock schema, our tests will not break.

The question remains though. How can we improve the readability of these tests without sacrificing maintenance effort? This is how I would refactor to achieve this.

test(`redirectUrl: returns reconstructed url when suggestLangSwitch is true`, () => {
  const { QP_SEPARATOR, QP_QUESTION, QP_LANG } = CNST;
  const { initObserver, redirectUrl } = useGoogleTranslate(AVAILABLE_LANGS);

  const observer = initObserver() as MutationObserverReturn;

  const locale = ES_LOCALE;

  document.documentElement.lang = locale.split(QP_SEPARATOR)[0];
  document.documentElement.className = `${CLASS}-ltr`;
  observer.trigger();

  const url = 'https://deep-link.vuenuxt.dev/';
  const newUrl = mockRedirectUrl(url, {
    locale,
    lang: QP_LANG,
    separator: QP_QUESTION,
  });

  expect(redirectUrl(url)).toBe(newUrl);
});

I like it. What do you think?

We improved readability and reusability without increasing maintenance effort.

Code

Let's explore the implementation of our method, which needs to be exposed in the public API of our composable. Our method will receive a string argument to define the redirect base URL from outside the function.

Query parameters can be complex, and utilities like URLSearchParams simplify their handling, as demonstrated in this implementation.

This method is the consumer of the stateful ref routeQueryParams we defined early in our process.

function redirectUrl(url: string) {
  if (!toValue(suggestLangSwitch)) return null;

  const locale = toValue(availableLangs).filter((lang: LanguageCode) =>
    lang.includes(toValue(pageLang))
  )[0];

  const qr = new URLSearchParams(toValue(routeQueryParams));

  qr.set(QP_LANG, locale);

  return `${url}?${qr.toString()}`;
}

We start by checking if suggestLangSwitch is falsy. If it is, we return null early because there's no reason to reconstruct a redirect URL if we don't have any matching localized content.

Next, we search for the detected page language within our available languages. We do this by using the filter method on our availableLangs array, matching against our pageLang reference. We then store the first part of our language code, [0], found in a new constant called locale.

Once we have our locale, we use routeQueryParams to create a new URLSearchParams instance, which we assign to a variable named qr. We can then update the new locale using our QP_LANG constant on this qr instance.

Finally, we return the base URL we received as an argument, appending the modified query parameters with qr.toString().

Final Code

There you have it, we are finally done!

You can review and play with the final code in the playground below.

Open Playground

We cannot functionaly test this feature in the playground but our tests gives us confidence that it works as expected.

import { ref, toValue, computed } from 'vue';
import { LanguageCode, type LangaugeCodes } from './useGoogleTranslate.types';

const LANG_ATTR = 'lang';
const DE = 'de';
const QP_LANG = 'lng';
const QP_SEPARATOR = '-';
const QP_QUESTION = '?';
const QP_HASH = '#';
const ATTRS = [LANG_ATTR, 'class'];
const CLASS = 'translated';

export const CNST = {
  DE,
  ATTRS,
  CLASS,
  QP_SEPARATOR,
  QP_QUESTION,
  QP_LANG,
};

export const useGoogleTranslate = (availableLangs: LangaugeCodes) => {
  const pageLang = ref(DE);

  function getPageLang(): string {
    return document.documentElement[LANG_ATTR] || DE;
  }

  const routeLang = ref(DE);
  const routeQueryParams = ref('');

  function getRouteLang(): string {
    const url = window.location.href;

    if (!url.includes(QP_QUESTION) && !url.includes(QP_HASH)) return DE;
    if (!url) return DE;
    routeQueryParams.value = url.split(QP_QUESTION).pop()!.split(QP_HASH)[0];

    return new URLSearchParams(toValue(routeQueryParams))
      .get(QP_LANG)!
      .split(QP_SEPARATOR)[0];
  }

  function onTranslated(): void {
    hasTranslatedClass.value =
      !!document.documentElement.className.match(CLASS);
    pageLang.value = getPageLang();
    routeLang.value = getRouteLang();
  }

  function initObserver<T>(onEvent: Function = onTranslated) {
    const observer = new MutationObserver(() => {
      onEvent();
    });

    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ATTRS,
      childList: false,
      characterData: false,
    });

    return observer as T;
  }

  const hasTranslatedClass = ref(false);

  const hasLangMismatch = computed(
    () => toValue(pageLang) !== toValue(routeLang)
  );

  const isDetected = computed(
    () => toValue(hasTranslatedClass) && toValue(hasLangMismatch)
  );

  function hasMatchingLocale(
    detected: string = toValue(pageLang),
    available: LangaugeCodes = availableLangs
  ) {
    return available
      .map((lang: LanguageCode) => lang.split(QP_SEPARATOR)[0])
      .includes(detected);
  }

  const suggestLangSwitch = computed(
    () => toValue(isDetected) && hasMatchingLocale()
  );

  function redirectUrl(url: string) {
    if (!toValue(suggestLangSwitch)) return null;
    const locale = toValue(availableLangs).filter((lang: LanguageCode) =>
      lang.includes(toValue(pageLang))
    )[0];

    const qr = new URLSearchParams(toValue(routeQueryParams));
    qr.set(QP_LANG, locale);

    return `${url}?${qr.toString()}`;
  }

  return {
    initObserver,
    isDetected,
    suggestLangSwitch,
    redirectUrl,
    TEST: {
      pageLang,
      getPageLang,
      routeLang,
      getRouteLang,
      hasLangMismatch,
      hasTranslatedClass,
      hasMatchingLocale,
    },
  };
};

Thank you for sticking with me this long. I hope I made your time worthwhile.


Copyright © 2025