Skip to content

Commit

Permalink
useFormState's permalink option changes form target
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 committed Aug 28, 2023
1 parent 9a01f8d commit 51587ba
Show file tree
Hide file tree
Showing 2 changed files with 110 additions and 5 deletions.
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');
});
});
35 changes: 30 additions & 5 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 @@ -560,7 +557,35 @@ function useFormState<S, P>(
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') {
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 51587ba

Please sign in to comment.