DEV Community

LunarDrift
LunarDrift

Posted on

Race-Proof On-Demand Translation in Tencent RTC TUIChat

A member opens a message, requests a French translation, changes the target to German, and then sees the French result appear under the German label.

Nothing “random” happened. Two valid asynchronous operations completed in a different order from the one in which they started.

This is the same category of confusion behind JavaScript expressions such as {} + []: the runtime followed its rules, but those rules were not the contract the developer thought they had created.

For multilingual social messaging, the fix is not another loading spinner. We need to make the message identity, source text, target language, request generation, and visible result explicit.

In this tutorial, we will build a framework-independent TypeScript controller for on-demand translation around a Tencent RTC TUIChat integration. It will:

  • keep results associated with the correct target language;
  • ignore stale completions without assuming requests can be canceled;
  • avoid JavaScript cache-key coercion;
  • distinguish unsupported configurations from network failures;
  • preserve the original message alongside its translation;
  • remain reproducible without inventing an undocumented SDK method.

Tencent RTC documents on-demand TUIChat message translation, including supported content types, languages, and edition requirements. Check those constraints before exposing the control in your application: TUIChat message translation.

The bug we are going to reproduce

Suppose the current message is:

In JavaScript, why does {} + [] sometimes produce 0?
Enter fullscreen mode Exit fullscreen mode

A reader selects French and immediately switches to German:

Request A: message-42 → French
Request B: message-42 → German
Enter fullscreen mode Exit fullscreen mode

If A finishes last and the component stores only one translatedText field, it can overwrite B:

// Too little state
let translatedText = "";
let selectedLanguage = "de";

async function translate(targetLanguage: string) {
  translatedText = await requestTranslation(targetLanguage);
}
Enter fullscreen mode Exit fullscreen mode

The interface now says “German” while displaying French. The translation service did not necessarily fail; the application lost the relationship between request and result.

A second tempting mistake is to build cache keys through +:

const key = message.id + targetLanguage + message.text;
Enter fullscreen mode Exit fullscreen mode

This relies on implicit string conversion and has no structural boundaries. Different tuples can produce the same string, while non-string values can be coerced in surprising ways.

We will instead treat a translation as a value identified by this tuple:

(message ID, exact source text, target language)
Enter fullscreen mode Exit fullscreen mode

Create the testable project

Start with a minimal TypeScript project:

mkdir chat-translation-state
cd chat-translation-state
npm init -y
npm install --save-dev typescript tsx @types/node
Enter fullscreen mode Exit fullscreen mode

Add a test script to package.json:

