Language support¶
Status: PROPOSED DESIGN — none of this is implemented. Date: 2026-09-01.
ZeroZ Stack speaks English and nothing else.
There is no message catalog, no locale, no way for a person to pick a language, and no way for the
server to answer in one.
The only java.util.Locale in the whole checkout is Locale.ROOT, used twice in test code to
lower-case a word before comparing it.
This document is the design for adding language support to both tiers. It is written before any code because one finding changes the shape of the whole thing, and because one consequence — the framework's own error messages stop being strings and become keys — breaks every application and every test that reads them. Both are cheaper to decide now than to unpick later.
Everything under "What exists today" and "What TeaVM actually does" is verified against the code and cited. Everything under "Design" is a proposal.
1. What exists today¶
Nothing, and that is the whole of it¶
No ResourceBundle, no .properties catalog, no MessageFormat, no locale on any context object.
Searching the checkout for Locale returns two test files doing toLowerCase(Locale.ROOT).
So this is greenfield: there is no existing mechanism to keep working, and no migration to write.
The English that is already written down¶
There is not much of it, and knowing exactly how much is what makes the breaking section below affordable. It falls into four groups.
The server's own refusals, in WasmRmiServerEngine.java.
These reach the client word for word, by an explicit decision at :1735-1742:
static String clientSafeMessage(Throwable failure, String reference) {
if (failure instanceof ClientVisibleException
|| failure instanceof SecurityException
|| failure instanceof NoSuchMethodException) {
String message = failure.getMessage();
if (message != null && !message.isEmpty()) {
return message;
}
}
return "The server could not complete this request. Reference: " + reference;
}
The sentences behind that are at :1618 ("Rejected RMI call to unregistered service: "), :1634
("Authentication required for: "), :1651 ("Access denied: requires role "), and the generic
sentence at :1742.
The live-mutation refusal reasons are the same shape: :1143, :1208, :1212, :1216.
Around a dozen sentences in total.
The validation messages the annotation processor writes into generated code.
RmiAnnotationProcessor.java:674-718 builds four default messages by string concatenation:
These are generated into <Model>_Rules, which is compiled into both tiers, so the same English
is used for a browser form hint and for a server-side rejection.
The component library's own words.
"Close" on a drawer's dismiss overlay (Drawer.java:129), "Copied" after a copy button
(CodeBlock.java:78), "never" for an age that has none (RefreshControl.java:174),
"Sending..." during an upload (FileUpload.java:445), " star" / " stars" on a rating
(Rating.java:57), and "Dark theme" as a theme toggle's spoken name
(ThemeController.java:33).
Roughly a dozen again.
The client runtime's words. "Connection lost — reconnecting…" (Zeroz4jClient.java:134) and
the static offline page at
zerozstack-server-core/src/main/resources/META-INF/resources/zeroz4j-offline.html.
Call it forty strings altogether. That number matters: it is small enough that the framework's own catalog is a weekend, and the work is all in the mechanism, not the translating.
Where an identity already lives¶
RmiRequestContext carries five thread-locals — principal, roles, session id, tenant id and
client id — set once per frame by the engine and cleared in a finally.
It is the place a locale belongs, and §4.6 puts one there.
Scope.CLIENT already solves the hardest part of "remember this browser's choice".
Every connection carries a server-issued, HMAC-signed 256-bit id in an HttpOnly cookie, minted
when the page is served and again at the handshake if the browser presents none, valid for a year
by default (zeroz.clientId.ttlDays).
It survives reconnects, page reloads, restarts and a cluster.
See Client identity without a login.
2. What TeaVM actually does, and how that was established¶
The client is Java compiled to JavaScript by TeaVM 0.15.0.
That a class exists in teavm-classlib-0.15.0.jar proves nothing about whether it works in a
browser, so every claim here was checked against TeaVM's own source and against generated
JavaScript.
This section is the reason the design looks the way it does. Read it before §4 or §4 will look arbitrary.
ResourceBundle works, but it is a compile-time mechanism¶
java.util.ResourceBundle, PropertyResourceBundle and ListResourceBundle are all present.
They are not what a Java developer expects.
TResourceBundle looks a bundle up in a map built once, at compile time, by
org.teavm.classlib.impl.ResourceBundleImpl.createBundleMap — a TeaVM metaprogramming method
annotated @Meta.
That method scans the compile classpath for every META-INF/services/java.util.ResourceBundle
resource.
Each line is a fully qualified bundle name including its locale suffix:
For each line it looks for a class with a no-argument constructor; failing that it looks for
com/example/app/Messages_de.properties on the classpath, reads it at compile time, and emits a
synthetic ListResourceBundle whose getContents() returns a hard-coded Object[][] of the
key/value pairs.
Three consequences, and all three are load-bearing:
- The translations are baked into
classes.jsas string literals. They are not fetched, not read from a file, and not loadable from anywhere at runtime. - A language that is not on the TeaVM compile classpath does not exist. Adding Portuguese means recompiling the client, not dropping a file on a server.
- A bundle that is not listed in the services file is invisible, even if the
.propertiesfile is right there. There is no directory scan.
So the browser cannot load a .properties file at runtime. That is the finding that shapes
everything. A design that assumed otherwise would have to be thrown away.
Locale.getDefault() is a build constant, and nothing reads the browser's language¶
TLocale's static initializer reads CLDRHelper.getDefaultLocale(), which is generated by
DefaultLocaleMetadataGenerator:
That is a TeaVM build property, resolved when the compiler runs.
Locale.getDefault() in a browser therefore returns en_GB unless the build says otherwise, on
every machine in the world, regardless of the person's own settings.
Nothing in TeaVM ever reads navigator.language.
The value must contain an underscore: the initializer does
localeName.substring(0, localeName.indexOf('_')) with no guard, so java.util.Locale.default=de
throws at class-init time and takes the whole application down.
Locale.setDefault(...) does exist and does work at runtime, so the default can be corrected once
the application knows better.
Two cautions: TResourceBundle's cache is static and caches "no such bundle" results, and
getBundle(name, locale) falls back to Locale.getDefault() when the requested locale has no
bundle. Changing the default after lookups have happened gives results that depend on call order.
The rest of the toolkit, checked one class at a time¶
| Class | In TeaVM 0.15? |
|---|---|
java.text.MessageFormat |
yes |
java.text.ChoiceFormat |
yes |
java.text.NumberFormat, DecimalFormat, DecimalFormatSymbols |
yes |
java.text.SimpleDateFormat, DateFormatSymbols |
yes |
java.util.Currency |
yes |
java.text.Collator |
no |
java.text.BreakIterator |
no |
java.text.Normalizer |
no |
java.text.Bidi |
no |
The four missing ones are why §6 rules out locale-aware sorting and right-to-left layout rather than leaving them as future work: there is nothing to build them on.
The five that are present are all expensive, which the measurements below quantify and which §4.3 and §4.12 are built around.
How much CLDR data is compiled in is a build property¶
teavm-classlib-0.15.0.jar carries cldr-json.zip, 15.8 MB of locale data.
CLDRReader reads the build property java.util.Locale.available, default "en_EN", splits it on
commas, and keeps only the locales it names:
String availableLocalesString = properties.getProperty("java.util.Locale.available", "en_EN").trim();
So the CLDR weight in a bundle is chosen, not inherited, and it is chosen in the
teavm-maven-plugin configuration — the same <properties> block the example poms already use for
java.util.ServiceLoader.parseServiceConfiguration.
Dead-code elimination removes all of it when nothing calls NumberFormat or SimpleDateFormat,
which is why today's client bundles carry none.
The measurements¶
Every number below is the emitted target/js/classes.js of the todo-signals example client,
compiled with that module's own settings — targetType=JAVASCRIPT, optimizationLevel=SIMPLE,
minifying=false — plus its gzip size, which is what a browser actually downloads.
Each row is one real build.
| What was compiled | bytes | gzip |
|---|---|---|
| The example, unchanged | 1,261,677 | 208,530 |
+ a ResourceBundle with 2 languages of 40 keys |
1,289,814 | 213,746 |
| + the same with 6 languages | 1,310,537 | 217,120 |
| + the same with 20 languages | 1,383,397 | 228,198 |
20 locales declared in java.util.Locale.available, no formatting call |
1,289,814 | 213,746 |
One NumberFormat.getInstance(locale).format(n) call, nothing else |
1,495,041 | 251,904 |
One ChoiceFormat.format(n) call, nothing else |
1,526,590 | 265,975 |
One MessageFormat.format(pattern, arg) call, 2 languages |
1,779,525 | 302,648 |
+ NumberFormat and SimpleDateFormat on top, 1 locale's CLDR data |
1,780,823 | 302,917 |
| + the same with 6 locales' CLDR data | 1,958,200 | 331,008 |
| + the same with 20 locales' CLDR data | 2,460,982 | 418,468 |
Five things come out of that, and the second one changes the design.
1. Translated text is cheap. About 5.2 KB raw and 0.8 KB gzip per extra language of 40 strings, measured consistently across the 2-, 6- and 20-language builds. Twenty languages of interface text cost under 20 KB of download in total. Text is not the problem.
2. Touching java.text at all is expensive, and MessageFormat is the worst of it.
One MessageFormat.format call adds 490 KB raw and 89 KB gzip — a 43% increase in what the
browser downloads, from one call. NumberFormat alone costs 233 KB raw and 43 KB gzip;
ChoiceFormat alone, 265 KB and 57 KB. There is no cheap corner of that package.
This is more expensive than twenty languages of translated text, several times over, and §4.3
and §4.12 are shaped around avoiding it.
3. CLDR locale data costs about 36 KB raw and 6 KB gzip per locale listed in
java.util.Locale.available, and it is charged only once java.text formatting is reachable at
all.
4. Declaring locales you never format with is free. Twenty locales declared, with no formatting
call anywhere, produced a file byte-identical to the two-language build. The data is
demand-driven, not fixed.
That is worth knowing because it means a wrong java.util.Locale.available costs nothing until
somebody formats a number, and then costs a great deal all at once.
5. Locale.setDefault(...) compiles and links. A build calling it and then requesting a German
bundle succeeded. It was not executed in a browser — no claim is made here about which bundle it
selects at run time.
Two caveats on the figures. They were taken on the examples' unminified SIMPLE build; the
archetype generates ADVANCED and minified, so a real application's absolute numbers are smaller
and the ratios are what transfer. And every test string was ASCII, so accented and CJK text will
cost more per string than the 5.2 KB per language above.
3. The decisions already taken¶
These three are settled and this document builds on them rather than arguing with them.
- Both tiers are translated. The server formats its own messages — validation failures,
live-mutation refusals,
ClientVisibleException, its own refusals — in the caller's language, using the identity the connection already carries. - The language is remembered per signed-in user, with the browser's own setting as the first visit's default. An anonymous visitor falls back to the browser.
- Switching updates the screen live. The current language is a signal: text re-reads and the page changes with nothing lost.
§4.6 keeps decision 2 honestly rather than completely, and §8 says so out loud.
4. Design¶
4.1 Two catalogs, two problems, two mechanisms¶
An application's catalog and the framework's own catalog look alike and behave differently, and conflating them is the mistake to avoid.
| The application's catalog | The framework's catalog | |
|---|---|---|
| Size | hundreds of strings, grows forever | about forty, barely grows |
| Who writes it | the application author | this project |
| When it is needed | after the UI mounts | before the socket is up — "Connection lost" has to work when there is no connection |
| Which languages | whatever the project sells in | whatever this project ships |
| How it reaches the browser | over the wire, per connection (§4.4) | compiled in, every shipped language (§4.5) |
| How it is built | by the annotation processor (§4.3) | by hand, with a build-failing parity check (§4.5) |
The split is not tidiness. It is forced: the framework's client-side words are the ones a person reads when nothing is working, so they cannot arrive over the thing that is not working.
4.2 Where an application's catalog lives¶
In the shared module, as ordinary .properties files:
myapp-shared/src/main/resources/i18n/app.properties # the fallback language
myapp-shared/src/main/resources/i18n/app_de.properties
myapp-shared/src/main/resources/i18n/app_fr.properties
The shared module is the only one compiled into both tiers, which is what makes one file the source for both a browser label and a server-side validation message. Its resources are on the server's runtime classpath and on the client's TeaVM compile classpath, so both tiers can reach them by the route each one needs.
An application declares the catalog with one marker class, in the same module:
package com.example.shared;
import com.zeroz4j.api.i18n.MessageCatalog;
@MessageCatalog(baseName = "i18n/app", fallback = "en")
public final class AppText {
private AppText() { }
}
baseName is a classpath path, not a package name, so it reads the same on both tiers.
fallback names the language whose file has no suffix and whose text is compiled into the browser.
4.3 The annotation processor's part: keys become methods¶
The processor already generates _Serializer, _Rules, _Live, _Stub and the SPI registrar.
It gains one more output, and it is the piece that keeps message keys from being free strings.
From AppText above the processor reads i18n/app.properties through
Filer.getResource(StandardLocation.CLASS_PATH, ...) and generates AppText_Text: one method
per key, named by camel-casing the key, with one parameter per placeholder.
Given:
it generates:
public final class AppText_Text {
public static Message taskAdd() {
return new Message("i18n/app", "task.add");
}
public static Message taskRemaining(Object arg0, Object arg1) {
return new Message("i18n/app", "task.remaining", arg0, arg1);
}
}
A method returns a Message, not a String, and that is the whole trick.
Message is a plain immutable value — catalog, key, arguments — that compiles on both tiers and
carries no locale of its own. Turning it into words is a separate, explicit act:
text() resolves against the current language — the signal on the client, the caller's locale from
RmiRequestContext on the server — so the same call site is right on both.
That mirrors Signals.scoped, whose mine() and forTarget(...) split already teaches this shape.
Placeholders are {0}, {1} and nothing else, substituted by about forty lines of the
framework's own code on both tiers. Not java.text.MessageFormat, and the reason is measured:
one MessageFormat.format call adds 89 KB gzip to the client bundle (§2), which is a 43% increase
in what every visitor downloads, for a feature that is almost always plain substitution.
Positional substitution and nothing more also buys something the fancier syntax cannot: the client and the server render a message byte-identically, because they run the same forty lines. A message formatted one way in a browser hint and another way in the server's rejection of the same value would be a defect, and this design cannot produce one.
An application that wants {0,number,currency} formats the value itself and passes the words:
which keeps the 43 KB java.text cost (§4.10) on the one application that asked for it.
Five things fall out of it, and each one is a class of bug that never happens:
- A misspelled key is a compile error, not a
???task.add???on a screen. - A wrong argument count is a compile error. Passing one value to a two-placeholder message cannot be written.
- A key deleted from the fallback file breaks every call site, so dead keys get noticed.
- A message can be carried without being turned into words yet, which is what lets an exception travel to the edge of the server and be rendered in the caller's language there while the log line keeps its English (§4.8).
- The build check in §4.9 has one method name to look for.
.text()is a single greppable token across every catalog in the checkout; free-string lookups would be neither.
The processor also emits AppText_Catalog, the fallback language baked into Java as a switch
on the key returning a string literal.
That is what the browser falls back to when the server's catalog has no answer, and it deliberately
does not go through TeaVM's ResourceBundle machinery — a generated switch is smaller,
predictable, and free of the static-cache and Locale.getDefault() coupling described in §2.
Two errors the processor reports rather than emitting broken code: two keys that camel-case to the
same method name (task.add and taskAdd), and a key whose placeholders are not a contiguous run
from {0}. A third is worth a warning: a key containing a comma inside its braces, such as
{0,number}, which reads like MessageFormat and is not.
Every other language file is never seen by the processor and never reaches the client build.
Adding French is dropping app_fr.properties into the shared module.
Nothing is regenerated, nothing is recompiled for the browser, and the bundle does not grow.
A separate build-failing test — the project's established answer — reads every app_*.properties
beside the fallback and fails when one is missing keys the fallback has, or carries keys it does
not, or gives a key a different number of placeholders.
A translation that is merely absent is a blank screen area; a translation with the wrong placeholder
count leaves a literal {1} on somebody's screen, or drops a value they needed to read.
4.4 The client's catalog arrives on the frame that already says "you can start"¶
The browser cannot load a catalog from a file (§2) and should not carry every language (§4.10). So the server sends it.
The AUTH frame (0x03) is already sent on every connection, authenticated or not, and it
already begins with a protocol version byte, currently 2.
Bump it to 3 and append the catalog for the resolved locale: a count, then key/value pairs.
The client stores them in a map and Messages.lookup reads from it.
This costs no extra round trip.
The framework already tells applications to mount the UI from onResolved(...) — the callback that
fires when the AUTH frame has arrived — so the catalog is in hand at the exact moment the first
screen is built.
There is no window in which English is shown and then replaced.
What it costs is bytes on one frame.
A three-hundred-string catalog at forty characters a string is about 12 KB, once per connection.
That is inside the 4 MB default message ceiling (zeroz.ws.maxBinaryMessageBytes) by three orders
of magnitude, and it is smaller than what a single language would add to the bundle if compiled in.
Two refinements, both worth having and neither required for a first version:
- Send a hash first. The client keeps the last catalog it received in
localStorage, keyed by locale, and sends the hash it holds on the handshake. A match means the server sends the hash back and no strings. This turns 12 KB per connection into 12 KB per deployment per browser. - Only for the fallback locale, send nothing at all. The fallback is already compiled in.
The compiled-in fallback catalog (§4.3) covers three moments: before the AUTH frame arrives, when the connection is down and a screen is rebuilt, and when the server's catalog lacks a key the client asks for.
4.5 The framework's own catalog is compiled in, in every language it ships¶
"Connection lost — reconnecting…" has to be readable while the connection is lost.
So the framework's own client-side words are compiled into classes.js for every language this
project ships, and there is no wire path for them at all.
They live in zerozstack-shared-api, which every other module already depends on — including
zerozstack-ui-components, which is where most of them are used:
zerozstack-shared-api/src/main/resources/i18n/zeroz4j.properties
zerozstack-shared-api/src/main/resources/i18n/zeroz4j_de.properties
They cannot be generated by the annotation processor.
zerozstack-apt depends on zerozstack-shared-api, so shared-api is built first and cannot run a
processor that does not exist yet.
So FrameworkText is written by hand — a class with a switch per language — and kept honest by
FrameworkCatalogParityTest, a build-failing test that reads both the class and the .properties
files and fails when they disagree in either direction.
That is not a workaround; it is the pattern this project already uses six times over, and it costs
no new build machinery.
Forty strings times the number of shipped languages is a few kilobytes. §4.10 has the measured figure.
The offline page is a static HTML file with no Java in it.
It carries every shipped language as hidden blocks and a three-line inline script that shows the one
matching navigator.language, falling back to English.
It is the one place in the design where navigator.language is read directly, because it is the one
place with no framework running.
4.6 How the server learns the caller's language¶
RmiRequestContext gains a sixth thread-local and a getter:
Never null. It returns the deployment default when nothing better is known, so no caller ever has to null-check, and a service that formats a message never accidentally formats it in the JVM's own locale — which on a server in Frankfurt is German and has nothing to do with the person calling.
The engine sets it per frame from the session, exactly as it already sets principal, roles, tenant
and client id, and clears it in the same finally.
The session's locale is resolved once, at the handshake, in this order:
- The
langhandshake parameter, when the client sends one. The client sends it because it may already know the answer from its own stored choice. - A
zeroz-langcookie, read from the handshake headers. This is what makes the choice survive a restart and a new connection, and it is what the client reads to fill in step 1. - The
Accept-Languageheader. Browsers send it on a WebSocket upgrade like any other request, andHandshakeCredentials.header("Accept-Language")already exposes it. zeroz.i18n.defaultLocale, the deployment's own setting.en.
Whatever is resolved is then narrowed to the languages the deployment actually has a catalog for:
de-AT becomes de when only de exists, and falls to the default when neither does.
A person's browser asking for a language nobody translated must never produce a half-translated
screen.
A connection that never chose one gets step 3, then 4, then 5 — which is to say it gets the browser's own preference, which is decision 2's "first-visit default" and needs nothing stored anywhere.
Where the remembering happens is the honest gap. Decision 2 says the language is remembered per signed-in user. The framework has no user store and cannot acquire one without answering the much larger question the transactions design leaves open — whether the framework writes to the application's database at all.
So the framework remembers per browser, in a cookie, and offers a one-method seam for an application that wants it per person.
The client writes that cookie, not the server, and this is deliberate. The client-id cookie is
HttpOnly and written by the server at the handshake, and the code that does it carries a caution
worth reading (RmiEndpointConfigurator.java:350-354):
} catch (RuntimeException ex) {
// Some containers expose an immutable response header map. The id still works for
// this connection; it just will not persist past it.
A language is not a secret and nothing is protected by it, so it needs none of that. The client sets
an ordinary document.cookie — SameSite=Lax, a year, no HttpOnly — the moment the language
changes, and the server only ever reads it. That works in every container, has no failure mode to
log, and needs nothing on the handshake response.
The seam for per-person memory:
package com.zeroz4j.server;
public interface LocalePreferenceStore {
Locale forUser(String userName); // null when unknown
void remember(String userName, Locale locale);
}
Discovered by ServiceLoader, like AuthenticationProvider, because the handshake runs before CDI
beans exist.
Registered, it is consulted between steps 1 and 2 for an authenticated connection, and written
whenever the language changes.
Not registered, the cookie is the whole story.
This covers the common case completely and the second-device case not at all, and §8 puts that in front of the owner rather than burying it.
4.7 Switching, live¶
The current language is a scoped writable signal, declared once by the framework in the shared module:
public final class Zeroz4jSignals {
public static final ScopedSignal<String> LOCALE =
Signals.scopedWritable("zeroz.locale", "en", Scope.CLIENT);
}
Scope.CLIENT because a language must work with no login, and because the client id already
survives reconnects and reloads.
The whole switch is then:
and everything else falls out of machinery that already exists:
- The client applies the write optimistically, writes its own
zeroz-langcookie (§4.6) and sends the value up. - The server accepts it, stores it on the session, calls
LocalePreferenceStore.remember(...)if one is registered, fetches the catalog for the new language and sends it down, then broadcasts the accepted value. Messages.lookupon the client readsZeroz4jSignals.LOCALE.mine(). EveryEffectandComputedthat has read a message is therefore subscribed to the language, and re-runs. Text updates for the same reason every other value in this framework updates. Nothing new is invented, which is the point.- Nothing is lost, because nothing is rebuilt except the text: an effect re-runs and calls
setTexton the label it already owns. Field contents, scroll position and focus are untouched.
Server-side ordering is already guaranteed. Since 0.8.0 one connection's messages are handled in the order the browser wrote them, so a service call sent after a language write is answered in the new language. There is no race to design around and no flush to write.
Two things to get right in the implementation:
- The catalog must be applied before the signal's new value is published, or the first effect to re-run reads the old catalog under the new language name.
- A locale the server has no catalog for is refused the way any invalid signal write is refused: the writer is snapped back to the accepted value and told why. A selector that only offers what the server has never triggers this, but a client can send anything.
4.8 The framework's own messages become keys — the breaking part¶
This is the largest consequence in the document and the reason it is written now.
What is true today. The framework's own refusals reach the client word for word
(WasmRmiServerEngine.java:1735-1742), and this is documented as a feature: the
security guide says those messages
"exist to be read, and clients already act on them", and ClientVisibleException's javadoc says the
message "travels to the client word for word".
Applications read getMessage(). Tests assert on exact English. The framework's own tests do
too. So do the examples.
What has to change. A message that is a String written on the server cannot be produced in the
caller's language unless the server knows the caller's language at the moment the string is
constructed — and the string is constructed deep inside a service method that knows nothing about
connections. The message must therefore stop being a string and become a key plus arguments,
formatted at the edge where the locale is known.
ClientVisibleException gains a second constructor and keeps the first:
// unchanged, and still correct — one language, sent as written
throw new ClientVisibleException("That invoice was already approved.");
// translated — the exception carries the Message, not words
throw new ClientVisibleException(AppText_Text.invoiceAlreadyApproved(invoiceNumber));
Carrying the Message rather than calling .text() at the throw site is what buys the log line.
sendError — the one place that already decides what a caller is told, and the one place with the
caller's locale in hand — renders it in the caller's language for the wire and in English for the
log, from the same value.
The framework's own refusals become Message values from the framework catalog, rendered in the
same place.
What breaks, precisely:
| Breaks | Who feels it |
|---|---|
A test asserting "Access denied: requires role approver" |
this project's own tests, and any application that copied the pattern |
| A client comparing an error message to a literal to decide what to show | applications; already a bad idea, now a broken one |
A validation message baked into <Model>_Rules |
every application, silently — the generated English is simply no longer what appears |
| Log-scraping on the server's own message text | operators |
What does not break: an application that throws ClientVisibleException with a plain string.
That constructor stays, and it stays correct. A single-language application changes nothing at all.
English stays the fallback, and stays exact. Three reasons, and the third is the one that settles it:
- The fallback catalog is compiled in, so English is always reachable with no lookup that can fail.
- A deployment that never configures a language sees byte-identical behavior to today.
- A test can then assert on the key rather than the sentence, which is what it should have been
asserting on all along. Provide
assertRefusedWith(FrameworkKeys.ACCESS_DENIED, thrown)inzerozstack-server-testand the upgrade is mechanical.
What an upgrader does, in order:
- Change nothing. Everything works, in English, exactly as before. This is the honest default and most projects stop here.
- When a test breaks, replace the literal with the key. The release notes carry the table of old sentence to new key — around a dozen rows, listed in §1.
- To translate their own refusals, add
.propertiesfiles and swapnew ClientVisibleException("...")for the key form, one call site at a time. The two forms coexist indefinitely.
One thing that only half works. The log keeps English, because an operator reading a log at
three in the morning should not have to know which language the caller had.
That is free for a refusal thrown as a Message: sendError renders English for the log and the
caller's language for the wire, from one value.
It is not free for a refusal thrown as a plain string in a language the server picked some other
way, and it is not free for a support ticket. Somebody quoting a German sentence off their screen
still has to be matched to an English log line. The reference code already solves this for unplanned
failures — The server could not complete this request. Reference: 4f2a91cc — and the same code
should be put on translated refusals, or support has a translation problem of its own.
4.9 The reactive-read hazard, and what a build check can and cannot catch¶
The mistake, which everyone will make:
// WRONG — .text() read once, at construction. Switching the language leaves this label behind.
Button add = new Button(AppText_Text.taskAdd().text());
// RIGHT — .text() read inside an effect, so the language signal is a dependency and it re-reads.
Button add = new Button();
Effect.create(() -> add.setText(AppText_Text.taskAdd().text()));
This is the same shape as the LiveSync hazard already in the troubleshooting table — "the getter
was read outside an Effect, so nothing subscribed" — and it will be more common, because a
screen has far more labels than live objects.
It is also invisible in testing. Route views are rebuilt on navigation, so a developer who switches language and then navigates sees everything correct. The stale label only shows on the screen that was open when the switch happened, which is exactly the screen nobody tests.
The check. A source-reading test in zerozstack-ui-components/src/test, in the established
style: it reads every Java file in the checkout, finds every .text() call on a Message, and
fails the build when that call is not lexically inside an Effect.create(...) or
new Computed<>(...) body.
.text() on a Message is the only way words are ever produced (§4.3), so one method name is the
whole surface the check has to watch. That is the practical reason the generated methods return a
value rather than a String: a design where every catalog method returned words would give the
check hundreds of names to know about, and it would go stale the first time somebody added a
catalog.
Brace matching over source text, the same technique DetachContractTest uses for
setInnerHTML("") and JsBodyNamingContractTest uses for @JSBody scripts. Those already work and
already fail builds, so the mechanism is proven.
The escape hatch is explicit, because a hidden one grows until it is the rule.
A method that legitimately reads a message outside an effect — a validation message assembled for a
single call, a string handed straight to the server — is annotated @ReadsMessagesOnce, and the
check skips it and says in its failure message that the annotation exists.
What it cannot catch. All of these are real and none has an automated answer:
- A read one call deep.
Effect.create(() -> add.setText(buildLabel()))wherebuildLabel()calls.text()is correct and the check calls it wrong; a constructor calling the samebuildLabel()is wrong and the check calls it right. The check sees one file's text and cannot follow a call. The@ReadsMessagesOncehatch is what makes the false positive survivable, and nothing makes the false negative visible. - A
Messagestored in a field and rendered later. TheMessageitself is fine to hold — it has no language in it — so passing one around is correct and encouraged. Where somebody calls.text()on it is the only thing that matters, and if that happens in a file the check has already cleared, it is cleared. - The opposite mistake — English left hard-coded. A screen with
new Button("Add task")in it is not translated at all, and no check reliably tells a user-visible literal from a CSS class name, a DOM attribute or a log line. A narrow version is possible and worth having: fail the build when a component in this repository passes a string literal tosetText,setLabelor anaria-label. That covers the framework's own words and covers nothing in anybody's application — the same honest limitation the accessibility checks already carry. - Whether a translation is any good. "Button" and "Delete this invoice permanently" both pass
the naming check today, and
taskAddreturning the German for "subtract task" passes everything. - Words read into a field and used later.
String label = AppText_Text.taskAdd().text();inside an effect passes, and every later use oflabelis stale. Correct at the moment it runs and indistinguishable from correct code afterwards.
4.10 Bundle size, and which languages ship¶
The measurements in §2 make this section short, because they settle it.
The application's languages cost the browser nothing. They travel on the wire (§4.4), so a
deployment that offers twelve languages compiles exactly the same bundle as one that offers one.
Adding a language is dropping app_pt.properties into the shared module and restarting the server.
The framework's own languages cost about 0.8 KB gzip each, because it has about forty strings
and they are compiled in (§4.5). Six languages is under 5 KB of download on a 209 KB baseline.
That figure was measured through TeaVM's ResourceBundle, which also charges a one-time 3.6 KB of
machinery; the generated switch of §4.3 skips that machinery, so it should land at or under the
measured per-language figure. It has not been measured separately.
Recommendation: ship the framework's own words in English and one other language, and let the per-language cost be a non-decision. It is small enough that the argument is about maintaining translations, not about bytes. §8 puts the choice of which languages in front of the owner.
The real budget item is java.text, and the design spends none of it by default.
| Choice | gzip cost | Who pays |
|---|---|---|
| Any number of translated languages | 0 | nobody — they come over the wire |
| The framework's own words, per language | ~0.8 KB | every application |
Reaching java.text at all (Formats, a {0,choice} message) |
43–89 KB | only an application that calls it |
Each locale in java.util.Locale.available, once java.text is reachable |
~6 KB | only that application |
So the default is: java.util.Locale.available stays at the deployment's own single locale, nothing
in the framework calls java.text, and an application that never formats a number pays nothing for
localization beyond the framework's own words.
A project that wants locale-correct numbers, money or dates opts in explicitly, in its own
client module's teavm-maven-plugin configuration — the same <properties> block that already
carries java.util.ServiceLoader.parseServiceConfiguration:
<properties>
<java.util.ServiceLoader.parseServiceConfiguration>true</java.util.ServiceLoader.parseServiceConfiguration>
<java.util.Locale.available>en_US,de_DE,fr_FR</java.util.Locale.available>
<java.util.Locale.default>en_US</java.util.Locale.default>
</properties>
The first call to Formats.number() costs 43 KB gzip; each locale in that list then costs 6 KB
more.
Both numbers belong in the guide beside the API, not in a caveats file, so nobody discovers
them from a bug report about a slow first load.
java.util.Locale.default must contain an underscore (§2) or the application dies at class-init
with no useful message. The archetype should set it, and a startup check should refuse a malformed
value rather than letting TeaVM's substring(0, -1) explain it.
4.11 The selector¶
LanguageSelector, in zerozstack-ui-components, beside ThemeController — which is the exact
precedent: a small framework-provided control that binds to one piece of global state and gets
dropped into an application's shell.
It extends the existing Select, which is a real <select> element, so the keyboard contract is
met by the browser and not by hand: Tab reaches it, arrows and typing move through the options,
Enter and Escape close it.
The keyboard and naming guide is satisfied the easy way, and
the easy way is the one it asks for.
The accessible name is the part to get right, and ThemeController shows how.
It carries a built-in name, "Language", translated in the framework's own catalog, and
setLabel(...) replaces it and removes the built-in one so the two cannot disagree.
Two rules about the option text, which is where most language pickers go wrong:
- Each language is named in itself —
Deutsch,Français,日本語— never in the current language. Somebody who has landed on a page in a language they cannot read is exactly the person who needs this control, and "German" is no help to them. - The names come from the framework catalog, not from
Locale.getDisplayLanguage(). That method works in TeaVM but resolves against CLDR data, so it only answers for languages injava.util.Locale.available— and §4.10's recommendation is that most deployments carry CLDR data for one. Fixed strings always work and cost about twenty bytes each.
It binds to the signal, and that is the whole component:
LanguageSelector picker = new LanguageSelector(); // options come from the server's list
picker.bindValue(Zeroz4jSignals.LOCALE.mine());
The list of offered languages arrives with the catalog on the AUTH frame, so the control offers exactly what the deployment can actually serve. It never offers a language that would be refused.
4.12 Numbers, dates and money¶
A translated interface that prints 1,234.56 to a German reader is not translated.
NumberFormat.getInstance(locale), getCurrencyInstance(locale) and SimpleDateFormat with a
locale all work in TeaVM, and all read CLDR data.
They are also the expensive part of this whole design, and they are therefore opt-in.
The first call into java.text costs 43 KB gzip, and each locale of CLDR data costs 6 KB more
(§2). Nothing in the framework calls it, so an application that never formats a number never pays.
Formats.number(), Formats.currency() and Formats.date() are how an application opts in. They
read the language signal on the client and RmiRequestContext.getLocale() on the server, so the
same call site works on both tiers — the mine() / forTarget(...) shape Signals.scoped already
teaches. They live in their own class for a reason: the moment anything in an application reaches
one, its bundle grows by 43 KB, and a class nobody imports by accident is a cost nobody pays by
accident.
Four limits to state where the feature is taught rather than in a distant file:
- The bill arrives with the first call. One
Formats.number()anywhere in a client module adds 43 KB gzip to what every visitor downloads, and it is invisible in code review. A build that reported the emitted size would catch it; nothing reports it today. - A locale with no CLDR data formats as the fallback language. No error, no warning: the numbers are simply grouped the English way. The build cannot catch it, because the two lists — languages you translated, locales whose CLDR data you compiled in — are set in different files for different reasons.
- Time zones are a separate axis and are off by default. TeaVM's
java.util.TimeZone.autodetectdefaults tofalse, so the browser's own zone is not detected unless the build asks for it. A timestamp translated into German and shown in UTC is still wrong. ZonedDateTimeandOffsetDateTimedo not cross the wire at all — they are on the serialization exclusion list in limitations. Send anInstantand format it against a zone the client knows.
5. Not adopted¶
TeaVM's ResourceBundle as the client's runtime mechanism. It works, and using it would mean
every language compiled into the browser bundle (§2), a services file listing every locale by hand,
and a static cache whose results depend on the order lookups happened in. The generated switch of
§4.3 gives the fallback with none of that, and the wire gives the rest.
java.text.MessageFormat as the placeholder mechanism. It is the obvious choice, it is the one
every Java developer would reach for, and it costs 89 KB gzip in the browser — 43% more download,
from one call (§2). Forty lines of positional substitution cost nothing, render identically on both
tiers, and cover every message this framework's own catalog contains. The format types it gives up
({0,number,currency}) are reachable by formatting the value first (§4.3).
Free-string keys — t("task.add"). Familiar from every JavaScript framework and wrong for this
one. A misspelled key would be a runtime blank in a framework whose entire thesis is that routes,
serializers, validation rules and stubs are all resolved at compile time. Generated methods (§4.3)
cost one build step and remove a whole class of defect.
A locale argument on every service method. Explicit, and wrong for the same reason the security guide gives for identity: "Never take the caller's identity from a method argument." A locale from an argument is a locale the caller can lie about and the framework cannot default. It belongs on the context, with everything else the connection carries.
Reconnecting to change language. It would make the server's locale a handshake-only value and save a small amount of session state. It also throws away every live object, every subscription and anything typed and not sent, which contradicts decision 3 and is worse than the problem.
Translating the framework's log output. English, always. An operator reading a log should not have to know what language the caller had, and a searchable log is worth more than a polite one.
6. Out of scope, said plainly¶
Named here so nobody assumes they are included.
Right-to-left layout. Arabic and Hebrew need mirrored layouts, not just translated words:
dir="rtl" on the document, and every physical direction in the stylesheet — margin-left,
text-align: right, an icon that points forward — replaced by a logical one. The component library
uses physical directions throughout and has never been looked at in a mirror. Translating the words
without doing the layout produces something worse than English. TeaVM has no java.text.Bidi
either, so mixed-direction text within one string has nothing to sort it out. Not designed, not
tested, not planned.
Plural rules of any kind. The framework's own substitution (§4.3) replaces {0} with a value
and does nothing else, so "{0} tasks left" says "1 tasks left" and the application has to pick
between two keys itself. java.text would give you
{0,choice,0#no tasks|1#one task|1<{0} tasks}, which is correct for English, German, Dutch and the
other two-form languages. It is not ICU's {count, plural, one{…} few{…} many{…} other{…}}, and
TeaVM has no ICU. Polish, Russian, Arabic and Welsh need three to six forms chosen by rules
ChoiceFormat cannot express, and writing the boundaries by hand gets them wrong.
There is a second reason not to reach for ChoiceFormat even where two forms are enough: it is part
of java.text, and one call costs 57 KB gzip in the browser (§2) — more than every language of
translated text this design will ever ship. Two forms are usually better written as two keys and an
if, which costs nothing. A project needing genuine plural rules must supply its own selection, and
should know what it is spending.
Translating content stored in the database. This design translates the interface — the words the framework and the application authors wrote. A product name, a coach's biography, a support article typed by a person: those are data, they belong to the application's model, and translating them is a schema question (which field, which language, what happens when only one is filled in) that no framework can answer. The framework translates its own words and the application's own words, and stops there.
Also out, and less likely to be assumed: locale-aware sorting and searching (no Collator, no
BreakIterator, no Normalizer in TeaVM — §2); any translation workflow, format conversion or
translator tooling, .po files included; machine translation of anything; per-tenant catalogs, as
opposed to per-language; and translated route paths, so /tasks stays /tasks in every language.
7. Sequence¶
| # | Item | Breaking? |
|---|---|---|
| 1 | RmiRequestContext.getLocale(), handshake resolution, zeroz.i18n.defaultLocale |
no |
| 2 | The framework's own catalog: .properties, FrameworkText, the parity test |
no — English is byte-identical |
| 3 | The framework's refusals become Message values; ClientVisibleException(Message); assertRefusedWith |
yes — §4.8 |
| 4 | @MessageCatalog, the generated _Text and _Catalog, the substitutor, the key-parity test |
no |
| 5 | Catalog on the AUTH frame at protocol version 3 | protocol change |
| 6 | The zeroz.locale signal, live switching, the cookie, LocalePreferenceStore |
no |
| 7 | LanguageSelector and Formats |
no |
3 is placed third because it is the breaking one and it gets more expensive with every application that ships. 5 is the protocol change, and it wants the version byte bumped once rather than twice, so anything else wanting a protocol change should ride with it.
Items 1, 2 and 3 together are a complete and useful release on their own: the server speaks the caller's language and the client does not change at all.
8. Decisions¶
Settled on 2026-09-01. Section 9 keeps the reasoning that led to each.
- Remembered per browser, with a hook for applications. Option B. The cookie is the behavior
nobody has to ask for; an application that wants the choice to follow a person to a second
computer implements
LocalePreferenceStore. The framework does not learn to write application data, which keeps the question the transactions design left open still closed. - The framework ships its own words in English and German. ~~English only, for now.~~ Reversed on 2026-09-01, while item 7 was being built. The original reasoning was that a second language costs every application that download forever and that there was no evidence which one was wanted. Two things were wrong with it.
The cost is not what the reasoning assumed. The ~0.8 KB per language figure in §2 was
measured through TeaVM's ResourceBundle. This design does not use it: the browser is sent its
words over the wire (§4.4) and only the English fallback is compiled in, as a generated
switch. A second language is a .properties file the server reads, so it costs the browser
nothing at all - a build with it and a build without it are byte-identical, measured. What it
costs is about 1.4 KB in one jar, and the same on the wire once per connection for a reader who
asked for German.
And English-only was not a default, it was a trap. LanguageSelector (§4.11) carries a
built-in name from this catalog. In the release that adds language support, the one control a
non-English reader most needs would have been labeled in a language they may not read - unless
every application shipped its own framework catalog to work around it. A default that only works
if everybody works around it is not a default.
German, specifically, because this project's author writes it, so the translation can be reviewed rather than guessed at. That is the whole of the criterion, and it is why there is not a third: a language nobody here can check is worse than one language, because a wrong sentence in a language you cannot read is indistinguishable from a right one.
One consequence had to be designed for. The framework's German is on every application's
classpath, translated or not. If it counted as a language the deployment can answer in, an
English-only application would answer a German browser with German refusals over an English
screen - the half-translated screen §4.6's narrowing rule exists to prevent, arrived at from
the other direction. So the framework's own catalog never contributes to the offered-language
list: what a deployment can answer in is decided by the deployment's own catalogs, and the
framework's words ride along with whichever of them it has.
3. The catalog rides on the AUTH frame. As designed. It is already sent, it already carries a
version byte, and a separate fetch would be a second round trip before the first screen.
4. Numbers, dates and money stay off unless a project asks. One MessageFormat call makes the
download 43% bigger -- more than twenty languages of text. The price is written next to the
instructions, not buried.
5. English stays the fallback and today's behavior is unchanged. A project that adds no
language keeps working exactly as it does now, including anything reading a message's wording.
6. The stale-label build check fails from the first day. Nothing uses language support yet, so
there is no violation to grandfather in and no cost to being strict. Six checks added in 0.8.0
fail the build rather than warning, and every one of them earned its keep; a warning nobody
fails on is a warning nobody reads.
9. How each decision was reached¶
1. Remembering the language per person needs somewhere to write it. Which?
Decision 2 says the language is remembered per signed-in user. The framework has no user store, so by itself it can only remember per browser.
| Option | What it gives | What it costs |
|---|---|---|
| A. Cookie only | Right on the browser they chose it on. Survives restarts, reloads, a cluster. | A second computer, or a private window, starts from the browser's setting again. |
B. Cookie + a LocalePreferenceStore an application implements |
Truly per person, for applications that want it | One interface, one ServiceLoader file, and every application has to write it |
| C. The framework writes to the application's store | Per person, free for the application | Opens the question the transactions design deliberately left open: does the framework own any data at all |
Recommendation: B. It is one small interface, it makes the framework's own limit explicit rather than pretending, and it does not commit the project to owning application data. A is the behavior when nobody implements it, which is most projects, and it is good enough for them.
2. Which languages does this project ship its own forty strings in?
Every one of them is compiled into every application's bundle, in every language shipped, forever. It is the only place per-language cost is unavoidable.
It is also small: about 0.8 KB of download per language (§2), against a 209 KB baseline. So the question is not a budget one. It is a maintenance one: is this a framework that ships English and a documented way to add a language, or one that ships six and keeps them all correct forever?
Recommendation: English plus German, because German is the project's own second language and one
real translation proves the mechanism in a way zero do not. Everything else is contributed, and the
contribution is one .properties file.
3. Does the AUTH frame carry the catalog, or does the client ask for it?
§4.4 proposes the AUTH frame, because it removes a round trip and the catalog is then in hand at
onResolved. The alternative already has a pattern to copy: SyncFrameTypes.java:55-56 reserves
the name zeroz4j.signals for framework-internal calls shaped like ordinary RMI, which the engine
intercepts before it dispatches to any service. A zeroz4j.i18n alongside it would keep the
protocol unchanged and cost one round trip before any text can be drawn.
Recommendation: the AUTH frame. The protocol already has a version byte for exactly this, the frame is already sent to everybody, and a screen that draws in the wrong language and then corrects itself is the thing this whole design is meant to avoid.
4. Does ClientVisibleException keep its string constructor forever, or is it deprecated?
Keeping both means two ways to do one thing, and this project's documentation is unusually strict about that. Deprecating the string form means a single-language application gets warnings for writing the correct code for its situation.
Recommendation: keep both, permanently, and say why in the javadoc. A one-language application is a legitimate application, and the string constructor is exactly right for it. The two forms are not two ways to do one thing — they are the translated case and the untranslated case, and both exist.
5. Locale-correct numbers, money and dates cost 43 KB of download. Ship the door, or not?
Formats.number() and friends are the only way to print 1.234,56 to a German reader, and the
first call to any of them adds 43 KB gzip to every visitor's download, with 6 KB more for each
country's data (§2). A translated interface that still prints numbers the English way is visibly
half-done; a 43 KB tax on every application that touches one is real money on a slow connection.
| Option | What it gives |
|---|---|
A. Ship Formats, documented with the price, nothing calls it by default |
Applications choose, knowingly |
| B. Ship nothing; document that number formatting is the application's own problem | Nobody pays, nobody is helped |
| C. Ship a hand-written formatter for grouping and decimal separators only, no CLDR | Covers the common case for a few hundred bytes; wrong for currency, dates and anything unusual |
Recommendation: A, and write the number into the guide beside the API. The cost is real and the feature is real, and the framework's job is to make the trade visible rather than to make it for somebody. C is tempting and is how locale bugs get shipped — a formatter that is right for German and quietly wrong for Swiss French is worse than none.
6. Should the reactive-read check ship failing, or warning, for one release?
§4.9's check will fire on real, working code the first time it runs — the false positive it cannot avoid. The project's habit is that a check fails the build, and that habit is why the checks work.
Recommendation: fail from the start. The @ReadsMessagesOnce annotation is the escape hatch,
the failure message names it, and a warning nobody has to act on is a warning nobody reads. This
project has six failing checks and no warning ones, and that is not an accident.