Skip to content

Commit 4a4b481

Browse files
committed
Expose Frame load state via [loaded] attribute
Closes #429 --- Introduce the `<turbo-frame loaded>` 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 `[loaded]` 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 `[loaded]` attribute if the element [isConnected][] to the document, so that the `[loaded]` attribute is not modified prior to Snapshot Caching or when re-mounting a Cached Snapshot. The act of "reloading" involves the removal of the `[loaded]` attribute, which can be done either by `FrameElement.reload()` or `document.getElementById("frame-element").removeAttribute("loaded")`. A side-effect of introducing the `[loaded]` 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 `FrameElement` that's returned from the `FrameElement.observedAttributes` static property, including the `"src"` or `"loaded"` 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
1 parent aa9724d commit 4a4b481

File tree

5 files changed

+140
-51
lines changed

5 files changed

+140
-51
lines changed

src/core/frames/frame_controller.ts

+56-45
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FrameElement, FrameElementDelegate, FrameLoadingStyle } from "../../elements/frame_element"
1+
import { FrameElement, FrameElementDelegate, FrameLoadingStyle, FrameElementObservedAttribute } from "../../elements/frame_element"
22
import { FetchMethod, FetchRequest, FetchRequestDelegate, FetchRequestHeaders } from "../../http/fetch_request"
33
import { FetchResponse } from "../../http/fetch_response"
44
import { AppearanceObserver, AppearanceObserverDelegate } from "../../observers/appearance_observer"
@@ -14,20 +14,21 @@ import { FrameRenderer } from "./frame_renderer"
1414
import { session } from "../index"
1515
import { isAction } from "../types"
1616

17+
type ObservedAttribute = keyof FrameElement & FrameElementObservedAttribute
18+
1719
export class FrameController implements AppearanceObserverDelegate, FetchRequestDelegate, FormInterceptorDelegate, FormSubmissionDelegate, FrameElementDelegate, LinkInterceptorDelegate, ViewDelegate<Snapshot<FrameElement>> {
1820
readonly element: FrameElement
1921
readonly view: FrameView
2022
readonly appearanceObserver: AppearanceObserver
2123
readonly linkInterceptor: LinkInterceptor
2224
readonly formInterceptor: FormInterceptor
23-
currentURL?: string | null
2425
formSubmission?: FormSubmission
2526
fetchResponseLoaded = (fetchResponse: FetchResponse) => {}
2627
private currentFetchRequest: FetchRequest | null = null
2728
private resolveVisitPromise = () => {}
2829
private connected = false
2930
private hasBeenLoaded = false
30-
private settingSourceURL = false
31+
private ignoredAttributes: Set<ObservedAttribute> = new Set
3132

3233
constructor(element: FrameElement) {
3334
this.element = element
@@ -40,13 +41,13 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
4041
connect() {
4142
if (!this.connected) {
4243
this.connected = true
43-
this.reloadable = false
44+
this.linkInterceptor.start()
45+
this.formInterceptor.start()
4446
if (this.loadingStyle == FrameLoadingStyle.lazy) {
4547
this.appearanceObserver.start()
48+
} else {
49+
this.loadSourceURL()
4650
}
47-
this.linkInterceptor.start()
48-
this.formInterceptor.start()
49-
this.sourceURLChanged()
5051
}
5152
}
5253

@@ -66,11 +67,23 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
6667
}
6768

6869
sourceURLChanged() {
70+
if (this.isIgnoringChangesTo("src")) return
71+
72+
if (this.element.isConnected) {
73+
this.loaded = false
74+
}
75+
6976
if (this.loadingStyle == FrameLoadingStyle.eager || this.hasBeenLoaded) {
7077
this.loadSourceURL()
7178
}
7279
}
7380

81+
loadedChanged() {
82+
if (this.isIgnoringChangesTo("loaded")) return
83+
84+
this.loadSourceURL()
85+
}
86+
7487
loadingStyleChanged() {
7588
if (this.loadingStyle == FrameLoadingStyle.lazy) {
7689
this.appearanceObserver.start()
@@ -80,21 +93,12 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
8093
}
8194
}
8295

83-
async loadSourceURL() {
84-
if (!this.settingSourceURL && this.enabled && this.isActive && (this.reloadable || this.sourceURL != this.currentURL)) {
85-
const previousURL = this.currentURL
86-
this.currentURL = this.sourceURL
87-
if (this.sourceURL) {
88-
try {
89-
this.element.loaded = this.visit(expandURL(this.sourceURL))
90-
this.appearanceObserver.stop()
91-
await this.element.loaded
92-
this.hasBeenLoaded = true
93-
} catch (error) {
94-
this.currentURL = previousURL
95-
throw error
96-
}
97-
}
96+
private async loadSourceURL() {
97+
if (this.enabled && this.isActive && !this.loaded && this.sourceURL) {
98+
this.element.loaded = this.visit(expandURL(this.sourceURL))
99+
this.appearanceObserver.stop()
100+
await this.element.loaded
101+
this.hasBeenLoaded = true
98102
}
99103
}
100104

@@ -111,6 +115,7 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
111115
const renderer = new FrameRenderer(this.view.snapshot, snapshot, false, false)
112116
if (this.view.renderPromise) await this.view.renderPromise
113117
await this.view.render(renderer)
118+
this.loaded = true
114119
session.frameRendered(fetchResponse, this.element)
115120
session.frameLoaded(this.element)
116121
this.fetchResponseLoaded(fetchResponse)
@@ -140,7 +145,6 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
140145
}
141146

142147
linkClickIntercepted(element: Element, url: string) {
143-
this.reloadable = true
144148
this.navigateFrame(element, url)
145149
}
146150

@@ -155,7 +159,6 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
155159
this.formSubmission.stop()
156160
}
157161

158-
this.reloadable = false
159162
this.formSubmission = new FormSubmission(this, element, submitter)
160163
const { fetchRequest } = this.formSubmission
161164
this.prepareHeadersForRequest(fetchRequest.headers, fetchRequest)
@@ -256,7 +259,6 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
256259

257260
this.proposeVisitIfNavigatedWithAction(frame, element, submitter)
258261

259-
frame.setAttribute("reloadable", "")
260262
frame.src = url
261263
}
262264

