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 5, 2024
1 parent 172a7f6 commit 1d4e7f9
Show file tree
Hide file tree
Showing 23 changed files with 1,043 additions and 125 deletions.
33 changes: 30 additions & 3 deletions packages/react-debug-tools/src/ReactDebugHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
// This type check is for Flow only.
Dispatcher.useOptimistic(null, (s: mixed, a: mixed) => s);
}
if (typeof Dispatcher.useFormState === 'function') {
if (typeof Dispatcher.useActionState === 'function') {
// This type check is for Flow only.
Dispatcher.useFormState((s: mixed, p: mixed) => s, null);
Dispatcher.useActionState((s: mixed, p: mixed) => s, null);
}
if (typeof Dispatcher.use === 'function') {
// This type check is for Flow only.
Expand Down Expand Up @@ -520,6 +520,7 @@ function useFormState<S, P>(
permalink?: string,
): [Awaited<S>, (P) => void] {
const hook = nextHook(); // FormState
nextHook(); // PendingState
nextHook(); // ActionQueue
let state;
if (hook !== null) {
Expand All @@ -537,6 +538,32 @@ function useFormState<S, P>(
return [state, (payload: P) => {}];
}

function useActionState<S, P>(
action: (Awaited<S>, P) => S,
initialState: Awaited<S>,
permalink?: string,
): [Awaited<S>, (P) => void, boolean] {
const hook = nextHook(); // FormState

// TODO: how to handle pending state?
nextHook(); // PendingState
nextHook(); // ActionQueue
let state;
if (hook !== null) {
state = hook.memoizedState;
} else {
state = initialState;
}
hookLog.push({
displayName: null,
primitive: 'ActionState',
stackError: new Error(),
value: state,
debugInfo: null,
});
return [state, (payload: P) => {}, false];
}

const Dispatcher: DispatcherType = {
use,
readContext,
Expand All @@ -558,7 +585,7 @@ const Dispatcher: DispatcherType = {
useSyncExternalStore,
useDeferredValue,
useId,
useFormState,
useActionState,
};

// create a proxy to throw a custom error
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2161,6 +2161,85 @@ describe('ReactHooksInspectionIntegration', () => {
return value;
}

const renderer = ReactTestRenderer.create(<Foo />);
const childFiber = renderer.root.findByType(Foo)._currentFiber();
const tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
[
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "Foo",
"lineNumber": 0,
},
"id": null,
"isStateEditable": false,
"name": ".useFormState",
"subHooks": [
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "Object.useFormState",
"lineNumber": 0,
},
"id": 0,
"isStateEditable": false,
"name": "ActionState",
"subHooks": [],
"value": 0,
},
],
"value": undefined,
},
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "Foo",
"lineNumber": 0,
},
"id": 1,
"isStateEditable": false,
"name": "Memo",
"subHooks": [],
"value": "memo",
},
{
"debugInfo": null,
"hookSource": {
"columnNumber": 0,
"fileName": "**",
"functionName": "Foo",
"lineNumber": 0,
},
"id": 2,
"isStateEditable": false,
"name": "Memo",
"subHooks": [],
"value": "not used",
},
]
`);
});

// TODO
// @gate enableFormActions && enableAsyncActions
it('should support useActionState hook', () => {
function Foo() {
const [value] = React.useActionState(function increment(n) {
return n;
}, 0);
React.useMemo(() => 'memo', []);
React.useMemo(() => 'not used', []);

return value;
}

const renderer = ReactTestRenderer.create(<Foo />);
const childFiber = renderer.root.findByType(Foo)._currentFiber();
const tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
Expand All @@ -2176,7 +2255,7 @@ describe('ReactHooksInspectionIntegration', () => {
},
"id": 0,
"isStateEditable": false,
"name": "FormState",
"name": "ActionState",
"subHooks": [],
"value": 0,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
useEffect,
useOptimistic,
useState,
useActionState,
use,
} from 'react';
import {useFormState} from 'react-dom';
Expand Down Expand Up @@ -132,6 +133,22 @@ function Forms() {
);
}

function Actions() {
const [state, action, isPending] = useActionState(
(n: number, formData: FormData) => {
return n + 1;
},
0,
);
return (
<form>
{state}
{isPending ? 'Pending' : ''}
<button formAction={action}>Increment</button>
</form>
);
}

export default function CustomHooks(): React.Node {
return (
<Fragment>
Expand All @@ -140,6 +157,7 @@ export default function CustomHooks(): React.Node {
<ForwardRefWithHooks />
<HocWithHooks />
<Forms />
<Actions />
</Fragment>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ export function useFormState<S, P>(
} else {
const dispatcher = resolveDispatcher();
// $FlowFixMe[not-a-function] This is unstable, thus optional
return dispatcher.useFormState(action, initialState, permalink);
const [state, dispatch] = dispatcher.useActionState(
action,
initialState,
permalink,
);
return [state, dispatch];
}
}
24 changes: 24 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ let ReactDOMClient;
let useFormStatus;
let useOptimistic;
let useFormState;
let useActionState;

describe('ReactDOMFizzForm', () => {
beforeEach(() => {
Expand All @@ -33,6 +34,7 @@ describe('ReactDOMFizzForm', () => {
ReactDOMClient = require('react-dom/client');
useFormStatus = require('react-dom').useFormStatus;
useFormState = require('react-dom').useFormState;
useActionState = require('react').useActionState;
useOptimistic = require('react').useOptimistic;
act = require('internal-test-utils').act;
container = document.createElement('div');
Expand Down Expand Up @@ -494,6 +496,28 @@ describe('ReactDOMFizzForm', () => {
expect(container.textContent).toBe('0');
});

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

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

const stream = await ReactDOMServer.renderToReadableStream(<App />);
await readIntoContainer(stream);
expect(container.textContent).toBe('0');

await act(async () => {
ReactDOMClient.hydrateRoot(container, <App />);
});
expect(container.textContent).toBe('0');
});

// @gate enableFormActions
it('can provide a custom action on the server for actions', async () => {
const ref = React.createRef();
Expand Down
119 changes: 119 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ let useSyncExternalStore;
let useSyncExternalStoreWithSelector;
let use;
let useFormState;
let useActionState;
let PropTypes;
let textCache;
let writable;
Expand Down Expand Up @@ -90,6 +91,7 @@ describe('ReactDOMFizzServer', () => {
SuspenseList = React.unstable_SuspenseList;
}
useFormState = ReactDOM.useFormState;
useActionState = React.useActionState;

PropTypes = require('prop-types');

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

// @gate enableFormActions
// @gate enableAsyncActions
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.

async function action(state) {
return state;
}

const childRef = React.createRef(null);
function Form() {
const [state] = useActionState(action, 0);
const text = `Child: ${state}`;
return (
<div id="child" ref={childRef}>
{text}
</div>
);
}

function App() {
return (
<div>
<div>
<Form />
</div>
<span>Sibling</span>
</div>
);
}

await act(() => {
const {pipe} = renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div>
<div id="child">Child: 0</div>
</div>
<span>Sibling</span>
</div>,
);
const child = document.getElementById('child');

// Confirm that it hydrates correctly
await clientAct(() => {
ReactDOMClient.hydrateRoot(container, <App />);
});
expect(childRef.current).toBe(child);
});

// @gate enableFormActions
// @gate enableAsyncActions
it("useActionState hydrates without a mismatch if there's a render phase update", async () => {
async function action(state) {
return state;
}

const childRef = React.createRef(null);
function Form() {
const [localState, setLocalState] = React.useState(0);
if (localState < 3) {
setLocalState(localState + 1);
}

// 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 useActionState instance.
const [formState] = useActionState(action, 0);
const text = `${readText('Child')}:${formState}:${localState}`;
return (
<div id="child" ref={childRef}>
{text}
</div>
);
}

function App() {
return (
<div>
<Suspense fallback="Loading...">
<Form />
</Suspense>
<span>Sibling</span>
</div>
);
}

await act(() => {
const {pipe} = renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(
<div>
Loading...<span>Sibling</span>
</div>,
);

await act(() => resolveText('Child'));
expect(getVisibleChildren(container)).toEqual(
<div>
<div id="child">Child:0:3</div>
<span>Sibling</span>
</div>,
);
const child = document.getElementById('child');

// Confirm that it hydrates correctly
await clientAct(() => {
ReactDOMClient.hydrateRoot(container, <App />);
});
expect(childRef.current).toBe(child);
});

describe('useEffectEvent', () => {
// @gate enableUseEffectEventHook
it('can server render a component with useEffectEvent', async () => {
Expand Down
Loading

0 comments on commit 1d4e7f9

Please sign in to comment.