mirror of
https://github.com/withastro/astro.git
synced 2025-02-17 22:44:24 -05:00
Support form submissions in the ViewTransitions router (#8963)
* 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>
This commit is contained in:
parent
8a51afdf50
commit
fda3a0213b
9 changed files with 204 additions and 8 deletions
43
.changeset/many-weeks-sort.md
Normal file
43
.changeset/many-weeks-sort.md
Normal file
|
@ -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
packages/astro/client.d.ts
vendored
1
packages/astro/client.d.ts
vendored
|
@ -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' {
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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'));
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue