diff --git a/src/core/drive/navigator.ts b/src/core/drive/navigator.ts index 768cf7123..8dc56ddc4 100644 --- a/src/core/drive/navigator.ts +++ b/src/core/drive/navigator.ts @@ -40,15 +40,10 @@ export class Navigator { this.currentVisit.start() } - submitForm(form: HTMLFormElement, submitter?: HTMLElement) { + submitForm(formSubmission: FormSubmission) { this.stop() - this.formSubmission = new FormSubmission(this, form, submitter, true) - - if (this.formSubmission.isIdempotent) { - this.proposeVisit(this.formSubmission.fetchRequest.url, { action: this.getActionForFormSubmission(this.formSubmission) }) - } else { - this.formSubmission.start() - } + this.formSubmission = formSubmission + this.formSubmission.start() } stop() { diff --git a/src/core/session.ts b/src/core/session.ts index b9c5b597e..f476fa32a 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -15,6 +15,7 @@ import { Action, Position, StreamSource, isAction } from "./types" import { dispatch } from "../util" import { PageView, PageViewDelegate } from "./drive/page_view" import { Visit, VisitOptions } from "./drive/visit" +import { FormSubmission } from "./drive/form_submission" import { PageSnapshot } from "./drive/page_snapshot" import { FrameElement } from "../elements/frame_element" import { FetchResponse } from "../http/fetch_response" @@ -135,7 +136,7 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin } followedLinkToLocation(link: Element, location: URL) { - const action = this.getActionForLink(link) + const action = this.getActionForLink(link) || "advance" this.convertLinkWithMethodClickToFormSubmission(link) || this.visit(location.href, { action }) } @@ -197,7 +198,18 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin } formSubmitted(form: HTMLFormElement, submitter?: HTMLElement) { - this.navigator.submitForm(form, submitter) + const formSubmission = new FormSubmission(this.navigator, form, submitter, true) + const { isIdempotent, fetchRequest: { url } } = formSubmission + + if (isIdempotent) { + const action = submitter && this.applicationAllowsFollowingLinkToLocation(submitter, url) ? + this.getActionForLink(submitter) || this.getActionForLink(form) : + this.getActionForLink(form) + + this.visit(url, { action: action || "advance" }) + } else { + this.navigator.submitForm(formSubmission) + } } // Page observer delegate @@ -330,9 +342,9 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin // Private - getActionForLink(link: Element): Action { + private getActionForLink(link: Element): Action | null { const action = link.getAttribute("data-turbo-action") - return isAction(action) ? action : "advance" + return isAction(action) ? action : null } get snapshot() { diff --git a/src/tests/fixtures/navigation.html b/src/tests/fixtures/navigation.html index 7abe7224f..0a886c022 100644 --- a/src/tests/fixtures/navigation.html +++ b/src/tests/fixtures/navigation.html @@ -33,10 +33,11 @@

Navigation

Same-origin unannotated link

-

+

+

Same-origin data-turbo-action=replace link

-

-

+

+

Same-origin data-turbo=false link

Same-origin unannotated link inside data-turbo=false container

Same-origin data-turbo=true link inside data-turbo=false container