@@ -287,11 +289,11 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
287289
const id = CSS.escape(this.id)
288290

289291
try {
290-
if (element = activateElement(container.querySelector(`turbo-frame#${id}`), this.currentURL)) {
292+
if (element = activateElement(container.querySelector(`turbo-frame#${id}`), this.sourceURL)) {
291293
return element
292294
}
293295

294-
if (element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.currentURL)) {
296+
if (element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.sourceURL)) {
295297
await element.loaded
296298
return await this.extractForeignFrameElement(element)
297299
}
@@ -355,25 +357,10 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
355357
}
356358
}
357359

358-
get reloadable() {
359-
const frame = this.findFrameElement(this.element)
360-
return frame.hasAttribute("reloadable")
361-
}
362-
363-
set reloadable(value: boolean) {
364-
const frame = this.findFrameElement(this.element)
365-
if (value) {
366-
frame.setAttribute("reloadable", "")
367-
} else {
368-
frame.removeAttribute("reloadable")
369-
}
370-
}
371-
372360
set sourceURL(sourceURL: string | undefined) {
373-
this.settingSourceURL = true
374-
this.element.src = sourceURL ?? null
375-
this.currentURL = this.element.src
376-
this.settingSourceURL = false
361+
this.ignoringChangesToAttribute("src", () => {
362+
this.element.src = sourceURL ?? null
363+
})
377364
}
378365

379366
get loadingStyle() {
@@ -384,6 +371,20 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
384371
return this.formSubmission !== undefined || this.resolveVisitPromise() !== undefined
385372
}
386373

374+
get loaded() {
375+
return this.element.hasAttribute("loaded")
376+
}
377+
378+
set loaded(value: boolean) {
379+
this.ignoringChangesToAttribute("loaded", () => {
380+
if (value) {
381+
this.element.setAttribute("loaded", "")
382+
} else {
383+
this.element.removeAttribute("loaded")
384+
}
385+
})
386+
}
387+
387388
get isActive() {
388389
return this.element.isActive && this.connected
389390
}
@@ -393,6 +394,16 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
393394
const root = meta?.content ?? "/"
394395
return expandURL(root)
395396
}
397+
398+
private isIgnoringChangesTo(attributeName: ObservedAttribute): boolean {
399+
return this.ignoredAttributes.has(attributeName)
400+
}
401+
402+
private ignoringChangesToAttribute(attributeName: ObservedAttribute, callback: () => void) {
403+
this.ignoredAttributes.add(attributeName)
404+
callback()
405+
this.ignoredAttributes.delete(attributeName)
406+
}
396407
}
397408

