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? 

A reader selects French and immediately switches to German:

Request A: message-42 → French Request B: message-42 → German 

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); } 

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; 

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) 

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 

Add a test script to package.json:

{ "scripts": { "test": "tsx --test translation.test.ts" } } 

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>; } 

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; } } 

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", }); }); 

Run the suite:

npm test 

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, }; } 

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 … 

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

B...