Skip to content

Commit

Permalink
Extract FrameVisit to drive FrameController
Browse files Browse the repository at this point in the history
The problem
---

Programmatically driving a `<turbo-frame>` element when its `[src]`
attribute changes is a suitable end-user experience in consumer
applications. It's a fitting black-box interface for the outside world:
change the value of the attribute and let Turbo handle the rest.

However, internally, it's a lossy abstraction.

For example, when the `FrameRedirector` class listens for page-wide
`click` and `submit` events, it determines if their targets are meant to
drive a `<turbo-frame>` element by:

1. finding an element that matches a clicked `<a>` element's `[data-turbo-frame]` attribute
2. finding an element that matches a submitted `<form>` element's `[data-turbo-frame]` attribute
3. finding an element that matches a submitted `<form>` element's
   _submitter's_ `[data-turbo-frame]` attribute
4. finding the closest `<turbo-frame>` ancestor to the `<a>` or `<form>`

Once it finds the matching frame element, it disposes of all that
additional context and navigates the `<turbo-frame>` by updating its
`[src]` attribute. This makes it impossible to control various aspects
of the frame navigation (like its "rendering" explored in
[hotwired#146][]) outside of its destination URL.

Similarly, since a `<form>` and submitter pairing have an impact on
which `<turbo-frame>` is navigated, the `FrameController` implementation
passes around a `HTMLFormElement` and `HTMLSubmitter?` data clump and
constantly re-fetches a matching `<turbo-frame>` instance.

Outside of frames, page-wide navigation is driven by a `Visit` instance
that manages the HTTP life cycle and delegates along the way to a
`VisitDelegate`. It also pairs calls to visit with a `VisitOption`
object to capture additional context.

The proposal
---

This commit introduces the `FrameVisit` class. It serves as an
encapsulation of the `FetchRequest` and `FormSubmission` lifecycle
events involved in navigating a frame.

It's implementation draws inspiration from the `Visit`, `VisitDelegate`,
and `VisitOptions` pairing. Since the `FrameVisit` knows how to unify
both `FetchRequest` and `FormSubmission` hooks, the resulting callbacks
fired from within the `FrameController` are flat and consistent.

Extra benefits
---

The biggest benefit is the introduction of a DRY abstraction to
manage the behind the scenes HTTP calls necessary to drive a
`<turbo-frame>`.

With the introduction of the `FrameVisit` concept, we can also declare a
`visit()` and `submit()` method for `FrameElementDelegate`
implementations in the place of other implementation-specific methods
like `loadResponse()` and `formSubmissionIntercepted()`.

In addition, these changes have the potential to close
[hotwired#326][], since we can consistently invoke
`loadResponse()` across `<a>`-click-initiated and
`<form>`-submission-initiated visits. To ensure that's the case, this
commit adds test coverage for navigating a `<turbo-frame>` by making a
`GET` request to an endpoint that responds with a `500` status.

[hotwired#146]: hotwired#146
[hotwired#326]: hotwired#326
  • Loading branch information
seanpdoyle committed Dec 22, 2022
1 parent ab4b184 commit d57b389
Show file tree
Hide file tree
Showing 7 changed files with 402 additions and 179 deletions.
212 changes: 59 additions & 153 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,10 @@ import {
FrameLoadingStyle,
FrameElementObservedAttribute,
} from "../../elements/frame_element"
import { FetchMethod, FetchRequest, FetchRequestDelegate } from "../../http/fetch_request"
import { FetchRequest, TurboFetchRequestErrorEvent } from "../../http/fetch_request"
import { FetchResponse } from "../../http/fetch_response"
import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer"
import {
clearBusyState,
dispatch,
getAttribute,
parseHTMLDocument,
markAsBusy,
uuid,
getHistoryMethodForAction,
getVisitAction,
} from "../../util"
import { FormSubmission, FormSubmissionDelegate } from "../drive/form_submission"
import { dispatch, getAttribute, parseHTMLDocument, uuid, getHistoryMethodForAction } from "../../util"
import { Snapshot } from "../snapshot"
import { ViewDelegate, ViewRenderOptions } from "../view"
import { Locatable, getAction, expandURL, urlsAreEqual, locationIsVisitable } from "../url"
Expand All @@ -28,22 +18,20 @@ import { FormLinkClickObserver, FormLinkClickObserverDelegate } from "../../obse
import { FrameRenderer } from "./frame_renderer"
import { session } from "../index"
import { Action } from "../types"
import { FrameVisit, FrameVisitDelegate, FrameVisitOptions } from "./frame_visit"
import { VisitOptions } from "../drive/visit"
import { TurboBeforeFrameRenderEvent } from "../session"
import { StreamMessage } from "../streams/stream_message"
import { PageSnapshot } from "../drive/page_snapshot"

type VisitFallback = (location: Response | Locatable, options: Partial<VisitOptions>) => Promise<void>
export type TurboFrameMissingEvent = CustomEvent<{ response: Response; visit: VisitFallback }>

export class FrameController
implements
AppearanceObserverDelegate<FrameElement>,
FetchRequestDelegate,
FormSubmitObserverDelegate,
FormSubmissionDelegate,
FrameElementDelegate,
FormLinkClickObserverDelegate,
FrameVisitDelegate,
LinkInterceptorDelegate,
ViewDelegate<FrameElement, Snapshot<FrameElement>>
{
Expand All @@ -53,18 +41,12 @@ export class FrameController
readonly formLinkClickObserver: FormLinkClickObserver
readonly linkInterceptor: LinkInterceptor
readonly formSubmitObserver: FormSubmitObserver
formSubmission?: FormSubmission
fetchResponseLoaded = (_fetchResponse: FetchResponse) => {}
private currentFetchRequest: FetchRequest | null = null
private resolveVisitPromise = () => {}
frameVisit?: FrameVisit
private connected = false
private hasBeenLoaded = false
private ignoredAttributes: Set<FrameElementObservedAttribute> = new Set()
private action: Action | null = null
readonly restorationIdentifier: string
private previousFrameElement?: FrameElement
private currentNavigationElement?: Element
pageSnapshot?: PageSnapshot

constructor(element: FrameElement) {
this.element = element
Expand Down Expand Up @@ -100,6 +82,11 @@ export class FrameController
}
}

visit(options: FrameVisitOptions): Promise<void> {
const frameVisit = new FrameVisit(this, this.element, options)
return frameVisit.start()
}

disabledChanged() {
if (this.loadingStyle == FrameLoadingStyle.eager) {
this.loadSourceURL()
Expand Down Expand Up @@ -145,14 +132,11 @@ export class FrameController

private async loadSourceURL() {
if (this.enabled && this.isActive && !this.complete && this.sourceURL) {
this.element.loaded = this.visit(expandURL(this.sourceURL))
this.appearanceObserver.stop()
await this.element.loaded
this.hasBeenLoaded = true
await this.visit({ url: this.sourceURL })
}
}

async loadResponse(fetchResponse: FetchResponse) {
async loadResponse(fetchResponse: FetchResponse, frameVisit: FrameVisit) {
if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) {
this.sourceURL = fetchResponse.response.url
}
Expand All @@ -174,13 +158,13 @@ export class FrameController
false
)
if (this.view.renderPromise) await this.view.renderPromise
this.changeHistory()
this.changeHistory(frameVisit.action)

await this.view.render(renderer)
this.complete = true
session.frameRendered(fetchResponse, this.element)
session.frameLoaded(this.element)
this.fetchResponseLoaded(fetchResponse)
this.proposeVisitIfNavigatedWithAction(frameVisit, fetchResponse)
} else if (this.willHandleFrameMissingFromResponse(fetchResponse)) {
console.warn(
`A matching frame for #${this.element.id} was missing from the response, transforming into full-page Visit.`
Expand All @@ -191,16 +175,12 @@ export class FrameController
} catch (error) {
console.error(error)
this.view.invalidate()
} finally {
this.fetchResponseLoaded = () => {}
}
}

// Appearance observer delegate

elementAppearedInViewport(element: FrameElement) {
this.pageSnapshot = PageSnapshot.fromElement(element).clone()
this.proposeVisitIfNavigatedWithAction(element, element)
elementAppearedInViewport(_element: FrameElement) {
this.loadSourceURL()
}

Expand Down Expand Up @@ -232,78 +212,42 @@ export class FrameController
}

formSubmitted(element: HTMLFormElement, submitter?: HTMLElement) {
if (this.formSubmission) {
this.formSubmission.stop()
}

this.formSubmission = new FormSubmission(this, element, submitter)
const { fetchRequest } = this.formSubmission
this.prepareRequest(fetchRequest)
this.formSubmission.start()
}

// Fetch request delegate

prepareRequest(request: FetchRequest) {
request.headers["Turbo-Frame"] = this.id

if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) {
request.acceptResponseType(StreamMessage.contentType)
}
}

requestStarted(_request: FetchRequest) {
markAsBusy(this.element)
}

requestPreventedHandlingResponse(_request: FetchRequest, _response: FetchResponse) {
this.resolveVisitPromise()
}

async requestSucceededWithResponse(request: FetchRequest, response: FetchResponse) {
await this.loadResponse(response)
this.resolveVisitPromise()
}

async requestFailedWithResponse(request: FetchRequest, response: FetchResponse) {
console.error(response)
await this.loadResponse(response)
this.resolveVisitPromise()
const frame = this.findFrameElement(element, submitter)
frame.delegate.visit(FrameVisit.optionsForSubmit(element, submitter))
}

requestErrored(request: FetchRequest, error: Error) {
console.error(error)
this.resolveVisitPromise()
}
// Frame visit delegate

requestFinished(_request: FetchRequest) {
clearBusyState(this.element)
shouldVisit(_frameVisit: FrameVisit) {
return this.enabled && this.isActive
}

// Form submission delegate

formSubmissionStarted({ formElement }: FormSubmission) {
markAsBusy(formElement, this.findFrameElement(formElement))
visitStarted(frameVisit: FrameVisit) {
this.ignoringChangesToAttribute("complete", () => {
this.frameVisit?.stop()
this.frameVisit = frameVisit
this.element.removeAttribute("complete")
})
}

formSubmissionSucceededWithResponse(formSubmission: FormSubmission, response: FetchResponse) {
const frame = this.findFrameElement(formSubmission.formElement, formSubmission.submitter)

frame.delegate.proposeVisitIfNavigatedWithAction(frame, formSubmission.formElement, formSubmission.submitter)

frame.delegate.loadResponse(response)
async visitSucceededWithResponse(frameVisit: FrameVisit, response: FetchResponse) {
await this.loadResponse(response, frameVisit)
}

formSubmissionFailedWithResponse(formSubmission: FormSubmission, fetchResponse: FetchResponse) {
this.element.delegate.loadResponse(fetchResponse)
async visitFailedWithResponse(frameVisit: FrameVisit, response: FetchResponse) {
await this.loadResponse(response, frameVisit)
}

formSubmissionErrored(formSubmission: FormSubmission, error: Error) {
visitErrored(frameVisit: FrameVisit, request: FetchRequest, error: Error) {
console.error(error)
dispatch<TurboFetchRequestErrorEvent>("turbo:fetch-request-error", {
target: this.element,
detail: { request, error },
})
}

formSubmissionFinished({ formElement }: FormSubmission) {
clearBusyState(formElement, this.findFrameElement(formElement))
visitCompleted(_frameVisit: FrameVisit) {
this.hasBeenLoaded = true
}

// View delegate
Expand Down Expand Up @@ -351,64 +295,32 @@ export class FrameController

// Private

private async visit(url: URL) {
const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element)

this.currentFetchRequest?.cancel()
this.currentFetchRequest = request

return new Promise<void>((resolve) => {
this.resolveVisitPromise = () => {
this.resolveVisitPromise = () => {}
this.currentFetchRequest = null
resolve()
private navigateFrame(element: Element, url: string) {
const frame = this.findFrameElement(element)
frame.delegate.visit(FrameVisit.optionsForClick(element, expandURL(url)))
}

private proposeVisitIfNavigatedWithAction({ action, element, snapshot }: FrameVisit, fetchResponse: FetchResponse) {
if (element.src && action) {
const { statusCode, redirected } = fetchResponse
const responseHTML = element.ownerDocument.documentElement.outerHTML
const options: Partial<VisitOptions> = {
action,
snapshot,
response: { statusCode, redirected, responseHTML },
restorationIdentifier: this.restorationIdentifier,
updateHistory: false,
visitCachedSnapshot: this.visitCachedSnapshot,
willRender: false,
}
request.perform()
})
}

private navigateFrame(element: Element, url: string, submitter?: HTMLElement) {
const frame = this.findFrameElement(element, submitter)
this.pageSnapshot = PageSnapshot.fromElement(frame).clone()

frame.delegate.proposeVisitIfNavigatedWithAction(frame, element, submitter)

this.withCurrentNavigationElement(element, () => {
frame.src = url
})
}

proposeVisitIfNavigatedWithAction(frame: FrameElement, element: Element, submitter?: HTMLElement) {
this.action = getVisitAction(submitter, element, frame)

if (this.action) {
const { visitCachedSnapshot } = frame.delegate

frame.delegate.fetchResponseLoaded = (fetchResponse: FetchResponse) => {
if (frame.src) {
const { statusCode, redirected } = fetchResponse
const responseHTML = frame.ownerDocument.documentElement.outerHTML
const response = { statusCode, redirected, responseHTML }
const options: Partial<VisitOptions> = {
response,
visitCachedSnapshot,
willRender: false,
updateHistory: false,
restorationIdentifier: this.restorationIdentifier,
snapshot: this.pageSnapshot,
}

if (this.action) options.action = this.action

session.visit(frame.src, options)
}
}
session.visit(element.src, options)
}
}

changeHistory() {
if (this.action) {
const method = getHistoryMethodForAction(this.action)
changeHistory(action: Action | null) {
if (action) {
const method = getHistoryMethodForAction(action)
session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier)
}
}
Expand Down Expand Up @@ -532,7 +444,7 @@ export class FrameController
}

get isLoading() {
return this.formSubmission !== undefined || this.resolveVisitPromise() !== undefined
return this.frameVisit !== undefined
}

get complete() {
Expand Down Expand Up @@ -568,12 +480,6 @@ export class FrameController
callback()
this.ignoredAttributes.delete(attributeName)
}

private withCurrentNavigationElement(element: Element, callback: () => void) {
this.currentNavigationElement = element
callback()
delete this.currentNavigationElement
}
}

function getFrameElementById(id: string | null) {
Expand Down
Loading

0 comments on commit d57b389

Please sign in to comment.