diff options
9 files changed, 204 insertions, 8 deletions
diff --git a/.changeset/many-weeks-sort.md b/.changeset/many-weeks-sort.md new file mode 100644 index 000000000..621daf8b6 --- /dev/null +++ b/.changeset/many-weeks-sort.md @@ -0,0 +1,43 @@ +--- +'astro': minor +--- + +Form support in View Transitions router + +The `<ViewTransitions />` router can now handle form submissions, allowing the same animated transitions and stateful UI retention on form posts that are already available on `<a>` links. With this addition, your Astro project can have animations in all of these scenarios: + +- Clicking links between pages. +- Making stateful changes in forms (e.g. updating site preferences). +- Manually triggering navigation via the `navigate()` API. + +This feature is opt-in for semver reasons and can be enabled by adding the `handleForms` prop to the `<ViewTransitions /> component: + +```astro +--- +// src/layouts/MainLayout.astro +import { ViewTransitions } from 'astro:transitions'; +--- + +<html> + <head> + <!-- ... --> + <ViewTransitions handleForms /> + </head> + <body> + <!-- ... --> + </body> +</html> +``` + +Just as with links, if you don't want the routing handling a form submission, you can opt out on a per-form basis with the `data-astro-reload` property: + +```astro +--- +// src/components/Contact.astro +--- +<form class="contact-form" action="/request" method="post" data-astro-reload> + <!-- ...--> +</form> +``` + +Form support works on post `method="get"` and `method="post"` forms. diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index 6ef5722b4..b318360e0 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -119,6 +119,7 @@ declare module 'astro:transitions/client' { export const supportsViewTransitions: TransitionRouterModule['supportsViewTransitions']; export const transitionEnabledOnThisPage: TransitionRouterModule['transitionEnabledOnThisPage']; export const navigate: TransitionRouterModule['navigate']; + export type Options = import('./dist/transitions/router.js').Options; } declare module 'astro:prefetch' { diff --git a/packages/astro/components/ViewTransitions.astro b/packages/astro/components/ViewTransitions.astro index 5ceed5d3d..df77d4af3 100644 --- a/packages/astro/components/ViewTransitions.astro +++ b/packages/astro/components/ViewTransitions.astro @@ -3,9 +3,10 @@ type Fallback = 'none' | 'animate' | 'swap'; export interface Props { fallback?: Fallback; + handleForms?: boolean; } -const { fallback = 'animate' } = Astro.props; +const { fallback = 'animate', handleForms } = Astro.props; --- <style is:global> @@ -24,10 +25,16 @@ const { fallback = 'animate' } = Astro.props; </style> <meta name="astro-view-transitions-enabled" content="true" /> <meta name="astro-view-transitions-fallback" content={fallback} /> +{ handleForms ? + <meta name="astro-view-transitions-forms" content="true" /> : + '' +} <script> +import type { Options } from 'astro:transitions/client'; import { supportsViewTransitions, navigate } from 'astro:transitions/client'; // NOTE: import from `astro/prefetch` as `astro:prefetch` requires the `prefetch` config to be enabled import { init } from 'astro/prefetch'; + export type Fallback = 'none' | 'animate' | 'swap'; function getFallback(): Fallback { @@ -38,6 +45,10 @@ const { fallback = 'animate' } = Astro.props; return 'animate'; } + function isReloadEl(el: HTMLElement): boolean { + return el.dataset.astroReload !== undefined; + } + if (supportsViewTransitions || getFallback() !== 'none') { document.addEventListener('click', (ev) => { let link = ev.target; @@ -50,7 +61,7 @@ const { fallback = 'animate' } = Astro.props; if ( !link || !(link instanceof HTMLAnchorElement) || - link.dataset.astroReload !== undefined || + isReloadEl(link) || link.hasAttribute('download') || !link.href || (link.target && link.target !== '_self') || @@ -72,6 +83,33 @@ const { fallback = 'animate' } = Astro.props; }); }); + if(document.querySelector('[name="astro-view-transitions-forms"]')) { + document.addEventListener('submit', (ev) => { + let el = ev.target as HTMLElement; + if ( + el.tagName !== 'FORM' || + isReloadEl(el) + ) { + return; + } + + const form = el as HTMLFormElement; + const formData = new FormData(form); + let action = form.action; + const options: Options = {}; + if(form.method === 'get') { + const params = new URLSearchParams(formData as any); + const url = new URL(action); + url.search = params.toString(); + action = url.toString(); + } else { + options.formData = formData; + } + ev.preventDefault(); + navigate(action, options); + }); + } + // @ts-expect-error injected by vite-plugin-transitions for treeshaking if (!__PREFETCH_DISABLED__) { init({ prefetchAll: true }); diff --git a/packages/astro/e2e/fixtures/view-transitions/src/components/Layout.astro b/packages/astro/e2e/fixtures/view-transitions/src/components/Layout.astro index 7ef7b93f8..ef82078e7 100644 --- a/packages/astro/e2e/fixtures/view-transitions/src/components/Layout.astro +++ b/packages/astro/e2e/fixtures/view-transitions/src/components/Layout.astro @@ -19,7 +19,7 @@ const { link } = Astro.props as Props; } </style> <link rel="stylesheet" href="/styles.css"> - <ViewTransitions /> + <ViewTransitions handleForms /> <DarkMode /> <meta name="script-executions" content="0"> <script is:inline defer> diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/contact.ts b/packages/astro/e2e/fixtures/view-transitions/src/pages/contact.ts new file mode 100644 index 000000000..055930dad --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/contact.ts @@ -0,0 +1,17 @@ +import type { APIContext } from 'astro'; + +export const POST = async ({ request, redirect }: APIContext) => { + const formData = await request.formData(); + const name = formData.get('name'); + const shouldThrow = formData.has('throw'); + if(shouldThrow) { + throw new Error('oh no!'); + } + + return redirect(`/form-response?name=${name}`); +} + +export const GET = async ({ url, redirect }: APIContext) => { + const name = url.searchParams.get('name'); + return redirect(`/form-response?name=${name}`); +} diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/form-one.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/form-one.astro new file mode 100644 index 000000000..daa03b723 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/form-one.astro @@ -0,0 +1,13 @@ +--- +import Layout from '../components/Layout.astro'; +const method = Astro.url.searchParams.get('method') ?? 'POST'; +const postShowThrow = Astro.url.searchParams.has('throw') ?? false; +--- +<Layout> + <h2>Contact Form</h2> + <form action="/contact" method={method}> + <input type="hidden" name="name" value="Testing"> + {postShowThrow ? <input type="hidden" name="throw" value="true"> : ''} + <input type="submit" value="Submit" id="submit"> + </form> +</Layout> diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/form-response.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/form-response.astro new file mode 100644 index 000000000..c98beb20b --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/form-response.astro @@ -0,0 +1,7 @@ +--- +import Layout from '../components/Layout.astro'; +const name = Astro.url.searchParams.get('name'); +--- +<Layout> + <div>Submitted contact: <span id="contact-name">{name}</span></div> +</Layout> diff --git a/packages/astro/e2e/view-transitions.test.js b/packages/astro/e2e/view-transitions.test.js index 06a90366b..2a1444258 100644 --- a/packages/astro/e2e/view-transitions.test.js +++ b/packages/astro/e2e/view-transitions.test.js @@ -888,6 +888,72 @@ test.describe('View Transitions', () => { await expect(locator).toHaveValue('Hello World'); }); + test('form POST that redirects to another page is handled', async ({ page, astro }) => { + const loads = []; + page.addListener('load', async (p) => { + loads.push(p); + }); + + await page.goto(astro.resolveUrl('/form-one')); + + let locator = page.locator('h2'); + await expect(locator, 'should have content').toHaveText('Contact Form'); + + // Submit the form + await page.click('#submit'); + const span = page.locator('#contact-name'); + await expect(span, 'should have content').toHaveText('Testing'); + + expect( + loads.length, + 'There should be only 1 page load. No additional loads for the form submission' + ).toEqual(1); + }); + + test('form GET that redirects to another page is handled', async ({ page, astro }) => { + const loads = []; + page.addListener('load', async (p) => { + loads.push(p); + }); + + await page.goto(astro.resolveUrl('/form-one?method=get')); + + let locator = page.locator('h2'); + await expect(locator, 'should have content').toHaveText('Contact Form'); + + // Submit the form + await page.click('#submit'); + const span = page.locator('#contact-name'); + await expect(span, 'should have content').toHaveText('Testing'); + + expect( + loads.length, + 'There should be only 1 page load. No additional loads for the form submission' + ).toEqual(1); + }); + + test('form POST when there is an error shows the error', async ({ page, astro }) => { + const loads = []; + page.addListener('load', async (p) => { + loads.push(p); + }); + + await page.goto(astro.resolveUrl('/form-one?throw')); + + let locator = page.locator('h2'); + await expect(locator, 'should have content').toHaveText('Contact Form'); + + // Submit the form + await page.click('#submit'); + const overlay = page.locator('vite-error-overlay'); + await expect(overlay).toBeVisible(); + + expect( + loads.length, + 'There should be only 1 page load. No additional loads for the form submission' + ).toEqual(1); + }); + test('Route announcer is invisible on page transition', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/no-directive-one')); diff --git a/packages/astro/src/transitions/router.ts b/packages/astro/src/transitions/router.ts index da5496f81..eeb5fffca 100644 --- a/packages/astro/src/transitions/router.ts +++ b/packages/astro/src/transitions/router.ts @@ -1,6 +1,9 @@ export type Fallback = 'none' | 'animate' | 'swap'; export type Direction = 'forward' | 'back'; -export type Options = { history?: 'auto' | 'push' | 'replace' }; +export type Options = { + history?: 'auto' | 'push' | 'replace'; + formData?: FormData; +}; type State = { index: number; @@ -91,10 +94,11 @@ const throttle = (cb: (...args: any[]) => any, delay: number) => { // returns the contents of the page or null if the router can't deal with it. async function fetchHTML( - href: string + href: string, + init?: RequestInit ): Promise<null | { html: string; redirected?: string; mediaType: DOMParserSupportedType }> { try { - const res = await fetch(href); + const res = await fetch(href, init); // drop potential charset (+ other name/value pairs) as parser needs the mediaType const mediaType = res.headers.get('content-type')?.replace(/;.*$/, ''); // the DOMParser can handle two types of HTML @@ -378,7 +382,12 @@ async function transition( ) { let finished: Promise<void>; const href = toLocation.href; - const response = await fetchHTML(href); + const init: RequestInit = {}; + if(options.formData) { + init.method = 'POST'; + init.body = options.formData; + } + const response = await fetchHTML(href, init); // If there is a problem fetching the new page, just do an MPA navigation to it. if (response === null) { location.href = href; @@ -398,7 +407,9 @@ async function transition( // see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString newDocument.querySelectorAll('noscript').forEach((el) => el.remove()); - if (!newDocument.querySelector('[name="astro-view-transitions-enabled"]')) { + // If ViewTransitions is not enabled on the incoming page, do a full page load to it. + // Unless this was a form submission, in which case we do not want to trigger another mutation. + if (!newDocument.querySelector('[name="astro-view-transitions-enabled"]') && !options.formData) { location.href = href; return; } |