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 18, 2021
1 parent 59074f0 commit 2cb2d4b
Show file tree
Hide file tree
Showing 7 changed files with 83 additions and 13 deletions.
19 changes: 10 additions & 9 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,14 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
if (html) {
const { body } = parseHTMLDocument(html)
const snapshot = new Snapshot(await this.extractForeignFrameElement(body))
const renderer = new FrameRenderer(this.view.snapshot, snapshot, false)
if (this.view.renderPromise) await this.view.renderPromise
await this.view.render(renderer)
session.frameRendered(fetchResponse, this.element)
session.frameLoaded(this.element)
const matchingFramePresent = snapshot.element.delegate.isActive
if (matchingFramePresent || session.frameMissing(fetchResponse, this.element)) {
const renderer = new FrameRenderer(this.view.snapshot, snapshot, false)
if (this.view.renderPromise) await this.view.renderPromise
await this.view.render(renderer)
session.frameRendered(fetchResponse, this.element)
session.frameLoaded(this.element)
}
}
} catch (error) {
console.error(error)
Expand Down Expand Up @@ -295,10 +298,8 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
await element.loaded
return await this.extractForeignFrameElement(element)
}

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

return new FrameElement()
Expand Down
13 changes: 13 additions & 0 deletions src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,19 @@ export class Session implements FormSubmitObserverDelegate, HistoryDelegate, Lin
this.notifyApplicationAfterFrameRender(fetchResponse, frame);
}

frameMissing(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
1 change: 1 addition & 0 deletions src/elements/frame_element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface FrameElementDelegate {
linkClickIntercepted(element: Element, url: string): void
loadResponse(response: FetchResponse): void
isLoading: boolean
isActive: boolean
}

/**
Expand Down
19 changes: 18 additions & 1 deletion src/tests/fixtures/frames.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@
<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 visitWhenFrameIsMissing = (event) => {
const { target, detail: { visit } } = event

event.preventDefault()
target.src = null
visit()
}

clicked.closest("turbo-frame")?.addEventListener("turbo:frame-missing", visitWhenFrameIsMissing, { once: true })
}
addEventListener("click", ({ target }) => {
if (target.id == "add-turbo-action-to-frame") {
target.closest("turbo-frame")?.setAttribute("data-turbo-action", "advance")
Expand Down Expand Up @@ -77,7 +88,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 All @@ -104,5 +119,7 @@ <h2>Frames: #nested-child</h2>
<form data-turbo-frame="frame" method="get" action="/src/tests/fixtures/frames/frame.html">
<input id="outer-frame-submit" type="submit" value="Outer form submit">
</form>

<button id="propose-visit-when-frame-missing" type="button">Propose Visit when frame missing</button>
</body>
</html>
1 change: 1 addition & 0 deletions src/tests/fixtures/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@
"turbo:visit",
"turbo:frame-load",
"turbo:frame-render",
"turbo:frame-missing",
])
41 changes: 39 additions & 2 deletions src/tests/functional/frame_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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 @@ -36,9 +39,12 @@ 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.nextBeat
await this.clickSelector("#missing-link")

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

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

async "test following a link within a frame with a target set navigates the target frame"() {
Expand Down Expand Up @@ -407,6 +413,37 @@ export class FrameTests extends TurboDriveTestCase {
this.assert.equal(await this.pathname, "/src/tests/fixtures/frames/frame.html")
}

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 2cb2d4b

Please sign in to comment.