Skip to content

Commit

Permalink
next/form (#68102)
Browse files Browse the repository at this point in the history
## 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
lubieowoce authored and ForsakenHarmony committed Aug 14, 2024
1 parent 4e007bf commit e783b3c
Show file tree
Hide file tree
Showing 32 changed files with 989 additions and 1 deletion.
1 change: 1 addition & 0 deletions crates/next-core/src/next_import_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ pub async fn get_next_edge_import_map(
"next/app" => "next/dist/api/app".to_string(),
"next/document" => "next/dist/api/document".to_string(),
"next/dynamic" => "next/dist/api/dynamic".to_string(),
"next/form" => "next/dist/api/form".to_string(),
"next/head" => "next/dist/api/head".to_string(),
"next/headers" => "next/dist/api/headers".to_string(),
"next/image" => "next/dist/api/image".to_string(),
Expand Down
3 changes: 3 additions & 0 deletions packages/next/form.d.ts
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
1 change: 1 addition & 0 deletions packages/next/form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('./dist/client/form')
2 changes: 2 additions & 0 deletions packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
"image.d.ts",
"link.js",
"link.d.ts",
"form.js",
"form.d.ts",
"router.js",
"router.d.ts",
"jest.js",
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/api/form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from '../client/form'
export * from '../client/form'
1 change: 1 addition & 0 deletions packages/next/src/build/create-compiler-aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export function createNextApiEsmAliases() {
dynamic: 'next/dist/api/dynamic',
script: 'next/dist/api/script',
link: 'next/dist/api/link',
form: 'next/dist/api/form',
navigation: 'next/dist/api/navigation',
headers: 'next/dist/api/headers',
og: 'next/dist/api/og',
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/build/handle-externals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export function makeExternalHandler({
}

const notExternalModules =
/^(?:private-next-pages\/|next\/(?:dist\/pages\/|(?:app|document|link|image|legacy\/image|constants|dynamic|script|navigation|headers|router)$)|string-hash|private-next-rsc-action-validate|private-next-rsc-action-client-wrapper|private-next-rsc-server-reference$)/
/^(?:private-next-pages\/|next\/(?:dist\/pages\/|(?:app|document|link|form|image|legacy\/image|constants|dynamic|script|navigation|headers|router)$)|string-hash|private-next-rsc-action-validate|private-next-rsc-action-client-wrapper|private-next-rsc-server-reference$)/
if (notExternalModules.test(request)) {
return
}
Expand Down
327 changes: 327 additions & 0 deletions packages/next/src/client/form.tsx
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 test/e2e/app-dir/next-form/basepath/app/forms/basic/page.tsx
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>
)
}
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>
)
}
Loading

0 comments on commit e783b3c

Please sign in to comment.