Skip to content

Commit

Permalink
Introduce turbo:frame-missing event
Browse files Browse the repository at this point in the history
Closes [hotwired#432][]
Follow-up to [hotwired#94][]
Follow-up to [hotwired#31][]

When a response from _within_ a frame is missing a matching frame, fire
the `turbo:frame-missing` event.

There is an existing [contract][] that dictates a request from within a
frame stays within a frame.

However, if an application is interested in reacting to a response
without a frame, dispatch a `turbo:frame-missing` event. The event's
`target` is the `FrameElement`, and the `detail` contains the
`fetchResponse:` key.

The `event.detail.visit` key provides handlers with a way to transform
the `fetchResponse` into a `Turbo.visit()` call without any knowledge of
the internal structure or logic necessary to do so. Event listeners for
`turbo:frame-missing` can invoke the `event.detail.visit` directly to
invoke `Turbo.visit()` behind the scenes. The yielded `visit()` function
accepts `Partial<VisitOptions>` as an optional argument:

```js
addEventListener("turbo:frame-missing", (event) => {
  // the details of `shouldRedirectOnMissingFrame(element: FrameElement)`
  // are up to the application to decide
  if (shouldRedirectOnMissingFrame(event.target)) {
    const { detail: { fetchResponse, visit } } = event

    event.preventDefault()
    visit()
  }
})
```

The event listener is also a good opportunity to change the
`<turbo-frame>` element itself to prevent future missing responses.

For example, if the reason the frame is missing is access (an expired
session, for example), the call to `visit()` can be made with `{ action:
"replace" }` to remove the current page from Turbo's page history.

Similarly, if the reason for the missing frame is particular to the page
referenced by the element's `[src]` attribute, this is an opportunity to
change that attribute (calling `event.target.removeAttribute("src")`,
for example) before navigating away so that re-visiting the page by
navigating backward in the Browser's history doesn't automatically load
the frame and re-trigger another `turbo:frame-missing` event.

[contract]: hotwired#94 (comment)
[hotwired#432]: hotwired#432
[hotwired#94]: hotwired#94
[hotwired#31]: hotwired#31
  • Loading branch information
seanpdoyle committed Nov 21, 2021
1 parent 4a9f220 commit 2dd01f2
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 16 deletions.
31 changes: 19 additions & 12 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,17 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
const html = await fetchResponse.responseHTML
if (html) {
const { body } = parseHTMLDocument(html)
const snapshot = new Snapshot(await this.extractForeignFrameElement(body))
const renderer = new FrameRenderer(this.view.snapshot, snapshot, false, false)
if (this.view.renderPromise) await this.view.renderPromise
await this.view.render(renderer)
session.frameRendered(fetchResponse, this.element)
session.frameLoaded(this.element)
this.fetchResponseLoaded(fetchResponse)
const element = await this.extractForeignFrameElement(body, fetchResponse)

if (element) {
const snapshot = new Snapshot(element)
const renderer = new FrameRenderer(this.view.snapshot, snapshot, false, false)
if (this.view.renderPromise) await this.view.renderPromise
await this.view.render(renderer)
session.frameRendered(fetchResponse, this.element)
session.frameLoaded(this.element)
this.fetchResponseLoaded(fetchResponse)
}
}
} catch (error) {
console.error(error)
Expand Down Expand Up @@ -282,7 +286,7 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
return getFrameElementById(id) ?? this.element
}

async extractForeignFrameElement(container: ParentNode): Promise<FrameElement> {
async extractForeignFrameElement(container: ParentNode, fetchResponse: FetchResponse): Promise<FrameElement | null> {
let element
const id = CSS.escape(this.id)

Expand All @@ -293,15 +297,18 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest

if (element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.currentURL)) {
await element.loaded
return await this.extractForeignFrameElement(element)
return await this.extractForeignFrameElement(element, fetchResponse)
}

console.error(`Response has no matching <turbo-frame id="${id}"> element`)
} catch (error) {
console.error(error)
}

return new FrameElement()
if (session.frameMissingIntercepted(fetchResponse, this.element)) {
return null
} else {
console.error(`Response has no matching <turbo-frame id="${id}"> element`)
return new FrameElement()
}
}

private formActionIsVisitable(form: HTMLFormElement, submitter?: HTMLElement) {
Expand Down
13 changes: 13 additions & 0 deletions src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin
this.notifyApplicationAfterFrameRender(fetchResponse, frame);
}

frameMissingIntercepted(fetchResponse: FetchResponse, target: FrameElement) {
const visit = async (options: Partial<VisitOptions> = {}) => {
const responseHTML = await fetchResponse.responseHTML
const { location, redirected, statusCode } = fetchResponse

this.visit(location, { response: { redirected, statusCode, responseHTML }, ...options })
}

const event = dispatch("turbo:frame-missing", { target, detail: { fetchResponse, visit }, cancelable: true })

return event.defaultPrevented
}

// Application events

applicationAllowsFollowingLinkToLocation(link: Element, location: URL) {
Expand Down
18 changes: 16 additions & 2 deletions src/tests/fixtures/frames.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@
<script src="/dist/turbo.es2017-umd.js" data-turbo-track="reload"></script>
<script src="/src/tests/fixtures/test.js"></script>
<script>
function proposeVisitWhenFrameIsMissingInResponse(clicked) {
const frame = clicked.closest("turbo-frame")

frame?.addEventListener("turbo:frame-missing", (event) => {
event.preventDefault()

event.target.src = null
event.detail.visit()
}, { once: true })
}
addEventListener("click", ({ target }) => {
if (target.id == "add-turbo-action-to-frame") {
target.closest("turbo-frame")?.setAttribute("data-turbo-action", "advance")
Expand All @@ -21,6 +31,7 @@
<body>
<h1>Frames</h1>

<a id="outside-frame-form" href="/src/tests/fixtures/frames/form.html" data-turbo-frame="frame">Navigate #frame to /frames/form.html</a>
<turbo-frame id="frame" data-loaded-from="/src/tests/fixtures/frames.html">
<h2>Frames: #frame</h2>

Expand All @@ -31,7 +42,6 @@ <h2>Frames: #frame</h2>
<a id="link-frame" href="/src/tests/fixtures/frames/frame.html">Navigate #frame from within</a>
<a id="link-nested-frame-action-advance" href="/src/tests/fixtures/frames/frame.html" data-turbo-action="advance">Navigate #frame from within with a[data-turbo-action="advance"]</a>
</turbo-frame>
<a id="outside-frame-form" href="/src/tests/fixtures/frames/form.html" data-turbo-frame="frame">Navigate #frame to /frames/form.html</a>

<a id="link-outside-frame-action-advance" href="/src/tests/fixtures/frames/frame.html" data-turbo-frame="frame" data-turbo-action="advance">Navigate #frame from outside with a[data-turbo-action="advance"]</a>
<form id="form-get-frame-action-advance" action="/__turbo/redirect" data-turbo-frame="frame" data-turbo-action="advance">
Expand Down Expand Up @@ -89,7 +99,11 @@ <h2>Frames: #nested-child</h2>
</turbo-frame>

<turbo-frame id="missing">
<a href="/src/tests/fixtures/frames/frame.html">Missing frame</a>
<a id="missing-link" href="/src/tests/fixtures/frames/frame.html">Missing frame</a>
<a id="missing-link-visit"href="/src/tests/fixtures/frames/frame.html" onclick="proposeVisitWhenFrameIsMissingInResponse(this)">Visit when missing frame</a>
<form action="/src/tests/fixtures/frames/frame.html">
<button id="missing-form-visit" onclick="proposeVisitWhenFrameIsMissingInResponse(this)">Visit when missing frame</button>
</form>
</turbo-frame>

<turbo-frame id="body-script" target="body-script">
Expand Down
1 change: 1 addition & 0 deletions src/tests/fixtures/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@
"turbo:visit",
"turbo:frame-load",
"turbo:frame-render",
"turbo:frame-missing",
])
60 changes: 59 additions & 1 deletion src/tests/functional/frame_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export class FrameTests extends TurboDriveTestCase {
async "test a frame whose src references itself does not infinitely loop"() {
await this.clickSelector("#frame-self")

await this.nextEventOnTarget("frame", "turbo:before-fetch-request")
await this.nextEventOnTarget("frame", "turbo:before-fetch-response")
await this.nextEventOnTarget("frame", "turbo:frame-missing")
await this.nextEventOnTarget("frame", "turbo:frame-render")
await this.nextEventOnTarget("frame", "turbo:frame-load")

Expand All @@ -47,8 +50,9 @@ export class FrameTests extends TurboDriveTestCase {
}

async "test following a link to a page without a matching frame results in an empty frame"() {
await this.clickSelector("#missing a")
await this.clickSelector("#missing-link")
await this.nextBeat

this.assert.notOk(await this.innerHTMLForSelector("#missing"))
}

Expand Down Expand Up @@ -523,6 +527,60 @@ export class FrameTests extends TurboDriveTestCase {
this.assert.equal(await this.pathname, "/src/tests/fixtures/frames/frame.html")
}

async "test following a link to a page without a matching frame dispatches a turbo:frame-missing event"() {
await this.clickSelector("#missing-link")

const { fetchResponse } = await this.nextEventOnTarget("missing", "turbo:frame-missing")
await this.nextEventOnTarget("missing", "turbo:frame-render")
await this.nextEventOnTarget("missing", "turbo:frame-load")

const src = await this.attributeForSelector("#missing", "src")
this.assert(fetchResponse, "dispatchs turbo:frame-missing with event.detail.fetchResponse")
this.assert(src?.includes("/src/tests/fixtures/frames/frame.html"), "navigates frame by setting the [src]")
}

async "test following a link to a page with a matching frame does not dispatch a turbo:frame-missing event"() {
await this.clickSelector("#link-frame")

const eventNames = (await this.eventLogChannel.read()).map(([ name ]) => name)
await this.nextBeat
const src = await this.attributeForSelector("#frame", "src")

this.assert.notOk(eventNames.includes("turbo:frame-missing"), "does not dispatch turbo:frame-missing")
this.assert(src?.includes("/src/tests/fixtures/frames/frame.html"), "navigates frame without dispatching turbo:frame-missing")
}

async "test navigating frame resulting in response without matching frame can be re-purposed to navigate entire page"() {
await this.clickSelector("#missing-link-visit")
await this.nextEventOnTarget("missing", "turbo:frame-missing")
await this.nextBody

this.assert.notOk(await this.hasSelector("#missing"))
this.assert.equal(await (await this.querySelector("h1")).getVisibleText(), "Frames: #frame")
this.assert.equal(await this.pathname, "/src/tests/fixtures/frames/frame.html")
}

async "test missing frame response re-purposed to a visit preserves frame contents in browser history"() {
await this.clickSelector("#missing-link-visit")
await this.nextEventOnTarget("missing", "turbo:frame-missing")
await this.nextEventNamed("turbo:load")
await this.goBack()
await this.nextBody

this.assert.equal(await this.pathname, "/src/tests/fixtures/frames.html")
this.assert.ok(await this.innerHTMLForSelector("#missing"))
}

async "test submitting frame resulting in response without matching frame can be re-purposed to navigate entire page"() {
await this.clickSelector("#missing-form-visit")
await this.nextEventOnTarget("missing", "turbo:frame-missing")
await this.nextBody

this.assert.notOk(await this.hasSelector("#missing"))
this.assert.equal(await (await this.querySelector("h1")).getVisibleText(), "Frames: #frame")
this.assert.equal(await this.pathname, "/src/tests/fixtures/frames/frame.html")
}

async "test turbo:before-fetch-request fires on the frame element"() {
await this.clickSelector("#hello a")
this.assert.ok(await this.nextEventOnTarget("frame", "turbo:before-fetch-request"))
Expand Down
2 changes: 1 addition & 1 deletion src/tests/helpers/functional_test_case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class FunctionalTestCase extends InternTestCase {
}

async innerHTMLForSelector(selector: string): Promise<string> {
const element = await this.remote.findAllByCssSelector(selector)
const element = await this.remote.findByCssSelector(selector)
return this.evaluate(element => element.innerHTML, element)
}

Expand Down

0 comments on commit 2dd01f2

Please sign in to comment.