Skip to content

Commit 435e9b3

Browse files
committed
Add React.useActionState
1 parent 17eaaca commit 435e9b3

File tree

18 files changed

+323
-47
lines changed

18 files changed

+323
-47
lines changed

packages/react-debug-tools/src/ReactDebugHooks.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
103103
// This type check is for Flow only.
104104
Dispatcher.useFormState((s: mixed, p: mixed) => s, null);
105105
}
106+
if (typeof Dispatcher.useActionState === 'function') {
107+
// This type check is for Flow only.
108+
Dispatcher.useActionState((s: mixed, p: mixed) => s, null);
109+
}
106110
if (typeof Dispatcher.use === 'function') {
107111
// This type check is for Flow only.
108112
Dispatcher.use(
@@ -586,6 +590,75 @@ function useFormState<S, P>(
586590
return [state, (payload: P) => {}, false];
587591
}
588592

593+
function useActionState<S, P>(
594+
action: (Awaited<S>, P) => S,
595+
initialState: Awaited<S>,
596+
permalink?: string,
597+
): [Awaited<S>, (P) => void, boolean] {
598+
const hook = nextHook(); // FormState
599+
nextHook(); // PendingState
600+
nextHook(); // ActionQueue
601+
const stackError = new Error();
602+
let value;
603+
let debugInfo = null;
604+
let error = null;
605+
606+
if (hook !== null) {
607+
const actionResult = hook.memoizedState;
608+
if (
609+
typeof actionResult === 'object' &&
610+
actionResult !== null &&
611+
// $FlowFixMe[method-unbinding]
612+
typeof actionResult.then === 'function'
613+
) {
614+
const thenable: Thenable<Awaited<S>> = (actionResult: any);
615+
switch (thenable.status) {
616+
case 'fulfilled': {
617+
value = thenable.value;
618+
debugInfo =
619+
thenable._debugInfo === undefined ? null : thenable._debugInfo;
620+
break;
621+
}
622+
case 'rejected': {
623+
const rejectedError = thenable.reason;
624+
error = rejectedError;
625+
break;
626+
}
627+
default:
628+
// If this was an uncached Promise we have to abandon this attempt
629+
// but we can still emit anything up until this point.
630+
error = SuspenseException;
631+
debugInfo =
632+
thenable._debugInfo === undefined ? null : thenable._debugInfo;
633+
value = thenable;
634+
}
635+
} else {
636+
value = (actionResult: any);
637+
}
638+
} else {
639+
value = initialState;
640+
}
641+
642+
hookLog.push({
643+
displayName: null,
644+
primitive: 'ActionState',
645+
stackError: stackError,
646+
value: value,
647+
debugInfo: debugInfo,
648+
});
649+
650+
if (error !== null) {
651+
throw error;
652+
}
653+
654+
// value being a Thenable is equivalent to error being not null
655+
// i.e. we only reach this point with Awaited<S>
656+
const state = ((value: any): Awaited<S>);
657+
658+
// TODO: support displaying pending value
659+
return [state, (payload: P) => {}, false];
660+
}
661+
589662
const Dispatcher: DispatcherType = {
590663
use,
591664
readContext,
@@ -608,6 +681,7 @@ const Dispatcher: DispatcherType = {
608681
useDeferredValue,
609682
useId,
610683
useFormState,
684+
useActionState,
611685
};
612686

613687
// create a proxy to throw a custom error

packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ let ReactDOMServer;
2323
let ReactDOMClient;
2424
let useFormStatus;
2525
let useOptimistic;
26-
let useFormState;
26+
let useActionState;
2727

2828
describe('ReactDOMFizzForm', () => {
2929
beforeEach(() => {
@@ -32,11 +32,16 @@ describe('ReactDOMFizzForm', () => {
3232
ReactDOMServer = require('react-dom/server.browser');
3333
ReactDOMClient = require('react-dom/client');
3434
useFormStatus = require('react-dom').useFormStatus;
35-
useFormState = require('react-dom').useFormState;
3635
useOptimistic = require('react').useOptimistic;
3736
act = require('internal-test-utils').act;
3837
container = document.createElement('div');
3938
document.body.appendChild(container);
39+
if (__VARIANT__) {
40+
// Remove after API is deleted.
41+
useActionState = require('react-dom').useFormState;
42+
} else {
43+
useActionState = require('react').useActionState;
44+
}
4045
});
4146

4247
afterEach(() => {
@@ -474,13 +479,13 @@ describe('ReactDOMFizzForm', () => {
474479

475480
// @gate enableFormActions
476481
// @gate enableAsyncActions
477-
it('useFormState returns initial state', async () => {
482+
it('useActionState returns initial state', async () => {
478483
async function action(state) {
479484
return state;
480485
}
481486

482487
function App() {
483-
const [state] = useFormState(action, 0);
488+
const [state] = useActionState(action, 0);
484489
return state;
485490
}
486491

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ let SuspenseList;
3030
let useSyncExternalStore;
3131
let useSyncExternalStoreWithSelector;
3232
let use;
33-
let useFormState;
33+
let useActionState;
3434
let PropTypes;
3535
let textCache;
3636
let writable;
@@ -89,9 +89,13 @@ describe('ReactDOMFizzServer', () => {
8989
if (gate(flags => flags.enableSuspenseList)) {
9090
SuspenseList = React.unstable_SuspenseList;
9191
}
92-
useFormState = ReactDOM.useFormState;
93-
9492
PropTypes = require('prop-types');
93+
if (__VARIANT__) {
94+
// Remove after API is deleted.
95+
useActionState = ReactDOM.useFormState;
96+
} else {
97+
useActionState = React.useActionState;
98+
}
9599

96100
const InternalTestUtils = require('internal-test-utils');
97101
waitForAll = InternalTestUtils.waitForAll;
@@ -6203,8 +6207,8 @@ describe('ReactDOMFizzServer', () => {
62036207

62046208
// @gate enableFormActions
62056209
// @gate enableAsyncActions
6206-
it('useFormState hydrates without a mismatch', async () => {
6207-
// This is testing an implementation detail: useFormState emits comment
6210+
it('useActionState hydrates without a mismatch', async () => {
6211+
// This is testing an implementation detail: useActionState emits comment
62086212
// nodes into the SSR stream, so this checks that they are handled correctly
62096213
// during hydration.
62106214

@@ -6214,7 +6218,7 @@ describe('ReactDOMFizzServer', () => {
62146218

62156219
const childRef = React.createRef(null);
62166220
function Form() {
6217-
const [state] = useFormState(action, 0);
6221+
const [state] = useActionState(action, 0);
62186222
const text = `Child: ${state}`;
62196223
return (
62206224
<div id="child" ref={childRef}>
@@ -6257,7 +6261,7 @@ describe('ReactDOMFizzServer', () => {
62576261

62586262
// @gate enableFormActions
62596263
// @gate enableAsyncActions
6260-
it("useFormState hydrates without a mismatch if there's a render phase update", async () => {
6264+
it("useActionState hydrates without a mismatch if there's a render phase update", async () => {
62616265
async function action(state) {
62626266
return state;
62636267
}
@@ -6271,8 +6275,8 @@ describe('ReactDOMFizzServer', () => {
62716275

62726276
// Because of the render phase update above, this component is evaluated
62736277
// multiple times (even during SSR), but it should only emit a single
6274-
// marker per useFormState instance.
6275-
const [formState] = useFormState(action, 0);
6278+
// marker per useActionState instance.
6279+
const [formState] = useActionState(action, 0);
62766280
const text = `${readText('Child')}:${formState}:${localState}`;
62776281
return (
62786282
<div id="child" ref={childRef}>

0 commit comments

Comments
 (0)