{
  "scripts": {
    "test": "tsx --test translation.test.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

The controller will depend on an application-owned port rather than a guessed Tencent RTC API name:

export interface Translator {
  translate(input: {
    messageId: string;
    sourceText: string;
    targetLanguage: string;
  }): Promise<string>;
}
Enter fullscreen mode Exit fullscreen mode

This boundary is deliberate. The reproducible state logic should not depend on a particular UI framework or fabricate a convenience method that may not exist in the TUIChat package you selected.

Model what the reader can actually see

Create translation.ts:

export interface ChatMessage {
  id: string;
  text: string;
}

export interface Translator {
  translate(input: {
    messageId: string;
    sourceText: string;
    targetLanguage: string;
  }): Promise<string>;
}

export interface TranslationPolicy {
  enabled: boolean;
  allowedTargetLanguages: ReadonlySet<string>;
}

export type TranslationView =
  | { status: "idle" }
  | {
      status: "unsupported";
      targetLanguage: string;
      reason: string;
    }
  | {
      status: "loading";
      targetLanguage: string;
      sourceText: string;
    }
  | {
      status: "ready";
      targetLanguage: string;
      sourceText: string;
      translatedText: string;
    }
  | {
      status: "failed";
      targetLanguage: string;
      sourceText: string;
      error: string;
    };

type Entry = Exclude<TranslationView, { status: "idle" }> & {
  requestId?: number;
};

type ActiveSelection = {
  key: string;
  message: ChatMessage;
  targetLanguage: string;
};

function translationKey(
  message: ChatMessage,
  targetLanguage: string,
): string {
  // A serialized tuple preserves boundaries and avoids `+` coercion.
  return JSON.stringify([message.id, message.text, targetLanguage]);
}

function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : "Translation failed";
}

export class TranslationController {
  private readonly entries = new Map<string, Entry>();
  private active?: ActiveSelection;
  private nextRequestId = 0;

  constructor(
    private readonly translator: Translator,
    private readonly policy: TranslationPolicy,
  ) {}

  get view(): TranslationView {
    if (!this.active) return { status: "idle" };
    return this.entries.get(this.active.key) ?? { status: "idle" };
  }

  select(message: ChatMessage, targetLanguage: string): void {
    const key = translationKey(message, targetLanguage);
    this.active = { key, message, targetLanguage };

    if (!this.policy.enabled) {
      this.entries.set(key, {
        status: "unsupported",
        targetLanguage,
        reason: "Translation is not enabled for this deployment.",
      });
      return;
    }

    if (!this.policy.allowedTargetLanguages.has(targetLanguage)) {
      this.entries.set(key, {
        status: "unsupported",
        targetLanguage,
        reason: "This target language is not configured as supported.",
      });
      return;
    }

    const existing = this.entries.get(key);

    // Reuse a completed result and deduplicate an in-flight request.
    if (existing?.status === "ready" || existing?.status === "loading") {
      return;
    }

    const requestId = ++this.nextRequestId;

    this.entries.set(key, {
      status: "loading",
      requestId,
      targetLanguage,
      sourceText: message.text,
    });

    void this.translator
      .translate({
        messageId: message.id,
        sourceText: message.text,
        targetLanguage,
      })
      .then((translatedText) => {
        const current = this.entries.get(key);

        // A retry may have replaced this request for the same key.
        if (
          current?.status !== "loading" ||
          current.requestId !== requestId
        ) {
          return;
        }

        this.entries.set(key, {
          status: "ready",
          targetLanguage,
          sourceText: message.text,
          translatedText,
        });
      })
      .catch((error: unknown) => {
        const current = this.entries.get(key);

        if (
          current?.status !== "loading" ||
          current.requestId !== requestId
        ) {
          return;
        }

        this.entries.set(key, {
          status: "failed",
          targetLanguage,
          sourceText: message.text,
          error: errorMessage(error),
        });
      });
  }

  retry(): void {
    if (!this.active) return;

    const { key, message, targetLanguage } = this.active;
    this.entries.delete(key);
    this.select(message, targetLanguage);
  }

  dismiss(): void {
    this.active = undefined;
  }
}
Enter fullscreen mode Exit fullscreen mode

There are two useful stale-result protections here.

First, the visible view is selected by a structured key. A French completion can update the French cache entry, but it cannot become the active German view.

Second, every request has a generation number. If a failed request is retried for the same key, the earlier completion cannot overwrite the retry.

We do not require cancellation for correctness. An adapter may cancel network work if its documented interface supports that, but stale-result rejection remains necessary because cancellation itself can race with completion.

Prove the out-of-order case

Create translation.test.ts:

import assert from "node:assert/strict";
import test from "node:test";
import {
  TranslationController,
  type Translator,
} from "./translation.js";

function deferred<T>() {
  let resolve!: (value: T) => void;
  let reject!: (reason?: unknown) => void;

  const promise = new Promise<T>((res, rej) => {
    resolve = res;
    reject = rej;
  });

  return { promise, resolve, reject };
}

const tick = () => new Promise<void>((resolve) => setImmediate(resolve));

test("a stale language result never replaces the active language", async () => {
  const french = deferred<string>();
  const german = deferred<string>();

  const translator: Translator = {
    translate: ({ targetLanguage }) => {
      if (targetLanguage === "fr") return french.promise;
      if (targetLanguage === "de") return german.promise;
      throw new Error("Unexpected language");
    },
  };

  const controller = new TranslationController(translator, {
    enabled: true,
    allowedTargetLanguages: new Set(["fr", "de"]),
  });

  const message = {
    id: "message-42",
    text: "Why does {} + [] sometimes produce 0?",
  };

  controller.select(message, "fr");
  controller.select(message, "de");

  french.resolve("French result");
  await tick();

  assert.deepEqual(controller.view, {
    status: "loading",
    targetLanguage: "de",
    sourceText: message.text,
  });

  german.resolve("German result");
  await tick();

  assert.deepEqual(controller.view, {
    status: "ready",
    targetLanguage: "de",
    sourceText: message.text,
    translatedText: "German result",
  });
});

test("unsupported languages do not start a request", () => {
  let calls = 0;

  const translator: Translator = {
    async translate() {
      calls += 1;
      return "unused";
    },
  };

  const controller = new TranslationController(translator, {
    enabled: true,
    allowedTargetLanguages: new Set(["fr"]),
  });

  controller.select({ id: "m1", text: "Hello" }, "xx");

  assert.equal(controller.view.status, "unsupported");
  assert.equal(calls, 0);
});

test("a failed translation can be retried explicitly", async () => {
  let calls = 0;

  const translator: Translator = {
    async translate() {
      calls += 1;
      if (calls === 1) throw new Error("Temporary failure");
      return "Bonjour";
    },
  };

  const controller = new TranslationController(translator, {
    enabled: true,
    allowedTargetLanguages: new Set(["fr"]),
  });

  controller.select({ id: "m2", text: "Hello" }, "fr");
  await tick();

  assert.equal(controller.view.status, "failed");

  controller.retry();
  await tick();

  assert.deepEqual(controller.view, {
    status: "ready",
    targetLanguage: "fr",
    sourceText: "Hello",
    translatedText: "Bonjour",
  });
});
Enter fullscreen mode Exit fullscreen mode

Run the suite:

npm test
Enter fullscreen mode Exit fullscreen mode

These tests do not prove translation quality. They prove something narrower and essential: the application will not knowingly display a valid result under the wrong request context.

Connect the controller to TUIChat without guessing APIs

Tencent RTC’s Social Messaging solution includes 1-to-1 chat, group discussions, communities, rich media, and live-room messaging. That makes translation a reading feature within a broader messaging experience, not a replacement for the original message. See the Social Messaging solution.

Use this production workflow:

  1. Confirm product eligibility. Check the official translation documentation for the supported message content types, target languages, and edition requirements.
  2. Configure TUIChat using the documented steps for your selected package. Do not copy an API name from a different platform or package version.
  3. Populate allowedTargetLanguages from deployment configuration. Do not hardcode a language merely because an external translation service recognizes it.
  4. Connect the documented translation operation to the Translator port. Keep Tencent-specific integration code inside that adapter.
  5. Render the controller state in your application-owned language picker or translation panel. If your chosen TUIChat component owns the entire translation interface and exposes no extension point, use its documented behavior rather than attempting to patch internal component state.

The adapter shape should remain small:

import type { Translator } from "./translation.js";

export function createTranslator(
  documentedTranslationBridge: Translator["translate"],
): Translator {
  return {
    translate: documentedTranslationBridge,
  };
}
Enter fullscreen mode Exit fullscreen mode

documentedTranslationBridge is intentionally not implemented here. Replace it with the exact integration described for your TUIChat package in the official documentation. This avoids presenting a made-up SDK method as working code.

Decide which messages should offer translation

A translate button on every object in a timeline is not automatically helpful. Use a content policy before making the action visible.

Message shape Suggested behavior Reason
Plain prose Offer on-demand translation This is the clearest use case
Prose with inline code Offer translation, retain the source beside it Identifiers and operators carry technical meaning
Code-only block Keep the original prominent; consider omitting translation Rewriting syntax can make valid code invalid
Attachment or rich media Follow the exact supported-content table Do not imply unsupported content was translated
Unknown target language Show unsupported; do not send a request A deterministic configuration issue is not a retryable outage

The JavaScript example demonstrates why this matters. In a developer community, braces, brackets, and operators may be syntax rather than punctuation. Translation can help with the surrounding explanation, but the original code must remain available for comparison.

Do not silently replace the source message. A useful rendering is:

Original
In JavaScript, why does {} + [] sometimes produce 0?

Translated to German
…
Enter fullscreen mode Exit fullscreen mode

Also render translated output as text, not injected HTML. In the DOM, prefer textContent; React’s normal text interpolation also escapes content. A translated string is display data, not trusted markup.

Failure behavior should name the next action

Different failures deserve different interfaces:

Translation is unavailable for the deployment

Show that translation is unavailable. Do not display a retry button that can never succeed.

The target language is unsupported

Keep the original visible and let the reader choose another configured language.

The request fails temporarily

Show the original message, retain the selected target, and offer an explicit retry. Avoid an unbounded automatic retry loop triggered by every render.

The reader switches languages during a request

Move the visible panel to the newly selected language immediately. An older completion may be cached under its own key, but it must not replace the active view.

The source text changes locally

Because the exact source text is part of the key, a result generated for the earlier text will not be shown for the new text. This is useful even if your normal message model is immutable: local reconciliation, moderation views, or application-specific updates can still replace displayed content.

The message disappears while translation is running

Call dismiss(). A late result may complete internally, but the controller remains idle and does not reopen the panel.

Verification before release

Run the unit tests, then verify the real integration with this checklist:

  • [ ] Translation is initiated only by a visible user action.
  • [ ] The original message remains readable while loading and after completion.
  • [ ] Rapidly switching between two target languages never mislabels a result.
  • [ ] Selecting the same language repeatedly does not create duplicate in-flight requests.
  • [ ] A retry cannot be overwritten by the request it replaced.
  • [ ] Unsupported languages produce no network request.
  • [ ] Deployment configuration matches the current official language, content-type, and edition documentation.
  • [ ] Code blocks and inline identifiers remain visible in their original form.
  • [ ] Translation errors do not hide or alter the source message.
  • [ ] Translated strings are rendered as text rather than trusted HTML.
  • [ ] Leaving the message or closing the panel prevents late completions from reopening it.
  • [ ] Telemetry distinguishes unsupported, failed, and stale completions instead of grouping everything under “translation error.”

The broader lesson is not that JavaScript or asynchronous messaging is broken. It is that implicit state creates misleading interfaces.

A translation result is not just a string. It is a string produced for a particular message body, target language, request generation, and deployment capability. Once those facts become part of the model, out-of-order completion becomes an ordinary case you can test—not a mysterious production bug.

What does your chat treat as the owner of the target language: the account, the conversation, or each individual translation request? That decision determines both your cache boundary and what users expect when they move between communities.


Disclosure: I have a content relationship with Tencent RTC. Official Tencent RTC documentation was used as the implementation reference for this article.

Top comments (0)