Skip to content

Commit

Permalink
Support form submissions in the ViewTransitions router (#8963)
Browse files Browse the repository at this point in the history
* Support form submissions in the ViewTransitions router

* Align with navigate API, add `formData` option

* Change API to handleForms

* Add a changeset

* Add a test for non-200 responses

* Update .changeset/many-weeks-sort.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/many-weeks-sort.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Add a little more on why this is exciting!

* Update .changeset/many-weeks-sort.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Switch to e.g.

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
  • Loading branch information
matthewp and sarah11918 committed Nov 8, 2023
1 parent 8a51afd commit fda3a02
Show file tree
Hide file tree
Showing 9 changed files with 204 additions and 8 deletions.
43 changes: 43 additions & 0 deletions .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.
1 change: 1 addition & 0 deletions packages/astro/client.d.ts
Expand Up @@ -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' {
Expand Down
42 changes: 40 additions & 2 deletions packages/astro/components/ViewTransitions.astro
Expand Up @@ -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>
Expand All @@ -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 {
Expand All @@ -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;
Expand All @@ -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') ||
Expand All @@ -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 });
Expand Down
Expand Up @@ -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>
Expand Down
17 changes: 17 additions & 0 deletions 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}`);
}
@@ -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>
@@ -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>
66 changes: 66 additions & 0 deletions packages/astro/e2e/view-transitions.test.js
Expand Up @@ -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'));

Expand Down
21 changes: 16 additions & 5 deletions 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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down

0 comments on commit fda3a02

Please sign in to comment.