Skip to content

Commit 1dfbde8

Browse files
authored
Upgrade react-hook-form and improve subnet pool member and IP range validation (#3188)
Resolvers are really for transforming form input into another shape. We were using it for form-level cross-field validation. The new form-level `validate` API added in react-hook-form/react-hook-form#13195 is perfect for what we were doing. It does not override existing validations and it runs whenever validations are supposed to run (I think `resolver` only runs on submit. ### Interesting changes in RHF I had Claude go through the releases since the old version and summarize the interesting stuff. **Features worth a look** - [v7.69.0](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.69.0) `reset({ keepIsValid: true })` fix — the existing `keepIsValid` option was buggy before. We don't currently use it but `SideModalForm`'s "name already exists" flow uses `setError` in a post-submit effect; switching to `errors` prop with focus ([v7.57.0](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.57.0) `focus form field for errors supplied by errors prop`) could be cleaner. - [v7.65.0](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.65.0) `<Watch />` + [v7.68.0](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.68.0) `<FormStateSubscribe />` — render-prop components that re-render only when a named field (or formState slice) changes. `instance-create.tsx` and `firewall-rules-common.tsx` have lots of `useWatch` at the top level; some of that churn could be isolated inside a `<Watch>` render-prop. Worth trying only if profiling shows the re-renders hurt. - [v7.61.0](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.61.0) `useWatch({ compute })` — subscribe to the whole form but only surface a derived value when a condition matches. Could collapse `useWatch + useMemo` pairs. `instance-create.tsx:451-463` computes `bootDiskSource` from four `useWatch` calls; a single `useWatch({ compute })` would work. - [v7.63.0](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.63.0) `getValues(undefined, { dirtyFields: true })` — extract only dirty fields. Useful for PATCH-style edits; our edit forms mostly send the full object so the win is small. - [v7.55.0](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.55.0) `createFormControl` / `subscribe` — subscribe to form state outside React (e.g., from a store). Not obviously useful here. - [v7.56.0](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.56.0) reactive `mode` / `reValidateMode` — these become reactive to re-renders. The firewall-rules `HACK` comment at `firewall-rules-common.tsx:136-143` is specifically about the validate/reValidate regime swap, but that HACK is about `isSubmitted` state after reset, not about mode being non-reactive, so this doesn't directly help. **Bug that might have been biting us** - [v7.72.1](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.72.1) — `setValue` with `shouldDirty` no longer pollutes unrelated dirty fields. We use `setValue` heavily (silo-create, idp-create, instance-create); if any of those use `shouldDirty` we likely had ghost-dirty fields. - This sounds really familiar........... - [v7.66.1](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.66.1) — `deepEqual` uses `Object.is` for NaN. `subnet-pool-member-add` stores `NaN` as the unset state for optional NumberFields — pre-fix, `isDirty` might have flipped on those. - [v7.69.0](https://github.com/react-hook-form/react-hook-form/releases/tag/v7.69.0) — race between `setError` and `setFocus` resolved. Relevant to our `SideModalForm` effect that calls both.
1 parent 93ae439 commit 1dfbde8

8 files changed

Lines changed: 136 additions & 148 deletions

File tree

app/components/form/fields/NumberField.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const NumberFieldInner = <
6565
name,
6666
label = capitalize(name),
6767
validate,
68+
deps,
6869
control,
6970
required,
7071
id: idProp,
@@ -83,6 +84,7 @@ export const NumberFieldInner = <
8384
control,
8485
rules: {
8586
required,
87+
deps,
8688
// it seems we need special logic to enforce required on NaN
8789
validate(value, values) {
8890
if (required && Number.isNaN(value)) return `${label} is required`

app/components/form/fields/TextField.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type FieldPath,
1313
type FieldPathValue,
1414
type FieldValues,
15+
type RegisterOptions,
1516
type Validate,
1617
} from 'react-hook-form'
1718

@@ -45,6 +46,7 @@ export interface TextFieldProps<
4546
placeholder?: string
4647
units?: string
4748
validate?: Validate<FieldPathValue<TFieldValues, TName>, TFieldValues>
49+
deps?: RegisterOptions<TFieldValues, TName>['deps']
4850
control: Control<TFieldValues>
4951
/** Alters the value of the input during the field's onChange event. */
5052
transform?: (value: string) => string
@@ -98,6 +100,7 @@ export const TextFieldInner = <
98100
type = 'text',
99101
label = capitalize(name),
100102
validate,
103+
deps,
101104
control,
102105
required,
103106
id: idProp,
@@ -109,7 +112,7 @@ export const TextFieldInner = <
109112
const {
110113
field: { onChange, ...fieldRest },
111114
fieldState: { error },
112-
} = useController({ name, control, rules: { required, validate } })
115+
} = useController({ name, control, rules: { required, validate, deps } })
113116
return (
114117
<>
115118
<UITextField

app/forms/ip-pool-range-add.tsx

Lines changed: 9 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { useForm, type FieldErrors } from 'react-hook-form'
8+
import { useForm } from 'react-hook-form'
99
import { useNavigate } from 'react-router'
1010

1111
import {
@@ -34,57 +34,11 @@ const defaultValues: IpRange = {
3434
last: '',
3535
}
3636

37-
// Using a resolver overrides all field-level validation (required, min, max,
38-
// etc.), so this function must cover everything. Field-level `required` props
39-
// still affect UI display (hiding the "optional" label) but are inert for
40-
// validation.
41-
42-
/**
43-
* Validates IP range addresses against the pool's IP version.
44-
* Ensures both addresses are valid IPs and match the pool's version.
45-
*/
46-
function createResolver(poolVersion: IpVersion) {
47-
return (values: IpRange) => {
48-
const first = parseIp(values.first)
49-
const last = parseIp(values.last)
50-
51-
const errors: FieldErrors<IpRange> = {}
52-
53-
// Validate first address matches pool version
54-
if (first.type === 'error') {
55-
errors.first = { type: 'pattern', message: first.message }
56-
} else if (first.type === 'v4' && poolVersion === 'v6') {
57-
errors.first = {
58-
type: 'pattern',
59-
message: 'IPv4 address not allowed in IPv6 pool',
60-
}
61-
} else if (first.type === 'v6' && poolVersion === 'v4') {
62-
errors.first = {
63-
type: 'pattern',
64-
message: 'IPv6 address not allowed in IPv4 pool',
65-
}
66-
}
67-
68-
// Validate last address matches pool version
69-
if (last.type === 'error') {
70-
errors.last = { type: 'pattern', message: last.message }
71-
} else if (last.type === 'v4' && poolVersion === 'v6') {
72-
errors.last = {
73-
type: 'pattern',
74-
message: 'IPv4 address not allowed in IPv6 pool',
75-
}
76-
} else if (last.type === 'v6' && poolVersion === 'v4') {
77-
errors.last = {
78-
type: 'pattern',
79-
message: 'IPv6 address not allowed in IPv4 pool',
80-
}
81-
}
82-
83-
// TODO: if we were really cool we could check first <= last but it would add
84-
// 6k gzipped to the bundle with ip-num
85-
86-
// no errors
87-
return Object.keys(errors).length > 0 ? { values: {}, errors } : { values, errors: {} }
37+
const validateAddress = (value: string, poolVersion: IpVersion) => {
38+
const parsed = parseIp(value)
39+
if (parsed.type === 'error') return parsed.message
40+
if (parsed.type !== poolVersion) {
41+
return `IP${parsed.type} address not allowed in IP${poolVersion} pool`
8842
}
8943
}
9044

@@ -107,10 +61,7 @@ export default function IpPoolAddRange() {
10761
},
10862
})
10963

110-
const form = useForm({
111-
defaultValues,
112-
resolver: createResolver(poolData.ipVersion),
113-
})
64+
const form = useForm({ defaultValues })
11465

11566
return (
11667
<SideModalForm
@@ -132,12 +83,14 @@ export default function IpPoolAddRange() {
13283
description="First address in the range"
13384
control={form.control}
13485
required
86+
validate={(value) => validateAddress(value, poolData.ipVersion)}
13587
/>
13688
<TextField
13789
name="last"
13890
description="Last address in the range"
13991
control={form.control}
14092
required
93+
validate={(value) => validateAddress(value, poolData.ipVersion)}
14194
/>
14295
<SideModalFormDocs docs={[docLinks.systemIpPools]} />
14396
</SideModalForm>

app/forms/subnet-pool-member-add.spec.ts

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,91 +7,93 @@
77
*/
88
import { describe, expect, it } from 'vitest'
99

10-
import { createResolver } from './subnet-pool-member-add'
10+
import { validateMember } from './subnet-pool-member-add'
1111

12-
const resolve = createResolver('v4')
13-
const resolve6 = createResolver('v6')
12+
const validate = (values: Parameters<typeof validateMember>[1]) =>
13+
validateMember('v4', values)
14+
const validate6 = (values: Parameters<typeof validateMember>[1]) =>
15+
validateMember('v6', values)
1416

1517
const valid = { subnet: '10.0.0.0/16', minPrefixLength: 20, maxPrefixLength: 28 }
1618

17-
type Result = ReturnType<typeof resolve>
19+
type Field = 'subnet' | 'minPrefixLength' | 'maxPrefixLength'
1820

19-
function errMsg(result: Result, field: keyof Result['errors']) {
20-
return result.errors[field]?.message
21+
function errMsg(result: ReturnType<typeof validate>, field: Field) {
22+
return result[field]
2123
}
2224

23-
describe('createResolver', () => {
25+
describe('validateMember', () => {
2426
it('accepts valid v4 input', () => {
25-
expect(Object.keys(resolve(valid).errors)).toEqual([])
27+
expect(validate(valid)).toEqual({})
2628
})
2729

2830
it('accepts valid v6 input', () => {
29-
const result = resolve6({
31+
const result = validate6({
3032
subnet: 'fd00:1000::/32',
3133
minPrefixLength: 48,
3234
maxPrefixLength: 64,
3335
})
34-
expect(Object.keys(result.errors)).toEqual([])
36+
expect(result).toEqual({})
3537
})
3638

3739
it('accepts omitted prefix lengths', () => {
38-
const result = resolve({
40+
const result = validate({
3941
subnet: '10.0.0.0/16',
4042
minPrefixLength: NaN,
4143
maxPrefixLength: NaN,
4244
})
43-
expect(Object.keys(result.errors)).toEqual([])
45+
expect(result).toEqual({})
4446
})
4547

4648
it('rejects invalid CIDR', () => {
47-
const result = resolve({ ...valid, subnet: 'not-a-cidr' })
49+
const result = validate({ ...valid, subnet: 'not-a-cidr' })
4850
expect(errMsg(result, 'subnet')).toMatch(/IP address/)
4951
})
5052

5153
it('rejects v6 subnet in v4 pool', () => {
52-
const result = resolve({ ...valid, subnet: 'fd00::/32' })
54+
const result = validate({ ...valid, subnet: 'fd00::/32' })
5355
expect(errMsg(result, 'subnet')).toBe('IPv6 subnet not allowed in IPv4 pool')
5456
})
5557

5658
it('rejects v4 subnet in v6 pool', () => {
57-
const result = resolve6({ ...valid, subnet: '10.0.0.0/16' })
59+
const result = validate6({ ...valid, subnet: '10.0.0.0/16' })
5860
expect(errMsg(result, 'subnet')).toBe('IPv4 subnet not allowed in IPv6 pool')
5961
})
6062

6163
it('rejects min > max prefix length', () => {
62-
const result = resolve({ ...valid, minPrefixLength: 28, maxPrefixLength: 20 })
64+
const result = validate({ ...valid, minPrefixLength: 28, maxPrefixLength: 20 })
6365
expect(errMsg(result, 'minPrefixLength')).toMatch(//)
6466
})
6567

6668
it('rejects min prefix length < subnet width', () => {
67-
const result = resolve({ ...valid, minPrefixLength: 8 })
69+
const result = validate({ ...valid, minPrefixLength: 8 })
6870
expect(errMsg(result, 'minPrefixLength')).toMatch(/ subnet prefix length \(16\)/)
6971
})
7072

7173
it('rejects max prefix length < subnet width', () => {
72-
const result = resolve({ ...valid, maxPrefixLength: 8 })
74+
const result = validate({ ...valid, maxPrefixLength: 8 })
7375
expect(errMsg(result, 'maxPrefixLength')).toMatch(/ subnet prefix length \(16\)/)
7476
})
7577

7678
it('rejects prefix length above max bound (v4: 32)', () => {
77-
const result = resolve({ ...valid, minPrefixLength: 33 })
79+
const result = validate({ ...valid, minPrefixLength: 33 })
7880
expect(errMsg(result, 'minPrefixLength')).toBe('Must be between 0 and 32')
7981
})
8082

8183
it('rejects prefix length below 0', () => {
82-
const result = resolve({ ...valid, maxPrefixLength: -1 })
84+
const result = validate({ ...valid, maxPrefixLength: -1 })
8385
expect(errMsg(result, 'maxPrefixLength')).toBe('Must be between 0 and 32')
8486
})
8587

8688
it('shows min-≤-max error even when min is also below subnet width', () => {
8789
// min(12) > max(10) AND min(12) < subnetWidth(16): the min-≤-max error
8890
// should take priority over the subnet-width error
89-
const result = resolve({ ...valid, minPrefixLength: 12, maxPrefixLength: 10 })
91+
const result = validate({ ...valid, minPrefixLength: 12, maxPrefixLength: 10 })
9092
expect(errMsg(result, 'minPrefixLength')).toMatch(//)
9193
})
9294

9395
it('rejects prefix length above max bound (v6: 128)', () => {
94-
const result = resolve6({
96+
const result = validate6({
9597
subnet: 'fd00::/32',
9698
minPrefixLength: 48,
9799
maxPrefixLength: 200,

0 commit comments

Comments
 (0)