diff --git a/src/tests/fixtures/test.js b/src/tests/fixtures/test.js index 0cc3ea174..1c01362d2 100644 --- a/src/tests/fixtures/test.js +++ b/src/tests/fixtures/test.js @@ -19,6 +19,7 @@ } }).observe(document, { subtree: true, childList: true, attributes: true }) })([ + "turbo:click", "turbo:before-cache", "turbo:before-render", "turbo:before-visit", diff --git a/src/tests/functional/navigation_tests.ts b/src/tests/functional/navigation_tests.ts index 55da888aa..dd34b45ba 100644 --- a/src/tests/functional/navigation_tests.ts +++ b/src/tests/functional/navigation_tests.ts @@ -37,6 +37,21 @@ export class NavigationTests extends TurboDriveTestCase { this.assert.equal(await this.visitAction, "advance") } + async "test following a same-origin location link"() { + await this.drainEventLog + + const link = await this.querySelector("#same-origin-unannotated-link") + const href = await link.getProperty("href") + await link.click() + await this.nextBody + + const [ eventName, { url }, id ] = await this.nextEvent() + + this.assert.equal(eventName, "turbo:click") + this.assert.equal(url, href, "turbo:click detail.url is href") + this.assert.equal(id, "same-origin-unannotated-link", "turbo:click target is link") + } + async "test following a same-origin unannotated custom element link"() { await this.nextBeat await this.remote.execute(() => { @@ -49,13 +64,44 @@ export class NavigationTests extends TurboDriveTestCase { this.assert.equal(await this.visitAction, "advance") } - async "test following a same-origin unannotated form[method=GET]"() { - this.clickSelector("#same-origin-unannotated-form button") + async "test submitting a same-origin unannotated form[method=GET]"() { + this.clickSelector("#same-origin-unannotated-submitter") await this.nextBody this.assert.equal(await this.pathname, "/src/tests/fixtures/one.html") this.assert.equal(await this.visitAction, "advance") } + async "test submitting a same-origin form by clicking a submitter"() { + await this.drainEventLog + + const form = await this.querySelector("#same-origin-unannotated-form") + const action = await form.getProperty("action") + const button = await this.querySelector("#same-origin-unannotated-submitter") + await button.click() + await this.nextBody + + const [ eventName, { url }, id ] = await this.nextEvent() + + this.assert.equal(eventName, "turbo:click") + this.assert.equal(url, action, "turbo:click detail.url is href") + this.assert.equal(id, "same-origin-unannotated-submitter", "turbo:click target is submitter") + } + + async "test submitting a same-origin form by clicking a submitter with formaction"() { + await this.drainEventLog + + const button = await this.querySelector("#same-origin-submitter-formaction") + const action = await this.expandURL(await button.getProperty("formAction")) + await button.click() + await this.nextBody + + const [ eventName, { url }, id ] = await this.nextEvent() + + this.assert.equal(eventName, "turbo:click") + this.assert.equal(url, action, "turbo:click detail.url is formaction") + this.assert.equal(id, "same-origin-submitter-formaction", "turbo:click target is submitter") + } + async "test following a same-origin data-turbo-action=replace link"() { this.clickSelector("#same-origin-replace-link") await this.nextBody @@ -232,6 +278,7 @@ export class NavigationTests extends TurboDriveTestCase { async "test same-page anchor visits do not trigger visit events"() { const events = [ + "turbo:click", "turbo:before-visit", "turbo:visit", "turbo:before-cache", @@ -241,7 +288,6 @@ export class NavigationTests extends TurboDriveTestCase { ] for (const eventName in events) { - await this.goToLocation("/src/tests/fixtures/navigation.html") await this.clickSelector('a[href="#main"]') this.assert.ok(await this.noNextEventNamed(eventName), `same-page links do not trigger ${eventName} events`) } diff --git a/src/tests/helpers/functional_test_case.ts b/src/tests/helpers/functional_test_case.ts index 7d17c2e30..09d778265 100644 --- a/src/tests/helpers/functional_test_case.ts +++ b/src/tests/helpers/functional_test_case.ts @@ -121,10 +121,18 @@ export class FunctionalTestCase extends InternTestCase { return await this.remote.execute(callback, args) } + async expandURL(pathname: string | null | undefined) { + return await this.evaluate((pathname) => new URL(pathname || "", document.baseURI), [pathname]) + } + get head(): Promise { return this.evaluate(() => document.head as any) } + get url(): Promise { + return this.evaluate(() => new URL(location.href)) + } + get body(): Promise { return this.evaluate(() => document.body as any) } diff --git a/src/tests/helpers/turbo_drive_test_case.ts b/src/tests/helpers/turbo_drive_test_case.ts index db1ad4d8e..d8b093dac 100644 --- a/src/tests/helpers/turbo_drive_test_case.ts +++ b/src/tests/helpers/turbo_drive_test_case.ts @@ -26,6 +26,14 @@ export class TurboDriveTestCase extends FunctionalTestCase { })() } + async nextEvent(): Promise { + let record: EventLog | undefined + while (!record) { + [ record ] = await this.eventLogChannel.read(1) + } + return record + } + async nextEventNamed(eventName: string): Promise { let record: EventLog | undefined while (!record) {