fix: pin translation behavior with tests and fix camelCase key generation#737
Merged
Quenty merged 24 commits intoJul 18, 2026
Merged
Conversation
…vior Add characterization tests covering the current translation stack so the upcoming work to reduce localization-table replication has a known baseline. The JSONTranslator tests assert exactly what gets written to the Roblox LocalizationTable, and deliberately pin the eager writes we intend to change: Init writes every decoded entry up front, ToTranslationKey writes an entry as a side effect, and SetEntryValue writes straight through. The TranslatorService tests pin the single shared, role-named localization table and the locale/ translator resolution. A TranslationKeyUtils spec pins key generation, including the space-collapse quirk that a follow-up commit fixes.
getTranslationKey stripped whitespace before camel-casing, which removed the word boundaries and lowercased everything, so "Play Now" produced "playnow" instead of "playNow" (underscore-separated text was unaffected). Camel-case first, then cap the length, so spaced and underscore text behave the same. Auto-generated keys for spaced source text change, so any translations already uploaded to the cloud localization table under the old keys need to be regenerated and re-uploaded.
Test Results
45 tested, 45 passed, 0 failed in 1m26s · View logs Deploy Results
1 deployed, 1 passed, 0 failed in 3.1s · View logs |
Quenty
force-pushed
the
users/quenty/clienttranslator-defer-translation-writes
branch
from
July 17, 2026 20:46
84dec8f to
57dc066
Compare
Restructure the JSONTranslator and TranslatorService specs to build an isolated world per test through a setup() call that returns a controller, mirroring the RogueProperty specs, instead of beforeEach/afterEach. Each test calls setup() up front and controller:destroy() at the end, so resource lifetime is explicit and local to the test. setup() stands up a real ServiceBag with the real TranslatorService rather than faking either, so the translators exercise the genuine service wiring and their writes land in the real (shared, role-named) LocalizationService table, which is cleared before and after every test for isolation. Tests that need the Roblox translator await it (PromiseTranslator/PromiseLoaded) the way the RogueProperty specs await binding. The shared setup(), await helpers, and localization-table helpers live in a new TranslatorTestUtils module so both specs stay focused on assertions, and entry counting reuses Table.count.
Quenty
force-pushed
the
users/quenty/clienttranslator-defer-translation-writes
branch
from
July 17, 2026 20:55
57dc066 to
13abdbe
Compare
…rService Each JSONTranslator used to write its entire decoded table into the shared Roblox LocalizationTable synchronously during Init, and re-replicate the whole table on every SetEntryValue. Translators now queue their entries with the centralized TranslatorService, which batches the writes and flushes them once at the end of the frame via task.defer, keeping the write/replication cost off the load path. Reads wait for the flush so a key is never read before it has been written: ObserveFormatByKey and PromiseFormatByKey gate on a new PromiseEntriesWritten, and the synchronous FormatByKey forces a flush first.
Add a GetLocalizationWriteCount metric to TranslatorService counting the raw mutating calls it makes to the shared LocalizationTable. Each such call invalidates every AutoLocalize entry in the engine, so this is the cost we want to minimize. Pin the current behavior: a flush issues one write per queued value and example, so three value+example entries cost six writes. A follow-up coalesces this to one.
The batched flush previously issued one SetEntryValue/SetEntryExample per queued value and example. Each of those invalidates every AutoLocalize entry in the engine, so a frame of translation loading cost O(entries) invalidations. Accumulate the queued writes as entry deltas keyed by Key+Source+Context, merge them into the table's current entries, and write the whole batch back with a single SetEntries call. A frame's worth of entries now costs one invalidation regardless of how many entries it contains.
A flush now diffs the queued entry deltas against the table's current entries and skips the SetEntries call entirely when nothing actually changed. Re-writing an entry that already matches the table costs zero invalidations instead of one. There is no partial-update API that invalidates once, so a redundant SetEntries is pure cost worth avoiding. Broaden coverage: three package-driven translators registering on one ServiceBag coalesce into a single write with no dropped entries; a merge preserves entries an external writer put in the table directly; and a genuinely changed value still writes.
Instance-decoded translators (a folder of per-locale JSON StringValues/ModuleScripts) used to decode and write every locale up front. On the client the target locale is knowable, so we now decode and write a locale's JSON only when it becomes the target, always loading the source locale as the fallback. This saves both the localization writes and the JSON decode for locales the player never uses. A locale already loaded is never decoded or written again. Off the client (no player locale) every locale is still loaded eagerly, and table-driven translators are unchanged. TranslatorService now resolves its realm through TieRealmService and gains SetForcedLocaleId, an override for the inferred player/Roblox locale (an in-game language selector, and the seam used to drive the locale in tests). Regional locales resolve to the closest available file via ResolveLocaleUtils (e.g. fr-fr -> fr). LocalizationEntryParserUtils gains getAvailableLocales and decodeLocaleFromInstance for single-locale decoding, plus a first unit spec. Realm and locale are injected in tests with TieRealmService + SetForcedLocaleId, mirroring the Raven stories.
…caleLoader The per-locale lazy loading state (available locales, loaded locales, the accumulated entry lookup, source instance/locale) was spread across five fields and a state machine on JSONTranslator, which was hard to follow. Move it into a dedicated InstanceLocaleLoader that owns the state and decodes/writes a locale's entries only the first time it is needed, writing through an injected writer (TranslatorService). JSONTranslator now holds a single _localeLoader and just drives it: load the source locale, then load the target locale on each locale change (or every locale off the client). The loader is unit-tested in isolation with a recording writer.
Loading a single closest-match file was wrong: a target of es-mx needs both the universal es file and the regional es-mx file, and a fr-fr player wants sibling fr-* files as fallbacks before dropping to English. LoadLocale now loads every available file that shares the target's language subtag (source ensured first for correct Source/Context), rather than resolving to one file. LoadLocale/LoadSourceLocale no longer return the locale they touched -- the caller should not care which files were loaded, only that the relevant ones are. Idempotency is unchanged: a file already loaded is never decoded or written again.
…oader JSONTranslator.Init branched on _entries vs _localeLoader and had two helper methods for the two loading paths. Give the table-driven case a TableLocaleLoader sharing the same surface (LoadSourceLocale / LoadLocale / LoadAllLocales) as InstanceLocaleLoader, so JSONTranslator holds a single _loader and Init has one realm-based flow: load the source locale then each target locale on the client, or everything off the client. The table loader has no per-locale laziness (the data is already in memory), so its LoadLocale just ensures the entries are queued once. Unit-tested in isolation.
The locale loaders take a ServiceBag and resolve the TranslatorService themselves instead of having a Writer threaded through every load call, so JSONTranslator builds each loader from a stored callback once the bag is available in Init. FormatByKey no longer forces the whole deferred batch to flush on a synchronous read. TranslatorService indexes pending writes by translation key and FlushEntryForKey lands only the locales a read can consult (the current and source locales plus their language subtags), leaving the rest batched. The full synchronous flush is renamed FlushEntriesForTesting to mark its cost. Claude-Session: https://claude.ai/code/session_01S53BM6wjU9HVHRTzReSfmb
strip-sourcemap-jest deleted every Jest node, so luau-lsp reported
"Unknown require: Jest" in every spec that requires it by name. Instead of
removing the nodes, strip only the Jest descendants whose names also exist
elsewhere in the sourcemap (the vendored copies like Promise that shadow
Nevermore packages under first-match resolution), plus the leaf "Jest"
submodule nested inside the jest-lua package folder whose duplicate name made
require("Jest") resolve to the copy without `.Globals`.
Jest and JestGlobals stay resolvable while the shadowing file set stays small.
Claude-Session: https://claude.ai/code/session_01S53BM6wjU9HVHRTzReSfmb
- SortedNodeValue: add the __le metamethod so `>=`/`<=` (and the type checker's handling of `>`) resolve; the other comparison metamethods were already found via __index. - ThrottledFunction.spec: make the recording callback variadic-any so one throttled function can be called with different arities (a generic T... bound to the first call's shape). - ObservableSortedList.spec: cast at the sites where the recursive Observable type will not unify inside Add's union, widen the Brio annotations to the actual pack arity, and cast the nil sort-value emissions. Claude-Session: https://claude.ai/code/session_01S53BM6wjU9HVHRTzReSfmb
Flips 41 spec files from --!nonstrict to --!strict where the stricter analysis passes with no changes, so future edits to these tests are type-checked. Spec files that still surface strict-mode type errors are left --!nonstrict for a follow-up pass. Claude-Session: https://claude.ai/code/session_01S53BM6wjU9HVHRTzReSfmb
Converts the last 19 --!nonstrict spec files to --!strict and fixes the resulting type errors so every spec in the repo is now type-checked. Changes are type-only (annotations, casts at genuine Luau type-system friction points such as recursive Observable unification, Brio/Observable variadic packs, and ValueObject<T> invariance) except for Brine.spec, where serialize() calls are parenthesized to truncate their multi-return to the single Brined value; the Brine suite still passes. Done via a per-package fan-out; luau-lsp, stylua, and selene are clean repo-wide. Claude-Session: https://claude.ai/code/session_01S53BM6wjU9HVHRTzReSfmb
…anslator-defer-translation-writes
…n key A LocalizationTable identifies entries by translation key alone: SetEntries rejects two entries that share a key even when their source/context differ. The deferred writer queued one pending entry per (key, source, context), so a game registering the same key twice with different source/context (e.g. collectable.toolUnlocked as both a collectable name and a generated dialog line) fed SetEntries a duplicate key and crashed the flush. Hold at most one pending delta per translation key (last write wins on source/context) and key the merge's existing-entry map by key, matching the table's real identity. Adds regression tests reproducing the crash and documents the engine behavior in docs/gotchas/localization.md. Claude-Session: https://claude.ai/code/session_015rBQUMs9WMHQx1UdVsHW8G
Two spec files broke when they were converted to --!strict earlier on this branch: - ellipticcurvecryptography benchmark.spec / testing.spec live *inside* the EllipticCurveCryptography ModuleScript, which has no `loader` child, so the standard `require(script.Parent.loader)` pattern fails at runtime with "loader is not a valid member". Restore the bootstrapStory require these nested specs need. - NevermoreCLIManifestUtils.spec used metadata fields typed as string? without narrowing, tripping the luau type check. Add the `:: string` casts the file already uses elsewhere. Verified with a full cloud batch run: 45 packages, 45 passed, 0 failed. Claude-Session: https://claude.ai/code/session_015rBQUMs9WMHQx1UdVsHW8G
Owner
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds characterization tests for the client translation stack so the upcoming work to reduce localization-table replication (currently every JSONTranslator writes its full table up front and re-replicates on each SetEntryValue) can be validated against a known baseline. The JSONTranslator tests assert exactly what is written to the Roblox LocalizationTable and pin the eager writes we intend to change; the TranslatorService tests pin the single shared, role-named table and locale resolution.
Also fixes getTranslationKey, which stripped whitespace before camel-casing and so lowercased spaced source text ("Play Now" became "playnow" instead of "playNow"). Auto-generated keys for spaced text change as a result, so translations already uploaded under the old keys need regenerating.
This is the first step; deferring and streaming the localization writes will follow as further commits on this branch.