Skip to content

Commit 8fd1e99

Browse files
committed
Add React.useActionState
1 parent 172a7f6 commit 8fd1e99

File tree

25 files changed

+1501
-140
lines changed

25 files changed

+1501
-140
lines changed

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
9999
// This type check is for Flow only.
100100
Dispatcher.useOptimistic(null, (s: mixed, a: mixed) => s);
101101
}
102-
if (typeof Dispatcher.useFormState === 'function') {
102+
if (typeof Dispatcher.useActionState === 'function') {
103103
// This type check is for Flow only.
104-
Dispatcher.useFormState((s: mixed, p: mixed) => s, null);
104+
Dispatcher.useActionState((s: mixed, p: mixed) => s, null);
105105
}
106106
if (typeof Dispatcher.use === 'function') {
107107
// This type check is for Flow only.
@@ -514,12 +514,15 @@ function useOptimistic<S, A>(
514514
return [state, (action: A) => {}];
515515
}
516516

517-
function useFormState<S, P>(
517+
function useActionState<S, P>(
518518
action: (Awaited<S>, P) => S,
519519
initialState: Awaited<S>,
520520
permalink?: string,
521-
): [Awaited<S>, (P) => void] {
521+
): [Awaited<S>, (P) => void, boolean] {
522522
const hook = nextHook(); // FormState
523+
524+
// TODO: how to handle pending state?
525+
nextHook(); // PendingState
523526
nextHook(); // ActionQueue
524527
let state;
525528
if (hook !== null) {
@@ -529,12 +532,12 @@ function useFormState<S, P>(
529532
}
530533
hookLog.push({
531534
displayName: null,
532-
primitive: 'FormState',
535+
primitive: 'ActionState',
533536
stackError: new Error(),
534537
value: state,
535538
debugInfo: null,
536539
});
537-
return [state, (payload: P) => {}];
540+
return [state, (payload: P) => {}, false];
538541
}
539542

540543
const Dispatcher: DispatcherType = {
@@ -558,7 +561,7 @@ const Dispatcher: DispatcherType = {
558561
useSyncExternalStore,
559562
useDeferredValue,
560563
useId,
561-
useFormState,
564+
useActionState,
562565
};
563566

564567
// create a proxy to throw a custom error

packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2161,6 +2161,85 @@ describe('ReactHooksInspectionIntegration', () => {
21612161
return value;
21622162
}
21632163

2164+
const renderer = ReactTestRenderer.create(<Foo />);
2165+
const childFiber = renderer.root.findByType(Foo)._currentFiber();
2166+
const tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
2167+
expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
2168+
[
2169+
{
2170+
"debugInfo": null,
2171+
"hookSource": {
2172+
"columnNumber": 0,
2173+
"fileName": "**",
2174+
"functionName": "Foo",
2175+
"lineNumber": 0,
2176+
},
2177+
"id": null,
2178+
"isStateEditable": false,
2179+
"name": ".useFormState",
2180+
"subHooks": [
2181+
{
2182+
"debugInfo": null,
2183+
"hookSource": {
2184+
"columnNumber": 0,
2185+
"fileName": "**",
2186+
"functionName": "Object.useFormState",
2187+
"lineNumber": 0,
2188+
},
2189+
"id": 0,
2190+
"isStateEditable": false,
2191+
"name": "ActionState",
2192+
"subHooks": [],
2193+
"value": 0,
2194+
},
2195+
],
2196+
"value": undefined,
2197+
},
2198+
{
2199+
"debugInfo": null,
2200+
"hookSource": {
2201+
"columnNumber": 0,
2202+
"fileName": "**",
2203+
"functionName": "Foo",
2204+
"lineNumber": 0,
2205+
},
2206+
"id": 1,
2207+
"isStateEditable": false,
2208+
"name": "Memo",
2209+
"subHooks": [],
2210+
"value": "memo",
2211+
},
2212+
{
2213+
"debugInfo": null,
2214+
"hookSource": {
2215+
"columnNumber": 0,
2216+
"fileName": "**",
2217+
"functionName": "Foo",
2218+
"lineNumber": 0,
2219+
},
2220+
"id": 2,
2221+
"isStateEditable": false,
2222+
"name": "Memo",
2223+
"subHooks": [],
2224+
"value": "not used",
2225+
},
2226+
]
2227+
`);
2228+
});
2229+
2230+
// TODO
2231+
// @gate enableFormActions && enableAsyncActions
2232+
it('should support useActionState hook', () => {
2233+
function Foo() {
2234+
const [value] = React.useActionState(function increment(n) {
2235+
return n;
2236+
}, 0);
2237+
React.useMemo(() => 'memo', []);
2238+
React.useMemo(() => 'not used', []);
2239+
2240+
return value;
2241+
}
2242+
21642243
const renderer = ReactTestRenderer.create(<Foo />);
21652244
const childFiber = renderer.root.findByType(Foo)._currentFiber();
21662245
const tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
@@ -2176,7 +2255,7 @@ describe('ReactHooksInspectionIntegration', () => {
21762255
},
21772256
"id": 0,
21782257
"isStateEditable": false,
2179-
"name": "FormState",
2258+
"name": "ActionState",
21802259
"subHooks": [],
21812260
"value": 0,
21822261
},

packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
useEffect,
2020
useOptimistic,
2121
useState,
22+
useActionState,
2223
use,
2324
} from 'react';
2425
import {useFormState} from 'react-dom';
@@ -132,6 +133,22 @@ function Forms() {
132133
);
133134
}
134135

136+
function Actions() {
137+
const [state, action, isPending] = useActionState(
138+
(n: number, formData: FormData) => {
139+
return n + 1;
140+
},
141+
0,
142+
);
143+
return (
144+
<form>
145+
{state}
146+
{isPending ? 'Pending' : ''}
147+
<button formAction={action}>Increment</button>
148+
</form>
149+
);
150+
}
151+
135152
export default function CustomHooks(): React.Node {
136153
return (
137154
<Fragment>
@@ -140,6 +157,7 @@ export default function CustomHooks(): React.Node {
140157
<ForwardRefWithHooks />
141158
<HocWithHooks />
142159
<Forms />
160+
<Actions />
143161
</Fragment>
144162
);
145163
}

packages/react-dom-bindings/src/shared/ReactDOMFormActions.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ export function useFormState<S, P>(
8686
} else {
8787
const dispatcher = resolveDispatcher();
8888
// $FlowFixMe[not-a-function] This is unstable, thus optional
89-
return dispatcher.useFormState(action, initialState, permalink);
89+
const [state, dispatch] = dispatcher.useActionState(
90+
action,
91+
initialState,
92+
permalink,
93+
);
94+
return [state, dispatch];
9095
}
9196
}

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(() => {
@@ -33,6 +34,7 @@ describe('ReactDOMFizzForm', () => {
3334
ReactDOMClient = require('react-dom/client');
3435
useFormStatus = require('react-dom').useFormStatus;
3536
useFormState = require('react-dom').useFormState;
37+
useActionState = require('react').useActionState;
3638
useOptimistic = require('react').useOptimistic;
3739
act = require('internal-test-utils').act;
3840
container = document.createElement('div');
@@ -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 & 0 deletions
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,6 +91,7 @@ describe('ReactDOMFizzServer', () => {
9091
SuspenseList = React.unstable_SuspenseList;
9192
}
9293
useFormState = ReactDOM.useFormState;
94+
useActionState = React.useActionState;
9395

9496
PropTypes = require('prop-types');
9597

@@ -6338,6 +6340,123 @@ describe('ReactDOMFizzServer', () => {
63386340
expect(childRef.current).toBe(child);
63396341
});
63406342

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

0 commit comments

Comments
 (0)