mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
useFormState's permalink option changes form target (#27302)
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.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-20
@@ -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.
|
||||
@@ -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);
|
||||
@@ -2072,7 +2072,7 @@ function updateFormState<S, P>(
|
||||
currentStateHook,
|
||||
action,
|
||||
initialState,
|
||||
url,
|
||||
permalink,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -2140,7 +2140,7 @@ function rerenderFormState<S, P>(
|
||||
currentStateHook,
|
||||
action,
|
||||
initialState,
|
||||
url,
|
||||
permalink,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -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) {
|
||||
@@ -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) {
|
||||
@@ -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) {
|
||||
@@ -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) {
|
||||
@@ -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) {
|
||||
@@ -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) {
|
||||
|
||||
+1
-1
@@ -417,7 +417,7 @@ export type Dispatcher = {
|
||||
useFormState?: <S, P>(
|
||||
action: (S, P) => Promise<S>,
|
||||
initialState: S,
|
||||
url?: string,
|
||||
permalink?: string,
|
||||
) => [S, (P) => void],
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ let React;
|
||||
let ReactDOMServer;
|
||||
let ReactServerDOMServer;
|
||||
let ReactServerDOMClient;
|
||||
let useFormState;
|
||||
|
||||
describe('ReactFlightDOMForm', () => {
|
||||
beforeEach(() => {
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+35
-6
@@ -14,6 +14,7 @@ import type {
|
||||
StartTransitionOptions,
|
||||
Thenable,
|
||||
Usable,
|
||||
ReactCustomFormAction,
|
||||
} from 'shared/ReactTypes';
|
||||
|
||||
import type {ResumableState} from './ReactFizzConfig';
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user