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

Support useFormStatus in progressively-enhanced forms #29019

Merged
merged 2 commits into from
May 9, 2024

Conversation

acdlite
Copy link
Collaborator

@acdlite acdlite commented May 8, 2024

Before this change, useFormStatus is only activated if a form is submitted by an action function (either <form action={actionFn}> or <button formAction={actionFn}>).

After this change, useFormStatus will also be activated if you call startTransition(actionFn) inside a submit event handler that is preventDefault-ed.

This is the last missing piece for implementing a custom action prop that is progressively enhanced using onSubmit while maintaining the same behavior as built-in form actions.

Here's the basic recipe for implementing a progressively-enhanced form action. This would typically be implemented in your UI component library, not regular application code:

import {requestFormReset} from 'react-dom';

// To implement progressive enhancement, pass both a form action *and* a
// submit event handler. The action is used for submissions that happen
// before hydration, and the submit handler is used for submissions that
// happen after.
<form
  action={action}
  onSubmit={(event) => {
    // After hydration, we upgrade the form with additional client-
    // only behavior.
    event.preventDefault();

    // Manually dispatch the action.
    startTransition(async () => {
      // (Optional) Reset any uncontrolled inputs once the action is
      // complete, like built-in form actions do.
      requestFormReset(event.target);

      // ...Do extra action-y stuff in here, like setting a custom
      // optimistic state...

      // Call the user-provided action
      const formData = new FormData(event.target);
      await action(formData);
    });
  }}
/>

@facebook-github-bot facebook-github-bot added CLA Signed React Core Team Opened by a member of the React Core Team labels May 8, 2024
@react-sizebot
Copy link

react-sizebot commented May 8, 2024

Comparing: 04b0588...a23e517

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.66 kB 6.66 kB = 1.82 kB 1.82 kB
oss-stable/react-dom/cjs/react-dom-client.production.js +0.19% 494.06 kB 495.02 kB +0.52% 88.22 kB 88.68 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.67 kB 6.67 kB = 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js +0.19% 498.86 kB 499.81 kB +0.49% 88.93 kB 89.36 kB
facebook-www/ReactDOM-prod.classic.js +0.16% 591.22 kB 592.16 kB +0.19% 103.96 kB 104.15 kB
facebook-www/ReactDOM-prod.modern.js +0.17% 567.44 kB 568.39 kB +0.19% 100.36 kB 100.55 kB
test_utils/ReactAllWarnings.js Deleted 64.26 kB 0.00 kB Deleted 16.02 kB 0.00 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
test_utils/ReactAllWarnings.js Deleted 64.26 kB 0.00 kB Deleted 16.02 kB 0.00 kB

Generated by 🚫 dangerJS against a23e517

return (actionProp: any);
} else {
if (__DEV__) {
checkAttributeStringCoercion(actionProp, 'action');
Copy link
Collaborator

Choose a reason for hiding this comment

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

Kind of annoying to need these even though we've checked that it's not a symbol already.

const pendingState: FormStatus = {
pending: true,
data: formData,
method: form.method,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I noticed we were already reading the method from the form element here. Should I change this to coerce the React prop like we're doing with action? Should this be getAttribute instead? Unsure of all the implications.

Copy link
Collaborator

@sebmarkbage sebmarkbage May 8, 2024

Choose a reason for hiding this comment

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

Yea, it's tricky. In this case it's good because we get the right default and it gets normalized to something that's easy to compare. E.g. you don't have to think of this enum having different cases.

However, for the action if it gets normalized the same thing is actually kind of annoying because now you have to think first normalizing your own url string to whatever the browser does.

So I could see the value in the normalization diverging here.

@@ -60,10 +111,6 @@ function extractEvents(
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

The submitterAction that's coming from formAction needs to be coerced too. Technically it was wrong before because it wasn't ignoring boolean/symbol and was treating them as the submitter taking over.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also the submitter being set to null in that branch is not correct in the case where it's not a function. It previously just assumed that if it wasn't a function it would've exited early anyway.

@sebmarkbage
Copy link
Collaborator

Noting that this currently doesn't fully cover isomorphic startTransition which I assume is a follow up.

Another thing is that even if you don't prevent default and it's not a function, ideally we should still update useFormStatus during the MPA action at least if the target is self. We'd need to track when it navigates or gets cancelled/intercepted though. If we listen to onunload I believe we might break BF cache so the right move would be to turn it off when you return back to the page with the back button from the other page. Not sure how to catch the stop button though.

@acdlite
Copy link
Collaborator Author

acdlite commented May 8, 2024

Noting that this currently doesn't fully cover isomorphic startTransition which I assume is a follow up.

Yeah I'm going to do that in its own PR since it's technically a separate issue

@acdlite
Copy link
Collaborator Author

acdlite commented May 8, 2024

Another thing is that even if you don't prevent default and it's not a function, ideally we should still update useFormStatus during the MPA action at least if the target is self. We'd need to track when it navigates or gets cancelled/intercepted though. If we listen to onunload I believe we might break BF cache so the right move would be to turn it off when you return back to the page with the back button from the other page. Not sure how to catch the stop button though.

Makes sense, I'll think I'll do that in a separate PR too

if (submitterAction != null) {
submitterAction = coerceFormActionProp(
submitterProps
? (submitterProps: any).formAction
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: You only really need to coerce this case, the getAttribute one will already be null | string.

When handling form action functions, we should coerce the submitter
formAction prop using the same logic as ReactDOMComponent before
deciding whether it should override the action on the <form>.

Before this fix, if the submitter's `formAction` prop was a symbol or
a boolean, it would block the form's action function from firing, but
the correct behavior is that symbols and booleans should be treated as
if they were null/empty, because that's how we treat those values when
setting DOM properties.
Before this change, `useFormStatus` is only activated if a form is
submitted by an action function (either <form action={actionFn}> or
<button formAction={actionFn}>).

After this change, `useFormStatus` will also be activated if you call
`startTransition(actionFn)` inside a submit event handler that is
`preventDefault`-ed.

This is the last missing piece for implementing a custom `action` prop
that is progressively enhanced using `onSubmit` while maintaining the
same behavior as built-in form actions.

Here's the basic recipe for implementing a progressively-enhanced
form action:

```js
import {requestFormReset} from 'react-dom';

// To implement progressive enhancement, pass both a form action *and* a
// submit event handler. The action is used for submissions that happen
// before hydration, and the submit handler is used for submissions that
// happen after.
<form
  action={action}
  onSubmit={(event) => {
    // After hydration, we upgrade the form with additional client-
    // only behavior.
    event.preventDefault();

    // Manually dispatch the action.
    startTransition(async () => {
      // (Optional) Reset any uncontrolled inputs once the action is
      // complete, like built-in form actions do.
      requestFormReset(event.target);

      // ...Do extra action-y stuff in here, like setting a custom
      // optimistic state...

      // Call the user-provided action
      const formData = new FormData(event.target);
      await action(formData);
    });
  }}
/>
```
@acdlite acdlite merged commit c334563 into facebook:main May 9, 2024
2 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants