Skip to content

Commit

Permalink
useFormState's permalink option changes form target (facebook#27302)
Browse files Browse the repository at this point in the history
When the `permalink` option is passed to `useFormState`, and the form is
submitted before it has hydrated, the permalink will be used as the
target of the form action, enabling MPA-style form submissions.

(Note that submitting a form without hydration is a feature of Server
Actions; it doesn't work with regular client actions.)

It does not have any effect after the form has hydrated.
  • Loading branch information
acdlite authored and AndyPengc12 committed Apr 15, 2024
1 parent 6fe78f4 commit 1587eb8
Show file tree
Hide file tree
Showing 5 changed files with 179 additions and 29 deletions.
4 changes: 2 additions & 2 deletions packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,13 @@ export function useFormStatus(): FormStatus {
export function useFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
if (!(enableFormActions && enableAsyncActions)) {
throw new Error('Not implemented.');
} else {
const dispatcher = resolveDispatcher();
// $FlowFixMe[not-a-function] This is unstable, thus optional
return dispatcher.useFormState(action, initialState, url);
return dispatcher.useFormState(action, initialState, permalink);
}
}
40 changes: 20 additions & 20 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -2008,7 +2008,7 @@ function formStateReducer<S>(oldState: S, newState: S): S {
function mountFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
// State hook. The state is stored in a thenable which is then unwrapped by
// the `use` algorithm during render.
Expand Down Expand Up @@ -2063,7 +2063,7 @@ function mountFormState<S, P>(
function updateFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
const stateHook = updateWorkInProgressHook();
const currentStateHook = ((currentHook: any): Hook);
Expand All @@ -2072,7 +2072,7 @@ function updateFormState<S, P>(
currentStateHook,
action,
initialState,
url,
permalink,
);
}

Expand All @@ -2081,7 +2081,7 @@ function updateFormStateImpl<S, P>(
currentStateHook: Hook,
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
const [thenable] = updateReducerImpl<Thenable<S>, Thenable<S>>(
stateHook,
Expand Down Expand Up @@ -2121,7 +2121,7 @@ function formStateActionEffect<S, P>(
function rerenderFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
// Unlike useState, useFormState doesn't support render phase updates.
// Also unlike useState, we need to replay all pending updates again in case
Expand All @@ -2140,7 +2140,7 @@ function rerenderFormState<S, P>(
currentStateHook,
action,
initialState,
url,
permalink,
);
}

Expand Down Expand Up @@ -3628,11 +3628,11 @@ if (__DEV__) {
function useFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
mountHookTypesDev();
return mountFormState(action, initialState, url);
return mountFormState(action, initialState, permalink);
};
}
if (enableAsyncActions) {
Expand Down Expand Up @@ -3798,11 +3798,11 @@ if (__DEV__) {
function useFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
updateHookTypesDev();
return mountFormState(action, initialState, url);
return mountFormState(action, initialState, permalink);
};
}
if (enableAsyncActions) {
Expand Down Expand Up @@ -3970,11 +3970,11 @@ if (__DEV__) {
function useFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
updateHookTypesDev();
return updateFormState(action, initialState, url);
return updateFormState(action, initialState, permalink);
};
}
if (enableAsyncActions) {
Expand Down Expand Up @@ -4142,11 +4142,11 @@ if (__DEV__) {
function useFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
updateHookTypesDev();
return rerenderFormState(action, initialState, url);
return rerenderFormState(action, initialState, permalink);
};
}
if (enableAsyncActions) {
Expand Down Expand Up @@ -4335,12 +4335,12 @@ if (__DEV__) {
function useFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
warnInvalidHookAccess();
mountHookTypesDev();
return mountFormState(action, initialState, url);
return mountFormState(action, initialState, permalink);
};
}
if (enableAsyncActions) {
Expand Down Expand Up @@ -4533,12 +4533,12 @@ if (__DEV__) {
function useFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
warnInvalidHookAccess();
updateHookTypesDev();
return updateFormState(action, initialState, url);
return updateFormState(action, initialState, permalink);
};
}
if (enableAsyncActions) {
Expand Down Expand Up @@ -4731,12 +4731,12 @@ if (__DEV__) {
function useFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
currentHookNameInDev = 'useFormState';
warnInvalidHookAccess();
updateHookTypesDev();
return rerenderFormState(action, initialState, url);
return rerenderFormState(action, initialState, permalink);
};
}
if (enableAsyncActions) {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-reconciler/src/ReactInternalTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ export type Dispatcher = {
useFormState?: <S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
) => [S, (P) => void],
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ let React;
let ReactDOMServer;
let ReactServerDOMServer;
let ReactServerDOMClient;
let useFormState;

describe('ReactFlightDOMForm', () => {
beforeEach(() => {
Expand All @@ -47,6 +48,7 @@ describe('ReactFlightDOMForm', () => {
ReactServerDOMServer = require('react-server-dom-webpack/server.edge');
ReactServerDOMClient = require('react-server-dom-webpack/client.edge');
ReactDOMServer = require('react-dom/server.edge');
useFormState = require('react-dom').experimental_useFormState;
container = document.createElement('div');
document.body.appendChild(container);
});
Expand Down Expand Up @@ -308,4 +310,123 @@ describe('ReactFlightDOMForm', () => {
expect(result).toBe('hello');
expect(foo).toBe('barobject');
});

// @gate enableFormActions
// @gate enableAsyncActions
it("useFormState's dispatch binds the initial state to the provided action", async () => {
let serverActionResult = null;

const serverAction = serverExports(function action(prevState, formData) {
const newState = {
count: prevState.count + parseInt(formData.get('incrementAmount'), 10),
};
serverActionResult = newState;
return newState;
});

const initialState = {count: 1};
function Client({action}) {
const [state, dispatch] = useFormState(action, initialState);
return (
<form action={dispatch}>
<span>Count: {state.count}</span>
<input type="text" name="incrementAmount" defaultValue="5" />
</form>
);
}
const ClientRef = await clientExports(Client);

const rscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={serverAction} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream);
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);

const form = container.firstChild;
const span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Count: 1');

await submit(form);
expect(serverActionResult.count).toBe(6);
});

// @gate enableFormActions
// @gate enableAsyncActions
it("useFormState can change the action's target with the `permalink` argument", async () => {
const serverAction = serverExports(function action(prevState) {
return {state: prevState.count + 1};
});

const initialState = {count: 1};
function Client({action}) {
const [state, dispatch] = useFormState(
action,
initialState,
'/permalink',
);
return (
<form action={dispatch}>
<span>Count: {state.count}</span>
</form>
);
}
const ClientRef = await clientExports(Client);

const rscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={serverAction} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream);
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);

const form = container.firstChild;
const span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Count: 1');

expect(form.target).toBe('/permalink');
});

// @gate enableFormActions
// @gate enableAsyncActions
it('useFormState `permalink` is coerced to string', async () => {
const serverAction = serverExports(function action(prevState) {
return {state: prevState.count + 1};
});

class Permalink {
toString() {
return '/permalink';
}
}

const permalink = new Permalink();

const initialState = {count: 1};
function Client({action}) {
const [state, dispatch] = useFormState(action, initialState, permalink);
return (
<form action={dispatch}>
<span>Count: {state.count}</span>
</form>
);
}
const ClientRef = await clientExports(Client);

const rscStream = ReactServerDOMServer.renderToReadableStream(
<ClientRef action={serverAction} />,
webpackMap,
);
const response = ReactServerDOMClient.createFromReadableStream(rscStream);
const ssrStream = await ReactDOMServer.renderToReadableStream(response);
await readIntoContainer(ssrStream);

const form = container.firstChild;
const span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Count: 1');

expect(form.target).toBe('/permalink');
});
});
41 changes: 35 additions & 6 deletions packages/react-server/src/ReactFizzHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
StartTransitionOptions,
Thenable,
Usable,
ReactCustomFormAction,
} from 'shared/ReactTypes';

import type {ResumableState} from './ReactFizzConfig';
Expand All @@ -40,6 +41,7 @@ import {
REACT_CONTEXT_TYPE,
REACT_MEMO_CACHE_SENTINEL,
} from 'shared/ReactSymbols';
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';

type BasicStateAction<S> = (S => S) | S;
type Dispatch<A> = A => void;
Expand Down Expand Up @@ -542,10 +544,6 @@ function unsupportedSetOptimisticState() {
throw new Error('Cannot update optimistic state while rendering.');
}

function unsupportedDispatchFormState() {
throw new Error('Cannot update form state while rendering.');
}

function useOptimistic<S, A>(
passthrough: S,
reducer: ?(S, A) => S,
Expand All @@ -557,10 +555,41 @@ function useOptimistic<S, A>(
function useFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
url?: string,
permalink?: string,
): [S, (P) => void] {
resolveCurrentlyRenderingComponent();
return [initialState, unsupportedDispatchFormState];

// Bind the initial state to the first argument of the action.
// TODO: Use the keypath (or permalink) to check if there's matching state
// from the previous page.
const boundAction = action.bind(null, initialState);

// Wrap the action so the return value is void.
const dispatch = (payload: P): void => {
boundAction(payload);
};

// $FlowIgnore[prop-missing]
if (typeof boundAction.$$FORM_ACTION === 'function') {
// $FlowIgnore[prop-missing]
dispatch.$$FORM_ACTION = (prefix: string) => {
// $FlowIgnore[prop-missing]
const metadata: ReactCustomFormAction = boundAction.$$FORM_ACTION(prefix);
// Override the target URL
if (permalink !== undefined) {
if (__DEV__) {
checkAttributeStringCoercion(permalink, 'target');
}
metadata.target = permalink + '';
}
return metadata;
};
} else {
// This is not a server action, so the permalink argument has
// no effect. The form will have to be hydrated before it's submitted.
}

return [initialState, dispatch];
}

function useId(): string {
Expand Down

0 comments on commit 1587eb8

Please sign in to comment.