From 5390cf24930bab62891034a58faff22ea0119238 Mon Sep 17 00:00:00 2001 From: Marko Stijak Date: Sun, 25 Oct 2026 23:04:32 +0100 Subject: [PATCH] fix(Culture): reset cached formats when culture settings change Culture setters only dropped part of the default culture's cache, so formats, expressions and templates used before the call kept the previous settings. All setters now reset the cache, as setCulture already did. Document culture scopes for content that needs a different culture. Fixes #1342 --- .../src/pages/docs/concepts/localization.mdx | 38 ++++++++++++++++ packages/cx/src/ui/Culture.spec.ts | 43 ++++++++++++++++++ packages/cx/src/ui/Culture.ts | 45 ++++++++----------- packages/cx/src/ui/Restate.spec.tsx | 30 +++++++++++++ 4 files changed, 129 insertions(+), 27 deletions(-) create mode 100644 packages/cx/src/ui/Culture.spec.ts 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"] }, + ], + }); + }); });