Skip to content

Commit

Permalink
Add React.useActionState
Browse files Browse the repository at this point in the history
  • Loading branch information
rickhanlonii committed Mar 12, 2024
1 parent 17eaaca commit a31a3df
Show file tree
Hide file tree
Showing 18 changed files with 323 additions and 48 deletions.
74 changes: 74 additions & 0 deletions packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
// This type check is for Flow only.
Dispatcher.useFormState((s: mixed, p: mixed) => s, null);
}
if (typeof Dispatcher.useActionState === 'function') {
// This type check is for Flow only.
Dispatcher.useActionState((s: mixed, p: mixed) => s, null);
}
if (typeof Dispatcher.use === 'function') {
// This type check is for Flow only.
Dispatcher.use(
Expand Down Expand Up @@ -586,6 +590,75 @@ function useFormState<S, P>(
return [state, (payload: P) => {}, false];
}

function useActionState<S, P>(
action: (Awaited<S>, P) => S,
initialState: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
const hook = nextHook(); // FormState
nextHook(); // PendingState
nextHook(); // ActionQueue
const stackError = new Error();
let value;
let debugInfo = null;
let error = null;

if (hook !== null) {
const actionResult = hook.memoizedState;
if (
typeof actionResult === 'object' &&
actionResult !== null &&
// $FlowFixMe[method-unbinding]
typeof actionResult.then === 'function'
) {
const thenable: Thenable<Awaited<S>> = (actionResult: any);
switch (thenable.status) {
case 'fulfilled': {
value = thenable.value;
debugInfo =
thenable._debugInfo === undefined ? null : thenable._debugInfo;
break;
}
case 'rejected': {
const rejectedError = thenable.reason;
error = rejectedError;
break;
}
default:
// If this was an uncached Promise we have to abandon this attempt
// but we can still emit anything up until this point.
error = SuspenseException;
debugInfo =
thenable._debugInfo === undefined ? null : thenable._debugInfo;
value = thenable;
}
} else {
value = (actionResult: any);
}
} else {
value = initialState;
}

hookLog.push({
displayName: null,
primitive: 'ActionState',
stackError: stackError,
value: value,
debugInfo: debugInfo,
});

if (error !== null) {
throw error;
}

// value being a Thenable is equivalent to error being not null
// i.e. we only reach this point with Awaited<S>
const state = ((value: any): Awaited<S>);

// TODO: support displaying pending value
return [state, (payload: P) => {}, false];
}

const Dispatcher: DispatcherType = {
use,
readContext,
Expand All @@ -608,6 +681,7 @@ const Dispatcher: DispatcherType = {
useDeferredValue,
useId,
useFormState,
useActionState,
};

// create a proxy to throw a custom error
Expand Down
13 changes: 9 additions & 4 deletions packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ let ReactDOMServer;
let ReactDOMClient;
let useFormStatus;
let useOptimistic;
let useFormState;
let useActionState;

describe('ReactDOMFizzForm', () => {
beforeEach(() => {
Expand All @@ -32,11 +32,16 @@ describe('ReactDOMFizzForm', () => {
ReactDOMServer = require('react-dom/server.browser');
ReactDOMClient = require('react-dom/client');
useFormStatus = require('react-dom').useFormStatus;
useFormState = require('react-dom').useFormState;
useOptimistic = require('react').useOptimistic;
act = require('internal-test-utils').act;
container = document.createElement('div');
document.body.appendChild(container);
if (__VARIANT__) {
// Remove after API is deleted.
useActionState = require('react-dom').useFormState;
} else {
useActionState = require('react').useActionState;
}
});

afterEach(() => {
Expand Down Expand Up @@ -474,13 +479,13 @@ describe('ReactDOMFizzForm', () => {

// @gate enableFormActions
// @gate enableAsyncActions
it('useFormState returns initial state', async () => {
it('useActionState returns initial state', async () => {
async function action(state) {
return state;
}

function App() {
const [state] = useFormState(action, 0);
const [state] = useActionState(action, 0);
return state;
}

Expand Down
22 changes: 13 additions & 9 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ let SuspenseList;
let useSyncExternalStore;
let useSyncExternalStoreWithSelector;
let use;
let useFormState;
let useActionState;
let PropTypes;
let textCache;
let writable;
Expand Down Expand Up @@ -89,9 +89,13 @@ describe('ReactDOMFizzServer', () => {
if (gate(flags => flags.enableSuspenseList)) {
SuspenseList = React.unstable_SuspenseList;
}
useFormState = ReactDOM.useFormState;

PropTypes = require('prop-types');
if (__VARIANT__) {
// Remove after API is deleted.
useActionState = ReactDOM.useFormState;
} else {
useActionState = React.useActionState;
}

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

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

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

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

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

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

0 comments on commit a31a3df

Please sign in to comment.