diff --git a/homedocs/src/pages/docs/concepts/localization.mdx b/homedocs/src/pages/docs/concepts/localization.mdx
index 48cb6ec02..e25f49a15 100644
--- a/homedocs/src/pages/docs/concepts/localization.mdx
+++ b/homedocs/src/pages/docs/concepts/localization.mdx
@@ -49,6 +49,44 @@ Available locale packages include: `de-de`, `en-us`, `es-es`, `fr-fr`, `nl-nl`,
| `Culture.setDefaultTimezone(tz)` | Sets the default timezone. |
| `Culture.setDefaultDateEncoding(fn)` | Sets function for encoding dates. |
+These methods configure the default culture. Call them before the content is rendered: they apply to content
+rendered after the call, while widgets that already exist may keep the previous settings. If you change settings at
+runtime, re-render the content afterwards, as shown in [Dynamic Culture Loading](#dynamic-culture-loading).
+
+### Culture Scopes
+
+To format part of the page with a different culture, timezone or currency, for example to show documents for
+different timezones side by side, wrap that content in a detached `Restate` with its own culture created by
+`createCulture`:
+
+```tsx
+import { createCulture, enableCultureSensitiveFormatting, Restate, tpl } from "cx/ui";
+
+enableCultureSensitiveFormatting();
+
+
+
+;
+```
+
+Each culture created by `createCulture` has its own format cache. `culture`, `defaultCurrency` and `dateEncoding`
+are taken from the current culture unless specified; other settings are not. Culture scopes require culture-sensitive
+formatting, which is enabled automatically by some widgets, such as `NumberField`.
+
+The culture of a scope is set when the scope is created. To switch it, create a new scope, for example inside
+a `ContentResolver`:
+
+```tsx
+ (
+
+ ...
+
+ )}
+/>
+```
+
### Dynamic Culture Loading
Use dynamic imports for code-splitting:
diff --git a/packages/cx/src/ui/Culture.spec.ts b/packages/cx/src/ui/Culture.spec.ts
new file mode 100644
index 000000000..8cdd466e8
--- /dev/null
+++ b/packages/cx/src/ui/Culture.spec.ts
@@ -0,0 +1,43 @@
+import assert from "assert";
+import { Culture, getDefaultCulture } from "./Culture";
+
+describe("Culture", function () {
+ afterEach(function () {
+ Object.assign(getDefaultCulture(), {
+ numberCulture: null,
+ dateTimeCulture: null,
+ defaultCurrency: "USD",
+ timezone: null,
+ dateEncoding: (date: Date) => date.toISOString(),
+ });
+ Culture.setCulture("en");
+ });
+
+ it("setDefaultTimezone applies to an already used date-time culture", function () {
+ let date = new Date("2026-04-24T12:00:00Z");
+ Culture.setDefaultTimezone("UTC");
+ assert.equal(Culture.getDateTimeCulture().getFormatter("HHmmN").format(date), "12:00");
+ Culture.setDefaultTimezone("Asia/Tokyo");
+ assert.equal(Culture.getDateTimeCulture().getFormatter("HHmmN").format(date), "21:00");
+ });
+
+ // With culture-sensitive formatting, formatters and compiled expressions and templates
+ // are cached on the culture and hold the culture objects they were built with.
+ it("setters drop everything cached on the default culture", function () {
+ let setters = [
+ () => Culture.setCulture("de"),
+ () => Culture.setNumberCulture("de-DE"),
+ () => Culture.setDateTimeCulture("de-DE"),
+ () => Culture.setDefaultCurrency("EUR"),
+ () => Culture.setDefaultTimezone("Asia/Tokyo"),
+ () => Culture.setDefaultDateEncoding((date) => date.toDateString()),
+ ];
+ for (let set of setters) {
+ let cache = getDefaultCulture().cache;
+ cache.numberCulture = cache.dateTimeCulture = {};
+ cache.formatCache = cache.exprCache = cache.strTplCache = {};
+ set();
+ assert.deepEqual(getDefaultCulture().cache, {});
+ }
+ });
+});
diff --git a/packages/cx/src/ui/Culture.ts b/packages/cx/src/ui/Culture.ts
index f9a64895f..9fde292d9 100644
--- a/packages/cx/src/ui/Culture.ts
+++ b/packages/cx/src/ui/Culture.ts
@@ -77,45 +77,36 @@ export function popCulture(cultureSpecs?: CultureInfo): CultureInfo | undefined
return stack.pop();
}
+// formatters, expressions and templates cached on the culture are built from its settings, so drop them all
+function updateDefaultCulture(changes: Partial): void {
+ Object.assign(getDefaultCulture(), changes, { cache: {} });
+ Culture.invalidateCache();
+}
+
export class Culture {
- static setCulture(cultureCode: string): void {
- let cultureSpecs = getDefaultCulture();
- cultureSpecs.culture = cultureCode;
- cultureSpecs.cache = {};
- Localization.setCulture(cultureCode);
- this.invalidateCache();
+ static setCulture(culture: string): void {
+ Localization.setCulture(culture);
+ updateDefaultCulture({ culture });
}
- static setNumberCulture(cultureCode: string): void {
- let cultureSpecs = getDefaultCulture();
- cultureSpecs.numberCulture = cultureCode;
- delete cultureSpecs.cache.numberCulture;
- this.invalidateCache();
+ static setNumberCulture(numberCulture: string): void {
+ updateDefaultCulture({ numberCulture });
}
- static setDateTimeCulture(cultureCode: string): void {
- let cultureSpecs = getDefaultCulture();
- cultureSpecs.dateTimeCulture = cultureCode;
- delete cultureSpecs.cache.dateTimeCulture;
- this.invalidateCache();
+ static setDateTimeCulture(dateTimeCulture: string): void {
+ updateDefaultCulture({ dateTimeCulture });
}
- static setDefaultCurrency(currencyCode: string): void {
- let cultureSpecs = getDefaultCulture();
- cultureSpecs.defaultCurrency = currencyCode;
- this.invalidateCache();
+ static setDefaultCurrency(defaultCurrency: string): void {
+ updateDefaultCulture({ defaultCurrency });
}
static setDefaultTimezone(timezone: string): void {
- let cultureSpecs = getDefaultCulture();
- cultureSpecs.timezone = timezone;
- this.invalidateCache();
+ updateDefaultCulture({ timezone });
}
- static setDefaultDateEncoding(encoding: (date: Date) => string): void {
- let cultureSpecs = getDefaultCulture();
- cultureSpecs.dateEncoding = encoding;
- this.invalidateCache();
+ static setDefaultDateEncoding(dateEncoding: (date: Date) => string): void {
+ updateDefaultCulture({ dateEncoding });
}
static invalidateCache(): void {
diff --git a/packages/cx/src/ui/Restate.spec.tsx b/packages/cx/src/ui/Restate.spec.tsx
index 090c4df0a..7b2a65f52 100644
--- a/packages/cx/src/ui/Restate.spec.tsx
+++ b/packages/cx/src/ui/Restate.spec.tsx
@@ -5,6 +5,7 @@ import assert from "assert";
import { Controller } from "./Controller";
import { bind } from "./bind";
import { FirstVisibleChildLayout } from "./layout/FirstVisibleChildLayout";
+import { Culture, createCulture } from "./Culture";
describe("Restate", () => {
it("provides a blank slate", async () => {
@@ -419,4 +420,33 @@ describe("Restate", () => {
children: [{ type: "div", props: {}, children: ["Second"] }],
});
});
+
+ it("renders detached content in its own culture", async () => {
+ let time = () =>
+ Culture.getDateTimeCulture().getFormatter("HHmmN").format(new Date("2026-04-24T12:00:00Z"));
+
+ let widget = (
+
+
+
+
+
+
+
+
+
+
+ );
+
+ const component = await createTestRenderer(new Store(), widget);
+
+ assert.deepEqual(component.toJSON(), {
+ type: "div",
+ props: {},
+ children: [
+ { type: "span", props: {}, children: ["12:00"] },
+ { type: "span", props: {}, children: ["21:00"] },
+ ],
+ });
+ });
});