Skip to content

fix: pin translation behavior with tests and fix camelCase key generation#737

Merged
Quenty merged 24 commits into
mainfrom
users/quenty/clienttranslator-defer-translation-writes
Jul 18, 2026
Merged

fix: pin translation behavior with tests and fix camelCase key generation#737
Quenty merged 24 commits into
mainfrom
users/quenty/clienttranslator-defer-translation-writes

Conversation

@Quenty

@Quenty Quenty commented Jul 17, 2026

Copy link
Copy Markdown
Owner

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.

Quenty added 2 commits July 17, 2026 12:34
…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.
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Test Results

Package Status Try it
@quenty/animations ✅ Passed (8/8) (735ms) Open | Play
@quenty/binder ✅ Passed (3/3) (751ms) Open | Play
@quenty/chatproviderservice ✅ Passed (3/3) (1.2s) Open | Play
@quenty/deathreport ✅ Passed (4/4) (802ms) Open | Play
@quenty/gameconfig ✅ Passed (1.1s) Open | Play
@quenty/gameproductservice ✅ Passed (1.0s) Open | Play
@quenty/ik ✅ Passed (3/3) (885ms) Open | Play
@quenty/saveslot ✅ Passed (20/20) (1.6s) Open | Play
@quenty/settings ✅ Passed (5/5) (1.2s) Open | Play
@quenty/settings-inputkeymap ✅ Passed (1/1) (1.1s) Open | Play
@quenty/rogue-humanoid ✅ Passed (1.1s) Open | Play
@quenty/rogue-properties ✅ Passed (153/153) (2.2s) Open | Play
@quenty/soundgroup ✅ Passed (4/4) (918ms) Open | Play
@quenty/blend ✅ Passed (3/3) (768ms) Open | Play
@quenty/snackbar ✅ Passed (3/3) (851ms) Open | Play
@quenty/camera ✅ Passed (8/8) (868ms) Open | Play
@quenty/brine ✅ Passed (59/61) (1.0s) Open | Play
@quenty/clienttranslator ✅ Passed (101/101) (1.8s) Open | Play
@quenty/inputkeymaputils ✅ Passed (6/6) (1.0s) Open | Play
@quenty/fakeskybox ✅ Passed (3/3) (884ms) Open | Play
@quenty/elo ✅ Passed (5/5) (884ms) Open | Play
@quenty/observablecollection ✅ Passed (204/204) (1.8s) Open | Play
@quenty/cmdrservice ✅ Passed (676ms) Open | Play
@quenty/secrets ✅ Passed (2/2) (968ms) Open | Play
@quenty/lipsum ✅ Passed (4/4) (734ms) Open | Play
@quenty/brio ✅ Passed (35/35) (784ms) Open | Play
@quenty/conditions ✅ Passed (914ms) Open | Play
@quenty/tie ✅ Passed (5/5) (984ms) Open | Play
@quenty/characterutils ✅ Passed (11/11) (818ms) Open | Play
@quenty/clipcharacters ✅ Passed (1/1) (800ms) Open | Play
@quenty/permissionprovider ✅ Passed (2/2) (851ms) Open | Play
@quenty/instanceutils ✅ Passed (15/15) (802ms) Open | Play
@quenty/datastore ✅ Passed (278/278) (24.7s) Open | Play
@quenty/roblox-api-dump ✅ Passed (32/33) (1.2s) Open | Play
@quenty/screenshothudservice ✅ Passed (768ms) Open | Play
@quenty/influxdbclient ✅ Passed (9/9) (784ms) Open | Play
@quenty/experiencecalculator ✅ Passed (2/2) (601ms) Open | Play
@quenty/fzy ✅ Passed (31/31) (684ms) Open | Play
@quenty/maid ✅ Passed (5/5) (485ms) Open | Play
@quenty/rx ✅ Passed (3/3) (717ms) Open | Play
@quenty/nevermoreclimanifest ✅ Passed (6/6) (635ms) Open | Play
@quenty/octree ✅ Passed (66/66) (951ms) Open | Play
@quenty/promise ✅ Passed (14/14) (1.2s) Open | Play
@quenty/randomutils ✅ Passed (5/5) (501ms) Open | Play
@quenty/throttle ✅ Passed (5/5) (534ms) Open | Play

45 tested, 45 passed, 0 failed in 1m26s · View logs

Deploy Results

Package Status Try it
@quenty/integration ✅ Deployed (v112) (1.9s) Open | Play

1 deployed, 1 passed, 0 failed in 3.1s · View logs

