|
1 | 1 | import { ClerkRuntimeError } from '@clerk/shared/error'; |
2 | 2 | import { act, renderHook } from '@testing-library/react'; |
| 3 | +import { createElement, StrictMode, useEffect, useLayoutEffect } from 'react'; |
| 4 | +import { flushSync } from 'react-dom'; |
3 | 5 | import { describe, expect, expectTypeOf, it, vi } from 'vitest'; |
4 | 6 |
|
5 | 7 | import { deferred, tick } from '../../../__tests__/async'; |
@@ -66,6 +68,131 @@ describe('useForm', () => { |
66 | 68 | expect(result.current.error).toBeUndefined(); |
67 | 69 | }); |
68 | 70 |
|
| 71 | + it('reports the exact result and submitted values once using the callback captured at submit', async () => { |
| 72 | + const request = deferred<{ id: string }>(); |
| 73 | + const onSuccess = vi.fn(); |
| 74 | + const replacement = vi.fn(); |
| 75 | + const { result, rerender } = renderHook( |
| 76 | + ({ onSuccess }) => useForm({ initialValues: { username: 'alex' }, onSubmit: () => request.promise, onSuccess }), |
| 77 | + { initialProps: { onSuccess } }, |
| 78 | + ); |
| 79 | + act(() => result.current.setValue('username', 'alexc')); |
| 80 | + const submittedValues = result.current.values; |
| 81 | + act(() => result.current.submit()); |
| 82 | + act(() => result.current.submit()); |
| 83 | + rerender({ onSuccess: replacement }); |
| 84 | + const saved = { id: 'saved' }; |
| 85 | + await act(async () => { |
| 86 | + request.resolve(saved); |
| 87 | + await tick(); |
| 88 | + }); |
| 89 | + expect(onSuccess).toHaveBeenCalledExactlyOnceWith(saved, submittedValues); |
| 90 | + expect(onSuccess.mock.calls[0]?.[0]).toBe(saved); |
| 91 | + expect(onSuccess.mock.calls[0]?.[1]).toBe(submittedValues); |
| 92 | + expect(replacement).not.toHaveBeenCalled(); |
| 93 | + expect(result.current.isSubmitting).toBe(false); |
| 94 | + }); |
| 95 | + |
| 96 | + it.each([ |
| 97 | + new FormSubmitError({ message: 'Could not save' }), |
| 98 | + new ClerkRuntimeError('User cancelled attempted verification', { code: 'reverification_cancelled' }), |
| 99 | + ])('does not report success when submission rejects with %s', async failure => { |
| 100 | + const onSuccess = vi.fn(); |
| 101 | + const { result } = renderHook(() => |
| 102 | + useForm({ |
| 103 | + initialValues: { username: 'alex' }, |
| 104 | + onSubmit: () => Promise.reject(failure), |
| 105 | + onSuccess, |
| 106 | + }), |
| 107 | + ); |
| 108 | + await act(async () => { |
| 109 | + result.current.submit(); |
| 110 | + await tick(); |
| 111 | + }); |
| 112 | + expect(onSuccess).not.toHaveBeenCalled(); |
| 113 | + expect(result.current.isSubmitting).toBe(false); |
| 114 | + }); |
| 115 | + |
| 116 | + it('does not report success after unmounting a pending form', async () => { |
| 117 | + const request = deferred<void>(); |
| 118 | + const onSuccess = vi.fn(); |
| 119 | + const { result, unmount } = renderHook(() => |
| 120 | + useForm({ |
| 121 | + initialValues: { username: 'alex' }, |
| 122 | + onSubmit: () => request.promise, |
| 123 | + onSuccess, |
| 124 | + }), |
| 125 | + ); |
| 126 | + act(() => result.current.submit()); |
| 127 | + unmount(); |
| 128 | + await act(async () => { |
| 129 | + request.resolve(); |
| 130 | + await tick(); |
| 131 | + }); |
| 132 | + expect(onSuccess).not.toHaveBeenCalled(); |
| 133 | + }); |
| 134 | + |
| 135 | + it('suppresses success when the request settles during unmount layout cleanup', async () => { |
| 136 | + const request = deferred<void>(); |
| 137 | + const onSuccess = vi.fn(); |
| 138 | + const { result, unmount } = renderHook(() => { |
| 139 | + const form = useForm({ initialValues: { username: 'alex' }, onSubmit: () => request.promise, onSuccess }); |
| 140 | + useLayoutEffect(() => () => request.resolve(), []); |
| 141 | + return form; |
| 142 | + }); |
| 143 | + act(() => result.current.submit()); |
| 144 | + unmount(); |
| 145 | + await act(tick); |
| 146 | + expect(onSuccess).not.toHaveBeenCalled(); |
| 147 | + }); |
| 148 | + |
| 149 | + it('reports success once when StrictMode replays effects during submission', async () => { |
| 150 | + const request = deferred<void>(); |
| 151 | + const onSubmit = vi.fn(() => request.promise); |
| 152 | + const onEffect = vi.fn(); |
| 153 | + const onSuccess = vi.fn(); |
| 154 | + renderHook( |
| 155 | + () => { |
| 156 | + const form = useForm({ initialValues: { username: 'alex' }, onSubmit, onSuccess }); |
| 157 | + const { submit } = form; |
| 158 | + useEffect(() => { |
| 159 | + onEffect(); |
| 160 | + submit(); |
| 161 | + }, [submit]); |
| 162 | + return form; |
| 163 | + }, |
| 164 | + { wrapper: ({ children }) => createElement(StrictMode, null, children) }, |
| 165 | + ); |
| 166 | + expect(onEffect).toHaveBeenCalledTimes(2); |
| 167 | + await act(async () => { |
| 168 | + request.resolve(); |
| 169 | + await tick(); |
| 170 | + }); |
| 171 | + expect(onSubmit).toHaveBeenCalledOnce(); |
| 172 | + expect(onSuccess).toHaveBeenCalledOnce(); |
| 173 | + }); |
| 174 | + |
| 175 | + it('allows success to synchronously unmount the form before pending clears', async () => { |
| 176 | + const request = deferred<void>(); |
| 177 | + const onSuccess = vi.fn(() => { |
| 178 | + expect(result.current.isSubmitting).toBe(true); |
| 179 | + flushSync(() => unmount()); |
| 180 | + }); |
| 181 | + const { result, unmount } = renderHook(() => |
| 182 | + useForm({ |
| 183 | + initialValues: { username: 'alex' }, |
| 184 | + onSubmit: () => request.promise, |
| 185 | + onSuccess, |
| 186 | + }), |
| 187 | + ); |
| 188 | + act(() => result.current.submit()); |
| 189 | + await act(async () => { |
| 190 | + request.resolve(); |
| 191 | + await tick(); |
| 192 | + }); |
| 193 | + expect(onSuccess).toHaveBeenCalledOnce(); |
| 194 | + }); |
| 195 | + |
69 | 196 | it('submits a value set in the same tick', () => { |
70 | 197 | const onSubmit = vi.fn(resolved); |
71 | 198 | const { result } = renderHook(() => useForm({ initialValues: { code: '' }, onSubmit })); |
|
0 commit comments