Skip to content

Fix fields.number rounding stored values to 3 decimal places - #1632

Open
ducroq wants to merge 1 commit into
Thinkmill:mainfrom
ducroq:fix/number-field-fraction-digits
Open

ducroq wants to merge 1 commit into
Thinkmill:mainfrom
ducroq:fix/number-field-fraction-digits

Conversation

@ducroq

@ducroq ducroq commented Sep 28, 2026

Copy link
Copy Markdown

Fixes #1614

Problem

fields.number silently rounds committed values to 3 decimal places. If you type 51.98771 and leave the field, 51.988 is saved. This breaks latitude/longitude and any other value that needs more than three decimals.

Values that are already stored are affected too. If an entry has 5.8123456 saved, the field shows 5.812. Focusing the field and tabbing out, without typing anything, calls onChange(5.812), so the rounded value is written back the next time the entry is saved.

Root cause

NumberFieldInput renders @keystar/ui's NumberField without formatOptions. useNumberFieldState builds its NumberFormatter/NumberParser from formatOptions alone. On commit it round-trips the value through that formatter, after snapping to step:

clampedValue = numberParser.parse(format(clampedValue));

With no options, Intl.NumberFormat uses its default maximumFractionDigits: 3, so the stored value is rounded, not just the text shown. step doesn't help, because it isn't part of the formatter options. A step of 0.00001 still gets rounded back to 3 decimals.

Fix

NumberFieldInput now passes a module-level constant formatOptions = { maximumFractionDigits: 20 } to NumberField.

  • Why 20: it's the largest value that every engine Keystatic supports accepts (ES2020 allows 0–20; ES2023 raised the limit to 100). Engines format a double from its shortest round-trip representation. So 0.1 still displays as 0.1, 51.98771 as 51.98771, and integers as before (1,234,567). With this setting the formatter never rounds anything a user can type.
  • Why no new option: once the formatter is lossless, step already controls precision through useNumberFieldState's snap-to-step, and validation already checks it. I considered two alternatives:
    • Deriving maximumFractionDigits from step. This fixes only fields that set a step. The common case (no step) would still round to 3 decimals.
    • Exposing a formatOptions option on fields.number. This adds API surface just to opt out of data loss. That should be the default. If display options (percent, currency, fixed decimals) are wanted later, they can be added as a separate feature on top of this.
  • Why hoisted: useNumberFieldState memoises its parser and formatter on formatOptions identity. A new object on every render would rebuild them each time. Older versions compared by identity and reset the input text while the user was typing.
  • fields.integer (IntegerFieldInput) is unchanged. Integers have no fraction digits, so the default doesn't affect them.

Tests

packages/keystatic/src/form/fields/number/ui.test.tsx renders NumberFieldInput in a KeystarProvider (locale="en-US", as the Keystatic app does) and drives it with @testing-library/user-event:

Test Before After
typing 51.98771 and blurring commits 51.98771 fails: expected 51.988 to be 51.98771 passes
an existing 5.8123456 is displayed in full and not changed by focus + blur fails: expected '5.812' to be '5.8123456' (with only the display assertion removed: expected [ 5.812 ] to deeply equal []) passes
with step: 0.00001, 51.98771 survives fails: expected 51.988 to be 51.98771 passes
integers are displayed and committed unchanged (1234567 → 1,234,567) passes passes
step: 0.01 still snaps 1.23456 to 1.23 not run (added after the fix) passes

vitest run packages/keystatic/src/form/fields/number/ gives 17/17 passing (also with STRICT_MODE=1). tsc, eslint and prettier --check are clean.

We also checked this manually in a real admin UI, using an equivalent patch applied to the built @keystatic/core (a hoisted formatOptions with maximumFractionDigits: 6). Typing 51.98771 and blurring keeps 51.98771, and 51.98771 is what gets saved to the entry's JSON. Without the change it's rounded to 3 decimals.

Changeset

@keystatic/core: patch.

NumberFieldInput rendered NumberField without formatOptions, so
useNumberFieldState formatted with the Intl.NumberFormat default of
maximumFractionDigits: 3 and rounded the committed value through it.
Pass a hoisted { maximumFractionDigits: 20 } so the formatter is lossless;
precision is then governed by step alone.

Fixes Thinkmill#1614
@ducroq
ducroq requested a review from emmatown as a code owner September 28, 2026 15:36
@changeset-bot

changeset-bot Bot commented Sep 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f30e3d3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@keystatic/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fields.number rounds stored values to 3 decimals: formatOptions is never forwarded to NumberField

1 participant