diff --git a/.changeset/fifty-shrimps-leave.md b/.changeset/fifty-shrimps-leave.md new file mode 100644 index 000000000..290f4924f --- /dev/null +++ b/.changeset/fifty-shrimps-leave.md @@ -0,0 +1,10 @@ +--- +"formik-native": major +"formik": major +"app": major +--- + +Use FormikApi + FormikState Subscriptions + +Switch underlying state implementation to use a subscription, and access that subscription via `useFormik().useState()` or its alias, `useFormikState()`. +useFormikContext() only contains a stable API as well as some config props which should eventually be moved to a separate FormikConfigContext for further optimization. diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..b03621422 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,49 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Run NPM Dev", + "request": "launch", + "type": "node", + "runtimeArgs": [ + "run", + "start:app" + ], + "runtimeExecutable": "npm", + "cwd": "${workspaceFolder}", + "skipFiles": [ + "/**" + ] + }, + { + "type": "node", + "name": "vscode-jest-tests", + "request": "launch", + "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/lerna", + "args": [ + "run", + "test", + "--", + "--runInBand", + // when filtering, every package not matching will have no tests + "--passWithNoTests", + "--testTimeout=9999" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "protocol": "inspector", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true + }, + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}/app" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 25fa6215f..10e02695f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "jest.pathToJest": "npm run test:vscode --", } 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/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/next.config.js b/app/next.config.js new file mode 100644 index 000000000..38fcdf276 --- /dev/null +++ b/app/next.config.js @@ -0,0 +1,50 @@ +// +// Use Next Loaders on Linked Packages +// https://github.com/vercel/next.js/pull/13542#issuecomment-679085557 +// +// eslint-disable-next-line @typescript-eslint/no-var-requires +const path = require('path'); + +module.exports = { + webpack: (config, { defaultLoaders, webpack }) => { + if (config.mode === 'development') { + config.module.rules = [ + ...config.module.rules, + { + test: /\.(tsx|ts|js|mjs|jsx)$/, + include: [path.resolve(config.context, '../')], + use: defaultLoaders.babel, + exclude: excludePath => { + return /node_modules/.test(excludePath); + }, + }, + ]; + + // tsdx uses __DEV__ + config.plugins.push( + new webpack.DefinePlugin({ + __DEV__: process.env.NODE_ENV === 'development', + }) + ); + } else { + // Remove TSConfigPath aliases. + // We should use a tool which supports tsconfig.build.json + config.resolve.plugins = config.resolve.plugins.filter( + plugin => plugin.constructor.name !== 'JsConfigPathsPlugin' + ); + } + + return config; + }, + + onDemandEntries: { + // Make sure entries are not getting disposed. + maxInactiveAge: 1000 * 60 * 60, + }, + + // we don't need to break on TS errors since app/ is not production code. + // in development we alias /app -> /packages/formik/src via TSConfig.paths + // then during build we remove the JsConfigPathsPlugin to remove that alias + // but there is no way to remove the link with Next + TypeScript, like using tsconfig.build.json + typescript: { ignoreBuildErrors: true } +}; diff --git a/app/package.json b/app/package.json index d29ce15bb..9b0cb0484 100644 --- a/app/package.json +++ b/app/package.json @@ -8,10 +8,10 @@ "start": "next start" }, "dependencies": { - "formik": "^2.1.5", + "formik": "^3.0.0-refs1", "next": "9.5.3", - "react": "16.13.1", - "react-dom": "16.13.1", - "yup": "^0.29.3" + "react": "^17.0.1", + "react-dom": "^17.0.1", + "yup": "^0.28.1" } } 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..80bd8017b 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

+ +