Skip to content

Commit

Permalink
Prevent double requests from within turbo-frame
Browse files Browse the repository at this point in the history
Closes hotwired#743

The change in behavior can be traced back to [hotwired#412][].
When the overlap between the `LinkInterceptor` and the
`LinkClickObserver` were unified, the technique used by the
`LinkInterceptor` to prevent duplicate event handlers from responding to
the same `click` event was changed in a subtle way.

In its place, this commit changes the `LinkClickObserver` and
`FormSubmitObserver` to ignore `<a>` clicks and `<form>` submissions if
they're annotated with `[data-remote="true"]`.

To exercise these edge cases, this commit also adds a `ujs.html` fixture
file along with a `ujs_tests.ts` module to cover situations when
`@rails/ujs` and `@hotwired/turbo` co-exist.

[hotwired#412]: hotwired#412
  • Loading branch information
seanpdoyle committed Sep 30, 2022
1 parent 732db00 commit 5871173
Show file tree
Hide file tree
Showing 4 changed files with 75 additions and 1 deletion.
10 changes: 10 additions & 0 deletions src/observers/form_submit_observer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getAttribute } from "../util"

export interface FormSubmitObserverDelegate {
willSubmitForm(form: HTMLFormElement, submitter?: HTMLElement): boolean
formSubmitted(form: HTMLFormElement, submitter?: HTMLElement): void
Expand Down Expand Up @@ -41,6 +43,7 @@ export class FormSubmitObserver {
form &&
submissionDoesNotDismissDialog(form, submitter) &&
submissionDoesNotTargetIFrame(form, submitter) &&
submissionDoesNotIntegrateWithUJS(form, submitter) &&
this.delegate.willSubmitForm(form, submitter)
) {
event.preventDefault()
Expand All @@ -65,3 +68,10 @@ function submissionDoesNotTargetIFrame(form: HTMLFormElement, submitter?: HTMLEl

return true
}

function submissionDoesNotIntegrateWithUJS(form: HTMLFormElement, submitter?: HTMLElement): boolean {
const value = getAttribute("data-remote", submitter, form)
const remote = /true/i.test(value || "")

return !remote
}
9 changes: 8 additions & 1 deletion src/observers/link_click_observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export class LinkClickObserver {
if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
const target = (event.composedPath && event.composedPath()[0]) || event.target
const link = this.findLinkFromClickTarget(target)
if (link && doesNotTargetIFrame(link)) {
if (link && doesNotTargetIFrame(link) && doesNotIntegrateWithUJS(link)) {
const location = this.getLocationForLink(link)
if (this.delegate.willFollowLinkToLocation(link, location, event)) {
event.preventDefault()
Expand Down Expand Up @@ -78,3 +78,10 @@ function doesNotTargetIFrame(anchor: HTMLAnchorElement): boolean {

return true
}

function doesNotIntegrateWithUJS(anchor: HTMLAnchorElement): boolean {
const value = anchor.getAttribute("data-remote")
const remote = /true/i.test(value || "")

return !remote
}
24 changes: 24 additions & 0 deletions src/tests/fixtures/ujs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Frame</title>
<script src="/src/tests/fixtures/test.js"></script>
<script type="module">
import Rails from "https://ga.jspm.io/npm:@rails/ujs@7.0.1/lib/assets/compiled/rails-ujs.js"

Rails.start()
</script>
<script src="/dist/turbo.es2017-umd.js" data-turbo-track="reload"></script>
</head>
<body>
<turbo-frame id="frame">
<h2>Frames: #frame</h2>

<a data-remote="true" href="/src/tests/fixtures/frames/frame.html">navigate #frame to /src/tests/fixtures/frames/frame.html</a>
<form data-remote="true" action="/src/tests/fixtures/frames/frame.html">
<button>navigate #frame to /src/tests/fixtures/frames/frame.html</button>
</form>
</turbo-frame>
</body>
</html>
33 changes: 33 additions & 0 deletions src/tests/functional/ujs_tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Page, test } from "@playwright/test"
import { assert } from "chai"
import { noNextEventOnTarget } from "../helpers/page"

test.beforeEach(async ({ page }) => {
await page.goto("/src/tests/fixtures/ujs.html")
})

test("test clicking a [data-remote=true] anchor within a turbo-frame", async ({ page }) => {
await assertRequestLimit(page, 1, async () => {
await page.click("#frame a[data-remote=true]")
await noNextEventOnTarget(page, "frame", "turbo:frame-load")

assert.equal(await page.textContent("#frame h2"), "Frames: #frame", "does not navigate the target frame")
})
})

test("test submitting a [data-remote=true] form within a turbo-frame", async ({ page }) => {
await assertRequestLimit(page, 1, async () => {
await page.click("#frame form[data-remote=true] button")
await noNextEventOnTarget(page, "frame", "turbo:frame-load")

assert.equal(await page.textContent("#frame h2"), "Frames: #frame", "does not navigate the target frame")
})
})

async function assertRequestLimit(page: Page, count: number, callback: () => Promise<void>) {
let requestsStarted = 0
await page.on("request", () => requestsStarted++)
await callback()

assert.equal(requestsStarted, count, `only submits ${count} requests`)
}

0 comments on commit 5871173

Please sign in to comment.