Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client-side routing for <form method="GET"> #7828

Merged
merged 16 commits into from Nov 30, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 14 additions & 0 deletions documentation/docs/20-core-concepts/30-form-actions.md
Expand Up @@ -439,3 +439,17 @@ const response = await fetch(this.action, {
### Alternatives

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.

### GET vs POST

As we've seen, to invoke a form action you must use `method="POST"`.

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:

```html
<form action="/search">
<input name="q">
</form>
```

As with `<a>` elements, you can use the [`data-sveltekit-reload`](/docs/link-options#data-sveltekit-reload) and [`data-sveltekit-noscroll`](/docs/link-options#data-sveltekit-noscroll) options to control the router's behaviour.
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
85 changes: 74 additions & 11 deletions packages/kit/src/runtime/client/client.js
Expand Up @@ -6,7 +6,14 @@ import {
normalize_path,
add_data_suffix
} from '../../utils/url.js';
import { find_anchor, get_base_uri, is_external_url, scroll_state } from './utils.js';
import {
find_anchor,
get_base_uri,
get_link_info,
get_router_options,
is_external_url,
scroll_state
} from './utils.js';
import {
lock_fetch,
unlock_fetch,
Expand Down Expand Up @@ -1221,9 +1228,15 @@ export function create_client({ target, base }) {
* @param {number} priority
*/
function preload(element, priority) {
const { url, options, external } = find_anchor(element, base);
const a = find_anchor(element, target);
if (!a) return;

const { url, external } = get_link_info(a, base);
if (external) return;

if (!external) {
const options = get_router_options(a);

if (!options.reload) {
if (priority <= options.preload_data) {
preload_data(/** @type {URL} */ (url));
} else if (priority <= options.preload_code) {
Expand All @@ -1236,9 +1249,10 @@ export function create_client({ target, base }) {
observer.disconnect();

for (const a of target.querySelectorAll('a')) {
const { url, external, options } = find_anchor(a, base);
const { url, external } = get_link_info(a, base);
const options = get_router_options(a);
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved

if (external) continue;
if (external || options.reload) continue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (external || options.reload) continue;

This is a remnant of my previous suggestion. Github didn't let me include this line in the selection because there was a deleted line in between.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, we also need to delete some of the empty line or lint would fail, removed in a separate commit.


if (options.preload_code === PRELOAD_PRIORITIES.viewport) {
observer.observe(a);
Expand Down Expand Up @@ -1444,11 +1458,12 @@ export function create_client({ target, base }) {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (event.defaultPrevented) return;

const { a, url, options, has } = find_anchor(
/** @type {Element} */ (event.composedPath()[0]),
base
);
if (!a || !url) return;
const a = find_anchor(/** @type {Element} */ (event.composedPath()[0]), target);
if (!a) return;

const { url, external, has } = get_link_info(a, base);
const options = get_router_options(a);
if (!url) return;

const is_svg_a_element = a instanceof SVGAElement;

Expand All @@ -1470,7 +1485,7 @@ export function create_client({ target, base }) {
if (has.download) return;

// Ignore the following but fire beforeNavigate
if (options.reload || has.rel_external || has.target) {
if (external || options.reload) {
const navigation = before_navigate({ url, type: 'link' });
if (!navigation) {
event.preventDefault();
Expand Down Expand Up @@ -1513,6 +1528,54 @@ export function create_client({ target, base }) {
});
});

target.addEventListener('submit', (event) => {
if (event.defaultPrevented) return;

const form = /** @type {HTMLFormElement} */ (
HTMLFormElement.prototype.cloneNode.call(event.target)
);

const submitter = /** @type {HTMLButtonElement | HTMLInputElement} */ (event.submitter);
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved

const method = event.submitter?.hasAttribute('formmethod')
? submitter.formMethod
: form.method;
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved

if (method !== 'get') return;

const url = new URL(
event.submitter?.hasAttribute('formaction') ? submitter.formAction : form.action
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
);

if (is_external_url(url, base)) return;

const { noscroll, reload } = get_router_options(
/** @type {HTMLFormElement} */ (event.target)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/** @type {HTMLFormElement} */ (event.target)
/** @type {HTMLFormElement | null} */ (event.target)

again, probably shouldn't pretend nullable things aren't possibly null and should just handle null in the function or return early

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is just TypeScript being dumb though, no? Could event.target ever actually be null?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not null in this case though, we know it exists. The types don't, that's why they play it safe.

);
if (reload) return;

event.preventDefault();
event.stopPropagation();

// @ts-ignore `URLSearchParams(fd)` is kosher, but typescript doesn't know that
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
url.search = new URLSearchParams(new FormData(event.target)).toString();

navigate({
url,
scroll: noscroll ? scroll_state() : null,
keepfocus: false,
redirect_chain: [],
details: {
state: {},
replaceState: false
},
nav_token: {},
accepted: () => {},
blocked: () => {},
type: 'form'
});
});

addEventListener('popstate', (event) => {
if (event.state?.[INDEX_KEY]) {
// if a popstate-driven navigation is cancelled, we need to counteract it
Expand Down
110 changes: 59 additions & 51 deletions packages/kit/src/runtime/client/utils.js
Expand Up @@ -80,12 +80,59 @@ const levels = {

/**
* @param {Element} element
* @returns {Element | null}
*/
function parent_element(element) {
let parent = element.assignedSlot ?? element.parentNode;

// @ts-expect-error handle shadow roots
if (parent?.nodeType === 11) parent = parent.host;

return /** @type {Element} */ (parent);
}

/**
* @param {Element} element
* @param {Element} target
*/
export function find_anchor(element, target) {
while (element !== target) {
if (element.nodeName.toUpperCase() === 'A') {
return /** @type {HTMLAnchorElement | SVGAElement} */ (element);
}

element = /** @type {Element} */ (parent_element(element));
}
}
Copy link
Contributor

@PatrickG PatrickG Nov 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export function find_anchor(element, target) {
while (element !== target) {
if (element.nodeName.toUpperCase() === 'A') {
return /** @type {HTMLAnchorElement | SVGAElement} */ (element);
}
element = /** @type {Element} */ (parent_element(element));
}
}
export function find_anchor(element, target) {
if (element.nodeName.toUpperCase() !== 'A') {
element = element.closest('a');
}
if (element && target.contains(element)) {
return /** @type {HTMLAnchorElement | SVGAElement} */ (element);
}
}

I'm not sure if this is considered premature optimization already, but I thought it might be worth a suggestion.
I'm not a benchmark expert, but I created this (probably unrealistic) benchmark which hints that closest + contains (which we need anyway) might be faster than the while loop:
https://measurethat.net/Benchmarks/Show/22320/0/find-anchor

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've no doubt that's true, but unfortunately closest() doesn't work, because it can't find elements in parent shadow DOM — yet another reason adding it to the web platform was a mistake

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(incidentally, element.closest(selector) will return element if it matches selector, so if this did work — if we decided not to bother with shadow DOM, on the basis that the wounds are self-inflicted — we could dispense with the first if block)

Copy link
Contributor

@PatrickG PatrickG Nov 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've no doubt that's true, but unfortunately closest() doesn't work, because it can't find elements in parent shadow DOM — yet another reason adding it to the web platform was a mistake

Oh, makes sense. I didn't thought about that.

(incidentally, element.closest(selector) will return element if it matches selector, so if this did work — if we decided not to bother with shadow DOM, on the basis that the wounds are self-inflicted — we could dispense with the first if block)

I included this element.nodeName.toUpperCase() !== 'A' check because it was so much faster than .closest('a') on an HTMLAnchorElement in my tests.


/**
* @param {HTMLAnchorElement | SVGAElement} a
* @param {string} base
*/
export function find_anchor(element, base) {
/** @type {HTMLAnchorElement | SVGAElement | undefined} */
let a;
export function get_link_info(a, base) {
/** @type {URL | undefined} */
let url;

try {
url = new URL(a instanceof SVGAElement ? a.href.baseVal : a.href, document.baseURI);
} catch {}

const has = {
rel_external: (a.getAttribute('rel') || '').split(/\s+/).includes('external'),
download: a.hasAttribute('download'),
target: !!(a instanceof SVGAElement ? a.target.baseVal : a.target)
};

const external =
!url || is_external_url(url, base) || has.rel_external || has.target || has.download;

return { url, has, external };
}

/**
* @param {HTMLFormElement | HTMLAnchorElement | SVGAElement} element
*/
export function get_router_options(element) {
/** @type {typeof valid_link_options['noscroll'][number] | null} */
let noscroll = null;

Expand All @@ -98,63 +145,24 @@ export function find_anchor(element, base) {
/** @type {typeof valid_link_options['reload'][number] | null} */
let reload = null;

while (element !== document.documentElement) {
if (!a && element.nodeName.toUpperCase() === 'A') {
// SVG <a> elements have a lowercase name
a = /** @type {HTMLAnchorElement | SVGAElement} */ (element);
}
/** @type {Element} */
let el = element;

if (a) {
if (preload_code === null) preload_code = link_option(element, 'preload-code');
if (preload_data === null) preload_data = link_option(element, 'preload-data');
if (noscroll === null) noscroll = link_option(element, 'noscroll');
if (reload === null) reload = link_option(element, 'reload');
}

// @ts-expect-error handle shadow roots
element = element.assignedSlot ?? element.parentNode;
while (el !== document.documentElement) {
if (preload_code === null) preload_code = link_option(el, 'preload-code');
if (preload_data === null) preload_data = link_option(el, 'preload-data');
if (noscroll === null) noscroll = link_option(el, 'noscroll');
if (reload === null) reload = link_option(el, 'reload');

// @ts-expect-error handle shadow roots
if (element.nodeType === 11) element = element.host;
el = /** @type {Element} */ (parent_element(el));
}

/** @type {URL | undefined} */
let url;

try {
url = a && new URL(a instanceof SVGAElement ? a.href.baseVal : a.href, document.baseURI);
} catch {}

const options = {
return {
preload_code: levels[preload_code ?? 'off'],
preload_data: levels[preload_data ?? 'off'],
noscroll: noscroll === 'off' ? false : noscroll === '' ? true : null,
reload: reload === 'off' ? false : reload === '' ? true : null
};

const has = a
? {
rel_external: (a.getAttribute('rel') || '').split(/\s+/).includes('external'),
download: a.hasAttribute('download'),
target: !!(a instanceof SVGAElement ? a.target.baseVal : a.target)
}
: {};

const external =
!url ||
is_external_url(url, base) ||
options.reload ||
has.rel_external ||
has.target ||
has.download;

return {
a,
url,
options,
external,
has
};
}

/** @param {any} value */
Expand Down
@@ -0,0 +1,18 @@
<script>
import { afterNavigate } from '$app/navigation';
import { page } from '$app/stores';

let type = '...';

afterNavigate((navigation) => {
type = /** @type {string} */ (navigation.type);
});
</script>

<h1>{$page.url.searchParams.get('q') ?? '...'}</h1>
<h2>{type}</h2>

<form>
<input name="q" />
<button>submit</button>
</form>
16 changes: 16 additions & 0 deletions packages/kit/test/apps/basics/test/client.test.js
Expand Up @@ -814,6 +814,22 @@ test.describe('Routing', () => {
await page.click('[href="/routing/link-outside-app-target/target"]');
expect(await page.textContent('h1')).toBe('target: 0');
});

test('responds to <form method="GET"> submission without reload', async ({ page }) => {
await page.goto('/routing/form-get');
expect(await page.textContent('h1')).toBe('...');
expect(await page.textContent('h2')).toBe('enter');

const requests = [];
page.on('request', (request) => requests.push(request.url()));

await page.locator('input').fill('updated');
await page.click('button');

expect(requests).toEqual([]);
expect(await page.textContent('h1')).toBe('updated');
expect(await page.textContent('h2')).toBe('form');
});
});

test.describe('Shadow DOM', () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/kit/types/index.d.ts
Expand Up @@ -715,12 +715,13 @@ export interface NavigationTarget {

/**
* - `enter`: The app has hydrated
* - `form`: The user submitted a `<form>`
* - `leave`: The user is leaving the app by closing the tab or using the back/forward buttons to go to a different document
* - `link`: Navigation was triggered by a link click
* - `goto`: Navigation was triggered by a `goto(...)` call or a redirect
* - `popstate`: Navigation was triggered by back/forward navigation
*/
export type NavigationType = 'enter' | 'leave' | 'link' | 'goto' | 'popstate';
export type NavigationType = 'enter' | 'form' | 'leave' | 'link' | 'goto' | 'popstate';

export interface Navigation {
/**
Expand All @@ -733,6 +734,7 @@ export interface Navigation {
to: NavigationTarget | null;
/**
* The type of navigation:
* - `form`: The user submitted a `<form>`
* - `leave`: The user is leaving the app by closing the tab or using the back/forward buttons to go to a different document
* - `link`: Navigation was triggered by a link click
* - `goto`: Navigation was triggered by a `goto(...)` call or a redirect
Expand Down Expand Up @@ -766,6 +768,7 @@ export interface AfterNavigate extends Navigation {
/**
* The type of navigation:
* - `enter`: The app has hydrated
* - `form`: The user submitted a `<form>`
* - `link`: Navigation was triggered by a link click
* - `goto`: Navigation was triggered by a `goto(...)` call or a redirect
* - `popstate`: Navigation was triggered by back/forward navigation
Expand Down