@Quenty
Quenty force-pushed the users/quenty/clienttranslator-defer-translation-writes branch from 84dec8f to 57dc066 Compare July 17, 2026 20:46
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
Quenty force-pushed the users/quenty/clienttranslator-defer-translation-writes branch from 57dc066 to 13abdbe Compare July 17, 2026 20:55
…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.
Quenty added 2 commits July 17, 2026 15:14
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.
Quenty added 7 commits July 17, 2026 17:31
…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
Quenty added 3 commits July 17, 2026 19:07
- 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
Quenty added 3 commits July 17, 2026 20:54
…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
@Quenty Quenty changed the title clienttranslator: pin translation behavior with tests and fix camelCase key generation fix: pin translation behavior with tests and fix camelCase key generation Jul 18, 2026
@Quenty
Quenty deployed to integration July 18, 2026 04:48 — with GitHub Actions Active
@Quenty
Quenty merged commit 1b7a536 into main Jul 18, 2026
11 checks passed
@Quenty

Quenty commented Jul 18, 2026

Copy link
Copy Markdown
Owner Author

🚀 PR was released in @quenty/actionmanager@13.36.0, @quenty/adorneeboundingbox@8.41.0, @quenty/adorneedata@7.36.0, @quenty/adorneevalue@10.40.0, @quenty/aggregator@1.18.0, @quenty/animationgroup@10.15.0, @quenty/animationprovider@11.41.0, @quenty/animations@8.35.0, @quenty/applytagtotaggedchildren@10.14.0, @quenty/assetserviceutils@5.36.0, @quenty/attributeutils@14.32.0, @quenty/avatareditorutils@7.42.0, @quenty/badgeutils@10.20.0, @quenty/baseobject@10.14.0, @quenty/basicpane@13.33.0, @quenty/binder@14.39.0, @quenty/bindtocloseservice@8.35.0, @quenty/blend@12.39.0, @quenty/bodycolorsutils@7.39.0, @quenty/boundlinkutils@14.40.0, @quenty/brine@1.8.0, @quenty/brio@14.32.0, @quenty/buttondragmodel@1.32.0, @quenty/buttonhighlightmodel@14.39.0, @quenty/camera@14.45.0, @quenty/camerastoryutils@10.24.0, @quenty/canceltoken@11.21.0, @quenty/characterutils@12.35.0, @quenty/chatproviderservice@9.57.0, @quenty/clienttranslator@14.41.0, @quenty/clipcharacters@12.44.0, @quenty/cmdrservice@13.49.0, @quenty/collectionserviceutils@8.33.0, @quenty/color3utils@11.39.0, @quenty/colorpalette@10.42.0, @quenty/colorpicker@10.40.0, @quenty/conditions@10.43.0, @quenty/contentproviderutils@12.34.0, @quenty/convexhull@4.16.0, @quenty/cooldown@11.41.0, @quenty/coreguienabler@12.36.0, @quenty/coreguiutils@10.20.0, @quenty/counter@7.33.0, @quenty/datastore@13.42.0, @quenty/deathreport@10.49.0, @quenty/depthoffield@11.41.0, @quenty/draw@7.15.0, @quenty/elo@7.40.0, @quenty/enabledmixin@11.29.0, @quenty/equippedtracker@13.37.0, @quenty/experiencecalculator@2.12.0, @quenty/fakeskybox@11.21.0, @quenty/firstpersoncharactertransparency@14.37.0, @quenty/flipbook@9.35.0, @quenty/friendutils@12.32.0, @quenty/funnels@1.27.0, @quenty/fzy@5.16.0, @quenty/gameconfig@12.53.0, @quenty/gameproductservice@14.56.0, @quenty/gamescalingutils@13.39.0, @quenty/genericscreenguiprovider@13.42.0, @quenty/geometryutils@6.15.0, @quenty/grouputils@10.22.0, @quenty/guivisiblemanager@12.25.0, @quenty/hide@11.41.0, @quenty/highlight@10.43.0, @quenty/hintscoringutils@14.46.0, @quenty/httppromise@10.20.0, @quenty/humanoiddescriptionutils@10.20.0, @quenty/humanoidspeed@12.53.0, @quenty/humanoidtracker@13.33.0, @quenty/humanoidutils@2.14.0, @quenty/idleservice@13.56.0, @quenty/ik@15.57.0, @quenty/influxdbclient@7.37.0, @quenty/inputkeymaputils@14.47.0, @quenty/inputmode@13.37.0, @quenty/inputobjectutils@4.32.0, @quenty/insertserviceutils@10.20.0, @quenty/instanceutils@13.32.0, @quenty/jsonutils@10.20.0, @quenty/linkutils@13.33.0, @quenty/lipsum@14.39.0, @quenty/localizedtextutils@12.33.0, @quenty/maid@3.10.0, @quenty/marketplaceutils@11.23.0, @quenty/memorystoreutils@6.20.0, @quenty/messagingserviceutils@7.24.0, @quenty/modeltransparencyeffect@11.20.0, @quenty/motor6d@7.45.0, @quenty/mouseovermixin@10.15.0, @quenty/mouseshiftlockservice@11.21.0, @quenty/multipleclickutils@13.30.0, @quenty/networkownerservice@5.10.0, @quenty/networkropeutils@10.14.0, @quenty/nevermore-cli@4.39.0, @quenty/nevermoreclimanifest@1.3.0, @quenty/nocollisionconstraintutils@10.14.0, @quenty/observablecollection@12.41.0, @quenty/octree@11.15.0, @quenty/overriddenproperty@10.16.0, @quenty/pagesutils@5.21.0, @quenty/particleengine@13.36.0, @quenty/particles@5.16.0, @quenty/parttouchingcalculator@14.44.0, @quenty/pathfindingutils@10.21.0, @quenty/permissionprovider@14.42.0, @quenty/physicsutils@8.30.0, @quenty/playerbinder@14.39.0, @quenty/playerhumanoidbinder@14.40.0, @quenty/playerinputmode@9.42.0, @quenty/playersservicepromises@10.20.0, @quenty/playerthumbnailutils@10.20.0, @quenty/playerutils@8.35.0, @quenty/policyserviceutils@6.20.0, @quenty/promise@10.20.0, @quenty/promisemaid@5.20.0, @quenty/promptqueue@1.40.0, @quenty/propertyvalue@7.32.0, @quenty/qframe@10.25.0, @quenty/r15utils@13.33.0, @quenty/racketingropeconstraint@12.46.0, @quenty/radial-image@9.40.0, @quenty/ragdoll@15.56.0, @quenty/randomutils@6.18.0, @quenty/receiptprocessing@7.36.0, @quenty/remotefunctionutils@10.20.0, @quenty/remoting@12.34.0, @quenty/resetservice@11.39.0, @quenty/rigbuilderutils@10.37.0, @quenty/roblox-api-dump@8.24.0, @quenty/rogue-humanoid@10.53.0, @quenty/rogue-properties@11.49.0, @quenty/rx@13.30.0, @quenty/rxbinderutils@14.39.0, @quenty/rxsignal@7.30.0, @quenty/saveslot@1.6.0, @quenty/scoredactionservice@16.48.0, @quenty/screenshothudservice@7.37.0, @quenty/scrollingframe@12.20.0, @quenty/seatutils@7.37.0, @quenty/secrets@7.54.0, @quenty/selectionutils@8.33.0, @quenty/servicebag@11.19.0, @quenty/settings-inputkeymap@10.60.0, @quenty/settings@11.57.0, @quenty/singleton@6.15.0, @quenty/snackbar@11.45.0, @quenty/socialserviceutils@10.20.0, @quenty/softshutdown@9.52.0, @quenty/soundgroup@1.44.0, @quenty/soundplayer@7.45.0, @quenty/sounds@10.24.0, @quenty/spawning@10.51.0, @quenty/sprites@13.32.0, @quenty/statestack@14.34.0, @quenty/streamingutils@10.20.0, @quenty/sunpositionutils@2.10.0, @quenty/teamtracker@13.33.0, @quenty/teamutils@10.33.0, @quenty/teleportserviceutils@9.33.0, @quenty/templateprovider@11.41.0, @quenty/textboxutils@7.30.0, @quenty/textfilterservice@13.34.0, @quenty/textfilterutils@10.20.0, @quenty/textserviceutils@13.39.0, @quenty/throttle@10.13.0, @quenty/tie@10.43.0, @quenty/timedtween@7.39.0, @quenty/timesyncservice@13.35.0, @quenty/toolutils@1.19.0, @quenty/trajectory@2.16.0, @quenty/transitionmodel@7.41.0, @quenty/ugcsanitize@1.19.0, @quenty/uiobjectutils@6.31.0, @quenty/undostack@7.33.0, @quenty/userserviceutils@9.34.0, @quenty/valuebaseutils@13.32.0, @quenty/valueobject@13.33.0, @quenty/viewport@11.46.0, @quenty/voicechat@5.20.0 🚀

@Quenty Quenty added the released This issue/pull request has been released. label Jul 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released This issue/pull request has been released.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant