Skip to content

Commit 14b5597

Browse files
fix(formula,core,react): a template formatter locale rides a reserved context channel, never a root (#26282)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XGSSiXPYtWQirMK53tWA8Qo)_ Fixes #26232 Clause-②: yes (narrowing) Metadata-protocol: none ## What changed The `template` dialect's formatters used to read their locale from `ctx.extra.locale`, the same bag every caller passes the template's author-visible roots in. A root named `locale` (a bulk param, a flow variable) therefore set the formatter locale of the whole sentence, and no caller passed the viewer's locale, so every viewer read `en-US`. - **`@objectstack/formula`**: `EvalContext.locale?: string` is a reserved channel, like `timezone`: not bound as a variable, never set by a root. `templateEngine.evaluate` reads its formatter locale from it alone (`en-US` when unset or empty). The undeclared `(ctx as { locale }).locale` read it already had becomes the declared, only rung; the `extra.locale` rung is gone. - **`@object-ui/core`**: `fillTemplateHoles(source, roots, locale?)` passes `locale` on that channel, apart from the roots. `ActionRunner.setDisplayLocale(read)` installs where the runner reads the viewer's locale, read per fill; `confirmText` and `successMessage` format in it, and a `callContext` dispatch (`scopedTo`) keeps it. - **`@object-ui/react`**: `ActionProvider` and `useActionRunner` install `useDisplayLocale()` beside the translator (`useActionRunnerDisplayLocale`, same latest-ref pattern), so there is no second copy of the locale resolution and a locale switch reaches an already-built runner. - **`@objectstack/service-automation`**: comment only. `renderTextSlot`'s doc said a flow variable named `locale` was also the formatters' locale; under the ruling it is a value like any other, so the line now says that. - `scripts/sdui-manifest.sources/{core,react}.json` re-recorded (`node scripts/gen-sdui-manifest-node.mjs`); one changeset (`patch` ×4, BREAKING banner, ADR-0087 `not-required (no-migration-prescription)`). ## Red first (base `9598405ec`) Probe through `templateEngine.evaluate` on `origin/main`, source `Set {{ amount | number:2 }} on {{ count }} records, due {{ due | date:long }}`: ```text no locale root : "Set 1,234,567.50 on 1200 records, due May 6, 2031" root named locale : "Set 1.234.567,50 on 1200 records, due 6. Mai 2031" (extra.locale = 'de-DE') ctx.locale zh-CN : "Set 1,234,567.50 on 1200 records, due 2031年5月6日" ``` The root sets the formatter locale; and the console filler passed only `{ extra: roots }`, so no viewer locale was ever reached. ## Every caller that passed `extra.locale` (measured) `git grep` for `templateEngine`, `ExpressionEngine.evaluate`, `fillTemplateHoles`, `renderTextSlot` and `formatValue` callers at base: - `ui/packages/core` `fillTemplateHoles` (ActionRunner `confirmMessage` / `composeSuccessMessage`, plugin-grid `bulkConfirmText`): roots only, no locale. Now the runner passes its display locale. - `service-automation` `renderTextSlot`: flow variables as `extra`, documented as the locale channel. No engine code sets a flow variable named `locale` and no example or package flow declares one (`git grep` of `name: 'locale'` under `examples`, `apps`, `packages/qa`: 0 hits). Under the ruling it stays a value; flow text formats in `en-US`. - `packages/formula/src/template-formatters.test.ts`: the one caller that set the locale via `extra: { locale: 'en-US' }`; now `locale: 'en-US'`. - Registry callers (`seed-eval`, objectql defaults and formula fields, plugin-approvals, plugin-sharing, automation conditions) pass no `locale` in `extra`. `formatValue` (plugin-email) takes `opts.locale` and is untouched. ## Clause-② from the diff `yes`: `EvalContext` gains an optional member and `fillTemplateHoles` an optional argument (public surface grows). `(narrowing)`: a caller that set the formatter locale through a root (`extra: { locale }`, a flow variable or bulk param named `locale`) now formats in `en-US`, and its value prints only where a hole names it. In-repo producers of that: 0 outside the one test above. A template that names no `locale` root and is evaluated with no locale renders exactly as before (pinned). ## Tests - `packages/formula/src/template-engine.test.ts` (new block): the context locale formats; a root named `locale` prints and does not format (alone, and beside a context locale); no or empty locale is `en-US`. `formula` package: 53 files / 1427 tests pass; `typecheck` exit 0. - `service-automation/src/builtin/text-slot-template.test.ts`: a flow variable named `locale` prints and formats in `en-US`. Package: 207 files / 2699 tests pass; `typecheck` exit 0. - `ui/packages/core/.../ActionRunner.copyTemplateLocale-26232.test.ts`: `fillTemplateHoles` root vs argument; the runner's `confirmText` with and without a display locale, read per fill, kept across a `callContext`; `successMessage`. Core: 213 files / 4097 pass, 27 skipped; `type-check` exit 0. - `ui/packages/react/.../ActionProvider.copyTemplateLocale-26232.test.tsx`: real `I18nProvider` + `LocalizationProvider` + `ActionProvider` / `useActionRunner`: tenant locale reaches the toast and the confirm; with no tenant locale the UI language does, and a language switch reaches the same runner. React: 120 files / 1483 pass; `type-check` exit 0. - plugin-grid bulk suites (the other `fillTemplateHoles` caller): 27 files / 264 pass. - Expected strings are computed with `Intl` in each test (no ICU drift between the container's Node and CI's). **Ablation** (head `7f9627df`, via `scripts/ablation-replace.mjs`, formula tests import `./template-engine` from `src`): put back the `extra.locale` rung ahead of `ctx.locale` → `a root named locale is a value a hole prints, not the formatter locale` FAILS (1 failed / 10 passed); restored, blob `a2e9acd8fba9` == HEAD, `git diff HEAD` empty. ## Gates (local, head `6507217d8`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 72 commands, every one exit 0 except: `check-plugin-teardown-shape --self-test` exit 3 on the shallow clone (fetched its control `621a4876`, rerun exit 0); `check-sdui-manifest` exit 1 until the re-record (rerun exit 0); `check:dual-build-cjs-loads` exit 3 before a full build (rerun after the full build, below). Lint (`eslint` on the 13 touched TS files, `--no-inline-config`): 0 errors, 29 warnings, the same set the base versions carry. Full build, `check:dual-build-cjs-loads`, `check:dts-closure` and the full dogfood battery (owed by the `(narrowing)` arm) are in the os-dev report on the card. ## Acceptance notes - **Not wired here: the bulk confirm dialog.** `BulkActionDialog` → `bulkConfirmText` → `fillTemplateHoles` still passes no locale, because open draft #26217 (#25942 step 2) rewrites exactly those two files. The hijack is closed for it by the engine change; reaching the viewer's locale there is one argument (`useDisplayLocale()` into `fillTemplateHoles`'s third parameter) once #26217 lands. - Flow text slots format in `en-US` (one text for every recipient, as `notify` documents); a per-run locale for them is not this card. --- _Generated by [Claude Code](https://claude.ai/code/session_01XbF1LGGBRTWwTfLay1d51s)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3249e20 commit 14b5597

16 files changed

Lines changed: 385 additions & 20 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/formula': patch
3+
'@objectstack/service-automation': patch
4+
'@object-ui/core': patch
5+
'@object-ui/react': patch
6+
---
7+
8+
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
9+
10+
Clause-②: yes (narrowing)
11+
Metadata-protocol: none
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped. What changes is which input sets the template dialect's formatter locale: the evaluate context's own `locale` field instead of a root named `locale`. A template that names a root `locale` keeps compiling and rendering; its hole still prints that value, only the number and date formatting no longer follows it. There is nothing for `objectstack migrate meta` to rewrite. The other categories are closed on facts: every package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no runtime interface, type or export is removed or narrowed: `EvalContext` gains an optional member and `fillTemplateHoles` an optional trailing argument (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
14+
15+
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.
16+
17+
- **`@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.
18+
- **`@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.
19+
- **`@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.
20+
- **`@object-ui/react`**: `<ActionProvider>` 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.
21+
22+
**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.

‎packages/formula/src/template-engine.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,38 @@ describe('templateEngine', () => {
6969
expect(r).toEqual({ ok: true, value: 'hot' });
7070
});
7171
});
72+
73+
// The formatter locale rides the context's reserved `locale` channel, never a
74+
// root (#26232). Expected strings come from `Intl` itself, so a newer ICU in CI
75+
// cannot drift them.
76+
describe('templateEngine — formatter locale channel', () => {
77+
const source = '{{ amount | number:2 }} · {{ due | date:long }} · {{ locale }}';
78+
const roots = { amount: 1234567.5, due: '2031-05-06T12:00:00Z' };
79+
const due = new Date('2031-05-06T12:00:00Z');
80+
const expected = (tag: string, printed = '') =>
81+
`${new Intl.NumberFormat(tag, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1234567.5)} · ` +
82+
`${new Intl.DateTimeFormat(tag, { dateStyle: 'long' }).format(due)} · ${printed}`;
83+
const render = (ctx: Parameters<typeof templateEngine.evaluate>[1]) =>
84+
templateEngine.evaluate<string>({ dialect: 'template', source }, ctx);
85+
86+
it('formats in the locale the context names', () => {
87+
expect(render({ extra: roots, locale: 'de-DE' })).toEqual({ ok: true, value: expected('de-DE') });
88+
expect(expected('de-DE')).not.toBe(expected('en-US'));
89+
});
90+
91+
it('a root named `locale` is a value a hole prints, not the formatter locale', () => {
92+
expect(render({ extra: { ...roots, locale: 'de-DE' } })).toEqual({
93+
ok: true,
94+
value: expected('en-US', 'de-DE'),
95+
});
96+
expect(render({ extra: { ...roots, locale: 'de-DE' }, locale: 'fr-FR' })).toEqual({
97+
ok: true,
98+
value: expected('fr-FR', 'de-DE'),
99+
});
100+
});
101+
102+
it('no locale, or an empty one, renders en-US as before', () => {
103+
expect(render({ extra: roots })).toEqual({ ok: true, value: expected('en-US') });
104+
expect(render({ extra: roots, locale: '' })).toEqual({ ok: true, value: expected('en-US') });
105+
});
106+
});

‎packages/formula/src/template-engine.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -272,10 +272,9 @@ export const templateEngine: DialectEngine = {
272272
if (!check.ok) return check as EvalResult<T>;
273273

274274
const scope = buildScope(ctx);
275-
const locale =
276-
(ctx.extra && typeof ctx.extra.locale === 'string' && ctx.extra.locale) ||
277-
(typeof (ctx as { locale?: string }).locale === 'string' && (ctx as { locale?: string }).locale) ||
278-
'en-US';
275+
// Formatter locale: the context's reserved `locale` channel only, never a
276+
// root — a root named `locale` is a value like any other (#26232).
277+
const locale = typeof ctx.locale === 'string' && ctx.locale !== '' ? ctx.locale : 'en-US';
279278
// Reference timezone for `datetime` rendering (ADR-0053 Phase 2). Unset →
280279
// Intl uses the runtime zone, matching pre-Phase-2 behavior.
281280
const timeZone = typeof ctx.timezone === 'string' ? ctx.timezone : undefined;
@@ -285,7 +284,7 @@ export const templateEngine: DialectEngine = {
285284
if (!parsed) return _match; // compile already validated; defensive
286285
const value = resolvePath(scope, parsed.path);
287286
if (parsed.filter) {
288-
return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale as string, timeZone);
287+
return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale, timeZone);
289288
}
290289
return baseString(value);
291290
});

‎packages/formula/src/template-formatters.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-eng
44
function render(source: string, record: Record<string, unknown>, timezone?: string): string {
55
const r = templateEngine.evaluate<string>(
66
{ dialect: 'template', source },
7-
{ record, extra: { locale: 'en-US' }, ...(timezone ? { timezone } : {}) },
7+
{ record, locale: 'en-US', ...(timezone ? { timezone } : {}) },
88
);
99
if (!r.ok) throw new Error(r.error.message);
1010
return r.value;

‎packages/formula/src/types.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,19 @@ export interface EvalContext {
8282
* Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive.
8383
*/
8484
timezone?: string;
85+
/**
86+
* Locale (BCP-47 tag, e.g. `zh-CN`) the `template` dialect's formatters
87+
* render numbers, money, percents and dates in. Defaults to `en-US` when
88+
* unset or empty.
89+
*
90+
* A reserved channel, like {@link timezone}: it is NOT bound as a variable,
91+
* and nothing in {@link extra} or any other root sets it. A template's roots
92+
* are author-visible values (a bulk action's params, a flow's variables), so
93+
* a root named `locale` is just a value a hole can print — it used to also
94+
* set the formatter locale of the whole sentence (objectstack#26232). The
95+
* caller that knows the viewer's display locale passes it here.
96+
*/
97+
locale?: string;
8598
/**
8699
* Current authenticated subject (hook / action / view contexts).
87100
*

‎packages/services/service-automation/src/builtin/template.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,10 @@ export function textTemplateScope(variables: VariableMap): Record<string, unknow
102102
* Value→string is the engine's, defined per value (ADR-0032 §3): `null` /
103103
* absent renders nothing, an object or array renders as JSON, a `Date` as its
104104
* ISO text, and a formatter makes the rest explicit (`{{ amount | currency }}`,
105-
* `{{ due | date:iso }}`). A flow variable named `locale` is also the
106-
* formatters' locale — the engine reads its scope's `locale` for that.
105+
* `{{ due | date:iso }}`). The formatters render in the engine's default
106+
* `en-US`: a flow variable named `locale` is a value like any other, never the
107+
* formatters' locale (#26232 — the engine reads that from its own reserved
108+
* channel, not from a root).
107109
*
108110
* Absent in, absent out: a slot the author left unset — or one that renders
109111
* no text at all — returns `undefined`, so a screen falls back to its node

‎packages/services/service-automation/src/builtin/text-slot-template.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,13 @@ describe('#22110 — renderTextSlot, the one text renderer', () => {
267267
it('leaves a single-brace token as literal text — the doors refuse it before a run, so the renderer never reads it', () => {
268268
expect(renderTextSlot('Hello {record.name}', vars({ record: ACME }))).toBe('Hello {record.name}');
269269
});
270+
271+
it('a flow variable named `locale` is a value a hole prints, not the formatters\' locale (#26232)', () => {
272+
// Expected text from `Intl` itself, so a newer ICU in CI cannot drift it.
273+
const amount = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1234567.5);
274+
expect(renderTextSlot('{{ amount | number:2 }} ({{ locale }})', vars({ amount: 1234567.5, locale: 'de-DE' })))
275+
.toBe(`${amount} (de-DE)`);
276+
});
270277
});
271278

272279
/** This package's `src` — the runtime whose `$` variables the spec judge lists. */
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/core","sources":111,"sha256":"05fe45802fffcc482f95bad59a66279b70572f8757decd9770dc126f119dab2e","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/core","sources":111,"sha256":"c2cf28d9a3a53535b8a328f099857f84b3ac90402fedc8b898a9266cb261f55c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/react","sources":70,"sha256":"e3ea09e281e39a1d3f30d87b199d3756e88d7ffb48f91a11e777b2806a8bddff","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/react","sources":70,"sha256":"d89393cc7203be3bc88ea59d1a5e0ad6cbda588b8b8ad44baefed0a44da5d9d2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}

‎ui/packages/core/src/actions/ActionRunner.ts‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,6 +1283,7 @@ export class ActionRunner {
12831283
private paramCollectionHandler: ParamCollectionHandler | null;
12841284
private resultDialogHandler: ResultDialogHandler | null;
12851285
private translate: ((key: string, options: { defaultValue: string }) => string) | null;
1286+
private displayLocale: (() => string | undefined) | null;
12861287

12871288
/**
12881289
* Built-in dispatch, one entry per runnable action type.
@@ -1322,6 +1323,7 @@ export class ActionRunner {
13221323
this.paramCollectionHandler = null;
13231324
this.resultDialogHandler = null;
13241325
this.translate = null;
1326+
this.displayLocale = null;
13251327
}
13261328

13271329
/**
@@ -1341,6 +1343,20 @@ export class ActionRunner {
13411343
this.translate = translate;
13421344
}
13431345

1346+
/**
1347+
* Set where the runner reads the viewer's display locale (a BCP-47 tag) —
1348+
* the host's own, injected so this package resolves none. It is read when a
1349+
* copy template is filled, so a language switch reaches a runner built
1350+
* before it. The `template` formatters in `confirmText` and
1351+
* `successMessage` render in it (`{{ amount | currency }}`,
1352+
* `{{ due | date:long }}`), through the engine's reserved locale channel, so
1353+
* a root named `locale` never sets it (objectstack#26232). With none
1354+
* installed they format in `en-US`.
1355+
*/
1356+
setDisplayLocale(displayLocale: () => string | undefined): void {
1357+
this.displayLocale = displayLocale;
1358+
}
1359+
13441360
/**
13451361
* Set a custom confirmation handler (e.g., Shadcn AlertDialog).
13461362
*/
@@ -1811,7 +1827,11 @@ export class ActionRunner {
18111827
const successCopy = actionCopySource(action.successMessage);
18121828
if (successCopy !== undefined && successCopy.trim() !== '') {
18131829
return hasTemplateHoles(successCopy)
1814-
? fillTemplateHoles(successCopy, { result: payload, record: this.dispatchRecord(action) })
1830+
? fillTemplateHoles(
1831+
successCopy,
1832+
{ result: payload, record: this.dispatchRecord(action) },
1833+
this.displayLocale?.(),
1834+
)
18151835
: successCopy;
18161836
}
18171837
return this.runnerText(RUNNER_TEXT.completedSuccessfully);
@@ -1846,7 +1866,11 @@ export class ActionRunner {
18461866
const staticParams = params && typeof params === 'object' && !Array.isArray(params)
18471867
? Object.fromEntries(Object.entries(params).filter(([key]) => key !== '_rowRecord'))
18481868
: undefined;
1849-
return fillTemplateHoles(text, { record: this.dispatchRecord(action), params: staticParams });
1869+
return fillTemplateHoles(
1870+
text,
1871+
{ record: this.dispatchRecord(action), params: staticParams },
1872+
this.displayLocale?.(),
1873+
);
18501874
}
18511875

18521876
/**
@@ -2917,9 +2941,9 @@ export class ActionRunner {
29172941
/**
29182942
* A one-shot runner for {@link ActionRunner.execute}'s `callContext`
29192943
* (objectui#13063): this runner's context with `callContext` merged over it,
2920-
* and this runner's handlers, translator and registries, shared rather than
2921-
* copied, so the dispatch confirms, collects params, toasts and navigates
2922-
* exactly as this runner does.
2944+
* and this runner's handlers, translator, display locale and registries,
2945+
* shared rather than copied, so the dispatch confirms, collects params,
2946+
* toasts and navigates exactly as this runner does.
29232947
*
29242948
* Two keys follow the merge rather than the base:
29252949
*
@@ -2961,6 +2985,7 @@ export class ActionRunner {
29612985
scoped.paramCollectionHandler = this.paramCollectionHandler;
29622986
scoped.resultDialogHandler = this.resultDialogHandler;
29632987
scoped.translate = this.translate;
2988+
scoped.displayLocale = this.displayLocale;
29642989
return scoped;
29652990
}
29662991

0 commit comments

Comments
 (0)