diff --git a/src/core/drive/form_submission.ts b/src/core/drive/form_submission.ts index e9433cf77..809a351ab 100644 --- a/src/core/drive/form_submission.ts +++ b/src/core/drive/form_submission.ts @@ -1,4 +1,12 @@ -import { FetchRequest, FetchMethod, fetchMethodFromString, FetchRequestHeaders } from "../../http/fetch_request" +import { + FetchEnctype, + FetchMethod, + FetchRequest, + FetchRequestHeaders, + fetchMethodFromString, + fetchEnctypeFromString, + isIdempotent, +} from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" import { expandURL } from "../url" import { dispatch, getAttribute, getMetaContent, hasAttribute } from "../../util" @@ -23,35 +31,17 @@ export enum FormSubmissionState { stopped, } -enum FormEnctype { - urlEncoded = "application/x-www-form-urlencoded", - multipart = "multipart/form-data", - plain = "text/plain", -} - export type TurboSubmitStartEvent = CustomEvent<{ formSubmission: FormSubmission }> export type TurboSubmitEndEvent = CustomEvent< { formSubmission: FormSubmission } & { [K in keyof FormSubmissionResult]?: FormSubmissionResult[K] } > -function formEnctypeFromString(encoding: string): FormEnctype { - switch (encoding.toLowerCase()) { - case FormEnctype.multipart: - return FormEnctype.multipart - case FormEnctype.plain: - return FormEnctype.plain - default: - return FormEnctype.urlEncoded - } -} - export class FormSubmission { readonly delegate: FormSubmissionDelegate readonly formElement: HTMLFormElement readonly submitter?: HTMLElement - readonly formData: FormData - readonly location: URL readonly fetchRequest: FetchRequest + readonly enctype: FetchEnctype readonly mustRedirect: boolean state = FormSubmissionState.initialized result?: FormSubmissionResult @@ -70,53 +60,44 @@ export class FormSubmission { submitter?: HTMLElement, mustRedirect = false ) { + const method = getMethod(formElement, submitter) + const action = getAction(getFormAction(formElement, submitter), method) + const body = getFormData(formElement, submitter) + const enctype = getEnctype(formElement, submitter) this.delegate = delegate + this.fetchRequest = new FetchRequest(this, action, formElement, method, body, enctype) this.formElement = formElement this.submitter = submitter - this.formData = buildFormData(formElement, submitter) - this.location = expandURL(this.action) - if (this.method == FetchMethod.get) { - mergeFormDataEntries(this.location, [...this.body.entries()]) - } - this.fetchRequest = new FetchRequest(this, this.method, this.location, this.body, this.formElement) + this.enctype = enctype this.mustRedirect = mustRedirect } - get method(): FetchMethod { - const method = this.submitter?.getAttribute("formmethod") || this.formElement.getAttribute("method") || "" - return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get + get location(): URL { + return this.fetchRequest.url } - get action(): string { - const formElementAction = typeof this.formElement.action === "string" ? this.formElement.action : null + get method() { + return this.fetchRequest.method + } - if (this.submitter?.hasAttribute("formaction")) { - return this.submitter.getAttribute("formaction") || "" - } else { - return this.formElement.getAttribute("action") || formElementAction || "" - } + set method(value: FetchMethod | string) { + this.fetchRequest.method = value } - get body() { - if (this.enctype == FormEnctype.urlEncoded || this.method == FetchMethod.get) { - return new URLSearchParams(this.stringFormData) - } else { - return this.formData - } + get action(): string { + return this.fetchRequest.url.toString() } - get enctype(): FormEnctype { - return formEnctypeFromString(this.submitter?.getAttribute("formenctype") || this.formElement.enctype) + set action(value: string) { + this.fetchRequest.url = expandURL(value) } - get isIdempotent() { - return this.fetchRequest.isIdempotent + get body() { + return this.fetchRequest.body } - get stringFormData() { - return [...this.formData].reduce((entries, [name, value]) => { - return entries.concat(typeof value == "string" ? [[name, value]] : []) - }, [] as [string, string][]) + get isIdempotent() { + return this.fetchRequest.isIdempotent } // The submission process @@ -220,7 +201,36 @@ export class FormSubmission { } } -function buildFormData(formElement: HTMLFormElement, submitter?: HTMLElement): FormData { +function getFormAction(formElement: HTMLFormElement, submitter: HTMLElement | undefined): string { + const formElementAction = typeof formElement.action === "string" ? formElement.action : null + + if (submitter?.hasAttribute("formaction")) { + return submitter.getAttribute("formaction") || "" + } else { + return formElement.getAttribute("action") || formElementAction || "" + } +} + +function getAction(formAction: string, fetchMethod: FetchMethod): URL { + const action = expandURL(formAction) + + if (isIdempotent(fetchMethod)) { + action.search = "" + } + + return action +} + +function getMethod(formElement: HTMLFormElement, submitter: HTMLElement | undefined): FetchMethod { + const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || "" + return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get +} + +function getEnctype(formElement: HTMLFormElement, submitter: HTMLElement | undefined): FetchEnctype { + return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype) +} + +function getFormData(formElement: HTMLFormElement, submitter: HTMLElement | undefined): FormData { const formData = new FormData(formElement) const name = submitter?.getAttribute("name") const value = submitter?.getAttribute("value") @@ -246,17 +256,3 @@ function getCookieValue(cookieName: string | null) { function responseSucceededWithoutRedirect(response: FetchResponse) { return response.statusCode == 200 && !response.redirected } - -function mergeFormDataEntries(url: URL, entries: [string, FormDataEntryValue][]): URL { - const searchParams = new URLSearchParams() - - for (const [name, value] of entries) { - if (value instanceof File) continue - - searchParams.append(name, value) - } - - url.search = searchParams.toString() - - return url -} diff --git a/src/core/drive/visit.ts b/src/core/drive/visit.ts index 90c650c7a..f014c4165 100644 --- a/src/core/drive/visit.ts +++ b/src/core/drive/visit.ts @@ -1,5 +1,5 @@ import { Adapter } from "../native/adapter" -import { FetchMethod, FetchRequest, FetchRequestDelegate, FetchRequestHeaders } from "../../http/fetch_request" +import { FetchRequest, FetchRequestDelegate, FetchRequestHeaders } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" import { History } from "./history" import { getAnchor } from "../url" @@ -216,7 +216,7 @@ export class Visit implements FetchRequestDelegate { if (this.hasPreloadedResponse()) { this.simulateRequest() } else if (this.shouldIssueRequest() && !this.request) { - this.request = new FetchRequest(this, FetchMethod.get, this.location) + this.request = new FetchRequest(this, this.location) this.request.perform() } } diff --git a/src/core/frames/frame_controller.ts b/src/core/frames/frame_controller.ts index 86fa98aa8..10887ab51 100644 --- a/src/core/frames/frame_controller.ts +++ b/src/core/frames/frame_controller.ts @@ -4,7 +4,7 @@ import { FrameLoadingStyle, FrameElementObservedAttribute, } from "../../elements/frame_element" -import { FetchMethod, FetchRequest, FetchRequestDelegate, FetchRequestHeaders } from "../../http/fetch_request" +import { FetchRequest, FetchRequestDelegate, FetchRequestHeaders } from "../../http/fetch_request" import { FetchResponse } from "../../http/fetch_response" import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer" import { @@ -352,7 +352,7 @@ export class FrameController // Private private async visit(url: URL) { - const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element) + const request = new FetchRequest(this, url, this.element) this.currentFetchRequest?.cancel() this.currentFetchRequest = request diff --git a/src/http/fetch_request.ts b/src/http/fetch_request.ts index 983076469..5e2f19e38 100644 --- a/src/http/fetch_request.ts +++ b/src/http/fetch_request.ts @@ -1,5 +1,6 @@ import { FetchResponse } from "./fetch_response" import { FrameElement } from "../elements/frame_element" +import { Locatable, expandURL } from "../core/url" import { dispatch } from "../util" export type TurboBeforeFetchRequestEvent = CustomEvent<{ @@ -28,14 +29,14 @@ export interface FetchRequestDelegate { } export enum FetchMethod { - get, - post, - put, - patch, - delete, + get = "GET", + post = "POST", + put = "PUT", + patch = "PATCH", + delete = "DELETE", } -export function fetchMethodFromString(method: string) { +export function fetchMethodFromString(method: FetchMethod | string) { switch (method.toLowerCase()) { case "get": return FetchMethod.get @@ -50,10 +51,40 @@ export function fetchMethodFromString(method: string) { } } -export type FetchRequestBody = FormData | URLSearchParams +export enum FetchEnctype { + urlEncoded = "application/x-www-form-urlencoded", + multipart = "multipart/form-data", + plain = "text/plain", +} + +export function fetchEnctypeFromString(encoding: string): FetchEnctype { + switch (encoding.toLowerCase()) { + case FetchEnctype.multipart: + return FetchEnctype.multipart + case FetchEnctype.plain: + return FetchEnctype.plain + default: + return FetchEnctype.urlEncoded + } +} +export type FetchRequestBody = FormData | URLSearchParams +export type FetchRequestInit = RequestInit & { + body: FetchRequestBody | null + headers: FetchRequestHeaders + method: FetchMethod +} export type FetchRequestHeaders = { [header: string]: string } +const defaultHeaders: FetchRequestHeaders = { + Accept: "text/html, application/xhtml+xml", +} + +const defaultFetchOptions: RequestInit = { + credentials: "same-origin", + redirect: "follow", +} + export interface FetchRequestOptions { headers: FetchRequestHeaders body: FetchRequestBody @@ -62,27 +93,71 @@ export interface FetchRequestOptions { export class FetchRequest { readonly delegate: FetchRequestDelegate - readonly method: FetchMethod - readonly headers: FetchRequestHeaders - readonly url: URL - readonly body?: FetchRequestBody readonly target?: FrameElement | HTMLFormElement | null readonly abortController = new AbortController() + readonly fetchOptions: FetchRequestInit + url: URL + private readonly enctype: FetchEnctype private resolveRequestPromise = (_value: any) => {} constructor( delegate: FetchRequestDelegate, - method: FetchMethod, - location: URL, - body: FetchRequestBody = new URLSearchParams(), - target: FrameElement | HTMLFormElement | null = null + location: Locatable, + target: FrameElement | HTMLFormElement | null = null, + method: FetchMethod = FetchMethod.get, + requestBody: FetchRequestBody = new URLSearchParams(), + enctype: FetchEnctype = FetchEnctype.urlEncoded ) { this.delegate = delegate - this.method = method - this.headers = this.defaultHeaders - this.body = body - this.url = location + this.enctype = enctype + const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, this.enctype) + this.url = url this.target = target + this.fetchOptions = { + ...defaultFetchOptions, + method: method, + headers: { ...defaultHeaders }, + body: body, + signal: this.abortSignal, + referrer: this.delegate.referrer?.href, + } + } + + get method(): FetchMethod | string { + return this.fetchOptions.method + } + + set method(value: FetchMethod | string) { + const fetchBody = this.isIdempotent ? this.url.searchParams : this.fetchOptions.body || new FormData() + const fetchMethod = fetchMethodFromString(value) || FetchMethod.get + + this.url.search = "" + + const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype) + + this.url = url + this.fetchOptions.body = body + this.fetchOptions.method = fetchMethod + } + + get headers() { + return this.fetchOptions.headers + } + + set headers(value: FetchRequestHeaders) { + this.fetchOptions.headers = value + } + + get body(): FetchRequestBody | null { + if (this.isIdempotent) { + return this.url.searchParams + } else { + return this.fetchOptions.body + } + } + + set body(value: FetchRequestBody | null) { + this.fetchOptions.body = value } get location(): URL { @@ -138,26 +213,8 @@ export class FetchRequest { return fetchResponse } - get fetchOptions(): RequestInit { - return { - method: FetchMethod[this.method].toUpperCase(), - credentials: "same-origin", - headers: this.headers, - redirect: "follow", - body: this.isIdempotent ? null : this.body, - signal: this.abortSignal, - referrer: this.delegate.referrer?.href, - } - } - - get defaultHeaders() { - return { - Accept: "text/html, application/xhtml+xml", - } - } - get isIdempotent() { - return this.method == FetchMethod.get + return isIdempotent(this.method) } get abortSignal() { @@ -179,6 +236,7 @@ export class FetchRequest { }, target: this.target as EventTarget, }) + this.url = event.detail.url if (event.defaultPrevented) await requestInterception } @@ -192,3 +250,46 @@ export class FetchRequest { return !event.defaultPrevented } } + +export function isIdempotent(method: FetchMethod | string): boolean { + const fetchMethod = typeof method == "string" ? fetchMethodFromString(method) : method + + return fetchMethod == FetchMethod.get +} + +function buildResourceAndBody( + resource: URL, + method: FetchMethod, + requestBody: FetchRequestBody, + enctype: FetchEnctype +): [URL, FetchRequestBody | null] { + const searchParams = + Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams + + if (isIdempotent(method)) { + return [mergeIntoURLSearchParams(resource, searchParams), null] + } else if (enctype == FetchEnctype.urlEncoded) { + return [resource, searchParams] + } else { + return [resource, requestBody] + } +} + +function entriesExcludingFiles(requestBody: FetchRequestBody) { + const entries = [] + + for (const [name, value] of requestBody) { + if (value instanceof File) continue + else entries.push([name, value]) + } + + return entries +} + +function mergeIntoURLSearchParams(url: URL, requestBody: FetchRequestBody): URL { + const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody)) + + url.search = searchParams.toString() + + return url +} diff --git a/src/tests/functional/form_submission_tests.ts b/src/tests/functional/form_submission_tests.ts index 1dd6f7a46..b916c4b13 100644 --- a/src/tests/functional/form_submission_tests.ts +++ b/src/tests/functional/form_submission_tests.ts @@ -151,6 +151,52 @@ test("test standard POST form submission events", async ({ page }) => { await nextEventNamed(page, "turbo:load") }) +test("test supports transforming a POST submission to a GET in a turbo:submit-start listener", async ({ page }) => { + await page.evaluate(() => + addEventListener("turbo:submit-start", (({ detail }: CustomEvent) => { + detail.formSubmission.method = "GET" + detail.formSubmission.action = "/src/tests/fixtures/one.html" + detail.formSubmission.body.set("greeting", "Hello, from an event listener") + }) as EventListener) + ) + await page.click("#standard form[method=post] [type=submit]") + await nextEventNamed(page, "turbo:load") + + assert.equal(await page.textContent("h1"), "One", "overrides the method and action") + assert.equal(getSearchParam(page.url(), "greeting"), "Hello, from an event listener") +}) + +test("test supports transforming a GET submission to a POST in a turbo:submit-start listener", async ({ page }) => { + await page.evaluate(() => + addEventListener("turbo:submit-start", (({ detail }: CustomEvent) => { + detail.formSubmission.method = "POST" + detail.formSubmission.body.set("path", "/src/tests/fixtures/one.html") + detail.formSubmission.body.set("greeting", "Hello, from an event listener") + }) as EventListener) + ) + await page.click("#standard form[method=get] [type=submit]") + await nextEventNamed(page, "turbo:load") + + assert.equal(await page.textContent("h1"), "One", "overrides the method and action") + assert.equal(getSearchParam(page.url(), "greeting"), "Hello, from an event listener") +}) + +test("test supports modifying the submission in a turbo:before-fetch-request listener", async ({ page }) => { + await page.evaluate(() => + addEventListener("turbo:before-fetch-request", (({ detail }: CustomEvent) => { + detail.url = new URL("/src/tests/fixtures/one.html", document.baseURI) + detail.url.search = new URLSearchParams(detail.fetchOptions.body).toString() + detail.fetchOptions.body = null + detail.fetchOptions.method = "GET" + }) as EventListener) + ) + await page.click("#standard form[method=post] [type=submit]") + await nextEventNamed(page, "turbo:load") + + assert.equal(await page.textContent("h1"), "One", "overrides the method and action") + assert.equal(getSearchParam(page.url(), "greeting"), "Hello from a redirect") +}) + test("test standard POST form submission merges values from both searchParams and body", async ({ page }) => { await page.click("#form-action-post-redirect-self-q-b") await nextBody(page) diff --git a/src/tests/functional/navigation_tests.ts b/src/tests/functional/navigation_tests.ts index 6bcc601aa..0b6273b3a 100644 --- a/src/tests/functional/navigation_tests.ts +++ b/src/tests/functional/navigation_tests.ts @@ -116,7 +116,7 @@ test("test following a same-origin GET form[data-turbo-action=replace]", async ( }) test("test following a same-origin GET form button[data-turbo-action=replace]", async ({ page }) => { - page.click("#same-origin-replace-form-submitter-get button") + await page.click("#same-origin-replace-form-submitter-get button") await nextBody(page) assert.equal(pathname(page.url()), "/src/tests/fixtures/one.html") assert.equal(await visitAction(page), "replace") diff --git a/src/tests/functional/rendering_tests.ts b/src/tests/functional/rendering_tests.ts index e95d67e1a..017bb5de1 100644 --- a/src/tests/functional/rendering_tests.ts +++ b/src/tests/functional/rendering_tests.ts @@ -516,8 +516,9 @@ test("test before-cache event", async ({ page }) => { addEventListener("turbo:before-cache", () => (document.body.innerHTML = "Modified"), { once: true }) }) await page.click("#same-origin-link") - await nextBody(page) + await nextEventNamed(page, "turbo:load") await page.goBack() + await nextEventNamed(page, "turbo:load") assert.equal(await page.textContent("body"), "Modified") })