Skip to content

Commit f8a94c5

Browse files
authored
Introduce FormLinkInterceptor (#631)
* Introduce `FormLinkInterceptor` In an effort to match the patterns established by `LinkInterceptor` and `FormInterceptor`, this commit introduces a `FormLinkInterceptor` and `FormLinkInterceptorDelegate`. Behind the scenes, the `FormLinkInterceptor` relies on an instance of the `LinkInterceptor` to intervene in `<a>` element clicks when `[data-turbo-method]` or `[data-turbo-stream]` are present. When those clicks are detected, it creates a `<form hidden>` element, attaches it to the document, delegates to a `FormLinkInterceptorDelegate` to map the `<a>` element's attributes to the `<form>` element, submits the form through the polyfilled [HTMLFormElement.requestSubmit][] method, then removes the `<form>` from the document. The `Session` serves as a `FormLinkInterceptorDelegate`, making sure to start and stop the observer _before_ its `LinkInterceptor` instance, so that clicks that are intercepted by the `FormLinkInterceptor` are not also intercepted by the `LinkInterceptor`. [HTMLFormElement.requestSubmit]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/requestSubmit * Account for `[data-turbo="false"]` and `Turbo.session.drive = false` Closes #500
1 parent a54ac17 commit f8a94c5

5 files changed

Lines changed: 142 additions & 55 deletions

File tree

src/core/frames/frame_controller.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ import {
77
import { FetchMethod, FetchRequest, FetchRequestDelegate, FetchRequestHeaders } from "../../http/fetch_request"
88
import { FetchResponse } from "../../http/fetch_response"
99
import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer"
10-
import { clearBusyState, getAttribute, parseHTMLDocument, markAsBusy, attributeTrue } from "../../util"
10+
import { clearBusyState, getAttribute, parseHTMLDocument, markAsBusy } from "../../util"
1111
import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission"
1212
import { Snapshot } from "../snapshot"
1313
import { ViewDelegate } from "../view"
1414
import { getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url"
1515
import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor"
1616
import { FrameView } from "./frame_view"
1717
import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor"
18+
import { FormLinkInterceptor, FormLinkInterceptorDelegate } from "../../observers/form_link_interceptor"
1819
import { FrameRenderer } from "./frame_renderer"
1920
import { session } from "../index"
2021
import { isAction } from "../types"
@@ -26,12 +27,14 @@ export class FrameController
2627
FormInterceptorDelegate,
2728
FormSubmissionDelegate,
2829
FrameElementDelegate,
30+
FormLinkInterceptorDelegate,
2931
LinkInterceptorDelegate,
3032
ViewDelegate<Snapshot<FrameElement>>
3133
{
3234
readonly element: FrameElement
3335
readonly view: FrameView
3436
readonly appearanceObserver: AppearanceObserver
37+
readonly formLinkInterceptor: FormLinkInterceptor
3538
readonly linkInterceptor: LinkInterceptor
3639
readonly formInterceptor: FormInterceptor
3740
formSubmission?: FormSubmission
@@ -46,6 +49,7 @@ export class FrameController
4649
this.element = element
4750
this.view = new FrameView(this, this.element)
4851
this.appearanceObserver = new AppearanceObserver(this, this.element)
52+
this.formLinkInterceptor = new FormLinkInterceptor(this, this.element)
4953
this.linkInterceptor = new LinkInterceptor(this, this.element)
5054
this.formInterceptor = new FormInterceptor(this, this.element)
5155
}
@@ -58,6 +62,7 @@ export class FrameController
5862
} else {
5963
this.loadSourceURL()
6064
}
65+
this.formLinkInterceptor.start()
6166
this.linkInterceptor.start()
6267
this.formInterceptor.start()
6368
}
@@ -67,6 +72,7 @@ export class FrameController
6772
if (this.connected) {
6873
this.connected = false
6974
this.appearanceObserver.stop()
75+
this.formLinkInterceptor.stop()
7076
this.linkInterceptor.stop()
7177
this.formInterceptor.stop()
7278
}
@@ -146,14 +152,21 @@ export class FrameController
146152
this.loadSourceURL()
147153
}
148154

