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

next/form #68102

Merged
merged 45 commits into from
Jul 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
1dc69c8
next/form: initial implementation
lubieowoce Jul 18, 2024
a9e5767
test: basic functionality of next/form
lubieowoce Jul 18, 2024
5e4c200
fix: navigation to formAction (squash me)
lubieowoce Jul 18, 2024
387f34f
fix: more formAction fixes
lubieowoce Jul 18, 2024
bc1cbc8
test: button with formAction
lubieowoce Jul 18, 2024
1441287
scaffolding: edge alias stuff
lubieowoce Jul 19, 2024
3bee299
todo
lubieowoce Jul 23, 2024
f942a4b
handle errors/warnings from server and client actions
lubieowoce Jul 23, 2024
50163ca
disallow form props: method,encType,target
lubieowoce Jul 23, 2024
598e46e
call user-provided early onSubmit early and respect preventDefault
lubieowoce Jul 23, 2024
229bd24
be stricter about disallowed form props
lubieowoce Jul 23, 2024
e8de9a2
basePath support
lubieowoce Jul 24, 2024
41cca4a
refactor: pull huge onSubmit callback out
lubieowoce Jul 24, 2024
2613191
wip: prefetch when visible (instead of on mount)
lubieowoce Jul 24, 2024
c50dea3
refactor: use useMergedRef
lubieowoce Jul 25, 2024
34b5db4
add "scroll" prop to Form
lubieowoce Jul 25, 2024
01e63d5
fix: match native behavior for file inputs
lubieowoce Jul 25, 2024
614fb72
warn about search params in action
lubieowoce Jul 25, 2024
a2d0774
warn about "replace" and "scroll" if action is a function
lubieowoce Jul 25, 2024
39e4d96
clean up FormProps and document the props better
lubieowoce Jul 25, 2024
846f01b
improve some error messages
lubieowoce Jul 25, 2024
7faddee
more helpful error for banned props
lubieowoce Jul 25, 2024
72bc448
todos
lubieowoce Jul 25, 2024
662467c
test: move tests
lubieowoce Jul 26, 2024
e03aa8d
test: add tests for basePath
lubieowoce Jul 26, 2024
be9453f
test: functions passed to formAction
lubieowoce Jul 26, 2024
bf8741c
test: functions passed to action
lubieowoce Jul 26, 2024
303f960
test: unsupported attributes on submitter
lubieowoce Jul 26, 2024
5cf30ef
todo
lubieowoce Jul 26, 2024
f993996
action validation improvements: don't warn for action="", warn for in…
lubieowoce Jul 29, 2024
d01ff22
change "next/form" to "<Form>" in error messages to match Link
lubieowoce Jul 29, 2024
912e4d4
remove TODO
lubieowoce Jul 29, 2024
d36ffaa
add some whatwg spec references for weird behaviors
lubieowoce Jul 29, 2024
b0d83ab
fix: avoid dynamic requests in prefetch (prefetchKind: auto)
lubieowoce Jul 29, 2024
0d65f1e
refactor: deduplicate "typeof actionProp" checks
lubieowoce Jul 29, 2024
3c2bcc0
fix: resolve "action" relative to location.href
lubieowoce Jul 29, 2024
2847d38
leave file input test for later
lubieowoce Jul 29, 2024
f49d59b
test: replace prop
lubieowoce Jul 29, 2024
f0a4dfc
test: preventDefault in onSubmit
lubieowoce Jul 29, 2024
7f8b0f5
test: enable for deployment
lubieowoce Jul 29, 2024
331ca4d
refactor: common logic for asserting we didn't hard-navigate
lubieowoce Jul 29, 2024
da580dc
remove unnecessary TODO
lubieowoce Jul 29, 2024
1f6bb86
fix: validation message for scroll/replace
lubieowoce Jul 29, 2024
850d6f6
remove unnecessary test skip logic
lubieowoce Jul 30, 2024
b854edf
test: add test for file inputs
lubieowoce Jul 30, 2024
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
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
Loading