Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useFormState's permalink option changes form target #27302

Merged
merged 3 commits into from
Aug 29, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,82 @@ 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');
});
});
37 changes: 31 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 Down Expand Up @@ -542,10 +543,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 +554,38 @@ 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 (typeof permalink === 'string') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be !== undefined and coerced? Because generally we allow toString/valueOf.

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