Skip to content

Commit 54ab3ab

Browse files
committed
Add React.useActionState
1 parent 97a9ef2 commit 54ab3ab

File tree

18 files changed

+1358
-3
lines changed

18 files changed

+1358
-3
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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ let ReactDOMClient;
2424
let useFormStatus;
2525
let useOptimistic;
2626
let useFormState;
27+
let useActionState;
2728

2829
describe('ReactDOMFizzForm', () => {
2930
beforeEach(() => {
@@ -34,6 +35,7 @@ describe('ReactDOMFizzForm', () => {
3435
useFormStatus = require('react-dom').useFormStatus;
3536
useFormState = require('react-dom').useFormState;
3637
useOptimistic = require('react').useOptimistic;
38+
useActionState = require('react').useActionState;
3739
act = require('internal-test-utils').act;
3840
container = document.createElement('div');
3941
document.body.appendChild(container);
@@ -494,6 +496,28 @@ describe('ReactDOMFizzForm', () => {
494496
expect(container.textContent).toBe('0');
495497
});
496498

499+
// @gate enableFormActions
500+
// @gate enableAsyncActions
501+
it('useActionState returns initial state', async () => {
502+
async function action(state) {
503+
return state;
504+
}
505+
506+
function App() {
507+
const [state] = useActionState(action, 0);
508+
return state;
509+
}
510+
511+
const stream = await ReactDOMServer.renderToReadableStream(<App />);
512+
await readIntoContainer(stream);
513+
expect(container.textContent).toBe('0');
514+
515+
await act(async () => {
516+
ReactDOMClient.hydrateRoot(container, <App />);
517+
});
518+
expect(container.textContent).toBe('0');
519+
});
520+
497521
// @gate enableFormActions
498522
it('can provide a custom action on the server for actions', async () => {
499523
const ref = React.createRef();

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

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ let useSyncExternalStore;
3131
let useSyncExternalStoreWithSelector;
3232
let use;
3333
let useFormState;
34+
let useActionState;
3435
let PropTypes;
3536
let textCache;
3637
let writable;
@@ -90,7 +91,7 @@ describe('ReactDOMFizzServer', () => {
9091
SuspenseList = React.unstable_SuspenseList;
9192
}
9293
useFormState = ReactDOM.useFormState;
93-
94+
useActionState = React.useActionState;
9495
PropTypes = require('prop-types');
9596

9697
const InternalTestUtils = require('internal-test-utils');
@@ -6338,6 +6339,123 @@ describe('ReactDOMFizzServer', () => {
63386339
expect(childRef.current).toBe(child);
63396340
});
63406341

6342+
// @gate enableFormActions
6343+
// @gate enableAsyncActions
6344+
it('useActionState hydrates without a mismatch', async () => {
6345+
// This is testing an implementation detail: useActionState emits comment
6346+
// nodes into the SSR stream, so this checks that they are handled correctly
6347+
// during hydration.
6348+
6349+
async function action(state) {
6350+
return state;
6351+
}
6352+
6353+
const childRef = React.createRef(null);
6354+
function Form() {
6355+
const [state] = useActionState(action, 0);
6356+
const text = `Child: ${state}`;
6357+
return (
6358+
<div id="child" ref={childRef}>
6359+
{text}
6360+
</div>
6361+
);
6362+
}
6363+
6364+
function App() {
6365+
return (
6366+
<div>
6367+
<div>
6368+
<Form />
6369+
</div>
6370+
<span>Sibling</span>
6371+
</div>
6372+
);
6373+
}
6374+
6375+
await act(() => {
6376+
const {pipe} = renderToPipeableStream(<App />);
6377+
pipe(writable);
6378+
});
6379+
expect(getVisibleChildren(container)).toEqual(
6380+
<div>
6381+
<div>
6382+
<div id="child">Child: 0</div>
6383+
</div>
6384+
<span>Sibling</span>
6385+
</div>,
6386+
);
6387+
const child = document.getElementById('child');
6388+
6389+
// Confirm that it hydrates correctly
6390+
await clientAct(() => {
6391+
ReactDOMClient.hydrateRoot(container, <App />);
6392+
});
6393+
expect(childRef.current).toBe(child);
6394+
});
6395+
6396+
// @gate enableFormActions
6397+
// @gate enableAsyncActions
6398+
it("useActionState hydrates without a mismatch if there's a render phase update", async () => {
6399+
async function action(state) {
6400+
return state;
6401+
}
6402+
6403+
const childRef = React.createRef(null);
6404+
function Form() {
6405+
const [localState, setLocalState] = React.useState(0);
6406+
if (localState < 3) {
6407+
setLocalState(localState + 1);
6408+
}
6409+
6410+
// Because of the render phase update above, this component is evaluated
6411+
// multiple times (even during SSR), but it should only emit a single
6412+
// marker per useActionState instance.
6413+
const [formState] = useActionState(action, 0);
6414+
const text = `${readText('Child')}:${formState}:${localState}`;
6415+
return (
6416+
<div id="child" ref={childRef}>
6417+
{text}
6418+
</div>
6419+
);
6420+
}
6421+
6422+
function App() {
6423+
return (
6424+
<div>
6425+
<Suspense fallback="Loading...">
6426+
<Form />
6427+
</Suspense>
6428+
<span>Sibling</span>
6429+
</div>
6430+
);
6431+
}
6432+
6433+
await act(() => {
6434+
const {pipe} = renderToPipeableStream(<App />);
6435+
pipe(writable);
6436+
});
6437+
expect(getVisibleChildren(container)).toEqual(
6438+
<div>
6439+
Loading...<span>Sibling</span>
6440+
</div>,
6441+
);
6442+
6443+
await act(() => resolveText('Child'));
6444+
expect(getVisibleChildren(container)).toEqual(
6445+
<div>
6446+
<div id="child">Child:0:3</div>
6447+
<span>Sibling</span>
6448+
</div>,
6449+
);
6450+
const child = document.getElementById('child');
6451+
6452+
// Confirm that it hydrates correctly
6453+
await clientAct(() => {
6454+
ReactDOMClient.hydrateRoot(container, <App />);
6455+
});
6456+
expect(childRef.current).toBe(child);
6457+
});
6458+
63416459
describe('useEffectEvent', () => {
63426460
// @gate enableUseEffectEventHook
63436461
it('can server render a component with useEffectEvent', async () => {

0 commit comments

Comments
 (0)