Skip to content

Commit 8fc8bca

Browse files
authored
GET Submissions: don't merge into searchParams (#461)
* Dispatch `turbo:before-fetch-request` with URL Dispatch `turbo:before-fetch-request` with a direct reference to the `FetchRequest` instance's `url: URL` property as part of the `event.detail`. * `GET` Submissions: don't merge into `searchParams` The background --- According to the HTML Specification's [§ 4.10.21.3 Form submission algorithm][] section, submissions transmitted as `GET` requests [mutate the `[action]` URL][mutate], overriding any search parameters already encoded into the `[action]` value: > [Mutate action URL][algorithm] > --- > > Let <var>pairs</var> be the result of converting to a list of > name-value pairs with <var>entry list</var>. > > Let <var>query</var> be the result of running the > `application/x-www-form-urlencoded` serializer with <var>pairs</var> > and <var>encoding</var>. > > Set <Var>parsed action</var>'s query component to <var>query</var>. > > Plan to navigate to <var>parsed action</var>. [§ 4.10.21.3 Form submission algorithm]: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm [algorithm]: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#submit-mutate-action [mutate]: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm:submit-mutate-action Form submissions made with `POST` requests, on the other hand, encode _both_ the `[action]` value's query parameters and any additionally encoded body data: > [Submit as entity body][post-submit] > --- > > … > > Plan to navigate to a new request whose URL is <var>parsed > action</var>, method is <var>method</var>, header list is > « (`Content-Type`, mimeType) », and body is <var>body</var>. [post-submit]: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#submit-body The problem --- Ever since [18585fc][] (and subsequently [#108][]), Turbo's `FetchRequest` has merged submission values from `<form>` elements with `[method="get"]` and without `[action]` attributes _into_ the current URL's query parameters. For example, consider the following forms rendered on the `/articles` page: ```html <form> <label for="q">Term</label> <input id="q" name="q"> <button>Search</button> </form> <!-- elsewhere in the page --> <form> <button name="order" value="asc">Sort ascending</button> <button name="order" value="desc">Sort ascending</button> </form> ``` Without Turbo, entering "Hotwire" as the search term into `input[name="q"]` and clicking the "Search" button would navigate the browser to `/articles?q=Hotwire`. Then, clicking the "Sort ascending" button would navigate the page to `/articles?order=asc`. Without Turbo, entering "Hotwire" as the search term into `input[name="q"]` and clicking the "Search" button navigates the browser to `/articles?q=Hotwire`. Then, clicking the "Sort ascending" button **navigates the page to `/articles?q=Hotwire&order=asc`**, effectively _merging_ values from the page's URL and the `<form>` element's fields. [18585fc]: 18585fc#diff-68b647dc2963716dc27c070f261d0b942ee9c00be7c4149ecb3a5acd94842d40R135-R142 [#108]: #108 The solution --- This commit modifies the way that `FetchRequest` constructs its `url: URL` and `body: FormData | URLSearchParams` properties. First, it _always_ assigns a `body` property, but _conditionally_ encodes that value into the `fetchOptions: RequestInit` property based on whether or not the request is an idempotent `GET`. Next, if constructed with a `body: URLSearchParams` that has entries, **replace** the `url: URL` instance's search params _entirely_ with those values, like the HTML Specification algorithm entails. If constructed with a `body: URLSearchParams` that is empty, pass the `url: URL` through and assign the property **without modifying it**. Additionally, this commit adds test cases to ensure that `POST` requests transmit data in both the body and the URL. While the previous multi-form merging behavior can be desirable, it is not the behavior outlined by the HTML Specification, so Turbo should not provide it out-of-the-box. Having said that, there are two ways for applications to restore that behavior: 1. declare a [turbo:before-fetch-request][] event listener to merge values _into_ the event's `detail.url` instance: ```js addEventListener("turbo:before-fetch-request", ({ target, detail: { url, fetchOptions: { method } } }) => { if (target instanceof HTMLFormElement && method == "GET") { for (const [ name, value ] of new URLSearchParams(window.location.search)) { // conditionally call `set` or `append`, // depending on your application's needs if (url.searchParams.has(name)) continue else url.searchParams.set(name, value) } } }) ``` 2. declare a [formdata][] event listener to merge values _into_ the submitted form's [FormData][] instance prior to entering the Turbo request pipeline: ```js addEventListener("submit", (event) => { if (event.defaultPrevented) return const { target, submitter } = event const action = submitter?.getAttribute("formaction") || target.getAttribute("action") if (target.method == "get" && !action) { target.addEventListener("formdata", ({ formData }) => { for (const [ name, value ] of new URLSearchParams(window.location.search)) { // conditionally call `set` or `append`, // depending on your application's needs if (formData.has(name)) continue else formData.set(name, value) } }, { once: true }) } }) ``` The conditionals in both cases could be omitted if applications controlled the behavior more directly, like by declaring a Stimulus controller and action (e.g. `<form data-controller="form" data-action="formdata->form#mergeSearchParams">`). [turbo:before-fetch-request]: https://turbo.hotwired.dev/reference/events [formdata]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/formdata_event [FormData]: https://developer.mozilla.org/en-US/docs/Web/API/FormData
1 parent 4a9f220 commit 8fc8bca

