Skip to content

Commit

Permalink
Expose Frame load state via [complete] attribute
Browse files Browse the repository at this point in the history
Closes hotwired#429

---

Introduce the `<turbo-frame complete>` boolean attribute. The
attribute's absence indicates that the frame has not yet been loaded,
and is ready to be navigated. Its presence means that the contents of
the frame have been fetch from its `[src]` attribute.

Encoding the load state into the element's HTML aims to integrate with
Snapshot caching. Once a frame is loaded, navigating away and then
restoring a page's state from an Historical Snapshot should preserve the
fact that the contents are already loaded.

For both `eager` and `lazy` loaded frames, changing the element's
`[src]` attribute (directly via JavaScript, or by clicking an `<a>`
element or submitting a `<form>` element) will remove the `[complete]`
attribute. Eager-loaded frames will immediately initiate a request to
fetch the contents, and Lazy-loaded frames will initiate the request
once they enter the viewport, or are changed to be eager-loading.

When the `[src]` attribute is changed, the `FrameController` will only
remove the `[complete]` attribute if the element [isConnected][] to the
document, so that the `[complete]` attribute is not modified prior to
Snapshot Caching or when re-mounting a Cached Snapshot.

The act of "reloading" involves the removal of the `[complete]`
attribute, which can be done either by `FrameElement.reload()` or
`document.getElementById("frame-element").removeAttribute("complete")`.

A side-effect of introducing the `[complete]` attribute is that the
`FrameController` no longer needs to internally track:

1. how the internal `currentURL` value compares to the external
  `sourceURL` value
2. whether or not the frame is "reloadable"

By no longer tracking the `sourceURL` and `currentURL` separately, the
implementation for the private `loadSourceURL` method can be simplified.
Since there is no longer a `currentURL` property to rollback, the `try {
... } catch (error) { ... }` can be omitted, and the `this.sourceURL`
presence check can be incorporated into the rest of the guard
conditional.

Finally, this commit introduce the `isIgnoringChangesTo()` and
`ignoringChangesToAttribute()` private methods to disable
FrameController observations for a given period of time. For example,
when setting the `<turbo-frame src="...">` attribute, previous
implementation would set, then check the value of a
`this.settingSourceURL` property to decide whether or not to fire
attribute change callback code. This commit refines that pattern to
support any property of the `FrameController`, including the
`"sourceURL"` or `"complete"` value. When making internal modifications
to those values, it's important to temporarily disable observation
callbacks to avoid unnecessary requests and to limit the potential for
infinitely recursing loops.

[isConnected]: https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected
  • Loading branch information
seanpdoyle committed Jun 19, 2022
1 parent bccbe40 commit f80597c
Show file tree
Hide file tree
Showing 4 changed files with 38 additions and 38 deletions.
22 changes: 11 additions & 11 deletions src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,16 @@ export class FrameController
if (this.isIgnoringChangesTo("src")) return

if (this.element.isConnected) {
this.loaded = false
this.complete = false
}

if (this.loadingStyle == FrameLoadingStyle.eager || this.hasBeenLoaded) {
this.loadSourceURL()
}
}

loadedChanged() {
if (this.isIgnoringChangesTo("loaded")) return
completeChanged() {
if (this.isIgnoringChangesTo("complete")) return

this.loadSourceURL()
}
Expand All @@ -106,7 +106,7 @@ export class FrameController
}