398409
class SnapshotSubstitution {

src/core/frames/frame_redirector.ts

-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ export class FrameRedirector implements LinkInterceptorDelegate, FormInterceptor
4242
formSubmissionIntercepted(element: HTMLFormElement, submitter?: HTMLElement) {
4343
const frame = this.findFrameElement(element, submitter)
4444
if (frame) {
45-
frame.removeAttribute("reloadable")
4645
frame.delegate.formSubmissionIntercepted(element, submitter)
4746
}
4847
}

src/elements/frame_element.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import { FetchResponse } from "../http/fetch_response"
22

33
export enum FrameLoadingStyle { eager = "eager", lazy = "lazy" }
44

5+
export type FrameElementObservedAttribute = "disabled" | "loaded" | "loading" | "src"
6+
57
export interface FrameElementDelegate {
68
connect(): void
79
disconnect(): void
10+
loadedChanged(): void
811
loadingStyleChanged(): void
912
sourceURLChanged(): void
1013
disabledChanged(): void
@@ -37,8 +40,8 @@ export class FrameElement extends HTMLElement {
3740
loaded: Promise<FetchResponse | void> = Promise.resolve()
3841
readonly delegate: FrameElementDelegate
3942

40-
static get observedAttributes() {
41-
return ["disabled", "loading", "src"]
43+
static get observedAttributes(): FrameElementObservedAttribute[] {
44+
return ["disabled", "loaded", "loading", "src"]
4245
}
4346

4447
constructor() {
@@ -56,13 +59,16 @@ export class FrameElement extends HTMLElement {
5659

5760
reload() {
5861
const { src } = this;
62+
this.removeAttribute("loaded")
5963
this.src = null;
6064
this.src = src;
6165
}
6266

6367
attributeChangedCallback(name: string) {
6468
if (name == "loading") {
6569
this.delegate.loadingStyleChanged()
70+
} else if (name == "loaded") {
71+
this.delegate.loadedChanged()
6672
} else if (name == "src") {
6773
this.delegate.sourceURLChanged()
6874
} else {

src/tests/fixtures/loading.html

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!DOCTYPE html>
2-
<html>
2+
<html data-skip-event-details="turbo:before-render">
33
<head>
44
<meta charset="utf-8">
55
<title>Turbo</title>
@@ -13,6 +13,8 @@
1313
<turbo-frame id="hello" src="/src/tests/fixtures/frames/hello.html" loading="lazy"></turbo-frame>
1414
</details>
1515

16+
<a id="link-lazy-frame" href="/src/tests/fixtures/frames.html" data-turbo-frame="hello">Navigate #loading-lazy turbo-frame</a>
17+
1618
<details id="loading-eager">
1719
<summary>Eager-loaded</summary>
1820

src/tests/functional/loading_tests.ts

+73-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { FrameElement } from "../../elements/frame_element"
12
import { TurboDriveTestCase } from "../helpers/turbo_drive_test_case"
23

34
declare global {
@@ -14,19 +15,22 @@ export class LoadingTests extends TurboDriveTestCase {
1415
async "test eager loading within a details element"() {
1516
await this.nextBeat
1617
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame#frame h2"))
18+
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame[loaded]"), "has [loaded] attribute")
1719
}
1820

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

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

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

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

3236
async "test changing loading attribute from lazy to eager loads frame"() {
@@ -92,9 +96,12 @@ export class LoadingTests extends TurboDriveTestCase {
9296

9397
const frameContent = "#loading-eager turbo-frame#frame h2"
9498
this.assert.ok(await this.hasSelector(frameContent))
95-
// @ts-ignore
96-
await this.remote.execute(() => document.querySelector("#loading-eager turbo-frame")?.reload())
99+
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame[loaded]"), "has [loaded] attribute")
100+
101+
await this.remote.execute(() => document.querySelector<FrameElement>("#loading-eager turbo-frame")?.reload())
102+
97103
this.assert.ok(await this.hasSelector(frameContent))
104+
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame:not([loaded])"), "clears [loaded] attribute")
98105
}
99106

100107
async "test navigating away from a page does not reload its frames"() {
@@ -106,6 +113,70 @@ export class LoadingTests extends TurboDriveTestCase {
106113
this.assert.equal(requestLogs.length, 1)
107114
}
108115

116+
async "test removing the [loaded] attribute of an eager frame reloads the content"() {
117+
await this.nextEventOnTarget("frame", "turbo:frame-load")
118+
await this.remote.execute(() => document.querySelector("#loading-eager turbo-frame")?.removeAttribute("loaded"))
119+
await this.nextEventOnTarget("frame", "turbo:frame-load")
120+
121+
this.assert.ok(await this.hasSelector("#loading-eager turbo-frame[loaded]"), "sets the [loaded] attribute after re-loading")
122+
}
123+
124+
async "test changing [src] attribute on a [loaded] frame with loading=lazy defers navigation"() {
125+
await this.nextEventOnTarget("frame", "turbo:frame-load")
126+
await this.clickSelector("#loading-lazy summary")
127+
await this.nextEventOnTarget("hello", "turbo:frame-load")
128+
129+
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame[loaded]"), "lazy frame is loaded")
130+
this.assert.equal(await (await this.querySelector("#hello h2")).getVisibleText(), "Hello from a frame")
131+
132+
await this.clickSelector("#loading-lazy summary")
133+
await this.clickSelector("#one")
134+
await this.nextEventNamed("turbo:load")
135+
await this.goBack()
136+
await this.nextBody
137+
await this.noNextEventNamed("turbo:frame-load")
138+
139+
let src = new URL(await this.attributeForSelector("#hello", "src") || "")
140+
141+
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame[loaded]"), "lazy frame is loaded")
142+
this.assert.equal(src.pathname, "/src/tests/fixtures/frames/hello.html", "lazy frame retains [src]")
143+
144+
await this.clickSelector("#link-lazy-frame")
145+
await this.noNextEventNamed("turbo:frame-load")
146+
147+
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame:not([loaded])"), "lazy frame is not loaded")
148+
149+
await this.clickSelector("#loading-lazy summary")
150+
await this.nextEventOnTarget("hello", "turbo:frame-load")
151+
152+
src = new URL(await this.attributeForSelector("#hello", "src") || "")
153+
154+
this.assert.equal(await (await this.querySelector("#loading-lazy turbo-frame h2")).getVisibleText(), "Frames: #hello")
155+
this.assert.ok(await this.hasSelector("#loading-lazy turbo-frame[loaded]"), "lazy frame is loaded")
156+
this.assert.equal(src.pathname, "/src/tests/fixtures/frames.html", "lazy frame navigates")
157+
}
158+
159+
async "test navigating away from a page and then back does not reload its frames"() {
160+
await this.clickSelector("#one")
161+
await this.nextBody
162+
await this.eventLogChannel.read()
163+
await this.goBack()
164+
await this.nextBody
165+
166+
const eventLogs = await this.eventLogChannel.read()
167+
const requestLogs = eventLogs.filter(([ name ]) => name == "turbo:before-fetch-request")
168+
const requestsOnEagerFrame = requestLogs.filter(record => record[2] == "frame")
169+
const requestsOnLazyFrame = requestLogs.filter(record => record[2] == "hello")
170+
171+
this.assert.equal(requestsOnEagerFrame.length, 0, "does not reload eager frame")
172+
this.assert.equal(requestsOnLazyFrame.length, 0, "does not reload lazy frame")
173+
174+
await this.clickSelector("#loading-lazy summary")
175+
await this.nextEventOnTarget("hello", "turbo:before-fetch-request")
176+
await this.nextEventOnTarget("hello", "turbo:frame-render")
177+
await this.nextEventOnTarget("hello", "turbo:frame-load")
178+
}
179+
109180
async "test disconnecting and reconnecting a frame does not reload the frame"() {
110181
await this.nextBeat
111182

0 commit comments

Comments
 (0)