Skip to content

Commit 5259a84

Browse files
Rich-Harriselliott-with-the-longest-name-on-githubgeoffrichPatrickGignatiusmb
authored
Client-side routing for <form method="GET"> (#7828)
* failing test for #7251 * separate find_anchor from get_link_options * get things basically working * allow opt-out * oops * docs * Update packages/kit/src/runtime/client/client.js * Update packages/kit/src/runtime/client/client.js Co-authored-by: S. Elliott Johnson <[email protected]> * Update packages/kit/src/runtime/client/client.js Co-authored-by: S. Elliott Johnson <[email protected]> * Update documentation/docs/20-core-concepts/30-form-actions.md Co-authored-by: Geoff Rich <[email protected]> * fix typescript things * Update packages/kit/src/runtime/client/client.js Co-authored-by: Patrick <[email protected]> * feat: Make types less gross looking (#7883) * remove suggestion remnant * Create soft-timers-thank.md Co-authored-by: S. Elliott Johnson <[email protected]> Co-authored-by: Geoff Rich <[email protected]> Co-authored-by: Patrick <[email protected]> Co-authored-by: Ignatius Bagus <[email protected]>
1 parent e407795 commit 5259a84

File tree

7 files changed

+202
-69
lines changed

7 files changed

+202
-69
lines changed

.changeset/soft-timers-thank.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sveltejs/kit": patch
3+
---
4+
5+
[breaking] Use client-side routing for `<form method="GET">`

documentation/docs/20-core-concepts/30-form-actions.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,3 +439,17 @@ const response = await fetch(this.action, {
439439
### Alternatives
440440

441441
Form actions are the preferred way to send data to the server, since they can be progressively enhanced, but you can also use [`+server.js`](/docs/routing#server) files to expose (for example) a JSON API.
442+
443+
### GET vs POST
444+
445+
As we've seen, to invoke a form action you must use `method="POST"`.
446+
447+
Some forms don't need to `POST` data to the server — search inputs, for example. For these you can use `method="GET"` (or, equivalently, no `method` at all), and SvelteKit will treat them like `<a>` elements, using the client-side router instead of a full page navigation:
448+
449+
```html
450+
<form action="/search">
451+
<input name="q">
452+
</form>
453+
```
454+
455+
As with `<a>` elements, you can set the [`data-sveltekit-reload`](/docs/link-options#data-sveltekit-reload) and [`data-sveltekit-noscroll`](/docs/link-options#data-sveltekit-noscroll) attributes on the `<form>` to control the router's behaviour.

packages/kit/src/runtime/client/client.js

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import {
66
normalize_path,
77
add_data_suffix
88
} from '../../utils/url.js';
9-
import { find_anchor, get_base_uri, is_external_url, scroll_state } from './utils.js';
9+
import {
10+
find_anchor,
11+
get_base_uri,
12+
get_link_info,
13+
get_router_options,
14+
is_external_url,
15+
scroll_state
16+
} from './utils.js';
1017
import {
1118
lock_fetch,
1219
unlock_fetch,
@@ -1221,9 +1228,15 @@ export function create_client({ target, base }) {
12211228
* @param {number} priority
12221229
*/
12231230
function preload(element, priority) {
1224-
const { url, options, external } = find_anchor(element, base);
1231+
const a = find_anchor(element, target);
1232+
if (!a) return;
1233+
1234+
const { url, external } = get_link_info(a, base);
1235+
if (external) return;
12251236

1226-
if (!external) {
1237+
const options = get_router_options(a);
1238+
1239+
if (!options.reload) {
12271240
if (priority <= options.preload_data) {
12281241
preload_data(/** @type {URL} */ (url));
12291242
} else if (priority <= options.preload_code) {
@@ -1236,10 +1249,12 @@ export function create_client({ target, base }) {
12361249
observer.disconnect();
12371250

12381251
for (const a of target.querySelectorAll('a')) {
1239-
const { url, external, options } = find_anchor(a, base);
1240-
1252+
const { url, external } = get_link_info(a, base);
12411253
if (external) continue;
12421254

1255+
const options = get_router_options(a);
1256+
if (options.reload) continue;
1257+
12431258
if (options.preload_code === PRELOAD_PRIORITIES.viewport) {
12441259
observer.observe(a);
12451260
}
@@ -1444,11 +1459,12 @@ export function create_client({ target, base }) {
14441459
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
14451460
if (event.defaultPrevented) return;
14461461

1447-
const { a, url, options, has } = find_anchor(
1448-
/** @type {Element} */ (event.composedPath()[0]),
1449-
base
1450-
);
1451-
if (!a || !url) return;
1462+
const a = find_anchor(/** @type {Element} */ (event.composedPath()[0]), target);
1463+
if (!a) return;
1464+
1465+
const { url, external, has } = get_link_info(a, base);
1466+
const options = get_router_options(a);
1467+
if (!url) return;
14521468

14531469
const is_svg_a_element = a instanceof SVGAElement;
14541470

@@ -1470,7 +1486,7 @@ export function create_client({ target, base }) {
14701486
if (has.download) return;
14711487

14721488
// Ignore the following but fire beforeNavigate
1473-
if (options.reload || has.rel_external || has.target) {
1489+
if (external || options.reload) {
14741490
const navigation = before_navigate({ url, type: 'link' });
14751491
if (!navigation) {
14761492
event.preventDefault();
@@ -1513,6 +1529,54 @@ export function create_client({ target, base }) {
15131529
});
15141530
});
15151531

1532+
target.addEventListener('submit', (event) => {
1533+
if (event.defaultPrevented) return;
1534+
1535+
const form = /** @type {HTMLFormElement} */ (
1536+
HTMLFormElement.prototype.cloneNode.call(event.target)
1537+
);
1538+
1539+
const submitter = /** @type {HTMLButtonElement | HTMLInputElement | null} */ (
1540+
event.submitter
1541+
);
1542+
1543+
const method = submitter?.formMethod || form.method;
1544+
1545+
if (method !== 'get') return;
1546+
1547+
const url = new URL(
1548+
(event.submitter?.hasAttribute('formaction') && submitter?.formAction) || form.action
1549+
);
1550+
1551+
if (is_external_url(url, base)) return;
1552+
1553+
const { noscroll, reload } = get_router_options(
1554+
/** @type {HTMLFormElement} */ (event.target)
1555+
);
1556+
if (reload) return;
1557+
1558+
event.preventDefault();
1559+
event.stopPropagation();
1560+
1561+
// @ts-expect-error `URLSearchParams(fd)` is kosher, but typescript doesn't know that
1562+
url.search = new URLSearchParams(new FormData(event.target)).toString();
1563+
1564+
navigate({
1565+
url,
1566+
scroll: noscroll ? scroll_state() : null,
1567+
keepfocus: false,
1568+
redirect_chain: [],
1569+
details: {
1570+
state: {},
1571+
replaceState: false
1572+
},
1573+
nav_token: {},
1574+
accepted: () => {},
1575+
blocked: () => {},
1576+
type: 'form'
1577+
});
1578+
});
1579+
15161580
addEventListener('popstate', (event) => {
15171581
if (event.state?.[INDEX_KEY]) {
15181582
// if a popstate-driven navigation is cancelled, we need to counteract it

packages/kit/src/runtime/client/utils.js

Lines changed: 70 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,18 @@ const valid_link_options = /** @type {const} */ ({
3535
reload: ['', 'off']
3636
});
3737

38+
/**
39+
* @template {LinkOptionName} T
40+
* @typedef {typeof valid_link_options[T][number]} ValidLinkOptions
41+
*/
42+
3843
/**
3944
* @template {LinkOptionName} T
4045
* @param {Element} element
4146
* @param {T} name
4247
*/
4348
function link_option(element, name) {
44-
const value = /** @type {typeof valid_link_options[T][number] | null} */ (
49+
const value = /** @type {ValidLinkOptions<T> | null} */ (
4550
element.getAttribute(`data-sveltekit-${name}`)
4651
);
4752

@@ -52,7 +57,7 @@ function link_option(element, name) {
5257

5358
/**
5459
* @template {LinkOptionName} T
55-
* @template {typeof valid_link_options[T][number] | null} U
60+
* @template {ValidLinkOptions<T> | null} U
5661
* @param {Element} element
5762
* @param {T} name
5863
* @param {U} value
@@ -80,80 +85,88 @@ const levels = {
8085

8186
/**
8287
* @param {Element} element
83-
* @param {string} base
88+
* @returns {Element | null}
8489
*/
85-
export function find_anchor(element, base) {
86-
/** @type {HTMLAnchorElement | SVGAElement | undefined} */
87-
let a;
88-
89-
/** @type {typeof valid_link_options['noscroll'][number] | null} */
90-
let noscroll = null;
91-
92-
/** @type {typeof valid_link_options['preload-code'][number] | null} */
93-
let preload_code = null;
90+
function parent_element(element) {
91+
let parent = element.assignedSlot ?? element.parentNode;
9492

95-
/** @type {typeof valid_link_options['preload-data'][number] | null} */
96-
let preload_data = null;
97-
98-
/** @type {typeof valid_link_options['reload'][number] | null} */
99-
let reload = null;
93+
// @ts-expect-error handle shadow roots
94+
if (parent?.nodeType === 11) parent = parent.host;
10095

101-
while (element !== document.documentElement) {
102-
if (!a && element.nodeName.toUpperCase() === 'A') {
103-
// SVG <a> elements have a lowercase name
104-
a = /** @type {HTMLAnchorElement | SVGAElement} */ (element);
105-
}
96+
return /** @type {Element} */ (parent);
97+
}
10698

107-
if (a) {
108-
if (preload_code === null) preload_code = link_option(element, 'preload-code');
109-
if (preload_data === null) preload_data = link_option(element, 'preload-data');
110-
if (noscroll === null) noscroll = link_option(element, 'noscroll');
111-
if (reload === null) reload = link_option(element, 'reload');
99+
/**
100+
* @param {Element} element
101+
* @param {Element} target
102+
*/
103+
export function find_anchor(element, target) {
104+
while (element !== target) {
105+
if (element.nodeName.toUpperCase() === 'A') {
106+
return /** @type {HTMLAnchorElement | SVGAElement} */ (element);
112107
}
113108

114-
// @ts-expect-error handle shadow roots
115-
element = element.assignedSlot ?? element.parentNode;
116-
117-
// @ts-expect-error handle shadow roots
118-
if (element.nodeType === 11) element = element.host;
109+
element = /** @type {Element} */ (parent_element(element));
119110
}
111+
}
120112

113+
/**
114+
* @param {HTMLAnchorElement | SVGAElement} a
115+
* @param {string} base
116+
*/
117+
export function get_link_info(a, base) {
121118
/** @type {URL | undefined} */
122119
let url;
123120

124121
try {
125-
url = a && new URL(a instanceof SVGAElement ? a.href.baseVal : a.href, document.baseURI);
122+
url = new URL(a instanceof SVGAElement ? a.href.baseVal : a.href, document.baseURI);
126123
} catch {}
127124

128-
const options = {
129-
preload_code: levels[preload_code ?? 'off'],
130-
preload_data: levels[preload_data ?? 'off'],
131-
noscroll: noscroll === 'off' ? false : noscroll === '' ? true : null,
132-
reload: reload === 'off' ? false : reload === '' ? true : null
125+
const has = {
126+
rel_external: (a.getAttribute('rel') || '').split(/\s+/).includes('external'),
127+
download: a.hasAttribute('download'),
128+
target: !!(a instanceof SVGAElement ? a.target.baseVal : a.target)
133129
};
134130

135-
const has = a
136-
? {
137-
rel_external: (a.getAttribute('rel') || '').split(/\s+/).includes('external'),
138-
download: a.hasAttribute('download'),
139-
target: !!(a instanceof SVGAElement ? a.target.baseVal : a.target)
140-
}
141-
: {};
142-
143131
const external =
144-
!url ||
145-
is_external_url(url, base) ||
146-
options.reload ||
147-
has.rel_external ||
148-
has.target ||
149-
has.download;
132+
!url || is_external_url(url, base) || has.rel_external || has.target || has.download;
133+
134+
return { url, has, external };
135+
}
136+
137+
/**
138+
* @param {HTMLFormElement | HTMLAnchorElement | SVGAElement} element
139+
*/
140+
export function get_router_options(element) {
141+
/** @type {ValidLinkOptions<'noscroll'> | null} */
142+
let noscroll = null;
143+
144+
/** @type {ValidLinkOptions<'preload-code'> | null} */
145+
let preload_code = null;
146+
147+
/** @type {ValidLinkOptions<'preload-data'> | null} */
148+
let preload_data = null;
149+
150+
/** @type {ValidLinkOptions<'reload'> | null} */
151+
let reload = null;
152+
153+
/** @type {Element} */
154+
let el = element;
155+
156+
while (el !== document.documentElement) {
157+
if (preload_code === null) preload_code = link_option(el, 'preload-code');
158+
if (preload_data === null) preload_data = link_option(el, 'preload-data');
159+
if (noscroll === null) noscroll = link_option(el, 'noscroll');
160+
if (reload === null) reload = link_option(el, 'reload');
161+
162+
el = /** @type {Element} */ (parent_element(el));
163+
}
150164

151165
return {
152-
a,
153-
url,
154-
options,
155-
external,
156-
has
166+
preload_code: levels[preload_code ?? 'off'],
167+
preload_data: levels[preload_data ?? 'off'],
168+
noscroll: noscroll === 'off' ? false : noscroll === '' ? true : null,
169+
reload: reload === 'off' ? false : reload === '' ? true : null
157170
};
158171
}
159172

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script>
2+
import { afterNavigate } from '$app/navigation';
3+
import { page } from '$app/stores';
4+
5+
let type = '...';
6+
7+
afterNavigate((navigation) => {
8+
type = /** @type {string} */ (navigation.type);
9+
});
10+
</script>
11+
12+
<h1>{$page.url.searchParams.get('q') ?? '...'}</h1>
13+
<h2>{type}</h2>
14+
15+
<form>
16+
<input name="q" />
17+
<button>submit</button>
18+
</form>

packages/kit/test/apps/basics/test/client.test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,22 @@ test.describe('Routing', () => {
814814
await page.click('[href="/routing/link-outside-app-target/target"]');
815815
expect(await page.textContent('h1')).toBe('target: 0');
816816
});
817+
818+
test('responds to <form method="GET"> submission without reload', async ({ page }) => {
819+
await page.goto('/routing/form-get');
820+
expect(await page.textContent('h1')).toBe('...');
821+
expect(await page.textContent('h2')).toBe('enter');
822+
823+
const requests = [];
824+
page.on('request', (request) => requests.push(request.url()));
825+
826+
await page.locator('input').fill('updated');
827+
await page.click('button');
828+
829+
expect(requests).toEqual([]);
830+
expect(await page.textContent('h1')).toBe('updated');
831+
expect(await page.textContent('h2')).toBe('form');
832+
});
817833
});
818834

819835
test.describe('Shadow DOM', () => {

0 commit comments

Comments
 (0)