File tree

6 files changed

+69
-15
lines changed

6 files changed

+69
-15
lines changed

src/core/frames/frame_controller.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { clearBusyState, getAttribute, parseHTMLDocument, markAsBusy } from "../
66
import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission"
77
import { Snapshot } from "../snapshot"
88
import { ViewDelegate } from "../view"
9-
import { getAction, expandURL, urlsAreEqual, locationIsVisitable, Locatable } from "../url"
9+
import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url"
1010
import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor"
1111
import { FrameView } from "./frame_view"
1212
import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor"
@@ -86,7 +86,7 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
8686
this.currentURL = this.sourceURL
8787
if (this.sourceURL) {
8888
try {
89-
this.element.loaded = this.visit(this.sourceURL)
89+
this.element.loaded = this.visit(expandURL(this.sourceURL))
9090
this.appearanceObserver.stop()
9191
await this.element.loaded
9292
this.hasBeenLoaded = true
@@ -235,8 +235,8 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
235235

236236
// Private
237237

238-
private async visit(url: Locatable) {
239-
const request = new FetchRequest(this, FetchMethod.get, expandURL(url), new URLSearchParams, this.element)
238+
private async visit(url: URL) {
239+
const request = new FetchRequest(this, FetchMethod.get, url, url.searchParams, this.element)
240240

241241
this.currentFetchRequest?.cancel()
242242
this.currentFetchRequest = request

src/http/fetch_request.ts

+13-8
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,10 @@ export class FetchRequest {
5656
this.delegate = delegate
5757
this.method = method
5858
this.headers = this.defaultHeaders
59-
if (this.isIdempotent) {
60-
this.url = mergeFormDataEntries(location, [ ...body.entries() ])
61-
} else {
62-
this.body = body
63-
this.url = location
64-
}
59+
this.body = body
60+
this.url = this.isIdempotent && this.entries.length ?
61+
mergeFormDataEntries(new URL(location.href), this.entries) :
62+
location
6563
this.target = target
6664
}
6765

@@ -118,7 +116,7 @@ export class FetchRequest {
118116
credentials: "same-origin",
119117
headers: this.headers,
120118
redirect: "follow",
121-
body: this.body,
119+
body: this.isIdempotent ? null : this.body,
122120
signal: this.abortSignal,
123121
referrer: this.delegate.referrer?.href
124122
}
@@ -144,7 +142,7 @@ export class FetchRequest {
144142
cancelable: true,
145143
detail: {
146144
fetchOptions,
147-
url: this.url.href,
145+
url: this.url,
148146
resume: this.resolveRequestPromise
149147
},
150148
target: this.target as EventTarget
@@ -155,6 +153,7 @@ export class FetchRequest {
155153

156154
function mergeFormDataEntries(url: URL, entries: [string, FormDataEntryValue][]): URL {
157155
const currentSearchParams = new URLSearchParams(url.search)
156+
deleteAll(url.searchParams)
158157

159158
for (const [ name, value ] of entries) {
160159
if (value instanceof File) continue
@@ -169,3 +168,9 @@ function mergeFormDataEntries(url: URL, entries: [string, FormDataEntryValue][])
169168

170169
return url
171170
}
171+
172+
function deleteAll(searchParams: URLSearchParams) {
173+
for (const name of searchParams.keys()) {
174+
searchParams.delete(name)
175+
}
176+
}

src/tests/fixtures/form.html

+12
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ <h1>Form</h1>
2525
<input type="hidden" name="greeting" value="Hello from a redirect">
2626
<input id="standard-get-form-submit" type="submit" value="form[method=get]">
2727
</form>
28+
<hr>
29+
<form>
30+
<button id="form-action-none-q-a" name="q" value="a">Submit ?q=a to form:not([action])</button>
31+
</form>
32+
<form action="/src/tests/fixtures/form.html?sort=asc">
33+
<button id="form-action-self-submit">Submit form[action]</button>
34+
<button id="form-action-self-q-b" name="q" value="b">Submit ?q=b to form[action]</button>
35+
</form>
36+
<form action="/__turbo/redirect?path=%2Fsrc%2Ftests%2Ffixtures%2Fform.html%3Fsort%3Dasc" method="post">
37+
<button id="form-action-post-redirect-self-q-b" name="q" value="b">POST q=b to form[action]</button>
38+
</form>
39+
<hr>
2840
<form action="/__turbo/messages" method="post" class="created">
2941
<input type="hidden" name="content" value="Hello!">
3042
<input type="submit" style="">

src/tests/functional/form_submission_tests.ts

+37
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ export class FormSubmissionTests extends TurboDriveTestCase {
8888
await this.nextEventNamed("turbo:load")
8989
}
9090

91+
async "test standard POST form submission merges values from both searchParams and body"() {
92+
await this.clickSelector("#form-action-post-redirect-self-q-b")
93+
await this.nextBody
94+
95+
this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
96+
this.assert.equal(await this.getSearchParam("q"), "b")
97+
this.assert.equal(await this.getSearchParam("sort"), "asc")
98+
}
99+
91100
async "test standard POST form submission toggles submitter [disabled] attribute"() {
92101
await this.clickSelector("#standard-post-form-submit")
93102

@@ -126,6 +135,34 @@ export class FormSubmissionTests extends TurboDriveTestCase {
126135
await this.nextEventNamed("turbo:load")
127136
}
128137

138+
async "test standard GET form submission does not incorporate the current page's URLSearchParams values into the submission"() {
139+
await this.clickSelector("#form-action-self-submit")
140+
await this.nextBody
141+
142+
this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
143+
this.assert.equal(await this.search, "?sort=asc")
144+
145+
await this.clickSelector("#form-action-none-q-a")
146+
await this.nextBody
147+
148+
this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
149+
this.assert.equal(await this.search, "?q=a", "navigates without omitted keys")
150+
}
151+
152+
async "test standard GET form submission does not merge values into the [action] attribute"() {
153+
await this.clickSelector("#form-action-self-submit")
154+
await this.nextBody
155+
156+
this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
157+
this.assert.equal(await this.search, "?sort=asc")
158+
159+
await this.clickSelector("#form-action-self-q-b")
160+
await this.nextBody
161+
162+
this.assert.equal(await this.pathname, "/src/tests/fixtures/form.html")
163+
this.assert.equal(await this.search, "?q=b", "navigates without omitted keys")
164+
}
165+
129166
async "test standard GET form submission toggles submitter [disabled] attribute"() {
130167
await this.clickSelector("#standard-get-form-submit")
131168

src/tests/functional/visit_tests.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export class VisitTests extends TurboDriveTestCase {
7676
const { url, fetchOptions } = await this.nextEventNamed("turbo:before-fetch-request")
7777

7878
this.assert.equal(fetchOptions.method, "GET")
79-
this.assert.include(url, "/src/tests/fixtures/one.html")
79+
this.assert.ok(url.toString().includes("/src/tests/fixtures/one.html"))
8080
}
8181

8282
async "test turbo:before-fetch-response open new site"() {

src/tests/server.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ router.use((request, response, next) => {
1919

2020
router.post("/redirect", (request, response) => {
2121
const { path, sleep, ...query } = request.body
22-
const pathname = path ?? "/src/tests/fixtures/one.html"
22+
const { pathname, query: searchParams } = url.parse(path ?? request.query.path ?? "/src/tests/fixtures/one.html", true)
2323
const enctype = request.get("Content-Type")
2424
if (enctype) {
2525
query.enctype = enctype
2626
}
27-
setTimeout(() => response.redirect(303, url.format({ pathname, query })), parseInt(sleep || "0", 10))
27+
setTimeout(() => response.redirect(303, url.format({ pathname, query: { ...query, ...searchParams } })), parseInt(sleep || "0", 10))
2828
})
2929

3030
router.get("/redirect", (request, response) => {

0 commit comments

Comments
 (0)