Skip to content

Commit

Permalink
Page refreshes
Browse files Browse the repository at this point in the history
This commit introduces the concept of page refresh. A page refresh
happens when Turbo renders the current page again. We will offer two new
options to control behavior when a page refresh happens:

- The method used to update the page: with a new option to use morphing
  (Turbo currently replaces the body).

- The scroll strategy: with a new option to keep it (Turbo currently
  resets scroll to the top-left).

The combination of morphing and scroll-keeping results in smoother
updates that keep the screen state. For example, this will keep both
horizontal and vertical scroll, the focus, the text selection, CSS
transition states, etc.

We will also introduce a new turbo stream action that, when broadcasted,
will request a page refresh. This will offer a simplified alternative
to fine-grained broadcasted turbo-stream actions.

Co-Authored-By: Jorge Manrubia <[email protected]>
  • Loading branch information
afcapel and jorgemanrubia committed Oct 4, 2023
1 parent 0ec99ae commit f5edb5e
Show file tree
Hide file tree
Showing 31 changed files with 619 additions and 28 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
"publishConfig": {
"access": "public"
},
"dependencies": {
"idiomorph": "https://github.com/basecamp/idiomorph#rollout-build"
},
"devDependencies": {
"@open-wc/testing": "^3.1.7",
"@playwright/test": "^1.28.0",
Expand Down
15 changes: 15 additions & 0 deletions src/core/drive/limited_set.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export class LimitedSet extends Set {
constructor(maxSize) {
super()
this.maxSize = maxSize
}

add(value) {
if (this.size >= this.maxSize) {
const iterator = this.values()
const oldestValue = iterator.next().value
this.delete(oldestValue)
}
super.add(value)
}
}
88 changes: 88 additions & 0 deletions src/core/drive/morph_renderer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Idiomorph from "idiomorph"
import { nextAnimationFrame } from "../../util"
import { Renderer } from "../renderer"

export class MorphRenderer extends Renderer {
async render() {
if (this.willRender) await this.#morphBody()
}

get renderMethod() {
return "morph"
}

// Private

async #morphBody() {
this.#morphElements(this.currentElement, this.newElement)
this.#reloadRemoteFrames()

this.#dispatchEvent("turbo:morph", { currentElement: this.currentElement, newElement: this.newElement })
}

#morphElements(currentElement, newElement, morphStyle = "outerHTML") {
Idiomorph.morph(currentElement, newElement, {
morphStyle: morphStyle,
callbacks: {
beforeNodeMorphed: this.#shouldMorphElement,
beforeNodeRemoved: this.#shouldRemoveElement,
afterNodeMorphed: this.#reloadStimulusControllers
}
})
}

#reloadRemoteFrames() {
this.#remoteFrames().forEach((frame) => {
if (this.#isFrameReloadedWithMorph(frame)) {
this.#renderFrameWithMorph(frame)
}
frame.reload()
})
}

#renderFrameWithMorph(frame) {
frame.addEventListener("turbo:before-frame-render", (event) => {
event.detail.render = this.#morphFrameUpdate
}, { once: true })
}

#morphFrameUpdate = (currentElement, newElement) => {
this.#dispatchEvent("turbo:before-frame-morph", { currentElement, newElement }, currentElement)
this.#morphElements(currentElement, newElement, "innerHTML")
}

#shouldRemoveElement = (node) => {
return this.#shouldMorphElement(node)
}

#shouldMorphElement = (node) => {
if (node instanceof HTMLElement) {
return !node.hasAttribute("data-turbo-permanent")
} else {
return true
}
}

#reloadStimulusControllers = async (node) => {
if (node instanceof HTMLElement && node.hasAttribute("data-controller")) {
const originalAttribute = node.getAttribute("data-controller")
node.removeAttribute("data-controller")
await nextAnimationFrame()
node.setAttribute("data-controller", originalAttribute)
}
}

#isFrameReloadedWithMorph(element) {
return element.getAttribute("src") && element.getAttribute("refresh") === "morph"
}

#remoteFrames() {
return document.querySelectorAll("turbo-frame[src]")
}

#dispatchEvent(name, detail, target = document.documentElement) {
const event = new CustomEvent(name, { bubbles: true, cancelable: true, detail })
target.dispatchEvent(event)
return event
}
}
8 changes: 8 additions & 0 deletions src/core/drive/page_snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export class PageSnapshot extends Snapshot {
return this.headSnapshot.getMetaValue("view-transition") === "same-origin"
}

get shouldMorphPage() {
return this.getSetting("refresh-method") === "morph"
}

get shouldPreserveScrollPosition() {
return this.getSetting("refresh-scroll") === "preserve"
}

// Private

getSetting(name) {
Expand Down
10 changes: 9 additions & 1 deletion src/core/drive/page_view.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { nextEventLoopTick } from "../../util"
import { View } from "../view"
import { ErrorRenderer } from "./error_renderer"
import { MorphRenderer } from "./morph_renderer"
import { PageRenderer } from "./page_renderer"
import { PageSnapshot } from "./page_snapshot"
import { SnapshotCache } from "./snapshot_cache"
Expand All @@ -15,7 +16,10 @@ export class PageView extends View {
}

renderPage(snapshot, isPreview = false, willRender = true, visit) {
const renderer = new PageRenderer(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender)
const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage
const rendererClass = shouldMorphPage ? MorphRenderer : PageRenderer

const renderer = new rendererClass(this.snapshot, snapshot, PageRenderer.renderElement, isPreview, willRender)

if (!renderer.shouldRender) {
this.forceReloaded = true
Expand Down Expand Up @@ -55,6 +59,10 @@ export class PageView extends View {
return this.snapshotCache.get(location)
}

isPageRefresh(visit) {
return visit && this.lastRenderedLocation.href === visit.location.href
}

get snapshot() {
return PageSnapshot.fromElement(this.element)
}
Expand Down
18 changes: 9 additions & 9 deletions src/core/drive/visit.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@ export class Visit {
}
}

issueRequest() {
async issueRequest() {
if (this.hasPreloadedResponse()) {
this.simulateRequest()
} else if (this.shouldIssueRequest() && !this.request) {
} else if (!this.request && await this.shouldIssueRequest()) {
this.request = new FetchRequest(this, FetchMethod.get, this.location)
this.request.perform()
}
Expand Down Expand Up @@ -231,14 +231,14 @@ export class Visit {
}
}

hasCachedSnapshot() {
return this.getCachedSnapshot() != null
async hasCachedSnapshot() {
return (await this.getCachedSnapshot()) != null
}

async loadCachedSnapshot() {
const snapshot = await this.getCachedSnapshot()
if (snapshot) {
const isPreview = this.shouldIssueRequest()
const isPreview = await this.shouldIssueRequest()
this.render(async () => {
this.cacheSnapshot()
if (this.isSamePage) {
Expand Down Expand Up @@ -335,7 +335,7 @@ export class Visit {
// Scrolling

performScroll() {
if (!this.scrolled && !this.view.forceReloaded) {
if (!this.scrolled && !this.view.forceReloaded && !this.view.snapshot.shouldPreserveScrollPosition) {
if (this.action == "restore") {
this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop()
} else {
Expand Down Expand Up @@ -391,11 +391,11 @@ export class Visit {
return typeof this.response == "object"
}

shouldIssueRequest() {
async shouldIssueRequest() {
if (this.isSamePage) {
return false
} else if (this.action == "restore") {
return !this.hasCachedSnapshot()
} else if (this.action === "restore") {
return !(await this.hasCachedSnapshot())
} else {
return this.willRender
}
Expand Down
2 changes: 1 addition & 1 deletion src/core/frames/frame_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export class FrameController {
return !defaultPrevented
}

viewRenderedSnapshot(_snapshot, _isPreview) {}
viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {}

preloadOnLoadLinksForView(element) {
session.preloadOnLoadLinksForView(element)
Expand Down
4 changes: 3 additions & 1 deletion src/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { PageRenderer } from "./drive/page_renderer"
import { PageSnapshot } from "./drive/page_snapshot"
import { FrameRenderer } from "./frames/frame_renderer"
import { FormSubmission } from "./drive/form_submission"
import { LimitedSet } from "./drive/limited_set"

const session = new Session()
const cache = new Cache(session)
const recentRequests = new LimitedSet(20)
const { navigator } = session
export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer }
export { navigator, session, cache, recentRequests, PageRenderer, PageSnapshot, FrameRenderer }

export { StreamActions } from "./streams/stream_actions"

Expand Down
6 changes: 1 addition & 5 deletions src/core/native/browser_adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@ export class BrowserAdapter {

visitRequestStarted(visit) {
this.progressBar.setValue(0)
if (visit.hasCachedSnapshot() || visit.action != "restore") {
this.showVisitProgressBarAfterDelay()
} else {
this.showProgressBar()
}
this.showVisitProgressBarAfterDelay()
}

visitRequestCompleted(visit) {
Expand Down
4 changes: 4 additions & 0 deletions src/core/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,8 @@ export class Renderer {
get permanentElementMap() {
return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot)
}

get renderMethod() {
return "replace"
}
}
8 changes: 4 additions & 4 deletions src/core/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,9 @@ export class Session {
return !defaultPrevented
}

viewRenderedSnapshot(_snapshot, isPreview) {
viewRenderedSnapshot(_snapshot, isPreview, renderMethod) {
this.view.lastRenderedLocation = this.history.location
this.notifyApplicationAfterRender(isPreview)
this.notifyApplicationAfterRender(isPreview, renderMethod)
}

preloadOnLoadLinksForView(element) {
Expand Down Expand Up @@ -328,8 +328,8 @@ export class Session {
})
}

notifyApplicationAfterRender(isPreview) {
return dispatch("turbo:render", { detail: { isPreview } })
notifyApplicationAfterRender(isPreview, renderMethod) {
return dispatch("turbo:render", { detail: { isPreview, renderMethod } })
}

notifyApplicationAfterPageLoad(timing = {}) {
Expand Down
9 changes: 9 additions & 0 deletions src/core/streams/stream_actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,14 @@ export const StreamActions = {
targetElement.innerHTML = ""
targetElement.append(this.templateContent)
})
},

refresh() {
const requestId = this.getAttribute("request-id")
const isRecentRequest = requestId && window.Turbo.recentRequests.has(requestId)
if (!isRecentRequest) {
window.Turbo.cache.exemptPageFromPreview()
window.Turbo.visit(window.location.href, { action: "replace" })
}
}
}
2 changes: 1 addition & 1 deletion src/core/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class View {
if (!immediateRender) await renderInterception

await this.renderSnapshot(renderer)
this.delegate.viewRenderedSnapshot(snapshot, isPreview)
this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod)
this.delegate.preloadOnLoadLinksForView(this.element)
this.finishRenderingSnapshot(renderer)
} finally {
Expand Down
15 changes: 15 additions & 0 deletions src/http/recent_fetch_requests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { uuid } from "../util"

const originalFetch = window.fetch

window.fetch = async function(url, options = {}) {
const modifiedHeaders = new Headers(options.headers || {})
const requestUID = uuid()
window.Turbo.recentRequests.add(requestUID)
modifiedHeaders.append("X-Turbo-Request-Id", requestUID)

return originalFetch(url, {
...options,
headers: modifiedHeaders
})
}
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "./polyfills"
import "./elements"
import "./script_warning"
import "./http/recent_fetch_requests"

import * as Turbo from "./core"

Expand Down
3 changes: 3 additions & 0 deletions src/tests/fixtures/frame_refresh_morph.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<turbo-frame id="refresh-morph">
<h2>Loaded morphed frame</h2>
</turbo-frame>
3 changes: 3 additions & 0 deletions src/tests/fixtures/frame_refresh_reload.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<turbo-frame id="refresh-reload">
<h2>Loaded reloadable frame</h2>
</turbo-frame>
52 changes: 52 additions & 0 deletions src/tests/fixtures/page_refresh.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html id="html" data-skip-event-details="turbo:submit-start turbo:submit-end turbo:fetch-request-error">
<head>
<meta charset="utf-8">
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">

<title>Turbo</title>
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<script src="/dist/turbo.es2017-umd.js" data-turbo-track="reload"></script>
<script src="/src/tests/fixtures/test.js"></script>

<style>
body {
margin: 0;
padding: 0;

/* Ensure the page is large enough to scroll */
width: 150vw;
height: 150vh;
}
</style>
</head>
<body>
<h1>Page to be refreshed</h1>

<turbo-frame id="refresh-morph" src="/src/tests/fixtures/frame_refresh_morph.html" refresh="morph">
<h2>Frame to be morphed</h2>
</turbo-frame>

<turbo-frame id="refresh-reload" src="/src/tests/fixtures/frame_refresh_reload.html" refresh="reload">
<h2>Frame to be reloaded</h2>
</turbo-frame>

<div id="preserve-me" data-turbo-permanent>
Preserve me!
</div>

<div id="stimulus-controller" data-controller="test">
<h3>Element with Stimulus controller</h3>
</div>

<p><a id="link" href="/src/tests/fixtures/one.html">Link to another page</a></p>

<form id="form" action="/__turbo/refresh" method="post" class="redirect">
<input type="text" name="text" value="">
<input type="hidden" name="path" value="/src/tests/fixtures/page_refresh.html">
<input type="hidden" name="sleep" value="50">
<input id="form-submit" type="submit" value="form[method=post]">
</form>
</body>
</html>
Loading

0 comments on commit f5edb5e

Please sign in to comment.