diff --git a/app/components/debugging/Collapse.tsx b/app/components/debugging/Collapse.tsx new file mode 100644 index 000000000..e4b52a93d --- /dev/null +++ b/app/components/debugging/Collapse.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +export const Collapse: React.FC = props => { + const [collapsed, setCollapsed] = React.useState(false); + + return ( +
+ +
+ {props.children} +
+
+ ); +}; diff --git a/app/components/debugging/DebugFieldState.tsx b/app/components/debugging/DebugFieldState.tsx new file mode 100644 index 000000000..b8cd4784b --- /dev/null +++ b/app/components/debugging/DebugFieldState.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { UseFieldProps, useField } from 'formik'; +import { DebugProps } from './DebugProps'; + +export const DebugFieldState = (props: UseFieldProps) => { + const [field, meta, helpers] = useField(props); + + return ; +}; diff --git a/app/components/debugging/DebugFormikState.tsx b/app/components/debugging/DebugFormikState.tsx new file mode 100644 index 000000000..a4c46c1fa --- /dev/null +++ b/app/components/debugging/DebugFormikState.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { useFormikContext } from 'formik'; +import { DebugProps } from './DebugProps'; + +export const DebugFormikState = () => { + const formikState = useFormikContext(); + + return ; +}; diff --git a/app/components/debugging/DebugProps.tsx b/app/components/debugging/DebugProps.tsx new file mode 100644 index 000000000..82cad4781 --- /dev/null +++ b/app/components/debugging/DebugProps.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; + +export const DebugProps = (props?: any) => { + const renderCount = React.useRef(0); + return ( +
+
+        props = {JSON.stringify(props, null, 2)}
+        renders = {renderCount.current++}
+      
+
+ ); +}; diff --git a/app/helpers/array-helpers.ts b/app/helpers/array-helpers.ts new file mode 100644 index 000000000..97c9c06b1 --- /dev/null +++ b/app/helpers/array-helpers.ts @@ -0,0 +1,9 @@ +import { selectRandomInt } from './random-helpers'; + +export const selectRange = (count: number) => Array.from(Array(count).keys()); + +export const selectRandomArrayItem = (array: T[]) => { + const index = selectRandomInt(array.length); + + return array[index]; +}; diff --git a/app/helpers/chaos-helpers.ts b/app/helpers/chaos-helpers.ts new file mode 100644 index 000000000..1a89ed920 --- /dev/null +++ b/app/helpers/chaos-helpers.ts @@ -0,0 +1,89 @@ +import { FormikApi } from 'formik'; +import { useMemo, useEffect } from 'react'; +import { selectRandomInt } from './random-helpers'; + +export type DynamicValues = Record; + +export const useChaosHelpers = ( + formik: FormikApi, + array: number[] +) => { + return useMemo( + () => [ + () => + formik.setValues( + array.reduce>((prev, id) => { + prev[`Input ${id}`] = selectRandomInt(500).toString(); + + if (prev[`Input ${id}`]) { + } + + return prev; + }, {}) + ), + () => + formik.setErrors( + array.reduce>((prev, id) => { + const error = selectRandomInt(500); + + // leave some errors empty + prev[`Input ${id}`] = error % 5 === 0 ? '' : error.toString(); + + return prev; + }, {}) + ), + () => + formik.setTouched( + array.reduce>((prev, id) => { + prev[`Input ${id}`] = selectRandomInt(500) % 2 === 0; + + return prev; + }, {}) + ), + () => formik.submitForm(), + () => + formik.setFieldValue( + `Input ${selectRandomInt(array.length)}`, + selectRandomInt(500).toString() + ), + () => + formik.setFieldError( + `Input ${selectRandomInt(array.length)}`, + selectRandomInt(500).toString() + ), + () => + formik.setFieldTouched( + `Input ${selectRandomInt(array.length)}`, + selectRandomInt(2) % 2 === 0 + ), + () => formik.setStatus(selectRandomInt(500).toString()), + () => formik.resetForm(), + ], + [array, formik] + ); +}; + +let skipCount = 0; + +/** + * https://github.com/dai-shi/will-this-react-global-state-work-in-concurrent-mode + */ +export const useAutoUpdate = () => { + useEffect(() => { + if (typeof document !== 'undefined') { + skipCount += 1; + + if (skipCount % 10 === 0) { + document.getElementById('update-without-transition')?.click(); + } + } + }, []); + + // SSR + if (typeof performance !== 'undefined') { + const start = performance?.now(); + while (performance?.now() - start < 2) { + // empty + } + } +}; diff --git a/app/helpers/random-helpers.ts b/app/helpers/random-helpers.ts new file mode 100644 index 000000000..71aa26d63 --- /dev/null +++ b/app/helpers/random-helpers.ts @@ -0,0 +1,9 @@ +/** + * @param minOrMax // The maximum is exclusive and the minimum is inclusive + * @param max + */ +export const selectRandomInt = (minOrMax: number, max?: number) => { + const min = max ? minOrMax : 0; + max = max ? max : minOrMax; + return Math.floor(Math.random() * (max - min)) + min; +}; diff --git a/app/helpers/tearing-helpers.ts b/app/helpers/tearing-helpers.ts new file mode 100644 index 000000000..882491f04 --- /dev/null +++ b/app/helpers/tearing-helpers.ts @@ -0,0 +1,41 @@ +import { selectRange } from './array-helpers'; +import { useState, useCallback, useMemo } from 'react'; +import { useEffect } from 'react'; + +/** + * Check if all elements show the same number. + * https://github.com/dai-shi/will-this-react-global-state-work-in-concurrent-mode + */ +export const useCheckTearing = (elementCount: number, skip = 0) => { + const ids = useMemo(() => selectRange(elementCount).slice(skip), [ + elementCount, + skip, + ]); + const checkMatches = useCallback(() => { + const [first, ...rest] = ids; + const firstValue = document.querySelector(`#input-${first} code`) + ?.innerHTML; + return rest.every(id => { + const thisValue = document.querySelector(`#input-${id} code`)?.innerHTML; + const tore = thisValue !== firstValue; + if (tore) { + console.log('useCheckTearing: tore'); + console.log(thisValue); + console.log(firstValue); + } + return !tore; + }); + }, [ids]); + const [didTear, setDidTear] = useState(false); + + // We won't create an infinite loop switching this boolean once, I promise. + // (famous last words) + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (!didTear && !checkMatches()) { + setDidTear(true); + } + }); + + return didTear; +}; diff --git a/app/pages/basic.js b/app/pages/fixtures/basic.js similarity index 100% rename from app/pages/basic.js rename to app/pages/fixtures/basic.js diff --git a/app/pages/fixtures/components.tsx b/app/pages/fixtures/components.tsx new file mode 100644 index 000000000..2095894ef --- /dev/null +++ b/app/pages/fixtures/components.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { Formik, Field, Form, FieldProps } from 'formik'; +import { DebugProps } from '../../components/debugging/DebugProps'; + +const initialValues = { + name: '', +}; + +const RenderComponent = (props: FieldProps) => ( + <> + + + +); +const ComponentComponent = ( + props: FieldProps +) => ( + <> + + + +); +const AsComponent = ( + props: FieldProps['field'] +) => ( + <> + + + +); + +const ComponentsPage = () => ( +
+

Test Components

+ { + console.log(values); + }} + onSubmit={async values => { + await new Promise(r => setTimeout(r, 500)); + alert(JSON.stringify(values, null, 2)); + }} + > +
+ + + + + + +
+
+); + +export default ComponentsPage; diff --git a/app/pages/fixtures/perf.tsx b/app/pages/fixtures/perf.tsx new file mode 100644 index 000000000..629ecf02e --- /dev/null +++ b/app/pages/fixtures/perf.tsx @@ -0,0 +1,123 @@ +import * as React from 'react'; +import { Formik, Field, Form, ErrorMessage } from 'formik'; +import * as Yup from 'yup'; + +let renderCount = 0; + +const PerfPage = () => ( +
+

Sign Up

+ { + await new Promise(r => setTimeout(r, 500)); + alert(JSON.stringify(values, null, 2)); + }} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Checkbox Group
+
+ + + +
+
Picked
+
+ + +
+ +
{renderCount++}
+ +
+
+); + +export default PerfPage; diff --git a/app/pages/fixtures/perf500-same.tsx b/app/pages/fixtures/perf500-same.tsx new file mode 100644 index 000000000..c92b1a9d9 --- /dev/null +++ b/app/pages/fixtures/perf500-same.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { Formik, Form, useField, FieldConfig } from 'formik'; +import { Collapse } from '../../components/debugging/Collapse'; +import { selectRange } from '../../helpers/array-helpers'; + +const Input = (p: FieldConfig) => { + const [field, meta] = useField(p); + const renders = React.useRef(0); + const committedRenders = React.useRef(0); + React.useLayoutEffect(() => { + committedRenders.current++; + }); + return ( + <> + + +
+ {renders.current++}, {committedRenders.current} +
+ {meta.touched && meta.error ?
{meta.error.toString()}
: null} + +
{JSON.stringify(meta, null, 2)}
+
+ + ); +}; + +const isRequired = (v: string) => { + return v && v.trim() !== '' ? undefined : 'Required'; +}; + +const fieldsArray = selectRange(500); +const initialValues = fieldsArray.reduce>((prev, id) => { + prev[`Input ${id}`] = ''; + + return prev; +}, {}); + +const onSubmit = async (values: typeof initialValues) => { + await new Promise(r => setTimeout(r, 500)); + alert(JSON.stringify(values, null, 2)); +}; + +const Perf500SamePage = () => { + return ( +
+
+

500 of the same controlled field

+
+ #, # = number of renders, number of committed renders +
+
+ +
+ + + {fieldsArray.map(id => ( + + ))} + + + +
+
+
+ ); +} + +export default Perf500SamePage; diff --git a/app/pages/fixtures/perf500.tsx b/app/pages/fixtures/perf500.tsx new file mode 100644 index 000000000..b0d67aec9 --- /dev/null +++ b/app/pages/fixtures/perf500.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import { Formik, Form, useField, FieldConfig } from 'formik'; +import { selectRange } from '../../helpers/array-helpers'; + +const Input = (p: FieldConfig) => { + const [field, meta] = useField(p); + const renders = React.useRef(0); + const committedRenders = React.useRef(0); + React.useLayoutEffect(() => { + committedRenders.current++; + }); + return ( + <> + + +
+ {renders.current++}, {committedRenders.current} +
+ {meta.touched && meta.error ?
{meta.error.toString()}
: null} + +
{JSON.stringify(meta, null, 2)}
+
+ + ); +}; + +const isRequired = (v: string) => { + return v && v.trim() !== '' ? undefined : 'Required'; +}; + +const array = selectRange(500); +const initialValues = array.reduce>((prev, id) => { + prev[`Input ${id}`] = ''; + return prev; +}, {}); + +const onSubmit = async (values: typeof initialValues) => { + await new Promise(r => setTimeout(r, 500)); + alert(JSON.stringify(values, null, 2)); +}; + +const kids = array.map(id => ( + +)); + +const Perf500Page = () => { + return ( +
+
+

Formik v3 with 500 controlled fields

+
+ #, # = number of renders, number of committed renders +
+
+ +
+ {kids} + +
+
+
+ ); +} + +export default Perf500Page; \ No newline at end of file diff --git a/app/pages/fixtures/tearing.tsx b/app/pages/fixtures/tearing.tsx new file mode 100644 index 000000000..f2afbd9e7 --- /dev/null +++ b/app/pages/fixtures/tearing.tsx @@ -0,0 +1,133 @@ +import * as React from 'react'; +import { + FieldConfig, + Form, + FormikProvider, + useField, + useFormik, + useFormikContext, +} from 'formik'; +import { selectRandomArrayItem, selectRange } from '../../helpers/array-helpers'; +import { useCheckTearing } from '../../helpers/tearing-helpers'; +import { + DynamicValues, + useChaosHelpers, +} from '../../helpers/chaos-helpers'; + +const selectFullState = (state: T) => state + +const Input = (p: FieldConfig) => { + useField(p); + const api = useFormikContext(); + const childState = api.useState(selectFullState); + + return ( +
+
+        {JSON.stringify(childState, null, 2)}
+      
+
+ ); +}; + +const isRequired = (v: string) => { + return v && v.trim() !== '' ? undefined : 'Required'; +}; + +const array = selectRange(50); +const initialValues = array.reduce>((prev, id) => { + prev[`Input ${id}`] = ''; + return prev; +}, {}); + +const onSubmit = async (values: DynamicValues) => { + await new Promise(r => setTimeout(r, 500)); + console.log(JSON.stringify(values, null, 2)); +}; + +const [parentId, lastId, ...inputIDs] = array; + +const kids = inputIDs.map(id => ( + +)); + +const TearingPage = () => { + const formik = useFormik({ onSubmit, initialValues }); + const parentState = formik.useState(selectFullState); + + const chaosHelpers = useChaosHelpers(formik, array); + + const handleClickWithoutTransition = React.useCallback(() => { + selectRandomArrayItem(chaosHelpers)(); + }, [chaosHelpers]); + + // skip form-level state to check inputs + const didInputsTear = useCheckTearing(array.length - 1, 1); + + // check form-level against inputs + const didFormStateTearWithInputs = useCheckTearing(array.length); + + return ( +
+
+ +

Formik Tearing Tests

+

+ Did inputs tear amongst themselves? {didInputsTear ? 'Yes' : 'No'} +

+

+ Did form-level state tear with inputs?{' '} + {didFormStateTearWithInputs ? 'Yes' : 'No'} +

+ +
+ +
+
+
+
+                {JSON.stringify(parentState, null, 2)}
+              
+
+
{kids}
+
+
+                {JSON.stringify(parentState, null, 2)}
+              
+
+
+ +
+
+
+ ); +} + +export default TearingPage; diff --git a/app/pages/index.tsx b/app/pages/index.tsx index 30daffeed..abc0c42f4 100644 --- a/app/pages/index.tsx +++ b/app/pages/index.tsx @@ -4,19 +4,52 @@ import Link from 'next/link'; function Home() { return (
-

Formik Examples and Fixtures

- +

Formik Tutorial and Fixtures

+ +