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. To transform the `FetchResponse` into a `Visit`,
clients can extract:

* `redirected: boolean`
* `statusCode: number`
* `responseHTML: Promise<string>`

Event listeners for `turbo:frame-missing` can forward the `detail`
directly to a `Turbo.visit` call:

```js
addEventListener("turbo:frame-missing", async ({ target, detail: { fetchResponse } }) => {
  // the details of `shouldRedirectOnMissingFrame(element: FrameElement)`
  // are up to the application to decide
  if (shouldRedirectOnMissingFrame(target)) {
    const { location, redirected, statusCode, responseHTML } = fetchResponse
    const response = { redirected, statusCode, responseHTML: await responseHTML }

    Turbo.visit(location, { response })
  }
})
```

[contract]: hotwired#94 (comment)
[hotwired#432]: hotwired#432
[hotwired#94]: hotwired#94
[hotwired#31]: hotwired#31
  • Loading branch information
seanpdoyle committed Nov 15, 2021
1 parent 59074f0 commit 7904c5f
Show file tree
Hide file tree
Showing 6 changed files with 60 additions and 17 deletions.
26 changes: 12 additions & 14 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 @@ -286,19 +290,13 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
let element
const id = CSS.escape(this.id)

try {
if (element = activateElement(container.querySelector(`turbo-frame#${id}`), this.currentURL)) {
return element
}

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

console.error(`Response has no matching <turbo-frame id="${id}"> element`)
} catch (error) {
console.error(error)
if (element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.currentURL)) {
await element.loaded
return await this.extractForeignFrameElement(element)
}

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

async frameMissing(fetchResponse: FetchResponse, target: FrameElement) {
dispatch("turbo:frame-missing", { target, detail: { fetchResponse } })
}

// 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
4 changes: 4 additions & 0 deletions src/tests/fixtures/frames.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ <h2>Frames: #nested-child</h2>

<turbo-frame id="missing">
<a href="/src/tests/fixtures/frames/frame.html">Missing frame</a>
<form action="/__turbo/redirect">
<input type="hidden" name="path" value="/src/tests/fixtures/frames/frame.html">
<button>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 @@ -29,4 +29,5 @@
"turbo:visit",
"turbo:frame-load",
"turbo:frame-render",
"turbo:frame-missing",
])
41 changes: 38 additions & 3 deletions src/tests/functional/frame_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +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:frame-render")
await this.nextEventOnTarget("frame", "turbo:frame-load")
await this.nextEventOnTarget("frame", "turbo:before-fetch-request")
await this.nextEventOnTarget("frame", "turbo:before-fetch-response")

const otherEvents = await this.eventLogChannel.read()
this.assert.equal(otherEvents.length, 0, "no more events")
Expand All @@ -37,8 +37,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 +410,26 @@ 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.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.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 +443,18 @@ export class FrameTests extends TurboDriveTestCase {
get frameScriptEvaluationCount(): Promise<number | undefined> {
return this.evaluate(() => window.frameScriptEvaluationCount)
}

proposeVisitWhenFrameIsMissingInResponse(): Promise<void> {
return this.evaluate(() => {
addEventListener("turbo:frame-missing", async (event: Event) => {
const { detail: { fetchResponse } } = event as CustomEvent
const { location, redirected, statusCode, responseHTML } = fetchResponse
const response = { redirected, statusCode, responseHTML: await responseHTML }

window.Turbo.visit(location, { response })
})
})
}
}

declare global {
Expand Down

0 comments on commit 7904c5f

Please sign in to comment.