Skip to content

Commit

Permalink
Introduce turbo:frame-missing event
Browse files Browse the repository at this point in the history
Closes [#432][]
Follow-up to [#94][]
Follow-up to [#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", async ({ target, detail: { fetchResponse, visit } }) => {
  // the details of `shouldRedirectOnMissingFrame(element: FrameElement)`
  // are up to the application to decide
  if (shouldRedirectOnMissingFrame(target)) {
    visit({ action: "replace" })
  }
})
```

[contract]: #94 (comment)
[#432]: #432
[#94]: #94
[#31]: #31
  • Loading branch information
seanpdoyle committed Nov 18, 2021
1 parent 59074f0 commit 6aba5c6
Show file tree
Hide file tree
Showing 6 changed files with 60 additions and 7 deletions.
14 changes: 8 additions & 6 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,12 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
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)
if (snapshot.element.delegate.isActive) {
session.frameRendered(fetchResponse, this.element)
session.frameLoaded(this.element)
} else {
session.frameMissing(fetchResponse, this.element)
}
}
} catch (error) {
console.error(error)
Expand Down Expand Up @@ -295,10 +299,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
11 changes: 11 additions & 0 deletions src/core/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,17 @@ 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 })
}

dispatch("turbo:frame-missing", { target, detail: { fetchResponse, visit } })
}

// 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
7 changes: 7 additions & 0 deletions src/tests/fixtures/frames.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
addEventListener("click", ({ target }) => {
if (target.id == "add-turbo-action-to-frame") {
target.closest("turbo-frame")?.setAttribute("data-turbo-action", "advance")
} else if (target.id == "propose-visit-when-frame-missing") {
addEventListener("turbo:frame-missing", ({ detail: { visit } }) => visit(), { once: true })
}
})
</script>
Expand Down Expand Up @@ -78,6 +80,9 @@ <h2>Frames: #nested-child</h2>

<turbo-frame id="missing">
<a href="/src/tests/fixtures/frames/frame.html">Missing frame</a>
<form action="/src/tests/fixtures/frames/frame.html">
<button>Missing frame</button>
</form>
</turbo-frame>

<turbo-frame id="body-script" target="body-script">
Expand All @@ -104,5 +109,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",
])
33 changes: 32 additions & 1 deletion src/tests/functional/frame_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ 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-render")
await this.nextEventOnTarget("frame", "turbo:frame-load")

Expand All @@ -37,8 +39,11 @@ 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

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 +412,28 @@ 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.proposeVisitWhenFrameIsMissingInResponse()
await this.clickSelector("#missing a")
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 submitting frame resulting in response without matching frame can be re-purposed to navigate entire page"() {
await this.proposeVisitWhenFrameIsMissingInResponse()
await this.clickSelector("#missing button")
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 All @@ -420,6 +447,10 @@ export class FrameTests extends TurboDriveTestCase {
get frameScriptEvaluationCount(): Promise<number | undefined> {
return this.evaluate(() => window.frameScriptEvaluationCount)
}

async proposeVisitWhenFrameIsMissingInResponse(): Promise<void> {
return await this.clickSelector("#propose-visit-when-frame-missing")
}
}

declare global {
Expand Down

0 comments on commit 6aba5c6

Please sign in to comment.