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 7, 2024
1 parent 97a9ef2 commit 54ab3ab
Show file tree
Hide file tree
Showing 18 changed files with 1,358 additions and 3 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
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 @@ -34,6 +35,7 @@ describe('ReactDOMFizzForm', () => {
useFormStatus = require('react-dom').useFormStatus;
useFormState = require('react-dom').useFormState;
useOptimistic = require('react').useOptimistic;
useActionState = require('react').useActionState;
act = require('internal-test-utils').act;
container = document.createElement('div');
document.body.appendChild(container);
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
120 changes: 119 additions & 1 deletion 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,7 +91,7 @@ describe('ReactDOMFizzServer', () => {
SuspenseList = React.unstable_SuspenseList;
}
useFormState = ReactDOM.useFormState;

useActionState = React.useActionState;
PropTypes = require('prop-types');

const InternalTestUtils = require('internal-test-utils');
Expand Down Expand Up @@ -6338,6 +6339,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 54ab3ab

Please sign in to comment.