private async loadSourceURL() {
if (this.enabled && this.isActive && !this.loaded && this.sourceURL) {
if (this.enabled && this.isActive && !this.complete && this.sourceURL) {
this.element.loaded = this.visit(expandURL(this.sourceURL))
this.appearanceObserver.stop()
await this.element.loaded
Expand All @@ -127,7 +127,7 @@ export class FrameController
const renderer = new FrameRenderer(this.view.snapshot, snapshot, false, false)
if (this.view.renderPromise) await this.view.renderPromise
await this.view.render(renderer)
this.loaded = true
this.complete = true
session.frameRendered(fetchResponse, this.element)
session.frameLoaded(this.element)
this.fetchResponseLoaded(fetchResponse)
Expand Down Expand Up @@ -392,16 +392,16 @@ export class FrameController
return this.formSubmission !== undefined || this.resolveVisitPromise() !== undefined
}

get loaded() {
return this.element.hasAttribute("loaded")
get complete() {
return this.element.hasAttribute("complete")
}

set loaded(value: boolean) {
this.ignoringChangesToAttribute("loaded", () => {
set complete(value: boolean) {
this.ignoringChangesToAttribute("complete", () => {
if (value) {
this.element.setAttribute("loaded", "")
this.element.setAttribute("complete", "")
} else {
this.element.removeAttribute("loaded")
this.element.removeAttribute("complete")
}
})
}
Expand Down
12 changes: 6 additions & 6 deletions src/elements/frame_element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ export enum FrameLoadingStyle {
lazy = "lazy",
}

export type FrameElementObservedAttribute = keyof FrameElement & ("disabled" | "loaded" | "loading" | "src")
export type FrameElementObservedAttribute = keyof FrameElement & ("disabled" | "complete" | "loading" | "src")

export interface FrameElementDelegate {
connect(): void
disconnect(): void
loadedChanged(): void
completeChanged(): void
loadingStyleChanged(): void
sourceURLChanged(): void
disabledChanged(): void
Expand Down Expand Up @@ -44,7 +44,7 @@ export class FrameElement extends HTMLElement {
readonly delegate: FrameElementDelegate

static get observedAttributes(): FrameElementObservedAttribute[] {
return ["disabled", "loaded", "loading", "src"]
return ["disabled", "complete", "loading", "src"]
}

constructor() {
Expand All @@ -62,16 +62,16 @@ export class FrameElement extends HTMLElement {

reload() {
const { src } = this
this.removeAttribute("loaded")
this.removeAttribute("complete")
this.src = null
this.src = src
}

attributeChangedCallback(name: string) {
if (name == "loading") {
this.delegate.loadingStyleChanged()
} else if (name == "loaded") {
this.delegate.loadedChanged()
} else if (name == "complete") {
this.delegate.completeChanged()
} else if (name == "src") {
this.delegate.sourceURLChanged()
} else {
Expand Down
14 changes: 7 additions & 7 deletions src/tests/functional/frame_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ export class FrameTests extends TurboDriveTestCase {
this.assert.equal(await title.getVisibleText(), "Frames")
this.assert.equal(await frameTitle.getVisibleText(), "Frame: Loaded")
this.assert.equal(await this.pathname, "/src/tests/fixtures/frames/frame.html")
this.assert.ok(await this.hasSelector("#frame[loaded]"), "marks the frame as [loaded]")
this.assert.ok(await this.hasSelector("#frame[complete]"), "marks the frame as [complete]")
}

async "test navigating frame with a[data-turbo-action=advance] pushes URL state"() {
Expand All @@ -465,7 +465,7 @@ export class FrameTests extends TurboDriveTestCase {
this.assert.equal(await title.getVisibleText(), "Frames")
this.assert.equal(await frameTitle.getVisibleText(), "Frame: Loaded")
this.assert.equal(await this.pathname, "/src/tests/fixtures/frames/frame.html")
this.assert.ok(await this.hasSelector("#frame[loaded]"), "marks the frame as [loaded]")
this.assert.ok(await this.hasSelector("#frame[complete]"), "marks the frame as [complete]")
}

async "test navigating frame with form[method=get][data-turbo-action=advance] pushes URL state"() {
Expand All @@ -480,7 +480,7 @@ export class FrameTests extends TurboDriveTestCase {
this.assert.equal(await title.getVisibleText(), "Frames")
this.assert.equal(await frameTitle.getVisibleText(), "Frame: Loaded")
this.assert.equal(await this.pathname, "/src/tests/fixtures/frames/frame.html")
this.assert.ok(await this.hasSelector("#frame[loaded]"), "marks the frame as [loaded]")
this.assert.ok(await this.hasSelector("#frame[complete]"), "marks the frame as [complete]")
}

async "test navigating frame with form[method=get][data-turbo-action=advance] to the same URL clears the [aria-busy] and [data-turbo-preview] state"() {
Expand Down Expand Up @@ -508,7 +508,7 @@ export class FrameTests extends TurboDriveTestCase {
this.assert.equal(await title.getVisibleText(), "Frames")
this.assert.equal(await frameTitle.getVisibleText(), "Frame: Loaded")
this.assert.equal(await this.pathname, "/src/tests/fixtures/frames/frame.html")
this.assert.ok(await this.hasSelector("#frame[loaded]"), "marks the frame as [loaded]")
this.assert.ok(await this.hasSelector("#frame[complete]"), "marks the frame as [complete]")
}

async "test navigating frame with form[method=post][data-turbo-action=advance] to the same URL clears the [aria-busy] and [data-turbo-preview] state"() {
Expand All @@ -522,7 +522,7 @@ export class FrameTests extends TurboDriveTestCase {
this.assert.equal(await this.attributeForSelector("#frame", "aria-busy"), null, "clears turbo-frame[aria-busy]")
this.assert.equal(await this.attributeForSelector("#html", "aria-busy"), null, "clears html[aria-busy]")
this.assert.equal(await this.attributeForSelector("#html", "data-turbo-preview"), null, "clears html[aria-busy]")
this.assert.ok(await this.hasSelector("#frame[loaded]"), "marks the frame as [loaded]")
this.assert.ok(await this.hasSelector("#frame[complete]"), "marks the frame as [complete]")
}

async "test navigating frame with button[data-turbo-action=advance] pushes URL state"() {
Expand All @@ -537,7 +537,7 @@ export class FrameTests extends TurboDriveTestCase {
this.assert.equal(await title.getVisibleText(), "Frames")
this.assert.equal(await frameTitle.getVisibleText(), "Frame: Loaded")
this.assert.equal(await this.pathname, "/src/tests/fixtures/frames/frame.html")
this.assert.ok(await this.hasSelector("#frame[loaded]"), "marks the frame as [loaded]")
this.assert.ok(await this.hasSelector("#frame[complete]"), "marks the frame as [complete]")
}

async "test navigating back after pushing URL state from a turbo-frame[data-turbo-action=advance] restores the frames previous contents"() {
Expand Down Expand Up @@ -573,7 +573,7 @@ export class FrameTests extends TurboDriveTestCase {
this.assert.equal(await title.getVisibleText(), "Frames")
this.assert.equal(await frameTitle.getVisibleText(), "Frame: Loaded")
this.assert.equal(await this.pathname, "/src/tests/fixtures/frames/frame.html")
this.assert.ok(await this.hasSelector("#frame[loaded]"), "marks the frame as [loaded]")
this.assert.ok(await this.hasSelector("#frame[complete]"), "marks the frame as [complete]")
}

async "test turbo:before-fetch-request fires on the frame element"() {
Expand Down
28 changes: 14 additions & 14 deletions src/tests/functional/loading_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,22 @@ export class LoadingTests extends TurboDriveTestCase {
async "test eager loading within a details element"() {
await this.nextBeat
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame#frame h2"))
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame[loaded]"), "has [loaded] attribute")
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame[complete]"), "has [complete] attribute")
}

async "test lazy loading within a details element"() {
await this.nextBeat

const frameContents = "#loading-lazy turbo-frame h2"
this.assert.notOk(await this.hasSelector(frameContents))
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame:not([loaded])"))
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame:not([complete])"))

await this.clickSelector("#loading-lazy summary")
await this.nextBeat

const contents = await this.querySelector(frameContents)
this.assert.equal(await contents.getVisibleText(), "Hello from a frame")
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame[loaded]"), "has [loaded] attribute")
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame[complete]"), "has [complete] attribute")
}

async "test changing loading attribute from lazy to eager loads frame"() {
Expand Down Expand Up @@ -101,11 +101,11 @@ export class LoadingTests extends TurboDriveTestCase {

const frameContent = "#loading-eager turbo-frame#frame h2"
this.assert.ok(await this.hasSelector(frameContent))
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame[loaded]"), "has [loaded] attribute")
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame[complete]"), "has [complete] attribute")

await this.remote.execute(() => (document.querySelector("#loading-eager turbo-frame") as any)?.reload())
this.assert.ok(await this.hasSelector(frameContent))
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame:not([loaded])"), "clears [loaded] attribute")
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame:not([complete])"), "clears [complete] attribute")
}

async "test navigating away from a page does not reload its frames"() {
Expand All @@ -117,23 +117,23 @@ export class LoadingTests extends TurboDriveTestCase {
this.assert.equal(requestLogs.length, 1)
}

async "test removing the [loaded] attribute of an eager frame reloads the content"() {
async "test removing the [complete] attribute of an eager frame reloads the content"() {
await this.nextEventOnTarget("frame", "turbo:frame-load")
await this.remote.execute(() => document.querySelector("#loading-eager turbo-frame")?.removeAttribute("loaded"))
await this.remote.execute(() => document.querySelector("#loading-eager turbo-frame")?.removeAttribute("complete"))
await this.nextEventOnTarget("frame", "turbo:frame-load")

this.assert.ok(
await this.hasSelector("#loading-eager turbo-frame[loaded]"),
"sets the [loaded] attribute after re-loading"
await this.hasSelector("#loading-eager turbo-frame[complete]"),
"sets the [complete] attribute after re-loading"
)
}

async "test changing [src] attribute on a [loaded] frame with loading=lazy defers navigation"() {
async "test changing [src] attribute on a [complete] frame with loading=lazy defers navigation"() {
await this.nextEventOnTarget("frame", "turbo:frame-load")
await this.clickSelector("#loading-lazy summary")
await this.nextEventOnTarget("hello", "turbo:frame-load")

this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame[loaded]"), "lazy frame is loaded")
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame[complete]"), "lazy frame is complete")
this.assert.equal(await (await this.querySelector("#hello h2")).getVisibleText(), "Hello from a frame")

await this.clickSelector("#loading-lazy summary")
Expand All @@ -145,13 +145,13 @@ export class LoadingTests extends TurboDriveTestCase {

let src = new URL((await this.attributeForSelector("#hello", "src")) || "")

this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame[loaded]"), "lazy frame is loaded")
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame[complete]"), "lazy frame is complete")
this.assert.equal(src.pathname, "/src/tests/fixtures/frames/hello.html", "lazy frame retains [src]")

await this.clickSelector("#link-lazy-frame")
await this.noNextEventNamed("turbo:frame-load")

this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame:not([loaded])"), "lazy frame is not loaded")
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame:not([complete])"), "lazy frame is not complete")

await this.clickSelector("#loading-lazy summary")
await this.nextEventOnTarget("hello", "turbo:frame-load")
Expand All @@ -162,7 +162,7 @@ export class LoadingTests extends TurboDriveTestCase {
await (await this.querySelector("#loading-lazy turbo-frame h2")).getVisibleText(),
"Frames: #hello"
)
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame[loaded]"), "lazy frame is loaded")
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame[complete]"), "lazy frame is complete")
this.assert.equal(src.pathname, "/src/tests/fixtures/frames.html", "lazy frame navigates")
}

Expand Down

0 comments on commit f80597c

Please sign in to comment.