diff --git a/crates/next-core/src/next_import_map.rs b/crates/next-core/src/next_import_map.rs index 11c4ff0302a41..b775f11b987c6 100644 --- a/crates/next-core/src/next_import_map.rs +++ b/crates/next-core/src/next_import_map.rs @@ -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(), diff --git a/packages/next/form.d.ts b/packages/next/form.d.ts new file mode 100644 index 0000000000000..4e43c720f1bd2 --- /dev/null +++ b/packages/next/form.d.ts @@ -0,0 +1,3 @@ +import Form from './dist/client/form' +export * from './dist/client/form' +export default Form diff --git a/packages/next/form.js b/packages/next/form.js new file mode 100644 index 0000000000000..dfde00888f701 --- /dev/null +++ b/packages/next/form.js @@ -0,0 +1 @@ +module.exports = require('./dist/client/form') diff --git a/packages/next/package.json b/packages/next/package.json index 7003fed9baf9a..c31a56dc17a51 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -41,6 +41,8 @@ "image.d.ts", "link.js", "link.d.ts", + "form.js", + "form.d.ts", "router.js", "router.d.ts", "jest.js", diff --git a/packages/next/src/api/form.ts b/packages/next/src/api/form.ts new file mode 100644 index 0000000000000..ef832ba2127b0 --- /dev/null +++ b/packages/next/src/api/form.ts @@ -0,0 +1,2 @@ +export { default } from '../client/form' +export * from '../client/form' diff --git a/packages/next/src/build/create-compiler-aliases.ts b/packages/next/src/build/create-compiler-aliases.ts index 720a6c0c3bf17..dad7c388ac83c 100644 --- a/packages/next/src/build/create-compiler-aliases.ts +++ b/packages/next/src/build/create-compiler-aliases.ts @@ -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', diff --git a/packages/next/src/build/handle-externals.ts b/packages/next/src/build/handle-externals.ts index 7d0db3eb37cd2..390f2e76e5e32 100644 --- a/packages/next/src/build/handle-externals.ts +++ b/packages/next/src/build/handle-externals.ts @@ -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 } diff --git a/packages/next/src/client/form.tsx b/packages/next/src/client/form.tsx new file mode 100644 index 0000000000000..9ad3776c66a32 --- /dev/null +++ b/packages/next/src/client/form.tsx @@ -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 +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 + /** + * 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 + +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( + `
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)[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( + 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 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 + } + + if (process.env.NODE_ENV === 'development') { + checkActionUrl(actionProp, 'action') + } + const actionHref = addBasePath(actionProp) + + return ( + + onFormSubmit(event, { + router, + actionHref, + replace, + scroll, + onSubmit: props.onSubmit, + }) + } + /> + ) +} + +function onFormSubmit( + event: FormEvent, + { + 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( + ` 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( + ` 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( + ` 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 \`\` 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( + `'s \`encType\` was set to an unsupported value via \`formEncType="${formEncType}"\`. ` + + `This will disable 's navigation functionality. If you need this, use a native 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( + `'s \`method\` was set to an unsupported value via \`formMethod="${formMethod}"\`. ` + + `This will disable 's navigation functionality. If you need this, use a native 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( + `'s \`target\` was set to an unsupported value via \`formTarget="${formTarget}"\`. ` + + `This will disable 's navigation functionality. If you need this, use a native 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) +} diff --git a/test/e2e/app-dir/next-form/basepath/app/forms/basic/page.tsx b/test/e2e/app-dir/next-form/basepath/app/forms/basic/page.tsx new file mode 100644 index 0000000000000..f0b1876904961 --- /dev/null +++ b/test/e2e/app-dir/next-form/basepath/app/forms/basic/page.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' +import Form from 'next/form' + +export default function Home() { + return ( + + + + + ) +} diff --git a/test/e2e/app-dir/next-form/basepath/app/forms/button-formaction/page.tsx b/test/e2e/app-dir/next-form/basepath/app/forms/button-formaction/page.tsx new file mode 100644 index 0000000000000..8d51e0013d3b4 --- /dev/null +++ b/test/e2e/app-dir/next-form/basepath/app/forms/button-formaction/page.tsx @@ -0,0 +1,15 @@ +import * as React from 'react' +import Form from 'next/form' + +export default function Home() { + return ( +
+ + {/* Note that unlike `action`, we have to include the basePath below */} + {/* vvvvvv */} + +
+ ) +} diff --git a/test/e2e/app-dir/next-form/basepath/app/layout.tsx b/test/e2e/app-dir/next-form/basepath/app/layout.tsx new file mode 100644 index 0000000000000..7e6180351862c --- /dev/null +++ b/test/e2e/app-dir/next-form/basepath/app/layout.tsx @@ -0,0 +1,9 @@ +import * as React from 'react' + +export default function Layout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/next-form/basepath/app/search/loading.tsx b/test/e2e/app-dir/next-form/basepath/app/search/loading.tsx new file mode 100644 index 0000000000000..72d5fcfd351b7 --- /dev/null +++ b/test/e2e/app-dir/next-form/basepath/app/search/loading.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export default function Loading() { + return
Loading...
+} diff --git a/test/e2e/app-dir/next-form/basepath/app/search/page.tsx b/test/e2e/app-dir/next-form/basepath/app/search/page.tsx new file mode 100644 index 0000000000000..67ee169cc717e --- /dev/null +++ b/test/e2e/app-dir/next-form/basepath/app/search/page.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' + +export default async function SearchPage({ searchParams }) { + const query = searchParams.query as string + await sleep(1000) + return
query: {JSON.stringify(query)}
+} + +function sleep(ms: number) { + return new Promise((res) => setTimeout(res, ms)) +} diff --git a/test/e2e/app-dir/next-form/basepath/next-form-basepath.test.ts b/test/e2e/app-dir/next-form/basepath/next-form-basepath.test.ts new file mode 100644 index 0000000000000..df9ad4f52fd83 --- /dev/null +++ b/test/e2e/app-dir/next-form/basepath/next-form-basepath.test.ts @@ -0,0 +1,43 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('app dir - form - with basepath', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('should add basePath to `action`', async () => { + const session = await next.browser('/base/forms/basic') + + const start = Date.now() + await session.eval(`window.__MPA_NAV_ID = ${start}`) + + const searchInput = await session.elementByCss('input[name="query"]') + await searchInput.fill('my search') + + const submitButton = await session.elementByCss('[type="submit"]') + await submitButton.click() + + const result = await session.waitForElementByCss('#search-results').text() + expect(result).toMatch(/query: "my search"/) + + expect(await session.eval(`window.__MPA_NAV_ID`)).toEqual(start) + }) + + it("should not add basePath to a submitter's formAction", async () => { + const session = await next.browser('/base/forms/button-formaction') + + const start = Date.now() + await session.eval(`window.__MPA_NAV_ID = ${start}`) + + const searchInput = await session.elementByCss('input[name="query"]') + await searchInput.fill('my search') + + const submitButton = await session.elementByCss('[type="submit"]') + await submitButton.click() + + const result = await session.waitForElementByCss('#search-results').text() + expect(result).toMatch(/query: "my search"/) + + expect(await session.eval(`window.__MPA_NAV_ID`)).toEqual(start) + }) +}) diff --git a/test/e2e/app-dir/next-form/basepath/next.config.js b/test/e2e/app-dir/next-form/basepath/next.config.js new file mode 100644 index 0000000000000..1773c854e2959 --- /dev/null +++ b/test/e2e/app-dir/next-form/basepath/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + basePath: '/base', +} diff --git a/test/e2e/app-dir/next-form/default/app/forms/basic/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/basic/page.tsx new file mode 100644 index 0000000000000..f0b1876904961 --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/forms/basic/page.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' +import Form from 'next/form' + +export default function Home() { + return ( +
+ + +
+ ) +} diff --git a/test/e2e/app-dir/next-form/default/app/forms/button-formaction-unsupported/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/button-formaction-unsupported/page.tsx new file mode 100644 index 0000000000000..50e079e2310ca --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/forms/button-formaction-unsupported/page.tsx @@ -0,0 +1,39 @@ +'use client' +import * as React from 'react' +import Form from 'next/form' + +export default function Home({ + searchParams, +}: { + searchParams: Record +}) { + const attribute = searchParams.attribute as string | undefined + return ( +
{ + // should fire if the form let the event bubble up + if (e.defaultPrevented) { + console.log('incorrect: default submit behavior was prevented') + } else { + console.log('correct: default submit behavior was not prevented') + e.preventDefault() // this submission will do something stupid, we don't want it to actually go through. + } + }} + > +
+ + +
+
+ ) +} diff --git a/test/e2e/app-dir/next-form/default/app/forms/button-formaction/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/button-formaction/page.tsx new file mode 100644 index 0000000000000..412fa7934689b --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/forms/button-formaction/page.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' +import Form from 'next/form' + +export default function Home() { + return ( +
+ + +
+ ) +} diff --git a/test/e2e/app-dir/next-form/default/app/forms/with-file-input/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/with-file-input/page.tsx new file mode 100644 index 0000000000000..68ff5b02ab4fe --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/forms/with-file-input/page.tsx @@ -0,0 +1,12 @@ +import * as React from 'react' +import Form from 'next/form' + +export default function Home() { + return ( +
+ + + +
+ ) +} diff --git a/test/e2e/app-dir/next-form/default/app/forms/with-function/action-client/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/with-function/action-client/page.tsx new file mode 100644 index 0000000000000..450f821db59cf --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/forms/with-function/action-client/page.tsx @@ -0,0 +1,27 @@ +'use client' +import * as React from 'react' +import { useActionState, useState } from 'react' +import Form from 'next/form' +import { useRouter } from 'next/navigation' + +export default function Page() { + const destination = '/redirected-from-action' + const router = useRouter() + const [, dispatch] = useActionState(() => { + const to = destination + '?' + new URLSearchParams({ query }) + router.push(to) + }, undefined) + + const [query, setQuery] = useState('') + return ( +
+ setQuery(e.target.value)} + /> + + +
+ ) +} diff --git a/test/e2e/app-dir/next-form/default/app/forms/with-function/action-server-closure/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/with-function/action-server-closure/page.tsx new file mode 100644 index 0000000000000..50bbb6dcadc08 --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/forms/with-function/action-server-closure/page.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' +import Form from 'next/form' +import { redirect } from 'next/navigation' + +export default function Page() { + const destination = '/redirected-from-action' + return ( +
{ + 'use server' + redirect( + destination + + '?' + + new URLSearchParams([...data.entries()] as [string, string][]) + ) + }} + id="search-form" + > + + + +
+ ) +} diff --git a/test/e2e/app-dir/next-form/default/app/forms/with-function/action-server/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/with-function/action-server/page.tsx new file mode 100644 index 0000000000000..bd668aaa7be5c --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/forms/with-function/action-server/page.tsx @@ -0,0 +1,23 @@ +import * as React from 'react' +import Form from 'next/form' +import { redirect } from 'next/navigation' + +export default function Page() { + return ( +
{ + 'use server' + redirect( + '/redirected-from-action' + + '?' + + new URLSearchParams([...data.entries()] as [string, string][]) + ) + }} + > + + + +
+ ) +} diff --git a/test/e2e/app-dir/next-form/default/app/forms/with-function/button-formaction-client/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/with-function/button-formaction-client/page.tsx new file mode 100644 index 0000000000000..eb049f62acb56 --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/forms/with-function/button-formaction-client/page.tsx @@ -0,0 +1,32 @@ +'use client' +import * as React from 'react' +import { type ComponentProps, useActionState, useState } from 'react' +import Form from 'next/form' +import { useRouter } from 'next/navigation' + +export default function Page() { + const destination = '/redirected-from-action' + const [query, setQuery] = useState('') + return ( +
+ setQuery(e.target.value)} + /> + + + Submit (client action) + +
+ ) +} + +function NavigateButton({ + to, + ...props +}: { to: string } & ComponentProps<'button'>) { + const router = useRouter() + const [, dispatch] = useActionState(() => router.push(to), undefined) + return + + ) +} diff --git a/test/e2e/app-dir/next-form/default/app/forms/with-function/button-formaction-server/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/with-function/button-formaction-server/page.tsx new file mode 100644 index 0000000000000..3e22b46c11e9c --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/forms/with-function/button-formaction-server/page.tsx @@ -0,0 +1,25 @@ +import * as React from 'react' +import Form from 'next/form' +import { redirect } from 'next/navigation' + +export default function Page() { + return ( +
+ + + +
+ ) +} diff --git a/test/e2e/app-dir/next-form/default/app/forms/with-onsubmit-preventdefault/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/with-onsubmit-preventdefault/page.tsx new file mode 100644 index 0000000000000..ab47915b97868 --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/forms/with-onsubmit-preventdefault/page.tsx @@ -0,0 +1,24 @@ +'use client' +import * as React from 'react' +import Form from 'next/form' +import { useRouter } from 'next/navigation' + +export default function Home() { + const router = useRouter() + return ( +
{ + e.preventDefault() + // `preventDefault()` should stop from running its navigation logic. + // if it doesn't (which'd be a bug), would call a `router.push` after this, + // and the last push wins, so this would be ignored. + router.push('/redirected-from-action') + }} + > + + +
+ ) +} diff --git a/test/e2e/app-dir/next-form/default/app/forms/with-replace/page.tsx b/test/e2e/app-dir/next-form/default/app/forms/with-replace/page.tsx new file mode 100644 index 0000000000000..59d756a5eb69b --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/forms/with-replace/page.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' +import Form from 'next/form' + +export default function Home() { + return ( +
+ + +
+ ) +} diff --git a/test/e2e/app-dir/next-form/default/app/layout.tsx b/test/e2e/app-dir/next-form/default/app/layout.tsx new file mode 100644 index 0000000000000..7e6180351862c --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/layout.tsx @@ -0,0 +1,9 @@ +import * as React from 'react' + +export default function Layout({ children }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/next-form/default/app/redirected-from-action/page.tsx b/test/e2e/app-dir/next-form/default/app/redirected-from-action/page.tsx new file mode 100644 index 0000000000000..83fef4979e14c --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/redirected-from-action/page.tsx @@ -0,0 +1,6 @@ +import * as React from 'react' + +export default function RedirectedPage({ searchParams }) { + const query = searchParams.query as string + return
query: {JSON.stringify(query)}
+} diff --git a/test/e2e/app-dir/next-form/default/app/search/loading.tsx b/test/e2e/app-dir/next-form/default/app/search/loading.tsx new file mode 100644 index 0000000000000..72d5fcfd351b7 --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/search/loading.tsx @@ -0,0 +1,5 @@ +import * as React from 'react' + +export default function Loading() { + return
Loading...
+} diff --git a/test/e2e/app-dir/next-form/default/app/search/page.tsx b/test/e2e/app-dir/next-form/default/app/search/page.tsx new file mode 100644 index 0000000000000..67ee169cc717e --- /dev/null +++ b/test/e2e/app-dir/next-form/default/app/search/page.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' + +export default async function SearchPage({ searchParams }) { + const query = searchParams.query as string + await sleep(1000) + return
query: {JSON.stringify(query)}
+} + +function sleep(ms: number) { + return new Promise((res) => setTimeout(res, ms)) +} diff --git a/test/e2e/app-dir/next-form/default/next-form.test.ts b/test/e2e/app-dir/next-form/default/next-form.test.ts new file mode 100644 index 0000000000000..5d3dd04743864 --- /dev/null +++ b/test/e2e/app-dir/next-form/default/next-form.test.ts @@ -0,0 +1,256 @@ +import { nextTestSetup } from 'e2e-utils' +import { BrowserInterface } from '../../../../lib/next-webdriver' + +describe('app dir - form', () => { + const { next, isNextDev } = nextTestSetup({ + files: __dirname, + }) + + it('should soft-navigate on submit and show the prefetched loading state', async () => { + const session = await next.browser('/forms/basic') + const navigationTracker = await trackMpaNavs(session) + + const searchInput = await session.elementByCss('input[name="query"]') + await searchInput.fill('my search') + + const submitButton = await session.elementByCss('[type="submit"]') + await submitButton.click() + + // we should have prefetched a loading state, so it should be displayed + await session.waitForElementByCss('#loading') + + const result = await session.waitForElementByCss('#search-results').text() + expect(result).toMatch(/query: "my search"/) + + expect(await navigationTracker.didMpaNavigate()).toBe(false) + }) + + it('should soft-navigate to the formAction url of the submitter', async () => { + const session = await next.browser('/forms/button-formaction') + const navigationTracker = await trackMpaNavs(session) + + const searchInput = await session.elementByCss('input[name="query"]') + await searchInput.fill('my search') + + const submitButton = await session.elementByCss('[type="submit"]') + await submitButton.click() + + // we didn't prefetch a loading state, so we don't know if it'll be displayed + // TODO: is this correct? it'll probably be there in dev, but what about prod? + // await session.waitForElementByCss('#loading') + + const result = await session.waitForElementByCss('#search-results').text() + expect(result).toMatch(/query: "my search"/) + + expect(await navigationTracker.didMpaNavigate()).toBe(false) + }) + + describe('functions passed to action', () => { + it.each([ + { + name: 'client action', + path: '/forms/with-function/action-client', + }, + { + name: 'server action', + path: '/forms/with-function/action-server', + }, + { + name: 'server action (closure)', + path: '/forms/with-function/action-server-closure', + }, + ])('runs $name', async ({ path }) => { + const session = await next.browser(path) + const navigationTracker = await trackMpaNavs(session) // actions should not MPA-navigate either. + + const searchInput = await session.elementByCss('input[name="query"]') + await searchInput.fill('will not be a search') + + const submitButton = await session.elementByCss('[type="submit"]') + await submitButton.click() + + const result = await session + .waitForElementByCss('#redirected-results') + .text() + expect(result).toMatch(/query: "will not be a search"/) + + expect(await navigationTracker.didMpaNavigate()).toBe(false) + }) + }) + + describe('functions passed to formAction', () => { + it.each([ + { + name: 'client action', + path: '/forms/with-function/button-formaction-client', + }, + { + name: 'server action', + path: '/forms/with-function/button-formaction-server', + }, + { + name: 'server action (closure)', + path: '/forms/with-function/button-formaction-server-closure', + }, + ])( + "runs $name from submitter and doesn't warn about unsupported attributes", + async ({ path }) => { + const session = await next.browser(path) + const navigationTracker = await trackMpaNavs(session) // actions should not MPA-navigate either. + + const searchInput = await session.elementByCss('input[name="query"]') + await searchInput.fill('will not be a search') + + const submitButton = await session.elementByCss('[type="submit"]') + await submitButton.click() + + const result = await session + .waitForElementByCss('#redirected-results') + .text() + expect(result).toMatch(/query: "will not be a search"/) + + expect(await navigationTracker.didMpaNavigate()).toBe(false) + + if (isNextDev) { + const logs = (await session.log()).map((item) => item.message) + + expect(logs).not.toContainEqual( + expect.stringMatching( + /
's `.+?` was set to an unsupported value/ + ) + ) + } + } + ) + }) + + describe('unsupported attributes on submitter', () => { + it.each([ + { name: 'formEncType', baseName: 'encType' }, + { name: 'formMethod', baseName: 'method' }, + { name: 'formTarget', baseName: 'target' }, + ])( + 'should warn if submitter sets "$name" to an unsupported value and fall back to default submit behavior', + async ({ name: attributeName, baseName: attributeBaseName }) => { + const session = await next.browser( + `/forms/button-formaction-unsupported?attribute=${attributeName}` + ) + + const submitButton = await session.elementByCss('[type="submit"]') + await submitButton.click() + + const logs = await session.log() + + if (isNextDev) { + expect(logs).toContainEqual( + expect.objectContaining({ + source: 'error', + message: expect.stringContaining( + `'s \`${attributeBaseName}\` was set to an unsupported value` + ), + }) + ) + } + + expect(logs).toContainEqual( + expect.objectContaining({ + source: 'log', + message: expect.stringContaining( + 'correct: default submit behavior was not prevented' + ), + }) + ) + expect(logs).not.toContainEqual( + expect.objectContaining({ + source: 'log', + message: expect.stringContaining( + 'incorrect: default submit behavior was prevented' + ), + }) + ) + } + ) + }) + + it('does not push a new history entry if `replace` is passed', async () => { + const session = await next.browser(`/forms/with-replace`) + const navigationTracker = await trackMpaNavs(session) + + // apparently this is usually not 1...? + const prevHistoryLength: number = await session.eval(`history.length`) + + const submitButton = await session.elementByCss('[type="submit"]') + await submitButton.click() + + await session.waitForElementByCss('#search-results') + + expect(await navigationTracker.didMpaNavigate()).toBe(false) + expect(await session.eval(`history.length`)).toEqual(prevHistoryLength) + }) + + it('does not navigate if preventDefault is called in onSubmit', async () => { + const session = await next.browser(`/forms/with-onsubmit-preventdefault`) + + const submitButton = await session.elementByCss('[type="submit"]') + await submitButton.click() + + // see fixture code for explanation why we expect this + + await session.waitForElementByCss('#redirected-results') + expect(new URL(await session.url()).pathname).toEqual( + '/redirected-from-action' + ) + }) + + it('url-encodes file inputs, but warns about them', async () => { + const session = await next.browser(`/forms/with-file-input`) + + const fileInputSelector = 'input[type="file"]' + // Fake a file to upload + await session.eval(` + const fileInput = document.querySelector(${JSON.stringify(fileInputSelector)}); + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }); + const list = new DataTransfer(); + list.items.add(file); + fileInput.files = list.files; + `) + + const searchInput = await session.elementByCss('input[name="query"]') + await searchInput.fill('my search') + + const submitButton = await session.elementByCss('[type="submit"]') + await submitButton.click() + + if (isNextDev) { + const logs = await session.log() + expect(logs).toContainEqual( + expect.objectContaining({ + source: 'warning', + message: expect.stringContaining( + ` only supports file inputs if \`action\` is a function` + ), + }) + ) + } + + const result = await session.waitForElementByCss('#search-results').text() + expect(result).toMatch(/query: "my search"/) + + const url = new URL(await session.url()) + expect([...url.searchParams.entries()]).toEqual([ + ['query', 'my search'], + ['file', 'hello.txt'], + ]) + }) +}) + +async function trackMpaNavs(session: BrowserInterface) { + const id = Date.now() + await session.eval(`window.__MPA_NAV_ID = ${id}`) + return { + async didMpaNavigate() { + const maybeId = await session.eval(`window.__MPA_NAV_ID`) + return id !== maybeId + }, + } +}