Skip to content

Commit

Permalink
Dispatch lifecycle events within turbo-frames
Browse files Browse the repository at this point in the history
Closes #54
Closes hotwired/turbo-rails#56

Dispatch life cycle events from within the `FrameController` and
`FrameRedirector` when a `<turbo-frame>` element is navigated. The
events _do not bubble_ since the conventional (and historical) style of
`turbolinks:` and `turbo:` event listeners has been to declare them on
the page's `document.documentElement`. If the `<turbo-frame>` events
were to bubble, either _all_ application-land event listeners would be
required to change, or else they would fire multiple times as the event
bubbled up the document.

Some tangential changes:

* Change `dispatch` default `{ bubbles: true }` value to be an argument
  instead
* Delete `TimingData` in favor of `TimingMetrics` and `TimingMetric`
  • Loading branch information
seanpdoyle committed Dec 30, 2020
1 parent c52d87c commit 7922187
Show file tree
Hide file tree
Showing 9 changed files with 179 additions and 27 deletions.
4 changes: 2 additions & 2 deletions src/core/drive/form_submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export class FormSubmission {

requestStarted(request: FetchRequest) {
this.state = FormSubmissionState.waiting
dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this } })
dispatch("turbo:submit-start", { target: this.formElement, detail: { formSubmission: this }, bubbles: true })
this.delegate.formSubmissionStarted(this)
}

Expand Down Expand Up @@ -121,7 +121,7 @@ export class FormSubmission {

requestFinished(request: FetchRequest) {
this.state = FormSubmissionState.stopped
dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result }})
dispatch("turbo:submit-end", { target: this.formElement, detail: { formSubmission: this, ...this.result }, bubbles: true })
this.delegate.formSubmissionFinished(this)
}

Expand Down
38 changes: 30 additions & 8 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission
import { FrameElement } from "../../elements/frame_element"
import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor"
import { Locatable, Location } from "../location"
import { nextAnimationFrame } from "../../util"
import { dispatch, nextAnimationFrame } from "../../util"
import { TimingMetric, TimingMetrics } from "../drive/visit"

