Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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