Skip to content

Upgrade React Aria and validate number fields without clamping - #3377

Open
david-crespo wants to merge 4 commits into
number-field-validationfrom
react-aria-upgrade
Open

Upgrade React Aria and validate number fields without clamping#3377
david-crespo wants to merge 4 commits into
number-field-validationfrom
react-aria-upgrade

Conversation

@david-crespo

Copy link
Copy Markdown
Collaborator

This is on top of #3376, which made NumberField validate min and max itself so we could stop relying on react-aria's clamping. This PR upgrades react-aria 3.44 → 3.52.1 and react-stately 3.32 → 3.50.0 and turns off clamping with the new commitBehavior: 'validate' option from adobe/react-spectrum#9679 (merged March 2026). It also adds whole-number validation to NumberField, which fixes a pre-existing bug: nothing client-side stopped you from submitting 2.5 CPUs.

What changes for the user

Before, typing an out-of-range value and leaving the field rewrote it to the nearest bound. Now, if a value is out of range, or fractional in a field that requires a whole number, submit is blocked and the field shows an error. The validation trigger is still react-hook-form's default: no errors until the first submit attempt, and after that it revalidates on change events.

Form state updates on every keystroke that parses to a number, as long as the text is already in canonical form. react-aria's commit would rewrite 007 or 1.0 to 7 or 1, so those wait for blur or Enter, as they did before. Out-of-range values no longer wait: they used to sit in the input until blur while form state kept the old value, and now they reach form state immediately, which is what lets the error clear as soon as you fix the number.

Disk size, min 6 GiB from the selected image:

Step Before After
Type 5 5 5
Tab away Box becomes 6, no message Box stays 5, no message
Click submit Creates a 6 GiB disk Blocked, "Must be at least 6 GiB"
Change to 20 n/a Error clears as soon as 20 is in the box

Prefix length on the external subnet form, where the max depends on the selected pool (32 for v4, 128 for v6):

Step Before After
v6 pool, type 64 64 64
Switch to v4 pool Box shows 32, form still holds 64 Box shows 64
Click submit Sends prefix_length: 64 (main). On #3376, blocked with "Can be at most 32" under a box that says 32 Blocked, "Can be at most 32"
Switch back to v6 64 reappears Error clears, 64
Switch to v4 again Box shows 32, form holds 64 Box shows 64, "Can be at most 32"

The middle row is the bug that motivated this. useNumberFieldState clamps the value prop we pass in before storing it, so the text in the box was derived from the clamped 32 while the value react-hook-form held (and would submit) was still 64. The validation from #3376 at least prevents submission, but the input still showed the wrong value. With commitBehavior: 'validate', the incoming value is used as-is.

What stays the same

  • Steppers still stop at the bounds. commitBehavior only affects the typed value and the incoming value prop. Increment and decrement still go through snapValueToStep(prev, minValue, maxValue, step) and the buttons still disable at min and max. Pressing an arrow while the box holds an out-of-range value snaps it to the nearest bound (type 2 with min 10, press up, get 10).
  • Blur and Enter still canonicalize. 007 becomes 7 and 1.0 becomes 1, as before.

Whole-number validation

No form passes step, so react-aria never snapped typed values to integers. On main, 2.5 in the CPU field is accepted as-is and sent to the API as ncpus: 2.5, which Nexus 400s on. This is independent of clamping, but it is fixed here. NumberField now checks Number.isInteger by default. Typing 2.5 CPUs leaves 2.5 in the box, but submit is blocked with "Must be a whole number". Correcting it to 3 clears the error. This covers CPU counts and CPU quotas, prefix lengths, firewall rule priority, disk sizes, and instance memory, including resize.

Memory and storage quotas on silo create and quota edit opt into allowDecimals. Those fields are entered in GiB and converted to bytes, so values like 1.5 GiB are allowed. Instance memory and disk sizes still require whole GiB because the API enforces 1 GiB alignment for memory and disk sizes.

Allowing decimals there exposed a pre-existing bug: the quota forms multiplied GiB by 2^30 without rounding, so 1.1 GiB sent a fractional byte count that the API rejects at deserialization. The last commit rounds to whole bytes in both submit handlers.

One thing left for a follow-up: the mock API accepts fractional CPU counts and unaligned sizes that Nexus would reject. The right fix is in the generated Zod schemas, which currently don't enforce integer-hood: oxidecomputer/oxide.ts#382 (draft).

Tests

Every existing assertion that a value got clamped, in the NumberInput browser spec and in the e2e specs for disks, instance create, and external subnets, is rewritten to assert the value stays put and the error appears. New NumberInput cases cover the steppers stopping at the bounds and snapping an out-of-range typed value back into range. A new NumberField browser spec covers the whole-number rule and allowDecimals.

The external subnet test is the regression test for the faux-clamping bug: type 64 on a v4 pool, submit, switch to v6 and back. It fails on main and on #3376, where the box shows 32.

React Aria packaging change

The lockfile got 1700 lines shorter because react-aria and react-stately now ship as single packages instead of dozens of @react-aria/* and @react-stately/* deps, which also forced two imports in Popover.tsx and DateField.tsx to move to the top-level package.

@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
console Ready Ready Preview Sep 9, 2026 9:50pm UTC

Request Review

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.

1 participant