Skip to content

Console: number cell/display renderers hardcode a grouping Intl.NumberFormat('en-US') — an ordinal Field.number (a year) always renders 2,026, and no field property can turn it off #4033

Description

@os-zhuang

Migrated from objectstack-ai/objectstack#5067 under the file-at-destination ruling (objectstack-ai/objectstack#7167, maintainer 2026-08-10). Originally filed 2026-08-04T01:54:50Z. The full prior thread — including triage rulings and hold/restart conditions — remains on the source issue and MUST be read before acting on this card.


Summary

Every numeric field the console renders goes through an Intl.NumberFormat that is constructed with grouping on and the locale hardcoded to en-US, and the only field property it consults is scale. There is no authorable way to say "this number is an ordinal — do not group it".

The visible consequence: a four-digit year stored as Field.number renders as 2,026 everywhere — grids, related lists, record headers — in every locale.

Evidence

Measured on a fresh database (@objectstack/console 17.0.0-rc.1, locale zh-CN), the three related lists on one customer record:

{ yearCol: "年度", sample: ["2,026", "2,026", "2,026"] }   // crm_competitor_supply
{ yearCol: "年度", sample: ["2,023", "2,025", "2,024"] }   // crm_supply_history
{ yearCol: "年度", sample: ["2,026", "2,026", "2,026"] }   // crm_supply_item

The field definition that produces this carries everything the spec offers for a discrete integer:

year: Field.number({
  label: 'Year',
  required: true,
  storage: { notNull: true },
  scale: 0,      // ← the only property the renderer reads
  min: 1900,
})

Mechanism

The number display renderer in ui-components reduces to:

new Intl.NumberFormat('en-US', {
  minimumFractionDigits: scale ?? 0,
  maximumFractionDigits: scale ?? 20,
}).format(value)

Two independent problems in one line:

  1. useGrouping is never set, and it defaults to true. scale: 0 controls fraction digits only; nothing controls the separator.
  2. The locale is hardcoded to en-US rather than taken from the active i18n locale, so a zh-CN or de-DE console still groups by the US convention.

The same construction appears in the grid cell renderer and in the compact formatNumber helper, so changing the surface does not change the answer.

Why "just store it as text" is not a fix, only a dodge

That is what we were forced to do (twice — see below), and it costs real capability:

  • The column loses numeric comparison at the driver, so range filters (year >= 2023) stop working and have to be re-expressed as string comparisons that only happen to work for zero-free four-digit years.
  • Dataset dimensions must be re-typed string, which changes how service-analytics sorts and buckets them.
  • Every writer (seed, REST client, test) has to be normalized by hand through a lifecycle hook, because the engine will happily store 2026 and "2026" as two different values — and if the field is part of an upsert external id, that divergence forks the key.

None of that is conceptually necessary. The value is a number; only its presentation is wrong.

Suggested fix

Any of these closes it; the first is the smallest:

  1. Honour an authorable presentation hint on the field — e.g. useGrouping: false, or a format / displayFormat property — and pass it to Intl.NumberFormat.
  2. Suppress grouping automatically when scale === 0 and the field has no currency, or when the field is flagged as an identifier/ordinal.
  3. Independently of the above: stop hardcoding 'en-US' and use the active locale, so grouping and decimal marks follow the user's language.

Downstream impact in our app

We have now paid for this three times in the same codebase:

  • crm_customer_rating.rating_year was converted from Field.number to Field.text for exactly this reason (yinlianghui/hotcrm-heimao#35, Migrate studio deployment from GitHub Pages to standalone Vercel at play.objectstack.ai #40).
  • crm_supply_item.year, crm_competitor_supply.year and crm_supply_history.year are being converted now (yinlianghui/hotcrm-heimao#59) — which additionally forces three new normalization hooks, three dataset dimension re-types, and a re-check of the cross-object "common comparison year" logic that decides a customer's supply share.

Each conversion is a permanent loss of numeric semantics traded for a display detail, and it will keep happening to every app that stores a year, a fiscal period, or any other ordinal integer.

Activity

  1. self-assigned this
    on Aug 11, 2026
  2. yinlianghui commented on Aug 11, 2026

    @yinlianghui
    Collaborator

    CLAIM + 代决 — session session_017Qqyix2QcnpUC9XeYVDzx3(objectui whole-repo PM seat;否决窗口开放)。Branch: claude/issue-4033-number-format-locale。

    按源线程(objectstack#5067)分诊路线执行,附裁决细化:

    1. 修法 3 无条件落:所有点名的 Intl.NumberFormat('en-US')(fields/index.tsx 多处、CurrencyField、MetricWidget)改用活跃 i18n locale——分组与小数记号跟随用户语言。
    2. 修法 2 按裁定形状落:scale === 0 && 无 currency ⇒ useGrouping: false,作为过渡默认。代价(大计数值失去千分组)已知且接受:年份/序数在网格里的错误分组是每 locale 每处都错,计数分组是可读性偏好;若实测某面(如 MetricWidget 紧凑大数)的分组是承重行为,把抑制收窄到卡片点名的 cell/display 渲染器并报告例外。双向钉住(年份不分组、currency 保持分组、非零 scale 保持分组)。
    3. 修法 1(authorable useGrouping/displayFormat)是正解但触 spec 词汇——本席按 contract-first 另行在 objectstack 立 spec 子卡,不在本卡动 spec;过渡默认的注释写明"spec 展示提示落地后由其覆盖"。
    4. 同一构造出现在 grid cell 渲染器与 formatNumber helper——收敛到单一格式化函数,不许三处各改各的。

    Surface:packages/fields、packages/components(数字 cell/display 渲染器,不碰 #4281 持有的 renderers/action)、plugin-dashboard/MetricWidget(注意 #4030 在飞持有 plugin-dashboard 标签网出口——MetricWidget 的 Intl 构造与其不相交,派单已互相点名)。


    Generated by Claude Code

  3. yinlianghui commented on Aug 11, 2026

    @yinlianghui
    Collaborator

    ACCEPT(重启后按 PR 正文复核) — session session_017Qqyix2QcnpUC9XeYVDzx3。宿主重启在 agent 发终报前将其终止;PR #4333 已开出且正文完整,以其替代 JSON 报告完成复核,已 ready + auto-merge。

    要点:双策略收敛单函数 formatDisplayNumber + useDisplayLocale(组合既有两条 locale 通道而非发明第三条,fresh-database 状态由语言切换通道覆盖——正是本卡被测出的环境);五处硬编码全收;MetricWidget 保留分组的例外是按其自身契约测出的(limb C 反证"分组承重"),useGrouping: true ≠ 省略的 Intl 陷阱有测量有 pin;恶意 locale 不炸单元格。反向验证三肢方向全中,还抓了自己一个空洞 pin(es-ES auto 无分组)重锚。#4332(currency 缺一位分)finding + 控制测试钉现状;#4272 上评论而非开孪生。裁决三点(locale 无条件、scale0 过渡默认、收敛单函数)全部执行,MetricWidget 例外符合"实测承重则收窄"的授权。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions