33
let React;
34
let ReactDOM;
35
let ReactDOMClient;
36
+ let Scheduler;
37
+ let assertLog;
38
+ let useState;
39
+ let Suspense;
40
+ let startTransition;
41
+ let textCache;
42
43
beforeEach(() => {
44
jest.resetModules();
45
React = require('react');
46
ReactDOM = require('react-dom');
47
ReactDOMClient = require('react-dom/client');
48
+ Scheduler = require('scheduler');
49
act = require('internal-test-utils').act;
50
+ assertLog = require('internal-test-utils').assertLog;
51
+ useState = React.useState;
52
+ Suspense = React.Suspense;
53
+ startTransition = React.startTransition;
54
container = document.createElement('div');
55
document.body.appendChild(container);
45
- });
56
47
- afterEach(() => {
48
- document.body.removeChild(container);
57
+ textCache = new Map();
58
});
59
51
- function submit(submitter) {
52
- const form = submitter.form || submitter;
53
- if (!submitter.form) {
54
- submitter = undefined;
60
+ function resolveText(text) {
61
+ const record = textCache.get(text);
62
+ if (record === undefined) {
63
+ const newRecord = {
64
+ status: 'resolved',
65
+ value: text,
66
+ };
67
+ textCache.set(text, newRecord);
68
+ } else if (record.status === 'pending') {
69
+ const thenable = record.value;
70
+ record.status = 'resolved';
71
+ record.value = text;
72
+ thenable.pings.forEach(t => t());
73
}
56
- const submitEvent = new Event('submit', {bubbles: true, cancelable: true});
57
- submitEvent.submitter = submitter;
58
- const returnValue = form.dispatchEvent(submitEvent);
59
- if (!returnValue) {
60
- return;
74
+ }
75
+ function resolveText(text) {
76
+ const record = textCache.get(text);
77
+ if (record === undefined) {
78
+ const newRecord = {
79
+ status: 'resolved',
80
+ value: text,
81
+ };
82
+ textCache.set(text, newRecord);
83
+ } else if (record.status === 'pending') {
84
+ const thenable = record.value;
85
+ record.status = 'resolved';
86
+ record.value = text;
87
+ thenable.pings.forEach(t => t());
88
}
62
- const action =
63
- (submitter && submitter.getAttribute('formaction')) || form.action;
64
- if (!/\s*javascript:/i.test(action)) {
65
- throw new Error('Navigate to: ' + action);
89
+ }
90
+
91
+ function readText(text) {
92
+ const record = textCache.get(text);
93
+ if (record !== undefined) {
94
+ switch (record.status) {
95
+ case 'pending':
96
+ Scheduler.log(`Suspend! [${text}]`);
97
+ throw record.value;
98
+ case 'rejected':
99
+ throw record.value;
100
+ case 'resolved':
101
+ return record.value;
102
+ }
103
+ } else {
104
+ Scheduler.log(`Suspend! [${text}]`);
105
+ const thenable = {
106
+ pings: [],
107
+ then(resolve) {
108
+ if (newRecord.status === 'pending') {
109
+ thenable.pings.push(resolve);
110
+ } else {
111
+ Promise.resolve().then(() => resolve(newRecord.value));
112
+ }
113
+ },
114
+ };
115
+
116
+ const newRecord = {
117
+ status: 'pending',
118
+ value: thenable,
119
+ };
120
+ textCache.set(text, newRecord);
121
+
122
+ throw thenable;
123
}
124
}
125
126
+ function getText(text) {
127
+ const record = textCache.get(text);
128
+ if (record === undefined) {
129
+ const thenable = {
130
+ pings: [],
131
+ then(resolve) {
132
+ if (newRecord.status === 'pending') {
133
+ thenable.pings.push(resolve);
134
+ } else {
135
+ Promise.resolve().then(() => resolve(newRecord.value));
136
+ }
137
+ },
138
+ };
139
+ const newRecord = {
140
+ status: 'pending',
141
+ value: thenable,
142
+ };
143
+ textCache.set(text, newRecord);
144
+ return thenable;
145
+ } else {
146
+ switch (record.status) {
147
+ case 'pending':
148
+ return record.value;
149
+ case 'rejected':
150
+ return Promise.reject(record.value);
151
+ case 'resolved':
152
+ return Promise.resolve(record.value);
153
+ }
154
+ }
155
+ }
156
+
157
+ function Text({text}) {
158
+ Scheduler.log(text);
159
+ return text;
160
+ }
161
+
162
+ function AsyncText({text}) {
163
+ readText(text);
164
+ Scheduler.log(text);
165
+ return text;
166
+ }
167
+
168
+ afterEach(() => {
169
+ document.body.removeChild(container);
170
+ });
171
+
172
+ async function submit(submitter) {
173
+ await act(() => {
174
+ const form = submitter.form || submitter;
175
+ if (!submitter.form) {
176
+ submitter = undefined;
177
+ }
178
+ const submitEvent = new Event('submit', {
179
+ bubbles: true,
180
+ cancelable: true,
181
+ });
182
+ submitEvent.submitter = submitter;
183
+ const returnValue = form.dispatchEvent(submitEvent);
184
+ if (!returnValue) {
185
+ return;
186
+ }
187
+ const action =
188
+ (submitter && submitter.getAttribute('formaction')) || form.action;
189
+ if (!/\s*javascript:/i.test(action)) {
190
+ throw new Error('Navigate to: ' + action);
191
+ }
192
+ });
193
+ }
194
+
195
// @gate enableFormActions
196
it('should allow passing a function to form action', async () => {
197
const ref = React.createRef();
210
);
211
});
212
87
- submit(ref.current);
213
+ await submit(ref.current);
214
215
expect(foo).toBe('bar');
216
228
);
229
});
230
105
- submit(ref.current);
231
+ await submit(ref.current);
232
233
expect(foo).toBe('bar2');
234
});
274
expect(savedTitle).toBe(null);
275
expect(deletedTitle).toBe(null);
276
151
- submit(inputRef.current);
277
+ await submit(inputRef.current);
278
expect(savedTitle).toBe('Hello');
279
expect(deletedTitle).toBe(null);
280
savedTitle = null;
281
156
- submit(buttonRef.current);
282
+ await submit(buttonRef.current);
283
expect(savedTitle).toBe(null);
284
expect(deletedTitle).toBe('Hello');
285
deletedTitle = null;
314
expect(savedTitle).toBe(null);
315
expect(deletedTitle).toBe(null);
316
191
- submit(inputRef.current);
317
+ await submit(inputRef.current);
318
expect(savedTitle).toBe('Hello2');
319
expect(deletedTitle).toBe(null);
320
savedTitle = null;
321
196
- submit(buttonRef.current);
322
+ await submit(buttonRef.current);
323
expect(savedTitle).toBe(null);
324
expect(deletedTitle).toBe('Hello2');
325
344
);
345
});
346
221
- submit(ref.current);
347
+ await submit(ref.current);
348
349
expect(actionCalled).toBe(false);
350
});
380
'\n in form (at **)',
381
]);
382
257
- submit(ref.current);
383
+ await submit(ref.current);
384
385
expect(data).toBe('innerinner');
386
});
422
);
423
});
424
299
- submit(ref.current);
425
+ await submit(ref.current);
426
427
expect(bubbledSubmit).toBe(true);
428
expect(outerCalled).toBe(0);
466
467
innerContainerRef.current.appendChild(innerContainer);
468
343
- submit(ref.current);
469
+ await submit(ref.current);
470
471
expect(bubbledSubmit).toBe(true);
472
expect(outerCalled).toBe(0);
503
}
504
});
505
380
- submit(ref.current);
506
+ await submit(ref.current);
507
508
expect(button).toBe('delete');
509
expect(title).toBe(null);
533
534
let nav;
535
try {
410
- submit(ref.current);
536
+ await submit(ref.current);
537
} catch (x) {
538
nav = x.message;
539
}
569
const node = container.getElementsByTagName('input')[0];
570
let nav;
571
try {
446
- submit(node);
572
+ await submit(node);
573
} catch (x) {
574
nav = x.message;
575
}
576
expect(nav).toBe('Navigate to: http://example.com/submit');
577
expect(actionCalled).toBe(false);
578
});
579
+
580
+ // @gate enableFormActions
581
+ // @gate enableAsyncActions
582
+ it('form actions are transitions', async () => {
583
+ const formRef = React.createRef();
584
+
585
+ function App() {
586
+ const [state, setState] = useState('Initial');
587
+ return (
588
+ <form action={() => setState('Updated')} ref={formRef}>
589
+ <Suspense fallback={<Text text="Loading..." />}>
590
+ <AsyncText text={state} />
591
+ </Suspense>
592
+ </form>
593
+ );
594
+ }
595
+
596
+ const root = ReactDOMClient.createRoot(container);
597
+ await resolveText('Initial');
598
+ await act(() => root.render(<App />));
599
+ assertLog(['Initial']);
600
+ expect(container.textContent).toBe('Initial');
601
+
602
+ // This should suspend because form actions are implicitly wrapped
603
+ // in startTransition.
604
+ await submit(formRef.current);
605
+ assertLog(['Suspend! [Updated]', 'Loading...']);
606
+ expect(container.textContent).toBe('Initial');
607
+
608
+ await act(() => resolveText('Updated'));
609
+ assertLog(['Updated']);
610
+ expect(container.textContent).toBe('Updated');
611
+ });
612
+
613
+ // @gate enableFormActions
614
+ // @gate enableAsyncActions
615
+ it('multiple form actions', async () => {
616
+ const formRef = React.createRef();
617
+
618
+ function App() {
619
+ const [state, setState] = useState(0);
620
+ return (
621
+ <form action={() => setState(n => n + 1)} ref={formRef}>
622
+ <Suspense fallback={<Text text="Loading..." />}>
623
+ <AsyncText text={'Count: ' + state} />
624
+ </Suspense>
625
+ </form>
626
+ );
627
+ }
628
+
629
+ const root = ReactDOMClient.createRoot(container);
630
+ await resolveText('Count: 0');
631
+ await act(() => root.render(<App />));
632
+ assertLog(['Count: 0']);
633
+ expect(container.textContent).toBe('Count: 0');
634
+
635
+ // Update
636
+ await submit(formRef.current);
637
+ assertLog(['Suspend! [Count: 1]', 'Loading...']);
638
+ expect(container.textContent).toBe('Count: 0');
639
+
640
+ await act(() => resolveText('Count: 1'));
641
+ assertLog(['Count: 1']);
642
+ expect(container.textContent).toBe('Count: 1');
643
+
644
+ // Update again
645
+ await submit(formRef.current);
646
+ assertLog(['Suspend! [Count: 2]', 'Loading...']);
647
+ expect(container.textContent).toBe('Count: 1');
648
+
649
+ await act(() => resolveText('Count: 2'));
650
+ assertLog(['Count: 2']);
651
+ expect(container.textContent).toBe('Count: 2');
652
+ });
653
+
654
+ // @gate enableFormActions
655
+ it('form actions can be asynchronous', async () => {
656
+ const formRef = React.createRef();
657
+
658
+ function App() {
659
+ const [state, setState] = useState('Initial');
660
+ return (
661
+ <form
662
+ action={async () => {
663
+ Scheduler.log('Async action started');
664
+ await getText('Wait');
665
+ startTransition(() => setState('Updated'));
666
+ }}
667
+ ref={formRef}>
668
+ <Suspense fallback={<Text text="Loading..." />}>
669
+ <AsyncText text={state} />
670
+ </Suspense>
671
+ </form>
672
+ );
673
+ }
674
+
675
+ const root = ReactDOMClient.createRoot(container);
676
+ await resolveText('Initial');
677
+ await act(() => root.render(<App />));
678
+ assertLog(['Initial']);
679
+ expect(container.textContent).toBe('Initial');
680
+
681
+ await submit(formRef.current);
682
+ assertLog(['Async action started']);
683
+
684
+ await act(() => resolveText('Wait'));
685
+ assertLog(['Suspend! [Updated]', 'Loading...']);
686
+ expect(container.textContent).toBe('Initial');
687
+ });
688
+
689
+ it('sync errors in form actions can be captured by an error boundary', async () => {
690
+ if (gate(flags => !(flags.enableFormActions && flags.enableAsyncActions))) {
691
+ // TODO: Uncaught JSDOM errors fail the test after the scope has finished
692
+ // so don't work with the `gate` mechanism.
693
+ return;
694
+ }
695
+
696
+ class ErrorBoundary extends React.Component {
697
+ state = {error: null};
698
+ static getDerivedStateFromError(error) {
699
+ return {error};
700
+ }
701
+ render() {
702
+ if (this.state.error !== null) {
703
+ return <Text text={this.state.error.message} />;
704
+ }
705
+ return this.props.children;
706
+ }
707
+ }
708
+
709
+ const formRef = React.createRef();
710
+
711
+ function App() {
712
+ return (
713
+ <ErrorBoundary>
714
+ <form
715
+ action={() => {
716
+ throw new Error('Oh no!');
717
+ }}
718
+ ref={formRef}>
719
+ <Text text="Everything is fine" />
720
+ </form>
721
+ </ErrorBoundary>
722
+ );
723
+ }
724
+
725
+ const root = ReactDOMClient.createRoot(container);
726
+ await act(() => root.render(<App />));
727
+ assertLog(['Everything is fine']);
728
+ expect(container.textContent).toBe('Everything is fine');
729
+
730
+ await submit(formRef.current);
731
+ assertLog(['Oh no!', 'Oh no!']);
732
+ expect(container.textContent).toBe('Oh no!');
733
+ });
734
+
735
+ it('async errors in form actions can be captured by an error boundary', async () => {
736
+ if (gate(flags => !(flags.enableFormActions && flags.enableAsyncActions))) {
737
+ // TODO: Uncaught JSDOM errors fail the test after the scope has finished
738
+ // so don't work with the `gate` mechanism.
739
+ return;
740
+ }
741
+
742
+ class ErrorBoundary extends React.Component {
743
+ state = {error: null};
744
+ static getDerivedStateFromError(error) {
745
+ return {error};
746
+ }
747
+ render() {
748
+ if (this.state.error !== null) {
749
+ return <Text text={this.state.error.message} />;
750
+ }
751
+ return this.props.children;
752
+ }
753
+ }
754
+
755
+ const formRef = React.createRef();
756
+
757
+ function App() {
758
+ return (
759
+ <ErrorBoundary>
760
+ <form
761
+ action={async () => {
762
+ Scheduler.log('Async action started');
763
+ await getText('Wait');
764
+ throw new Error('Oh no!');
765
+ }}
766
+ ref={formRef}>
767
+ <Text text="Everything is fine" />
768
+ </form>
769
+ </ErrorBoundary>
770
+ );
771
+ }
772
+
773
+ const root = ReactDOMClient.createRoot(container);
774
+ await act(() => root.render(<App />));
775
+ assertLog(['Everything is fine']);
776
+ expect(container.textContent).toBe('Everything is fine');
777
+
778
+ await submit(formRef.current);
779
+ assertLog(['Async action started']);
780
+ expect(container.textContent).toBe('Everything is fine');
781
+
782
+ await act(() => resolveText('Wait'));
783
+ assertLog(['Oh no!', 'Oh no!']);
784
+ expect(container.textContent).toBe('Oh no!');
785
+ });
786
});