summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.changeset/many-weeks-sort.md43
-rw-r--r--packages/astro/client.d.ts1
-rw-r--r--packages/astro/components/ViewTransitions.astro42
-rw-r--r--packages/astro/e2e/fixtures/view-transitions/src/components/Layout.astro2
-rw-r--r--packages/astro/e2e/fixtures/view-transitions/src/pages/contact.ts17
-rw-r--r--packages/astro/e2e/fixtures/view-transitions/src/pages/form-one.astro13
-rw-r--r--packages/astro/e2e/fixtures/view-transitions/src/pages/form-response.astro7
-rw-r--r--packages/astro/e2e/view-transitions.test.js66
-rw-r--r--packages/astro/src/transitions/router.ts21
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;
}