export class FrameController implements FetchRequestDelegate, FormInterceptorDelegate, FormSubmissionDelegate, LinkInterceptorDelegate {
readonly element: FrameElement
readonly linkInterceptor: LinkInterceptor
readonly formInterceptor: FormInterceptor
formSubmission?: FormSubmission
private resolveVisitPromise = () => {}
timingMetrics: TimingMetrics = {}

constructor(element: FrameElement) {
this.element = element
Expand All @@ -35,7 +37,13 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel
}

linkClickIntercepted(element: Element, url: string) {
this.navigateFrame(element, url)
const frame = this.findFrameElement(element)
const location = Location.wrap(url)
const event = dispatch("turbo:before-visit", { target: frame, bubbles: false, detail: { url: location.absoluteURL } })

if (!event.defaultPrevented) {
frame.src = url
}
}

shouldInterceptFormSubmission(element: HTMLFormElement) {
Expand All @@ -49,7 +57,8 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel

this.formSubmission = new FormSubmission(this, element, submitter)
if (this.formSubmission.fetchRequest.isIdempotent) {
this.navigateFrame(element, this.formSubmission.fetchRequest.url)
const frame = this.findFrameElement(element)
frame.src = this.formSubmission.fetchRequest.url
} else {
this.formSubmission.start()
}
Expand All @@ -58,13 +67,19 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel
async visit(url: Locatable) {
const location = Location.wrap(url)
const request = new FetchRequest(this, FetchMethod.get, location)
this.clearTimingMetrics()
this.recordTimingMetric(TimingMetric.visitStart)

return new Promise<void>(resolve => {
this.resolveVisitPromise = () => {
this.resolveVisitPromise = () => {}
resolve()
}
dispatch("turbo:visit", { target: this.element, bubbles: false, detail: { url: location.absoluteURL } })
request.perform()
}).then(() => {
this.recordTimingMetric(TimingMetric.visitEnd)
dispatch("turbo:load", { target: this.element, bubbles: false, detail: { url: location.absoluteURL, timing: this.timingMetrics } })
})
}

Expand All @@ -74,6 +89,7 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel

requestStarted(request: FetchRequest) {
this.element.setAttribute("busy", "")
this.recordTimingMetric(TimingMetric.requestStart)
}

requestPreventedHandlingResponse(request: FetchRequest, response: FetchResponse) {
Expand All @@ -97,6 +113,7 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel

requestFinished(request: FetchRequest) {
this.element.removeAttribute("busy")
this.recordTimingMetric(TimingMetric.requestEnd)
}

formSubmissionStarted(formSubmission: FormSubmission) {
Expand All @@ -120,11 +137,6 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel

}

private navigateFrame(element: Element, url: string) {
const frame = this.findFrameElement(element)
frame.src = url
}

private findFrameElement(element: Element) {
const id = element.getAttribute("data-turbo-frame")
return getFrameElementById(id) ?? this.element
Expand All @@ -133,6 +145,7 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel
private async loadResponse(response: FetchResponse): Promise<void> {
const fragment = fragmentFromHTML(await response.responseHTML)
const element = await this.extractForeignFrameElement(fragment)
dispatch("turbo:before-render", { target: this.element, bubbles: false, detail: { newBody: element } })

if (element) {
await nextAnimationFrame()
Expand All @@ -143,6 +156,14 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel
}
}

private clearTimingMetrics() {
this.timingMetrics = {}
}

private recordTimingMetric(metric: TimingMetric) {
this.timingMetrics[metric] = new Date().getTime()
}

private async extractForeignFrameElement(container: ParentNode): Promise<FrameElement | undefined> {
let element
const id = CSS.escape(this.id)
Expand All @@ -167,6 +188,7 @@ export class FrameController implements FetchRequestDelegate, FormInterceptorDel
sourceRange.selectNodeContents(frameElement)
this.element.appendChild(sourceRange.extractContents())
}
dispatch("turbo:render", { target: this.element, bubbles: false })
}

private focusFirstAutofocusableElement(): boolean {
Expand Down
10 changes: 9 additions & 1 deletion src/core/frames/frame_redirector.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { FormInterceptor, FormInterceptorDelegate } from "./form_interceptor"
import { FrameElement } from "../../elements/frame_element"
import { LinkInterceptor, LinkInterceptorDelegate } from "./link_interceptor"
import { Location } from "../location"
import { dispatch } from "../../util"

export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptorDelegate {
readonly element: Element
Expand Down Expand Up @@ -29,8 +31,14 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptor

linkClickIntercepted(element: Element, url: string) {
const frame = this.findFrameElement(element)
const location = Location.wrap(url)

if (frame) {
frame.src = url
const event = dispatch("turbo:before-visit", { target: frame, bubbles: false, detail: { url: location.absoluteURL } })

if (!event.defaultPrevented) {
frame.src = url
}
}
}

Expand Down
20 changes: 9 additions & 11 deletions src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ import { StreamObserver } from "../observers/stream_observer"
import { Action, Position, StreamSource, isAction } from "./types"
import { dispatch } from "../util"
import { View } from "./drive/view"
import { Visit, VisitOptions } from "./drive/visit"

export type TimingData = {}
import { TimingMetrics, Visit, VisitOptions } from "./drive/visit"

export class Session implements NavigatorDelegate {
readonly navigator = new Navigator(this)
Expand Down Expand Up @@ -212,31 +210,31 @@ export class Session implements NavigatorDelegate {
}

notifyApplicationAfterClickingLinkToLocation(link: Element, location: Location) {
return dispatch("turbo:click", { target: link, detail: { url: location.absoluteURL }, cancelable: true })
return dispatch("turbo:click", { target: link, detail: { url: location.absoluteURL }, cancelable: true, bubbles: true })
}

notifyApplicationBeforeVisitingLocation(location: Location) {
return dispatch("turbo:before-visit", { detail: { url: location.absoluteURL }, cancelable: true })
return dispatch("turbo:before-visit", { detail: { url: location.absoluteURL }, cancelable: true, bubbles: true })
}

notifyApplicationAfterVisitingLocation(location: Location) {
return dispatch("turbo:visit", { detail: { url: location.absoluteURL } })
return dispatch("turbo:visit", { detail: { url: location.absoluteURL }, bubbles: true })
}

notifyApplicationBeforeCachingSnapshot() {
return dispatch("turbo:before-cache")
return dispatch("turbo:before-cache", { bubbles: true })
}

notifyApplicationBeforeRender(newBody: HTMLBodyElement) {
return dispatch("turbo:before-render", { detail: { newBody }})
return dispatch("turbo:before-render", { detail: { newBody }, bubbles: true })
}

notifyApplicationAfterRender() {
return dispatch("turbo:render")
return dispatch("turbo:render", { bubbles: true })
}

notifyApplicationAfterPageLoad(timing: TimingData = {}) {
return dispatch("turbo:load", { detail: { url: this.location.absoluteURL, timing }})
notifyApplicationAfterPageLoad(timing: TimingMetrics = {}) {
return dispatch("turbo:load", { detail: { url: this.location.absoluteURL, timing }, bubbles: true })
}

// Private
Expand Down
4 changes: 2 additions & 2 deletions src/http/fetch_request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export class FetchRequest {

async perform(): Promise<FetchResponse> {
const { fetchOptions } = this
dispatch("turbo:before-fetch-request", { detail: { fetchOptions } })
dispatch("turbo:before-fetch-request", { bubbles: true, detail: { fetchOptions } })
try {
this.delegate.requestStarted(this)
const response = await fetch(this.url, fetchOptions)
Expand All @@ -96,7 +96,7 @@ export class FetchRequest {

async receive(response: Response): Promise<FetchResponse> {
const fetchResponse = new FetchResponse(response)
const event = dispatch("turbo:before-fetch-response", { cancelable: true, detail: { fetchResponse } })
const event = dispatch("turbo:before-fetch-response", { cancelable: true, bubbles: true, detail: { fetchResponse } })
if (event.defaultPrevented) {
this.delegate.requestPreventedHandlingResponse(this, fetchResponse)
} else if (fetchResponse.succeeded) {
Expand Down
20 changes: 20 additions & 0 deletions src/tests/fixtures/frame_navigation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Turbo</title>
<script src="/src/tests/fixtures/turbo.es2017-umd.js" data-turbo-track="reload"></script>
</head>
<body>
<div id="container">
<a id="outside" href="/src/tests/fixtures/frame_navigation.html" data-turbo-frame="frame">Outside Frame</a>

<turbo-frame id="frame">
<h2>Frame Navigation</h2>

<a id="inside" href="/src/tests/fixtures/frame_navigation.html">Inside Frame</a>
<a id="top" href="/src/tests/fixtures/frame_navigation.html" data-turbo-frame="_top">Top</a>
</turbo-frame>
</div>
</body>
</html>
103 changes: 103 additions & 0 deletions src/tests/functional/frame_navigation_tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { TurboDriveTestCase } from "../helpers/turbo_drive_test_case"

export class FrameNavigationTests extends TurboDriveTestCase {
async setup() {
await this.goToLocation("/src/tests/fixtures/frame_navigation.html")
}

async "test frame navigation with descendant link"() {
this.trackFrameEvents()
await this.clickSelector("#inside")
await this.nextBeat

const dispatchedEvents = await this.readDispatchedFrameEvents()
const [
[ beforeVisit, beforeVisitTarget, { url } ],
[ visit, visitTarget ],
[ beforeRender, beforeRenderTarget, { newBody } ],
[ render, renderTarget ],
[ load, loadTarget, { timing } ],
] = dispatchedEvents

this.assert.equal(beforeVisit, "turbo:before-visit")
this.assert.equal(beforeVisitTarget, "frame")
this.assert.ok(url.includes("/src/tests/fixtures/frame_navigation.html"))

this.assert.equal(visit, "turbo:visit")
this.assert.equal(visitTarget, "frame")

this.assert.equal(beforeRender, "turbo:before-render")
this.assert.equal(beforeRenderTarget, "frame")
this.assert.ok(newBody)

this.assert.equal(render, "turbo:render")
this.assert.equal(renderTarget, "frame")

this.assert.equal(load, "turbo:load")
this.assert.equal(loadTarget, "frame")
this.assert.ok(Object.keys(timing).length)
}

async "test frame navigation with exterior link"() {
this.trackFrameEvents()
await this.clickSelector("#outside")
await this.nextBeat

const dispatchedEvents = await this.readDispatchedFrameEvents()
const [
[ beforeVisit, beforeVisitTarget, { url } ],
[ visit, visitTarget ],
[ beforeRender, beforeRenderTarget, { newBody } ],
[ render, renderTarget ],
[ load, loadTarget, { timing } ],
] = dispatchedEvents

this.assert.equal(beforeVisit, "turbo:before-visit")
this.assert.equal(beforeVisitTarget, "frame")
this.assert.ok(url.includes("/src/tests/fixtures/frame_navigation.html"))

this.assert.equal(visit, "turbo:visit")
this.assert.equal(visitTarget, "frame")

this.assert.equal(beforeRender, "turbo:before-render")
this.assert.equal(beforeRenderTarget, "frame")
this.assert.ok(newBody)

this.assert.equal(render, "turbo:render")
this.assert.equal(renderTarget, "frame")

this.assert.equal(load, "turbo:load")
this.assert.equal(loadTarget, "frame")
this.assert.ok(Object.keys(timing).length)
}

async trackFrameEvents() {
this.remote.execute(() => {
const eventNames = "turbo:before-visit turbo:visit turbo:before-render turbo:render turbo:load".split(/\s+/)
document.head.insertAdjacentHTML("beforeend", `<meta id="events" content="[]">`)
const frame = document.getElementById("frame")

if (frame) {
eventNames.forEach(eventName => frame.addEventListener(eventName, (event) => {
const meta = document.getElementById("events")

if (meta instanceof HTMLMetaElement && event instanceof CustomEvent && event.target instanceof HTMLElement) {
const dispatchedEvents = JSON.parse(meta.content)
const detail = event.detail || {}
dispatchedEvents.push([ event.type, event.target.id, { ...detail, newBody: !!detail.newBody } ])
meta.content = JSON.stringify(dispatchedEvents)
}
}))
}
})
}

async readDispatchedFrameEvents() {
const meta = await this.querySelector("meta[id=events]")
const content = await meta.getAttribute("content")

return JSON.parse(content || "[]")
}
}

FrameNavigationTests.registerSuite()
1 change: 1 addition & 0 deletions src/tests/functional/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./async_script_tests"
export * from "./form_submission_tests"
export * from "./frame_navigation_tests"
export * from "./navigation_tests"
export * from "./rendering_tests"
export * from "./stream_tests"
Expand Down
6 changes: 3 additions & 3 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export type DispatchOptions = { target: EventTarget, cancelable: boolean, detail: any }
export type DispatchOptions = { target: EventTarget, cancelable: boolean, bubbles: boolean, detail: any }

export function dispatch(eventName: string, { target, cancelable, detail }: Partial<DispatchOptions> = {}) {
const event = new CustomEvent(eventName, { cancelable, bubbles: true, detail })
export function dispatch(eventName: string, { target, cancelable, bubbles, detail }: Partial<DispatchOptions> = {}) {
const event = new CustomEvent(eventName, { cancelable, bubbles, detail })
void (target || document.documentElement).dispatchEvent(event)
return event
}
Expand Down

0 comments on commit 7922187

Please sign in to comment.