155+
// Form link interceptor delegate
156+
157+
shouldInterceptFormLinkClick(link: Element): boolean {
158+
return this.shouldInterceptNavigation(link)
159+
}
160+
161+
formLinkClickIntercepted(link: Element, form: HTMLFormElement): void {
162+
const frame = this.findFrameElement(link)
163+
if (frame) form.setAttribute("data-turbo-frame", frame.id)
164+
}
165+
149166
// Link interceptor delegate
150167

151168
shouldInterceptLinkClick(element: Element, _url: string) {
152-
if (element.hasAttribute("data-turbo-method") || attributeTrue(element, "data-turbo-stream")) {
153-
return false
154-
} else {
155-
return this.shouldInterceptNavigation(element)
156-
}
169+
return this.shouldInterceptNavigation(element)
157170
}
158171

159172
linkClickIntercepted(element: Element, url: string) {

src/core/session.ts

Lines changed: 15 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ import { FormSubmitObserver, FormSubmitObserverDelegate } from "../observers/for
55
import { FrameRedirector } from "./frames/frame_redirector"
66
import { History, HistoryDelegate } from "./drive/history"
77
import { LinkClickObserver, LinkClickObserverDelegate } from "../observers/link_click_observer"
8+
import { FormLinkInterceptor, FormLinkInterceptorDelegate } from "../observers/form_link_interceptor"
89
import { getAction, expandURL, locationIsVisitable, Locatable } from "./url"
910
import { Navigator, NavigatorDelegate } from "./drive/navigator"
1011
import { PageObserver, PageObserverDelegate } from "../observers/page_observer"
1112
import { ScrollObserver } from "../observers/scroll_observer"
1213
import { StreamMessage } from "./streams/stream_message"
1314
import { StreamObserver } from "../observers/stream_observer"
1415
import { Action, Position, StreamSource, isAction } from "./types"
15-
import { attributeTrue, clearBusyState, dispatch, markAsBusy } from "../util"
16+
import { clearBusyState, dispatch, markAsBusy } from "../util"
1617
import { PageView, PageViewDelegate } from "./drive/page_view"
1718
import { Visit, VisitOptions } from "./drive/visit"
1819
import { PageSnapshot } from "./drive/page_snapshot"
@@ -35,6 +36,7 @@ export class Session
3536
implements
3637
FormSubmitObserverDelegate,
3738
HistoryDelegate,
39+
FormLinkInterceptorDelegate,
3840
LinkClickObserverDelegate,
3941
NavigatorDelegate,
4042
PageObserverDelegate,
@@ -53,7 +55,7 @@ export class Session
5355
readonly formSubmitObserver = new FormSubmitObserver(this)
5456
readonly scrollObserver = new ScrollObserver(this)
5557
readonly streamObserver = new StreamObserver(this)
56-
58+
readonly formLinkInterceptor = new FormLinkInterceptor(this, document.documentElement)
5759
readonly frameRedirector = new FrameRedirector(document.documentElement)
5860

5961
drive = true
@@ -66,6 +68,7 @@ export class Session
6668
if (!this.started) {
6769
this.pageObserver.start()
6870
this.cacheObserver.start()
71+
this.formLinkInterceptor.start()
6972
this.linkClickObserver.start()
7073
this.formSubmitObserver.start()
7174
this.scrollObserver.start()
@@ -86,6 +89,7 @@ export class Session
8689
if (this.started) {
8790
this.pageObserver.stop()
8891
this.cacheObserver.stop()
92+
this.formLinkInterceptor.stop()
8993
this.linkClickObserver.stop()
9094
this.formSubmitObserver.stop()
9195
this.scrollObserver.stop()
@@ -157,6 +161,14 @@ export class Session
157161
this.history.updateRestorationData({ scrollPosition: position })
158162
}
159163

164+
// Form link interceptor delegate
165+
166+
shouldInterceptFormLinkClick(_link: Element): boolean {
167+
return true
168+
}
169+
170+
formLinkClickIntercepted(_link: Element, _form: HTMLFormElement) {}
171+
160172
// Link click observer delegate
161173

162174
willFollowLinkToLocation(link: Element, location: URL, event: MouseEvent) {
@@ -169,39 +181,7 @@ export class Session
169181

170182
followedLinkToLocation(link: Element, location: URL) {
171183
const action = this.getActionForLink(link)
172-
this.convertLinkWithMethodClickToFormSubmission(link) || this.visit(location.href, { action })
173-
}
174-
175-
convertLinkWithMethodClickToFormSubmission(link: Element) {
176-
const linkMethod = link.getAttribute("data-turbo-method")
177-
const useTurboStream = attributeTrue(link, "data-turbo-stream")
178-
179-
if (linkMethod || useTurboStream) {
180-
const form = document.createElement("form")
181-
form.setAttribute("method", linkMethod || "get")
182-
form.action = link.getAttribute("href") || "undefined"
183-
form.hidden = true
184-
185-
const attributes = ["data-turbo-confirm", "data-turbo-stream"]
186-
attributes.forEach((attribute) => {
187-
if (link.hasAttribute(attribute)) {
188-
form.setAttribute(attribute, link.getAttribute(attribute)!)
189-
}
190-
})
191-
192-
const frame = this.getTargetFrameForLink(link)
193-
if (frame) {
194-
form.setAttribute("data-turbo-frame", frame)
195-
form.addEventListener("turbo:submit-start", () => form.remove())
196-
} else {
197-
form.addEventListener("submit", () => form.remove())
198-
}
199-
200-
document.body.appendChild(form)
201-
return dispatch("submit", { cancelable: true, target: form })
202-
} else {
203-
return false
204-
}
184+
this.visit(location.href, { action })
205185
}
206186

207187
// Navigator delegate
@@ -423,19 +403,6 @@ export class Session
423403
return isAction(action) ? action : "advance"
424404
}
425405

426-
getTargetFrameForLink(link: Element) {
427-
const frame = link.getAttribute("data-turbo-frame")
428-
429-
if (frame) {
430-
return frame
431-
} else {
432-
const container = link.closest("turbo-frame")
433-
if (container) {
434-
return container.id
435-
}
436-
}
437-
}
438-
439406
get snapshot() {
440407
return this.view.snapshot
441408
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { LinkInterceptor, LinkInterceptorDelegate } from "../core/frames/link_interceptor"
2+
3+
export type FormLinkInterceptorDelegate = {
4+
shouldInterceptFormLinkClick(link: Element): boolean
5+
formLinkClickIntercepted(link: Element, form: HTMLFormElement): void
6+
}
7+
8+
export class FormLinkInterceptor implements LinkInterceptorDelegate {
9+
readonly linkInterceptor: LinkInterceptor
10+
readonly delegate: FormLinkInterceptorDelegate
11+
12+
constructor(delegate: FormLinkInterceptorDelegate, element: HTMLElement) {
13+
this.delegate = delegate
14+
this.linkInterceptor = new LinkInterceptor(this, element)
15+
}
16+
17+
start() {
18+
this.linkInterceptor.start()
19+
}
20+
21+
stop() {
22+
this.linkInterceptor.stop()
23+
}
24+
25+
shouldInterceptLinkClick(link: Element): boolean {
26+
return (
27+
this.delegate.shouldInterceptFormLinkClick(link) &&
28+
(link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream"))
29+
)
30+
}
31+
32+
linkClickIntercepted(link: Element, action: string): void {
33+
const form = document.createElement("form")
34+
form.setAttribute("data-turbo", "true")
35+
form.setAttribute("action", action)
36+
form.setAttribute("hidden", "")
37+
38+
const method = link.getAttribute("data-turbo-method")
39+
if (method) form.setAttribute("method", method)
40+
41+
const turboConfirm = link.getAttribute("data-turbo-confirm")
42+
if (turboConfirm) form.setAttribute("data-turbo-confirm", turboConfirm)
43+
44+
const turboStream = link.getAttribute("data-turbo-stream")
45+
if (turboStream) form.setAttribute("data-turbo-stream", turboStream)
46+
47+
this.delegate.formLinkClickIntercepted(link, form)
48+
49+
document.body.appendChild(form)
50+
form.requestSubmit()
51+
form.remove()
52+
}
53+
}

src/tests/fixtures/form.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ <h2>Frame: Form</h2>
299299
<form method="post" action="https://httpbin.org/post">
300300
<button id="submit-external">POST to https://httpbin.org/post</button>
301301
</form>
302+
<a href="/__turbo/redirect?path=/src/tests/fixtures/frames/hello.html" data-turbo-method="post" data-turbo-frame="hello" id="turbo-method-post-to-targeted-frame">Turbo method post to targeted frame</a>
302303
<turbo-frame id="hello"></turbo-frame>
303304
<hr>
304305

src/tests/functional/form_submission_tests.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -803,7 +803,7 @@ test("test link method form submission inside frame", async ({ page }) => {
803803
await page.click("#link-method-inside-frame")
804804
await nextBeat()
805805

806-
assert.equal(await await page.textContent("#frame h2"), "Frame: Loaded")
806+
assert.equal(await page.textContent("#frame h2"), "Frame: Loaded")
807807
assert.notOk(await hasSelector(page, "#nested-child"))
808808
})
809809

@@ -898,6 +898,59 @@ test("test link method form submission outside frame", async ({ page }) => {
898898
assert.equal(await title.textContent(), "Hello")
899899
})
900900

901+
test("test following a link with [data-turbo-method] set and a target set navigates the target frame", async ({
902+
page,
903+
}) => {
904+
await page.click("#turbo-method-post-to-targeted-frame")
905+
906+
assert.equal(await page.textContent("#hello h2"), "Hello from a frame", "drives the turbo-frame")
907+
})
908+
909+
test("test following a link with [data-turbo-method] and [data-turbo=true] set when html[data-turbo=false]", async ({
910+
page,
911+
}) => {
912+
const html = await page.locator("html")
913+
await html.evaluate((html) => html.setAttribute("data-turbo", "false"))
914+
915+
const link = await page.locator("#turbo-method-post-to-targeted-frame")
916+
await link.evaluate((link) => link.setAttribute("data-turbo", "true"))
917+
918+
await link.click()
919+
920+
assert.equal(await page.textContent("h1"), "Form", "does not navigate the full page")
921+
assert.equal(await page.textContent("#hello h2"), "Hello from a frame", "drives the turbo-frame")
922+
})
923+
924+
test("test following a link with [data-turbo-method] and [data-turbo=true] set when Turbo.session.drive = false", async ({
925+
page,
926+
}) => {
927+
await page.evaluate(() => (window.Turbo.session.drive = false))
928+
929+
const link = await page.locator("#turbo-method-post-to-targeted-frame")
930+
await link.evaluate((link) => link.setAttribute("data-turbo", "true"))
931+
932+
await link.click()
933+
934+
assert.equal(await page.textContent("h1"), "Form", "does not navigate the full page")
935+
assert.equal(await page.textContent("#hello h2"), "Hello from a frame", "drives the turbo-frame")
936+
})
937+
938+
test("test following a link with [data-turbo-method] set when html[data-turbo=false]", async ({ page }) => {
939+
const html = await page.locator("html")
940+
await html.evaluate((html) => html.setAttribute("data-turbo", "false"))
941+
942+
await page.click("#turbo-method-post-to-targeted-frame")
943+
944+
assert.equal(await page.textContent("h1"), "Hello", "treats link as a full-page navigation")
945+
})
946+
947+
test("test following a link with [data-turbo-method] set when Turbo.session.drive = false", async ({ page }) => {
948+
await page.evaluate(() => (window.Turbo.session.drive = false))
949+
await page.click("#turbo-method-post-to-targeted-frame")
950+
951+
assert.equal(await page.textContent("h1"), "Hello", "treats link as a full-page navigation")
952+
})
953+
901954
test("test stream link method form submission outside frame", async ({ page }) => {
902955
await page.click("#stream-link-method-outside-frame")
903956
await nextBeat()

0 commit comments

Comments
 (0)