Skip to content

Commit c0f9a1d

Browse files
authored
Merge pull request #727 from code0-tech/feat/#724
useForm: getInputProps returns unstable references
2 parents 0ce5c90 + b7d3006 commit c0f9a1d

3 files changed

Lines changed: 156 additions & 9 deletions

File tree

src/components/form/Input.stories.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ import {
5353
FileInputTrigger
5454
} from "./FileInput";
5555
import {FileUploadFileChangeDetails} from "@ark-ui/react";
56+
import {expect, userEvent, within} from "storybook/test";
57+
import {ValidationProps} from "./useForm";
5658

5759
export default {
5860
title: "Form"
@@ -739,6 +741,99 @@ export const File = () => {
739741

740742
}
741743

744+
// ---- formValidation prop identity stability ----
745+
// Measures whether getInputProps(key) keeps referential identity for keys whose value
746+
// did NOT change. Typing happens only in the first input; the counters of the second
747+
// (untouched) input show how often its props were rebuilt anyway.
748+
// Hoisted: with "use no memo" the React Compiler no longer memoizes inline literals,
749+
// and useForm keys its reset effect off the identity of `initialValues`.
750+
const propStabilityInitialValues = {
751+
typed: "",
752+
untouched: ""
753+
}
754+
const propStabilityValidate = {
755+
typed: (value: string) => value ? null : "Required",
756+
untouched: (value: string) => value !== undefined ? null : "Required"
757+
}
758+
759+
const PropStabilityStats = () => {
760+
"use no memo" // opt out of the React Compiler so the raw getInputProps behavior is measured
761+
762+
const [inputs, validate] = useForm({
763+
useInitialValidation: false,
764+
initialValues: propStabilityInitialValues,
765+
validate: propStabilityValidate
766+
})
767+
768+
const keys = ["typed", "untouched"] as const
769+
const statsRef = React.useRef<Record<string, Record<string, number>>>({
770+
typed: {props: 0, setValue: 0, validation: 0, value: 0},
771+
untouched: {props: 0, setValue: 0, validation: 0, value: 0}
772+
})
773+
const prevRef = React.useRef<Record<string, ValidationProps<any>>>({})
774+
775+
const propsByKey: Record<string, ValidationProps<any>> = {}
776+
for (const key of keys) {
777+
const props = inputs.getInputProps(key)
778+
const prev = prevRef.current[key]
779+
if (prev) {
780+
const stats = statsRef.current[key]
781+
if (props !== prev) stats.props++
782+
if (props.formValidation?.setValue !== prev.formValidation?.setValue) stats.setValue++
783+
if (props.formValidation?.valid !== prev.formValidation?.valid
784+
|| props.formValidation?.notValidMessage !== prev.formValidation?.notValidMessage) stats.validation++
785+
if (!Object.is(props.initialValue, prev.initialValue)) stats.value++
786+
}
787+
prevRef.current[key] = props
788+
propsByKey[key] = props
789+
}
790+
791+
return <Card color={"secondary"} w={"400px"}>
792+
<TextInput
793+
placeholder={"typed"}
794+
title={"Typed input"}
795+
onChange={() => validate("typed")}
796+
{...propsByKey.typed}
797+
/>
798+
<br/>
799+
<TextInput
800+
placeholder={"untouched"}
801+
title={"Untouched input"}
802+
{...propsByKey.untouched}
803+
/>
804+
<br/>
805+
{keys.map(key => (
806+
<div key={key}>
807+
<Text size={"sm"}>{key}: </Text>
808+
<span data-testid={`stats-${key}`}>{JSON.stringify(statsRef.current[key])}</span>
809+
</div>
810+
))}
811+
</Card>
812+
}
813+
814+
export const FormValidationPropStability = {
815+
render: () => <PropStabilityStats/>,
816+
play: async ({canvasElement}: { canvasElement: HTMLElement }) => {
817+
const canvas = within(canvasElement)
818+
const readStats = (key: string): Record<string, number> =>
819+
JSON.parse(canvas.getByTestId(`stats-${key}`).textContent ?? "{}")
820+
821+
await userEvent.type(canvas.getByPlaceholderText("typed"), "hello")
822+
823+
console.log("prop identity changes after typing 5 chars into 'typed':",
824+
JSON.stringify({typed: readStats("typed"), untouched: readStats("untouched")}))
825+
826+
const untouched = readStats("untouched")
827+
// Sanity check: the untouched input's value really never changed.
828+
expect(untouched.value).toBe(0)
829+
// Desired behavior: props of a key whose value and validation result did not
830+
// change keep their identity. Fails while getInputProps rebuilds them per render.
831+
expect(untouched.validation).toBe(0)
832+
expect(untouched.setValue).toBe(0)
833+
expect(untouched.props).toBe(0)
834+
}
835+
}
836+
742837
// ---- Performance: many EditorInputs in a single form ----
743838
// Stress-tests the Slate-based EditorInput by rendering up to 50 instances at once,
744839
// each with token highlighting + suggestions, so we can spot rendering/typing lag.

src/components/form/useForm.ts

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,26 +37,39 @@ export interface IValidation<Values> {
3737
isValid(): boolean
3838
}
3939

40+
interface CachedInputProps<Value> {
41+
value: Value | null
42+
message: string | null
43+
required: boolean
44+
props: ValidationProps<Value>
45+
}
46+
4047
class Validation<Values> implements IValidation<Values> {
4148

4249
private readonly changeValue: (key: string, value: any) => void
4350
private readonly currentValues: Values
4451
private readonly currentValidations?: Validations<Values>
4552
private readonly shouldValidate: Map<keyof Values, boolean>
4653
private readonly cachedMessages: Map<keyof Values, string | null>
54+
private readonly cachedSetters: Map<keyof Values, (value: any) => void>
55+
private readonly cachedProps: Map<keyof Values, CachedInputProps<any>>
4756

4857
constructor(
4958
changeValue: (key: string, value: any) => void,
5059
values: Values,
5160
validations: Validations<Values>,
5261
shouldValidate: Map<keyof Values, boolean> = new Map<keyof Values, boolean>(),
53-
cachedMessages: Map<keyof Values, string | null> = new Map()
62+
cachedMessages: Map<keyof Values, string | null> = new Map(),
63+
cachedSetters: Map<keyof Values, (value: any) => void> = new Map(),
64+
cachedProps: Map<keyof Values, CachedInputProps<any>> = new Map()
5465
) {
5566
this.changeValue = changeValue
5667
this.currentValues = values
5768
this.currentValidations = validations
5869
this.shouldValidate = shouldValidate
5970
this.cachedMessages = cachedMessages
71+
this.cachedSetters = cachedSetters
72+
this.cachedProps = cachedProps
6073
}
6174

6275
isValid(): boolean {
@@ -94,23 +107,43 @@ class Validation<Values> implements IValidation<Values> {
94107
message = this.cachedMessages.get(key) ?? null
95108
}
96109

97-
return {
110+
const required = Boolean(this.currentValidations && this.currentValidations[key])
111+
112+
// Reuse the cached props object as long as nothing for this key changed, so
113+
// consumers can memoize directly on the getInputProps output.
114+
const cached = this.cachedProps.get(key)
115+
if (cached && Object.is(cached.value, currentValue) && cached.message === message && cached.required === required) {
116+
return cached.props
117+
}
118+
119+
// One setValue per key for the lifetime of the form: changeValue reads from
120+
// valuesRef and never goes stale, so the wrapper never has to be rebuilt.
121+
let setValue = this.cachedSetters.get(key)
122+
if (!setValue) {
123+
const changeValue = this.changeValue
124+
setValue = (value: any) => {
125+
changeValue(currentName, value)
126+
}
127+
this.cachedSetters.set(key, setValue)
128+
}
129+
130+
const props: ValidationProps<Values[Key]> = {
98131
// @ts-ignore – z.B. wenn dein Input `defaultValue` kennt
99132
defaultValue: currentValue ?? undefined,
100133
initialValue: currentValue ?? undefined,
101134
formValidation: {
102-
setValue: (value: any) => {
103-
this.changeValue(currentName, value)
104-
},
135+
setValue,
105136
...({
106137
notValidMessage: message,
107138
valid: message === null,
108139
})
109140
},
110-
...(this.currentValidations && this.currentValidations[key]
141+
...(required
111142
? {required: true}
112143
: {})
113144
}
145+
this.cachedProps.set(key, {value: currentValue, message, required, props})
146+
return props
114147
}
115148
}
116149

@@ -129,6 +162,8 @@ export const useForm = <
129162
const initValues = React.useMemo(() => initialValues as Values, [initialValues])
130163
const [values, setValues] = useState<Values>(initValues)
131164
const cachedMessagesRef = useRef<Map<keyof Values, string | null>>(new Map())
165+
const cachedSettersRef = useRef<Map<keyof Values, (value: any) => void>>(new Map())
166+
const cachedPropsRef = useRef<Map<keyof Values, CachedInputProps<any>>>(new Map())
132167
const valuesRef = useRef<Values>(values)
133168
valuesRef.current = values
134169

@@ -142,18 +177,30 @@ export const useForm = <
142177
values,
143178
validate,
144179
useInitialValidation ? new Map<keyof Values, boolean>(Object.keys(initValues).map(k => [k as keyof Values, true])) : new Map<keyof Values, boolean>(),
145-
cachedMessagesRef.current
180+
cachedMessagesRef.current,
181+
cachedSettersRef.current,
182+
cachedPropsRef.current
146183
))
147184

185+
const didInitRef = useRef(false)
148186
useEffect(() => {
149187
valuesRef.current = initValues
150188
setValues(initValues)
189+
// Form reset: props must be rebuilt from the new initial values. The setter
190+
// cache survives on purpose — changeValue is stable, so the setters stay valid.
191+
// On mount the cache only holds props built from these initValues, so keep it.
192+
if (didInitRef.current) {
193+
cachedPropsRef.current.clear()
194+
}
195+
didInitRef.current = true
151196
setValidation(new Validation<Values>(
152197
changeValue,
153198
initValues,
154199
validate,
155200
useInitialValidation ? new Map<keyof Values, boolean>(Object.keys(initValues).map(k => [k as keyof Values, true])) : new Map<keyof Values, boolean>(),
156-
cachedMessagesRef.current
201+
cachedMessagesRef.current,
202+
cachedSettersRef.current,
203+
cachedPropsRef.current
157204
))
158205
}, [initValues])
159206

@@ -170,7 +217,9 @@ export const useForm = <
170217
currentValues,
171218
validate,
172219
shouldValidateMap,
173-
cachedMessagesRef.current
220+
cachedMessagesRef.current,
221+
cachedSettersRef.current,
222+
cachedPropsRef.current
174223
)
175224

176225
setValidation(currentValidation)

vite.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export default defineConfig({
5757
storybookTest({
5858
configDir: path.join(dirname, '.storybook')
5959
})],
60+
optimizeDeps: {
61+
include: ['storybook/test']
62+
},
6063
test: {
6164
name: 'storybook',
6265
browser: {

0 commit comments

Comments
 (0)