-
Notifications
You must be signed in to change notification settings - Fork 26.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
## What This PR introduces `<Form>`, a form component integrated with the Next.js router. Form has two modes of operation. ### When `action` is a path/url ```jsx <Form action="/search"> ``` In essence, this is a progressively enhanced GET form. - it will prefetch the target route (or, more commonly in this case, its loading state) - when submitted, it will perform a **client navigation** to the target URL, encoding input fields from the form into the URL as search params. ### When `action` is a function ```jsx <Form action={myReactAction}> ``` This is a progressively enhanced POST form. In this case, `<Form>` will act just like `<form>` (i.e. execute the action function when submitted). This is mostly here for convenience/consistency with React. ## Notes ### Notable props (or lack thereof) - `replace` - same as Link, call `router.replace` instead of `router.push` when navigating. This only works in "GET mode". - `scroll` - same as Link, control the `scroll` option of router when navigating. This only works in "GET mode". - `onSubmit` - same as the usual, but notably, **calling `event.preventDefault()` inside it will prevent Next from navigating** (this matches how Link's `onClick` works) We disallow passing certain form props (`method`, `encType`, `target`) because both the GET and POST cases have specific requirements for these that can't be overridden. There is currently no way to control/disable the prefetching behavior (like `<Link prefetch={false}>`) or render a custom component instead of `<form>` (like Link's `passHref` behavior). These may be added in the future. ### Submitter overrides We have partial support for overriding the `action` via ```jsx <button type="submit" formAction="/other-search"> ``` It will also client-navigate, but can't be prefetched (because we don't know the route to prefetch until a submit occurs), so this isn't really recommended (unless the prefetch is triggered in some other way). Unfortunately, just like `formAction`, all of the "banned" props mentioned in the previous section (`method`, `encType`, `target`) are overridable by the submitting element (the ["submitter"](https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent/submitter)) via `formMethod` , `formEncType` and `formTarget` respectively. We don't have a good way of banning these within a `<Form>`, because we don't have control over `<button>` props. So in this case the best we can do is just warn and fall back to native browser behavior (because this is what'd happen before hydration). Which means that **if the submitter specifies a `formMethod` , `formEncType` and `formTarget` that Form don't support, we will not client-navigate.** ### handling `<input type="file">` Surprisingly, this works in a url-encoded form, even if it makes very little sense. But same as submitter props, we have no good way of banning it, so we match the browser behavior and submit _the filename_ instead of the file object.
- Loading branch information
1 parent
4e007bf
commit e783b3c
Showing
32 changed files
with
989 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import Form from './dist/client/form' | ||
export * from './dist/client/form' | ||
export default Form |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module.exports = require('./dist/client/form') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { default } from '../client/form' | ||
export * from '../client/form' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,327 @@ | ||
'use client' | ||
|
||
import { useEffect, type HTMLProps, type FormEvent } from 'react' | ||
import { useRouter } from './components/navigation' | ||
import { addBasePath } from './add-base-path' | ||
import { useIntersection } from './use-intersection' | ||
import { useMergedRef } from './use-merged-ref' | ||
import type { AppRouterInstance } from '../shared/lib/app-router-context.shared-runtime' | ||
import { PrefetchKind } from './components/router-reducer/router-reducer-types' | ||
|
||
const DISALLOWED_FORM_PROPS = ['method', 'encType', 'target'] as const | ||
|
||
type HTMLFormProps = HTMLProps<HTMLFormElement> | ||
type DisallowedFormProps = (typeof DISALLOWED_FORM_PROPS)[number] | ||
|
||
export type FormProps = { | ||
/** | ||
* `action` can be either a `string` or a function. | ||
* - If `action` is a string, it will be interpreted as a path or URL to navigate to when the form is submitted. | ||
* The path will be prefetched when the form becomes visible. | ||
* - If `action` is a function, it will be called when the form is submitted. See the [React docs](https://react.dev/reference/react-dom/components/form#props) for more. | ||
*/ | ||
action: NonNullable<HTMLFormProps['action']> | ||
/** | ||
* Whether submitting the form should replace the current `history` state instead of adding a new url into the stack. | ||
* Only valid if `action` is a string. | ||
* | ||
* @defaultValue `false` | ||
*/ | ||
replace?: boolean | ||
/** | ||
* Override the default scroll behavior when navigating. | ||
* Only valid if `action` is a string. | ||
* | ||
* @defaultValue `true` | ||
*/ | ||
scroll?: boolean | ||
} & Omit<HTMLFormProps, 'action' | DisallowedFormProps> | ||
|
||
export default function Form({ | ||
replace, | ||
scroll, | ||
ref: externalRef, | ||
...props | ||
}: FormProps) { | ||
const actionProp = props.action | ||
const isNavigatingForm = typeof actionProp === 'string' | ||
|
||
for (const key of DISALLOWED_FORM_PROPS) { | ||
if (key in props) { | ||
if (process.env.NODE_ENV === 'development') { | ||
console.error( | ||
`<Form> does not support changing \`${key}\`. ` + | ||
(isNavigatingForm | ||
? `If you'd like to use it to perform a mutation, consider making \`action\` a function instead.\n` + | ||
`Learn more: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations` | ||
: '') | ||
) | ||
} | ||
delete (props as Record<string, unknown>)[key] | ||
} | ||
} | ||
|
||
const router = useRouter() | ||
|
||
const [setIntersectionRef, isVisible] = useIntersection({ | ||
rootMargin: '200px', | ||
disabled: !isNavigatingForm, // if we don't have an action path, we can't preload anything anyway. | ||
}) | ||
|
||
const ownRef = useMergedRef<HTMLFormElement>( | ||
setIntersectionRef, | ||
externalRef ?? null | ||
) | ||
|
||
useEffect(() => { | ||
if (!isNavigatingForm) { | ||
return | ||
} | ||
|
||
if (!isVisible) { | ||
return | ||
} | ||
|
||
try { | ||
// TODO: do we need to take the current field values here? | ||
// or are we assuming that queryparams can't affect this (but what about rewrites)? | ||
router.prefetch(actionProp, { kind: PrefetchKind.AUTO }) | ||
} catch (err) { | ||
console.error(err) | ||
} | ||
}, [isNavigatingForm, isVisible, actionProp, router]) | ||
|
||
if (!isNavigatingForm) { | ||
if (process.env.NODE_ENV === 'development') { | ||
if (replace !== undefined || scroll !== undefined) { | ||
console.error( | ||
'Passing `replace` or `scroll` to a <Form> whose `action` is a function has no effect.\n' + | ||
'See the relevant docs to learn how to control this behavior for navigations triggered from actions:\n' + | ||
' `redirect()` - https://nextjs.org/docs/app/api-reference/functions/redirect#parameters\n' + | ||
' `router.replace()` - https://nextjs.org/docs/app/api-reference/functions/use-router#userouter\n' | ||
) | ||
} | ||
} | ||
return <form {...props} ref={ownRef} /> | ||
} | ||
|
||
if (process.env.NODE_ENV === 'development') { | ||
checkActionUrl(actionProp, 'action') | ||
} | ||
const actionHref = addBasePath(actionProp) | ||
|
||
return ( | ||
<form | ||
{...props} | ||
ref={ownRef} | ||
action={actionHref} | ||
onSubmit={(event) => | ||
onFormSubmit(event, { | ||
router, | ||
actionHref, | ||
replace, | ||
scroll, | ||
onSubmit: props.onSubmit, | ||
}) | ||
} | ||
/> | ||
) | ||
} | ||
|
||
function onFormSubmit( | ||
event: FormEvent<HTMLFormElement>, | ||
{ | ||
actionHref, | ||
onSubmit, | ||
replace, | ||
scroll, | ||
router, | ||
}: { | ||
actionHref: string | ||
onSubmit: FormProps['onSubmit'] | ||
replace: FormProps['replace'] | ||
scroll: FormProps['scroll'] | ||
router: AppRouterInstance | ||
} | ||
) { | ||
if (typeof onSubmit === 'function') { | ||
onSubmit(event) | ||
|
||
// if the user called event.preventDefault(), do nothing. | ||
// (this matches what Link does for `onClick`) | ||
if (event.defaultPrevented) { | ||
return | ||
} | ||
} | ||
|
||
const formElement = event.currentTarget | ||
const submitter = (event.nativeEvent as SubmitEvent).submitter | ||
|
||
let action = actionHref | ||
|
||
if (submitter) { | ||
if (process.env.NODE_ENV === 'development') { | ||
// the way server actions are encoded (e.g. `formMethod="post") | ||
// causes some unnecessary dev-mode warnings from `hasUnsupportedSubmitterAttributes`. | ||
// we'd bail out anyway, but we just do it silently. | ||
if (hasReactServerActionAttributes(submitter)) { | ||
return | ||
} | ||
} | ||
|
||
if (hasUnsupportedSubmitterAttributes(submitter)) { | ||
return | ||
} | ||
|
||
// client actions have `formAction="javascript:..."`. We obviously can't prefetch/navigate to that. | ||
if (hasReactClientActionAttributes(submitter)) { | ||
return | ||
} | ||
|
||
// If the submitter specified an alternate formAction, | ||
// use that URL instead -- this is what a native form would do. | ||
// NOTE: `submitter.formAction` is unreliable, because it will give us `location.href` if it *wasn't* set | ||
// NOTE: this should not have `basePath` added, because we can't add it before hydration | ||
const submitterFormAction = submitter.getAttribute('formAction') | ||
if (submitterFormAction !== null) { | ||
if (process.env.NODE_ENV === 'development') { | ||
checkActionUrl(submitterFormAction, 'formAction') | ||
} | ||
action = submitterFormAction | ||
} | ||
} | ||
|
||
let targetUrl: URL | ||
try { | ||
// NOTE: It might be more correct to resolve URLs relative to `document.baseURI`, | ||
// but we already do it relative to `location.href` elsewhere: | ||
// (see e.g. https://github.com/vercel/next.js/blob/bb0e6722f87ceb2d43015f5b8a413d0072f2badf/packages/next/src/client/components/app-router.tsx#L146) | ||
// so it's better to stay consistent. | ||
const base = window.location.href | ||
targetUrl = new URL(action, base) | ||
} catch (err) { | ||
throw new Error(`Cannot parse form action "${action}" as a URL`, { | ||
cause: err, | ||
}) | ||
} | ||
if (targetUrl.searchParams.size) { | ||
// url-encoded HTML forms *overwrite* any search params in the `action` url: | ||
// | ||
// "Let `query` be the result of running the application/x-www-form-urlencoded serializer [...]" | ||
// "Set parsed action's query component to `query`." | ||
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#submit-mutate-action | ||
// | ||
// We need to match that. | ||
// (note that all other parts of the URL, like `hash`, are preserved) | ||
targetUrl.search = '' | ||
} | ||
|
||
const formData = new FormData(formElement) | ||
|
||
for (let [name, value] of formData) { | ||
if (typeof value !== 'string') { | ||
// For file inputs, the native browser behavior is to use the filename as the value instead: | ||
// | ||
// "If entry's value is a File object, then let value be entry's value's name. Otherwise, let value be entry's value." | ||
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#converting-an-entry-list-to-a-list-of-name-value-pairs | ||
// | ||
if (process.env.NODE_ENV === 'development') { | ||
console.warn( | ||
`<Form> only supports file inputs if \`action\` is a function. File inputs cannot be used if \`action\` is a string, ` + | ||
`because files cannot be encoded as search params.` | ||
) | ||
} | ||
value = value.name | ||
} | ||
|
||
targetUrl.searchParams.append(name, value) | ||
} | ||
|
||
// Finally, no more reasons for bailing out. | ||
event.preventDefault() | ||
|
||
const method = replace ? 'replace' : 'push' | ||
router[method](targetUrl.href, { scroll }) | ||
} | ||
|
||
function checkActionUrl(action: string, source: 'action' | 'formAction') { | ||
const aPropName = source === 'action' ? `an \`action\`` : `a \`formAction\`` | ||
|
||
let testUrl: URL | ||
try { | ||
testUrl = new URL(action, 'http://n') | ||
} catch (err) { | ||
console.error( | ||
`<Form> received ${aPropName} that cannot be parsed as a URL: "${action}".` | ||
) | ||
return | ||
} | ||
|
||
// url-encoded HTML forms ignore any queryparams in the `action` url. We need to match that. | ||
if (testUrl.searchParams.size) { | ||
console.warn( | ||
`<Form> received ${aPropName} that contains search params: "${action}". This is not supported, and they will be ignored. ` + | ||
`If you need to pass in additional search params, use an \`<input type="hidden" />\` instead.` | ||
) | ||
} | ||
} | ||
|
||
const isSupportedEncType = (value: string) => | ||
value === 'application/x-www-form-urlencoded' | ||
const isSupportedMethod = (value: string) => value === 'get' | ||
const isSupportedTarget = (value: string) => value === '_self' | ||
|
||
function hasUnsupportedSubmitterAttributes(submitter: HTMLElement): boolean { | ||
// A submitter can override `encType` for the form. | ||
const formEncType = submitter.getAttribute('formEncType') | ||
if (formEncType !== null && !isSupportedEncType(formEncType)) { | ||
if (process.env.NODE_ENV === 'development') { | ||
console.error( | ||
`<Form>'s \`encType\` was set to an unsupported value via \`formEncType="${formEncType}"\`. ` + | ||
`This will disable <Form>'s navigation functionality. If you need this, use a native <form> element instead.` | ||
) | ||
} | ||
return true | ||
} | ||
|
||
// A submitter can override `method` for the form. | ||
const formMethod = submitter.getAttribute('formMethod') | ||
if (formMethod !== null && !isSupportedMethod(formMethod)) { | ||
if (process.env.NODE_ENV === 'development') { | ||
console.error( | ||
`<Form>'s \`method\` was set to an unsupported value via \`formMethod="${formMethod}"\`. ` + | ||
`This will disable <Form>'s navigation functionality. If you need this, use a native <form> element instead.` | ||
) | ||
} | ||
return true | ||
} | ||
|
||
// A submitter can override `target` for the form. | ||
const formTarget = submitter.getAttribute('formTarget') | ||
if (formTarget !== null && !isSupportedTarget(formTarget)) { | ||
if (process.env.NODE_ENV === 'development') { | ||
console.error( | ||
`<Form>'s \`target\` was set to an unsupported value via \`formTarget="${formTarget}"\`. ` + | ||
`This will disable <Form>'s navigation functionality. If you need this, use a native <form> element instead.` | ||
) | ||
} | ||
return true | ||
} | ||
|
||
return false | ||
} | ||
|
||
function hasReactServerActionAttributes(submitter: HTMLElement) { | ||
// https://github.com/facebook/react/blob/942eb80381b96f8410eab1bef1c539bed1ab0eb1/packages/react-client/src/ReactFlightReplyClient.js#L931-L934 | ||
const name = submitter.getAttribute('name') | ||
return ( | ||
name && (name.startsWith('$ACTION_ID_') || name.startsWith('$ACTION_REF_')) | ||
) | ||
} | ||
|
||
function hasReactClientActionAttributes(submitter: HTMLElement) { | ||
// CSR: https://github.com/facebook/react/blob/942eb80381b96f8410eab1bef1c539bed1ab0eb1/packages/react-dom-bindings/src/client/ReactDOMComponent.js#L482-L487 | ||
// SSR: https://github.com/facebook/react/blob/942eb80381b96f8410eab1bef1c539bed1ab0eb1/packages/react-dom-bindings/src/client/ReactDOMComponent.js#L2401 | ||
const action = submitter.getAttribute('formAction') | ||
return action && /\s*javascript:/i.test(action) | ||
} |
11 changes: 11 additions & 0 deletions
11
test/e2e/app-dir/next-form/basepath/app/forms/basic/page.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import * as React from 'react' | ||
import Form from 'next/form' | ||
|
||
export default function Home() { | ||
return ( | ||
<Form action="/search" id="search-form"> | ||
<input name="query" /> | ||
<button type="submit">Submit</button> | ||
</Form> | ||
) | ||
} |
15 changes: 15 additions & 0 deletions
15
test/e2e/app-dir/next-form/basepath/app/forms/button-formaction/page.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import * as React from 'react' | ||
import Form from 'next/form' | ||
|
||
export default function Home() { | ||
return ( | ||
<Form action="/" id="search-form"> | ||
<input name="query" /> | ||
{/* Note that unlike `action`, we have to include the basePath below */} | ||
{/* vvvvvv */} | ||
<button type="submit" formAction="/base/search"> | ||
Submit | ||
</button> | ||
</Form> | ||
) | ||
} |
Oops, something went wrong.