From 7f9627df63759dec8e3658a41e0d88dfd1574af4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:19:53 +0000 Subject: [PATCH 1/4] fix(formula): the template formatter locale rides a reserved context channel, never a root A template's roots are author-visible values: a bulk action's params, a flow's variables. The engine read its formatter locale from extra.locale, so a root named locale set the locale of every formatted value in the sentence, and no viewer locale was ever passed. EvalContext gains a reserved locale field the engine reads instead, the console filler takes the display locale from its caller, and both React runner owners install useDisplayLocale() on the runner. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XbF1LGGBRTWwTfLay1d51s --- packages/formula/src/template-engine.test.ts | 35 +++++++ packages/formula/src/template-engine.ts | 9 +- .../formula/src/template-formatters.test.ts | 2 +- packages/formula/src/types.ts | 13 +++ .../src/builtin/template.ts | 6 +- .../src/builtin/text-slot-template.test.ts | 7 ++ ui/packages/core/src/actions/ActionRunner.ts | 35 ++++++- ...ionRunner.copyTemplateLocale-26232.test.ts | 97 +++++++++++++++++++ .../core/src/actions/actionCopyTemplate.ts | 15 ++- .../react/src/context/ActionContext.tsx | 5 +- .../src/context/actionRunnerTranslator.ts | 29 +++++- .../react/src/hooks/useActionRunner.ts | 5 +- 12 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 ui/packages/core/src/actions/__tests__/ActionRunner.copyTemplateLocale-26232.test.ts diff --git a/packages/formula/src/template-engine.test.ts b/packages/formula/src/template-engine.test.ts index 448022e0838..386be3ff5bd 100644 --- a/packages/formula/src/template-engine.test.ts +++ b/packages/formula/src/template-engine.test.ts @@ -69,3 +69,38 @@ describe('templateEngine', () => { expect(r).toEqual({ ok: true, value: 'hot' }); }); }); + +// The formatter locale rides the context's reserved `locale` channel, never a +// root (#26232). Expected strings come from `Intl` itself, so a newer ICU in CI +// cannot drift them. +describe('templateEngine — formatter locale channel', () => { + const source = '{{ amount | number:2 }} · {{ due | date:long }} · {{ locale }}'; + const roots = { amount: 1234567.5, due: '2031-05-06T12:00:00Z' }; + const due = new Date('2031-05-06T12:00:00Z'); + const expected = (tag: string, printed = '') => + `${new Intl.NumberFormat(tag, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1234567.5)} · ` + + `${new Intl.DateTimeFormat(tag, { dateStyle: 'long' }).format(due)} · ${printed}`; + const render = (ctx: Parameters[1]) => + templateEngine.evaluate({ dialect: 'template', source }, ctx); + + it('formats in the locale the context names', () => { + expect(render({ extra: roots, locale: 'de-DE' })).toEqual({ ok: true, value: expected('de-DE') }); + expect(expected('de-DE')).not.toBe(expected('en-US')); + }); + + it('a root named `locale` is a value a hole prints, not the formatter locale', () => { + expect(render({ extra: { ...roots, locale: 'de-DE' } })).toEqual({ + ok: true, + value: expected('en-US', 'de-DE'), + }); + expect(render({ extra: { ...roots, locale: 'de-DE' }, locale: 'fr-FR' })).toEqual({ + ok: true, + value: expected('fr-FR', 'de-DE'), + }); + }); + + it('no locale, or an empty one, renders en-US as before', () => { + expect(render({ extra: roots })).toEqual({ ok: true, value: expected('en-US') }); + expect(render({ extra: roots, locale: '' })).toEqual({ ok: true, value: expected('en-US') }); + }); +}); diff --git a/packages/formula/src/template-engine.ts b/packages/formula/src/template-engine.ts index 293f1aaa996..a2e9acd8fba 100644 --- a/packages/formula/src/template-engine.ts +++ b/packages/formula/src/template-engine.ts @@ -272,10 +272,9 @@ export const templateEngine: DialectEngine = { if (!check.ok) return check as EvalResult; const scope = buildScope(ctx); - const locale = - (ctx.extra && typeof ctx.extra.locale === 'string' && ctx.extra.locale) || - (typeof (ctx as { locale?: string }).locale === 'string' && (ctx as { locale?: string }).locale) || - 'en-US'; + // Formatter locale: the context's reserved `locale` channel only, never a + // root — a root named `locale` is a value like any other (#26232). + const locale = typeof ctx.locale === 'string' && ctx.locale !== '' ? ctx.locale : 'en-US'; // Reference timezone for `datetime` rendering (ADR-0053 Phase 2). Unset → // Intl uses the runtime zone, matching pre-Phase-2 behavior. const timeZone = typeof ctx.timezone === 'string' ? ctx.timezone : undefined; @@ -285,7 +284,7 @@ export const templateEngine: DialectEngine = { if (!parsed) return _match; // compile already validated; defensive const value = resolvePath(scope, parsed.path); if (parsed.filter) { - return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale as string, timeZone); + return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale, timeZone); } return baseString(value); }); diff --git a/packages/formula/src/template-formatters.test.ts b/packages/formula/src/template-formatters.test.ts index 6d18d9669f9..bb4c744416a 100644 --- a/packages/formula/src/template-formatters.test.ts +++ b/packages/formula/src/template-formatters.test.ts @@ -4,7 +4,7 @@ import { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-eng function render(source: string, record: Record, timezone?: string): string { const r = templateEngine.evaluate( { dialect: 'template', source }, - { record, extra: { locale: 'en-US' }, ...(timezone ? { timezone } : {}) }, + { record, locale: 'en-US', ...(timezone ? { timezone } : {}) }, ); if (!r.ok) throw new Error(r.error.message); return r.value; diff --git a/packages/formula/src/types.ts b/packages/formula/src/types.ts index 632ca44ca51..2db1b4164e6 100644 --- a/packages/formula/src/types.ts +++ b/packages/formula/src/types.ts @@ -82,6 +82,19 @@ export interface EvalContext { * Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive. */ timezone?: string; + /** + * Locale (BCP-47 tag, e.g. `zh-CN`) the `template` dialect's formatters + * render numbers, money, percents and dates in. Defaults to `en-US` when + * unset or empty. + * + * A reserved channel, like {@link timezone}: it is NOT bound as a variable, + * and nothing in {@link extra} or any other root sets it. A template's roots + * are author-visible values (a bulk action's params, a flow's variables), so + * a root named `locale` is just a value a hole can print — it used to also + * set the formatter locale of the whole sentence (objectstack#26232). The + * caller that knows the viewer's display locale passes it here. + */ + locale?: string; /** * Current authenticated subject (hook / action / view contexts). * diff --git a/packages/services/service-automation/src/builtin/template.ts b/packages/services/service-automation/src/builtin/template.ts index 15ae86e126e..c7cc1cd2fec 100644 --- a/packages/services/service-automation/src/builtin/template.ts +++ b/packages/services/service-automation/src/builtin/template.ts @@ -102,8 +102,10 @@ export function textTemplateScope(variables: VariableMap): Record { it('leaves a single-brace token as literal text — the doors refuse it before a run, so the renderer never reads it', () => { expect(renderTextSlot('Hello {record.name}', vars({ record: ACME }))).toBe('Hello {record.name}'); }); + + it('a flow variable named `locale` is a value a hole prints, not the formatters\' locale (#26232)', () => { + // Expected text from `Intl` itself, so a newer ICU in CI cannot drift it. + const amount = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1234567.5); + expect(renderTextSlot('{{ amount | number:2 }} ({{ locale }})', vars({ amount: 1234567.5, locale: 'de-DE' }))) + .toBe(`${amount} (de-DE)`); + }); }); /** This package's `src` — the runtime whose `$` variables the spec judge lists. */ diff --git a/ui/packages/core/src/actions/ActionRunner.ts b/ui/packages/core/src/actions/ActionRunner.ts index 8b35266a435..a1fb0dd35d2 100644 --- a/ui/packages/core/src/actions/ActionRunner.ts +++ b/ui/packages/core/src/actions/ActionRunner.ts @@ -1283,6 +1283,7 @@ export class ActionRunner { private paramCollectionHandler: ParamCollectionHandler | null; private resultDialogHandler: ResultDialogHandler | null; private translate: ((key: string, options: { defaultValue: string }) => string) | null; + private displayLocale: (() => string | undefined) | null; /** * Built-in dispatch, one entry per runnable action type. @@ -1322,6 +1323,7 @@ export class ActionRunner { this.paramCollectionHandler = null; this.resultDialogHandler = null; this.translate = null; + this.displayLocale = null; } /** @@ -1341,6 +1343,20 @@ export class ActionRunner { this.translate = translate; } + /** + * Set where the runner reads the viewer's display locale (a BCP-47 tag) — + * the host's own, injected so this package resolves none. It is read when a + * copy template is filled, so a language switch reaches a runner built + * before it. The `template` formatters in `confirmText` and + * `successMessage` render in it (`{{ amount | currency }}`, + * `{{ due | date:long }}`), through the engine's reserved locale channel, so + * a root named `locale` never sets it (objectstack#26232). With none + * installed they format in `en-US`. + */ + setDisplayLocale(displayLocale: () => string | undefined): void { + this.displayLocale = displayLocale; + } + /** * Set a custom confirmation handler (e.g., Shadcn AlertDialog). */ @@ -1811,7 +1827,11 @@ export class ActionRunner { const successCopy = actionCopySource(action.successMessage); if (successCopy !== undefined && successCopy.trim() !== '') { return hasTemplateHoles(successCopy) - ? fillTemplateHoles(successCopy, { result: payload, record: this.dispatchRecord(action) }) + ? fillTemplateHoles( + successCopy, + { result: payload, record: this.dispatchRecord(action) }, + this.displayLocale?.(), + ) : successCopy; } return this.runnerText(RUNNER_TEXT.completedSuccessfully); @@ -1846,7 +1866,11 @@ export class ActionRunner { const staticParams = params && typeof params === 'object' && !Array.isArray(params) ? Object.fromEntries(Object.entries(params).filter(([key]) => key !== '_rowRecord')) : undefined; - return fillTemplateHoles(text, { record: this.dispatchRecord(action), params: staticParams }); + return fillTemplateHoles( + text, + { record: this.dispatchRecord(action), params: staticParams }, + this.displayLocale?.(), + ); } /** @@ -2909,9 +2933,9 @@ export class ActionRunner { /** * A one-shot runner for {@link ActionRunner.execute}'s `callContext` * (objectui#13063): this runner's context with `callContext` merged over it, - * and this runner's handlers, translator and registries, shared rather than - * copied, so the dispatch confirms, collects params, toasts and navigates - * exactly as this runner does. + * and this runner's handlers, translator, display locale and registries, + * shared rather than copied, so the dispatch confirms, collects params, + * toasts and navigates exactly as this runner does. * * Two keys follow the merge rather than the base: * @@ -2953,6 +2977,7 @@ export class ActionRunner { scoped.paramCollectionHandler = this.paramCollectionHandler; scoped.resultDialogHandler = this.resultDialogHandler; scoped.translate = this.translate; + scoped.displayLocale = this.displayLocale; return scoped; } diff --git a/ui/packages/core/src/actions/__tests__/ActionRunner.copyTemplateLocale-26232.test.ts b/ui/packages/core/src/actions/__tests__/ActionRunner.copyTemplateLocale-26232.test.ts new file mode 100644 index 00000000000..73e6313f011 --- /dev/null +++ b/ui/packages/core/src/actions/__tests__/ActionRunner.copyTemplateLocale-26232.test.ts @@ -0,0 +1,97 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#26232 — a copy template's formatters render in the viewer's + * display locale, and a root named `locale` never sets it. + * + * Measured before this card: `fillTemplateHoles` passed its roots as the + * template engine's `extra`, and the engine read its formatter locale from + * `extra.locale`. So a param named `locale` set the locale of every formatted + * value in the sentence, and with no such param every viewer read `en-US`. + * The locale now rides the engine's reserved channel: the host installs its + * display locale on the runner (`setDisplayLocale`), and the filler passes it + * apart from the roots. + * + * Expected strings come from `Intl` itself, so a newer ICU cannot drift them. + */ +import { describe, it, expect, vi } from 'vitest'; +import { ActionRunner, type ActionDef } from '../ActionRunner'; +import { fillTemplateHoles } from '../actionCopyTemplate'; + +const AMOUNT = 1234567.5; +const money = (tag: string) => new Intl.NumberFormat(tag, { style: 'currency', currency: 'EUR' }).format(AMOUNT); + +async function confirmTextFor(runner: ActionRunner, def: ActionDef, callContext?: object): Promise { + const confirm = vi.fn().mockResolvedValue(false); + runner.setConfirmHandler(confirm); + await runner.execute(def, callContext); + expect(confirm).toHaveBeenCalledTimes(1); + return confirm.mock.calls[0][0]; +} + +describe('fillTemplateHoles keeps the formatter locale apart from the roots (objectstack#26232)', () => { + it('a root named `locale` is a value a hole prints, not the formatter locale', () => { + expect(money('de-DE')).not.toBe(money('en-US')); + expect(fillTemplateHoles('{{ amount | currency:EUR }} ({{ locale }})', { amount: AMOUNT, locale: 'de-DE' })) + .toBe(`${money('en-US')} (de-DE)`); + }); + + it('formats in the locale the caller passes', () => { + expect(fillTemplateHoles('{{ amount | currency:EUR }}', { amount: AMOUNT }, 'de-DE')).toBe(money('de-DE')); + }); +}); + +describe('the runner fills its copy templates in the display locale it was given (objectstack#26232)', () => { + const CONFIRM: ActionDef = { + name: 'reprice', + onClick: () => {}, + confirmText: 'Set {{ record.amount | currency:EUR }} ({{ params.locale }})?', + params: { locale: 'fr-FR' }, + }; + const runner = () => new ActionRunner({ objectName: 'crm_deal', record: { amount: AMOUNT } }); + + it('CONTROL: with no display locale installed, the formatters render en-US as before', async () => { + expect(await confirmTextFor(runner(), CONFIRM)).toBe(`Set ${money('en-US')} (fr-FR)?`); + }); + + it('confirmText: the installed display locale is the formatter locale', async () => { + const r = runner(); + r.setDisplayLocale(() => 'de-DE'); + expect(await confirmTextFor(r, CONFIRM)).toBe(`Set ${money('de-DE')} (fr-FR)?`); + }); + + it('the display locale is read per fill, so a switch reaches a runner built before it', async () => { + const r = runner(); + let current = 'de-DE'; + r.setDisplayLocale(() => current); + expect(await confirmTextFor(r, CONFIRM)).toBe(`Set ${money('de-DE')} (fr-FR)?`); + current = 'ja-JP'; + expect(await confirmTextFor(r, CONFIRM)).toBe(`Set ${money('ja-JP')} (fr-FR)?`); + }); + + it('a dispatch with a callContext keeps the display locale', async () => { + const r = new ActionRunner({ objectName: 'crm_deal' }); + r.setDisplayLocale(() => 'de-DE'); + expect(await confirmTextFor(r, CONFIRM, { record: { amount: AMOUNT } })).toBe(`Set ${money('de-DE')} (fr-FR)?`); + }); + + it('successMessage: formats `result` in the display locale', async () => { + const r = runner(); + const toast = vi.fn(); + r.setToastHandler(toast); + r.setDisplayLocale(() => 'de-DE'); + r.registerHandler('script', async () => ({ success: true, data: { total: AMOUNT, locale: 'fr-FR' } })); + await r.execute({ + type: 'script', + name: 'reprice', + successMessage: { dialect: 'template', source: 'Total {{ result.total | currency:EUR }} ({{ result.locale }})' }, + }); + expect(toast.mock.calls[0][0]).toBe(`Total ${money('de-DE')} (fr-FR)`); + }); +}); diff --git a/ui/packages/core/src/actions/actionCopyTemplate.ts b/ui/packages/core/src/actions/actionCopyTemplate.ts index 9649da8a9d8..edab0671e0e 100644 --- a/ui/packages/core/src/actions/actionCopyTemplate.ts +++ b/ui/packages/core/src/actions/actionCopyTemplate.ts @@ -77,8 +77,19 @@ export function hasTemplateHoles(text: string): boolean { * unbalanced delimiter, logic inside a hole) is returned as written: the * publish gate refuses it on the server side, and the user still reads the * author's sentence rather than nothing. + * + * `locale` is the viewer's display locale (a BCP-47 tag) the formatters render + * in, passed by the caller that already has it — ⛔ never resolved here. It + * rides the engine's reserved `locale` channel, apart from `roots`, so a root + * named `locale` (a param of that name) is a value a hole prints, not the + * locale of the whole sentence (objectstack#26232). Omitted, the engine + * formats in `en-US`. */ -export function fillTemplateHoles(source: string, roots: Record): string { - const filled = templateEngine.evaluate({ dialect: 'template', source }, { extra: roots }); +export function fillTemplateHoles( + source: string, + roots: Record, + locale?: string, +): string { + const filled = templateEngine.evaluate({ dialect: 'template', source }, { extra: roots, locale }); return filled.ok && typeof filled.value === 'string' ? filled.value : source; } diff --git a/ui/packages/react/src/context/ActionContext.tsx b/ui/packages/react/src/context/ActionContext.tsx index 0f253d70d07..61ed1c21330 100644 --- a/ui/packages/react/src/context/ActionContext.tsx +++ b/ui/packages/react/src/context/ActionContext.tsx @@ -27,7 +27,7 @@ import { type ParamCollectionHandler, type ResultDialogHandler, } from '@object-ui/core'; -import { useActionRunnerTranslator } from './actionRunnerTranslator.js'; +import { useActionRunnerDisplayLocale, useActionRunnerTranslator } from './actionRunnerTranslator.js'; import { usePredicateScope } from '../hooks/useExpression.js'; export interface ActionProviderProps { @@ -100,6 +100,8 @@ export const ActionProvider: React.FC = ({ const [result, setResult] = useState(null); // The runner's own copy (the generic success toast) in the session's language. const translate = useActionRunnerTranslator(); + // Its copy templates' formatters in the viewer's display locale (#26232). + const displayLocale = useActionRunnerDisplayLocale(); const runner = useMemo(() => { // Normalize the evaluator scope so predicates can use *either* flat @@ -117,6 +119,7 @@ export const ActionProvider: React.FC = ({ } as ActionCtx; const r = new ActionRunner(normalizedContext); r.setTranslator(translate); + r.setDisplayLocale(displayLocale); if (onConfirm) r.setConfirmHandler(onConfirm); if (onToast) r.setToastHandler(onToast); if (onModal) r.setModalHandler(onModal); diff --git a/ui/packages/react/src/context/actionRunnerTranslator.ts b/ui/packages/react/src/context/actionRunnerTranslator.ts index 98d4abce772..cfd6a30efa3 100644 --- a/ui/packages/react/src/context/actionRunnerTranslator.ts +++ b/ui/packages/react/src/context/actionRunnerTranslator.ts @@ -7,7 +7,7 @@ */ import { useEffect, useRef, useState } from 'react'; -import { useObjectTranslation } from '@object-ui/i18n'; +import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; /** What `ActionRunner.setTranslator` takes. */ type ActionRunnerTranslate = (key: string, options: { defaultValue: string }) => string; @@ -40,3 +40,30 @@ export function useActionRunnerTranslator(): ActionRunnerTranslate { }); return translate; } + +/** + * What `ActionRunner.setDisplayLocale` takes: the viewer's display locale, read + * when a copy template is filled. + */ +type ActionRunnerDisplayLocale = () => string; + +/** + * The display locale both React owners of an `ActionRunner` install beside the + * translator, so the `template` formatters in an action's `confirmText` and + * `successMessage` render in the viewer's locale instead of `en-US` + * (objectstack#26232). It is `useDisplayLocale()` — the one resolution every + * date, number and currency renderer reads; ⛔ no second copy here. + * + * Read when CALLED, by the same latest-ref pattern as + * {@link useActionRunnerTranslator}, so a locale switch reaches a runner that + * was built before it. + */ +export function useActionRunnerDisplayLocale(): ActionRunnerDisplayLocale { + const displayLocale = useDisplayLocale(); + const latest = useRef(displayLocale); + useEffect(() => { + latest.current = displayLocale; + }); + const [read] = useState(() => () => latest.current); + return read; +} diff --git a/ui/packages/react/src/hooks/useActionRunner.ts b/ui/packages/react/src/hooks/useActionRunner.ts index c51d53a1a0b..7840e99cca8 100644 --- a/ui/packages/react/src/hooks/useActionRunner.ts +++ b/ui/packages/react/src/hooks/useActionRunner.ts @@ -19,7 +19,7 @@ import { type ParamCollectionHandler, type ResultDialogHandler, } from '@object-ui/core'; -import { useActionRunnerTranslator } from '../context/actionRunnerTranslator.js'; +import { useActionRunnerDisplayLocale, useActionRunnerTranslator } from '../context/actionRunnerTranslator.js'; export interface UseActionRunnerOptions { context?: ActionContext; @@ -71,10 +71,13 @@ export function useActionRunner( const [result, setResult] = useState(null); // The runner's own copy (the generic success toast) in the session's language. const translate = useActionRunnerTranslator(); + // Its copy templates' formatters in the viewer's display locale (#26232). + const displayLocale = useActionRunnerDisplayLocale(); const runner = useMemo(() => { const r = new ActionRunner(context); r.setTranslator(translate); + r.setDisplayLocale(displayLocale); if (onConfirm) r.setConfirmHandler(onConfirm); if (onToast) r.setToastHandler(onToast); if (onModal) r.setModalHandler(onModal); From 17889e60fca60526c42d5964ba1721d86ae51f99 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:21:27 +0000 Subject: [PATCH 2/4] test+changeset: pin the reserved locale channel at the console runner owners Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XbF1LGGBRTWwTfLay1d51s --- .../26232-template-locale-reserved-channel.md | 22 ++++ ...Provider.copyTemplateLocale-26232.test.tsx | 121 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 .changeset/26232-template-locale-reserved-channel.md create mode 100644 ui/packages/react/src/context/__tests__/ActionProvider.copyTemplateLocale-26232.test.tsx diff --git a/.changeset/26232-template-locale-reserved-channel.md b/.changeset/26232-template-locale-reserved-channel.md new file mode 100644 index 00000000000..2ce45a39e92 --- /dev/null +++ b/.changeset/26232-template-locale-reserved-channel.md @@ -0,0 +1,22 @@ +--- +'@objectstack/formula': patch +'@objectstack/service-automation': patch +'@object-ui/core': patch +'@object-ui/react': patch +--- + +fix(formula,core,react): a template's formatter locale comes from a reserved context channel, so a root named `locale` is just a value, and the console passes the viewer's display locale + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +The `template` dialect's formatters (`number`, `currency`, `percent`, `date`, `datetime`) used to take their locale from `extra.locale`, the same bag a caller passes the template's roots in. A template's roots are author-visible values, so a bulk action param or a flow variable named `locale` silently set the locale of every formatted value in the sentence, and no caller ever passed the viewer's locale. + +- **`@objectstack/formula`**: `EvalContext` gains `locale?: string`, a reserved channel like `timezone`. It is not bound as a variable, and the template engine reads its formatter locale from it alone, defaulting to `en-US` when unset or empty. `extra.locale` is no longer read. +- **`@objectstack/service-automation`**: a flow text slot's formatters render in `en-US`, and a flow variable named `locale` is a value like any other. The renderer's doc said the variable was also the formatters' locale; that line is gone. +- **`@object-ui/core`**: `fillTemplateHoles(source, roots, locale?)` passes `locale` on the engine's reserved channel, apart from the roots. `ActionRunner.setDisplayLocale(read)` installs where the runner reads the viewer's display locale; `confirmText` and `successMessage` templates format in it, and a `callContext` dispatch keeps it. +- **`@object-ui/react`**: `` and `useActionRunner` install `useDisplayLocale()` on the runner they build, read when a template is filled, so a locale switch reaches a runner built before it. + +**BREAKING** (a narrowing) for a caller that set the formatter locale through a root: a template evaluated with `extra: { locale }` (or a flow variable named `locale`) now formats in `en-US` and prints the value only where a hole names it. None was found in this repository: the one test that did so now passes `locale` on the context. What to do: pass the locale as the evaluate context's `locale` (`templateEngine.evaluate(expr, { extra: roots, locale })`), or as `fillTemplateHoles`'s third argument. diff --git a/ui/packages/react/src/context/__tests__/ActionProvider.copyTemplateLocale-26232.test.tsx b/ui/packages/react/src/context/__tests__/ActionProvider.copyTemplateLocale-26232.test.tsx new file mode 100644 index 00000000000..2ea97124661 --- /dev/null +++ b/ui/packages/react/src/context/__tests__/ActionProvider.copyTemplateLocale-26232.test.tsx @@ -0,0 +1,121 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#26232 — both React owners of an `ActionRunner` install the + * viewer's display locale on it (`useActionRunnerDisplayLocale`), so a copy + * template's formatters render in `useDisplayLocale()` instead of `en-US`. + * + * Measured by running a real action through a real provider under a real + * `I18nProvider` and `LocalizationProvider`, and reading what the host's + * confirm handler and `onToast` receive. Expected strings come from `Intl` + * itself, so a newer ICU cannot drift them. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, LocalizationProvider, useObjectTranslation } from '@object-ui/i18n'; +import type { ActionResult, ToastHandler } from '@object-ui/core'; +import { ActionProvider, useAction } from '../ActionContext'; +import { useActionRunner } from '../../hooks/useActionRunner'; + +afterEach(() => cleanup()); + +const AMOUNT = 1234567.5; +const money = (tag: string) => new Intl.NumberFormat(tag, { style: 'currency', currency: 'EUR' }).format(AMOUNT); +const CONFIG = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; +const SUCCEEDS = async (): Promise => ({ success: true, data: { total: AMOUNT } }); +const SUCCESS_COPY = { dialect: 'template' as const, source: 'Total {{ result.total | currency:EUR }}' }; + +function providerHarness(locale: string | undefined, onToast: ToastHandler, onConfirm?: (m: string) => Promise) { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); + return renderHook(() => ({ action: useAction(), i18n: useObjectTranslation() }), { wrapper }); +} + +describe(' — copy templates format in the display locale (objectstack#26232)', () => { + it('successMessage formats in the tenant display locale', async () => { + expect(money('de-DE')).not.toBe(money('en-US')); + const onToast = vi.fn(); + const { result } = providerHarness('de-DE', onToast); + + await act(async () => { + await result.current.action.execute({ type: 'script', name: 'reprice', successMessage: SUCCESS_COPY }); + }); + + expect(onToast).toHaveBeenCalledWith(`Total ${money('de-DE')}`, expect.objectContaining({ type: 'success' })); + }); + + it('confirmText formats in the display locale; a `locale` param is a value it prints', async () => { + const onConfirm = vi.fn(async (_message: string) => false); + const { result } = providerHarness('de-DE', vi.fn(), onConfirm); + + await act(async () => { + await result.current.action.execute({ + type: 'script', + name: 'reprice', + confirmText: 'Set {{ record.amount | currency:EUR }} ({{ params.locale }})?', + params: { locale: 'fr-FR' }, + }); + }); + + expect(onConfirm.mock.calls[0][0]).toBe(`Set ${money('de-DE')} (fr-FR)?`); + }); + + it('with no tenant locale, the UI language is the display locale, and a switch reaches the built runner', async () => { + const onToast = vi.fn(); + const { result } = providerHarness(undefined, onToast); + const runnerBefore = result.current.action.runner; + + await act(async () => { + await result.current.action.execute({ type: 'script', name: 'reprice', successMessage: SUCCESS_COPY }); + }); + expect(onToast).toHaveBeenLastCalledWith(`Total ${money('en')}`, expect.anything()); + + await act(async () => { + await result.current.i18n.changeLanguage('ja'); + }); + expect(result.current.action.runner).toBe(runnerBefore); + + await act(async () => { + await result.current.action.execute({ type: 'script', name: 'reprice', successMessage: SUCCESS_COPY }); + }); + expect(onToast).toHaveBeenLastCalledWith(`Total ${money('ja')}`, expect.anything()); + }); +}); + +describe('useActionRunner — copy templates format in the display locale (objectstack#26232)', () => { + it('successMessage formats in the tenant display locale', async () => { + const onToast = vi.fn(); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useActionRunner({ context: {}, onToast }), { wrapper }); + result.current.runner.registerHandler('script', SUCCEEDS); + + await act(async () => { + await result.current.execute({ type: 'script', name: 'reprice', successMessage: SUCCESS_COPY }); + }); + + expect(onToast).toHaveBeenCalledWith(`Total ${money('de-DE')}`, expect.objectContaining({ type: 'success' })); + }); +}); From 6507217d80fc2f835da1c22ef9aedd8a4975c255 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 18:42:48 +0000 Subject: [PATCH 3/4] chore(sdui): re-record the core and react registry digests Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XbF1LGGBRTWwTfLay1d51s --- scripts/sdui-manifest.sources/core.json | 2 +- scripts/sdui-manifest.sources/react.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/sdui-manifest.sources/core.json b/scripts/sdui-manifest.sources/core.json index 18bbd8105a4..1f546422c92 100644 --- a/scripts/sdui-manifest.sources/core.json +++ b/scripts/sdui-manifest.sources/core.json @@ -1 +1 @@ -{"package":"ui/packages/core","sources":111,"sha256":"a5c4bc9e88dbbe531e2d19963f3f5e4367e23b2adabbb838eb05770ef086d16c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/core","sources":111,"sha256":"7b1218e7ac42809602ceddd0816f5dfd1fd1e898d87ce4ef6b548f78536777b3","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/react.json b/scripts/sdui-manifest.sources/react.json index 3a0b892f9ae..da51ae7d757 100644 --- a/scripts/sdui-manifest.sources/react.json +++ b/scripts/sdui-manifest.sources/react.json @@ -1 +1 @@ -{"package":"ui/packages/react","sources":70,"sha256":"b4e342408561b4e02a5ec126437e0ca184dd96da8cc407d71f079288f882a34d","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/react","sources":70,"sha256":"ffedb535cfaadbe874a88913fb1d7581b717a21e53fd4aeaa11d4bc8bfed86b8","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} From 2d2b09be7683ba4bcd1ab278a1c5f420653baac4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:30:33 +0000 Subject: [PATCH 4/4] chore(sdui): re-record the core and react registry digests after merging main Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XbF1LGGBRTWwTfLay1d51s --- scripts/sdui-manifest.sources/core.json | 2 +- scripts/sdui-manifest.sources/react.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/sdui-manifest.sources/core.json b/scripts/sdui-manifest.sources/core.json index 5bf85fb5e8a..dea72002adf 100644 --- a/scripts/sdui-manifest.sources/core.json +++ b/scripts/sdui-manifest.sources/core.json @@ -1 +1 @@ -{"package":"ui/packages/core","sources":111,"sha256":"05fe45802fffcc482f95bad59a66279b70572f8757decd9770dc126f119dab2e","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/core","sources":111,"sha256":"c2cf28d9a3a53535b8a328f099857f84b3ac90402fedc8b898a9266cb261f55c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/react.json b/scripts/sdui-manifest.sources/react.json index 19cca11088e..bfa31928e40 100644 --- a/scripts/sdui-manifest.sources/react.json +++ b/scripts/sdui-manifest.sources/react.json @@ -1 +1 @@ -{"package":"ui/packages/react","sources":70,"sha256":"e3ea09e281e39a1d3f30d87b199d3756e88d7ffb48f91a11e777b2806a8bddff","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/react","sources":70,"sha256":"d89393cc7203be3bc88ea59d1a5e0ad6cbda588b8b8ad44baefed0a44da5d9d2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}