diff --git a/package.json b/package.json
index 03b07fa02..74c0d20fc 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,9 @@
"publishConfig": {
"access": "public"
},
+ "dependencies": {
+ "idiomorph": "^0.0.9"
+ },
"devDependencies": {
"@open-wc/testing": "^3.1.7",
"@playwright/test": "^1.28.0",
diff --git a/src/core/drive/error_renderer.js b/src/core/drive/error_renderer.js
index 0e5f2ce39..0d3b48b6b 100644
--- a/src/core/drive/error_renderer.js
+++ b/src/core/drive/error_renderer.js
@@ -3,6 +3,10 @@ import { Renderer } from "../renderer"
export class ErrorRenderer extends Renderer {
static renderElement(currentElement, newElement) {
+ ErrorRenderer.replace(currentElement, newElement)
+ }
+
+ static replace(currentElement, newElement) {
const { documentElement, body } = document
documentElement.replaceChild(newElement, body)
diff --git a/src/core/drive/limited_set.js b/src/core/drive/limited_set.js
new file mode 100644
index 000000000..a9b5643e7
--- /dev/null
+++ b/src/core/drive/limited_set.js
@@ -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)
+ }
+}
diff --git a/src/core/drive/morph_renderer.js b/src/core/drive/morph_renderer.js
new file mode 100644
index 000000000..1eedaacf6
--- /dev/null
+++ b/src/core/drive/morph_renderer.js
@@ -0,0 +1,20 @@
+import { Renderer } from "../renderer"
+import { Morph } from "../morph"
+
+export class MorphRenderer extends Renderer {
+ static renderElement(currentElement, newElement) {
+ MorphRenderer.morph(currentElement, newElement)
+ }
+
+ static morph(currentElement, newElement) {
+ Morph.render(currentElement, newElement)
+ }
+
+ async render() {
+ if (this.willRender) this.renderElement(this.currentElement, this.newElement)
+ }
+
+ get renderMethod() {
+ return "morph"
+ }
+}
diff --git a/src/core/drive/page_renderer.js b/src/core/drive/page_renderer.js
index 0c1a222e6..3d6db9542 100644
--- a/src/core/drive/page_renderer.js
+++ b/src/core/drive/page_renderer.js
@@ -3,6 +3,10 @@ import { activateScriptElement, waitForLoad } from "../../util"
export class PageRenderer extends Renderer {
static renderElement(currentElement, newElement) {
+ PageRenderer.replace(currentElement, newElement)
+ }
+
+ static replace(currentElement, newElement) {
if (document.body && newElement instanceof HTMLBodyElement) {
document.body.replaceWith(newElement)
} else {
diff --git a/src/core/drive/page_snapshot.js b/src/core/drive/page_snapshot.js
index 47488d785..b7fcb711e 100644
--- a/src/core/drive/page_snapshot.js
+++ b/src/core/drive/page_snapshot.js
@@ -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) {
diff --git a/src/core/drive/page_view.js b/src/core/drive/page_view.js
index 09a7299c6..bff0815f0 100644
--- a/src/core/drive/page_view.js
+++ b/src/core/drive/page_view.js
@@ -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"
@@ -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, isPreview, willRender)
if (!renderer.shouldRender) {
this.forceReloaded = true
@@ -28,7 +32,7 @@ export class PageView extends View {
renderError(snapshot, visit) {
visit?.changeHistory()
- const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false)
+ const renderer = new ErrorRenderer(this.snapshot, snapshot, false)
return this.render(renderer)
}
@@ -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)
}
diff --git a/src/core/drive/visit.js b/src/core/drive/visit.js
index d1929b03e..4fa57642d 100644
--- a/src/core/drive/visit.js
+++ b/src/core/drive/visit.js
@@ -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()
}
@@ -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) {
@@ -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 {
@@ -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
}
diff --git a/src/core/frames/frame_controller.js b/src/core/frames/frame_controller.js
index 1cab2902e..4a85196d8 100644
--- a/src/core/frames/frame_controller.js
+++ b/src/core/frames/frame_controller.js
@@ -276,7 +276,7 @@ export class FrameController {
return !defaultPrevented
}
- viewRenderedSnapshot(_snapshot, _isPreview) {}
+ viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {}
preloadOnLoadLinksForView(element) {
session.preloadOnLoadLinksForView(element)
@@ -307,7 +307,7 @@ export class FrameController {
if (newFrameElement) {
const snapshot = new Snapshot(newFrameElement)
- const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false)
+ const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, false, false)
if (this.view.renderPromise) await this.view.renderPromise
this.changeHistory()
diff --git a/src/core/frames/frame_renderer.js b/src/core/frames/frame_renderer.js
index 1384465d6..a9b974077 100644
--- a/src/core/frames/frame_renderer.js
+++ b/src/core/frames/frame_renderer.js
@@ -1,8 +1,17 @@
-import { activateScriptElement, nextAnimationFrame } from "../../util"
+import { activateScriptElement, nextAnimationFrame, dispatch } from "../../util"
import { Renderer } from "../renderer"
+import { Morph } from "../morph"
export class FrameRenderer extends Renderer {
static renderElement(currentElement, newElement) {
+ if (currentElement.src && currentElement.refresh === "morph") {
+ FrameRenderer.morph(currentElement, newElement)
+ } else {
+ FrameRenderer.replace(currentElement, newElement)
+ }
+ }
+
+ static replace(currentElement, newElement) {
const destinationRange = document.createRange()
destinationRange.selectNodeContents(currentElement)
destinationRange.deleteContents()
@@ -15,6 +24,15 @@ export class FrameRenderer extends Renderer {
}
}
+ static morph(currentElement, newElement) {
+ dispatch("turbo:before-frame-morph", {
+ target: currentElement,
+ detail: { currentElement, newElement }
+ })
+
+ Morph.render(currentElement, newElement.children, "innerHTML")
+ }
+
constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) {
super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender)
this.delegate = delegate
@@ -24,6 +42,14 @@ export class FrameRenderer extends Renderer {
return true
}
+ get renderMethod() {
+ if (this.currentElement.refresh === "morph") {
+ return "morph"
+ } else {
+ return super.renderMethod
+ }
+ }
+
async render() {
await nextAnimationFrame()
this.preservingPermanentElements(() => {
diff --git a/src/core/index.js b/src/core/index.js
index 2a3dd8fe5..743380d7a 100644
--- a/src/core/index.js
+++ b/src/core/index.js
@@ -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"
diff --git a/src/core/morph.js b/src/core/morph.js
new file mode 100644
index 000000000..42724aa1a
--- /dev/null
+++ b/src/core/morph.js
@@ -0,0 +1,54 @@
+import "idiomorph"
+import { nextAnimationFrame } from "../util"
+
+export class Morph {
+ static render(currentElement, newElement, morphStyle) {
+ const morph = new this(currentElement, newElement)
+
+ morph.render(morphStyle)
+ }
+
+ constructor(currentElement, newElement) {
+ this.currentElement = currentElement
+ this.newElement = newElement
+ }
+
+ render(morphStyle = "outerHTML") {
+ window.Idiomorph.morph(this.currentElement, this.newElement, {
+ ignoreActiveValue: true,
+ morphStyle: morphStyle,
+ callbacks: {
+ beforeNodeMorphed: shouldMorphElement,
+ beforeNodeRemoved: shouldRemoveElement,
+ afterNodeMorphed: reloadStimulusControllers
+ }
+ })
+
+ this.#remoteFrames.forEach((frame) => frame.reload())
+ }
+
+ get #remoteFrames() {
+ return this.currentElement.querySelectorAll("turbo-frame[src]")
+ }
+}
+
+function shouldRemoveElement(node) {
+ return shouldMorphElement(node)
+}
+
+function shouldMorphElement(node) {
+ if (node instanceof HTMLElement) {
+ return !node.hasAttribute("data-turbo-permanent")
+ } else {
+ return true
+ }
+}
+
+async function reloadStimulusControllers(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)
+ }
+}
diff --git a/src/core/native/browser_adapter.js b/src/core/native/browser_adapter.js
index 7505d7f8f..c3f1086de 100644
--- a/src/core/native/browser_adapter.js
+++ b/src/core/native/browser_adapter.js
@@ -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) {
diff --git a/src/core/renderer.js b/src/core/renderer.js
index 694a2004c..414335b88 100644
--- a/src/core/renderer.js
+++ b/src/core/renderer.js
@@ -3,12 +3,16 @@ import { Bardo } from "./bardo"
export class Renderer {
#activeElement = null
- constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) {
+ static renderElement(currentElement, newElement) {
+ // Abstract method
+ }
+
+ constructor(currentSnapshot, newSnapshot, isPreview, willRender = true) {
this.currentSnapshot = currentSnapshot
this.newSnapshot = newSnapshot
this.isPreview = isPreview
this.willRender = willRender
- this.renderElement = renderElement
+ this.renderElement = this.constructor.renderElement
this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject }))
}
@@ -79,4 +83,8 @@ export class Renderer {
get permanentElementMap() {
return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot)
}
+
+ get renderMethod() {
+ return "replace"
+ }
}
diff --git a/src/core/session.js b/src/core/session.js
index 44edc7856..6ffcea244 100644
--- a/src/core/session.js
+++ b/src/core/session.js
@@ -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) {
@@ -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 = {}) {
diff --git a/src/core/streams/stream_actions.js b/src/core/streams/stream_actions.js
index 7b06f5b84..62801fd69 100644
--- a/src/core/streams/stream_actions.js
+++ b/src/core/streams/stream_actions.js
@@ -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" })
+ }
}
}
diff --git a/src/core/view.js b/src/core/view.js
index ca81e8bdb..62ec136da 100644
--- a/src/core/view.js
+++ b/src/core/view.js
@@ -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 {
diff --git a/src/elements/frame_element.js b/src/elements/frame_element.js
index 0e2bee917..4feb36713 100644
--- a/src/elements/frame_element.js
+++ b/src/elements/frame_element.js
@@ -75,6 +75,24 @@ export class FrameElement extends HTMLElement {
}
}
+ /**
+ * Gets the refresh mode for the frame.
+ */
+ get refresh() {
+ return this.getAttribute("refresh")
+ }
+
+ /**
+ * Sets the refresh mode for the frame.
+ */
+ set refresh(value) {
+ if (value) {
+ this.setAttribute("refresh", value)
+ } else {
+ this.removeAttribute("refresh")
+ }
+ }
+
/**
* Determines if the element is loading
*/
diff --git a/src/http/recent_fetch_requests.js b/src/http/recent_fetch_requests.js
new file mode 100644
index 000000000..620afd0a5
--- /dev/null
+++ b/src/http/recent_fetch_requests.js
@@ -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
+ })
+}
diff --git a/src/index.js b/src/index.js
index 07e3e097d..b76e23871 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,7 @@
import "./polyfills"
import "./elements"
import "./script_warning"
+import "./http/recent_fetch_requests"
import * as Turbo from "./core"
diff --git a/src/tests/fixtures/frame_refresh_morph.html b/src/tests/fixtures/frame_refresh_morph.html
new file mode 100644
index 000000000..bdfeed9fc
--- /dev/null
+++ b/src/tests/fixtures/frame_refresh_morph.html
@@ -0,0 +1,3 @@
+
+ Loaded morphed frame
+
diff --git a/src/tests/fixtures/frame_refresh_reload.html b/src/tests/fixtures/frame_refresh_reload.html
new file mode 100644
index 000000000..7dd5e83ec
--- /dev/null
+++ b/src/tests/fixtures/frame_refresh_reload.html
@@ -0,0 +1,3 @@
+
+ Loaded reloadable frame
+
diff --git a/src/tests/fixtures/frames.html b/src/tests/fixtures/frames.html
index d6a82d831..fc8de4047 100644
--- a/src/tests/fixtures/frames.html
+++ b/src/tests/fixtures/frames.html
@@ -62,6 +62,17 @@
Frames: #hello
+
+ Source: #refresh-morph
+
+ Navigate #refresh-morph
+
+
+
+
advance #hello
diff --git a/src/tests/fixtures/frames/refresh_morph.html b/src/tests/fixtures/frames/refresh_morph.html
new file mode 100644
index 000000000..a08cca945
--- /dev/null
+++ b/src/tests/fixtures/frames/refresh_morph.html
@@ -0,0 +1,23 @@
+
+
+
+
+ Frames: Refresh=Morph
+
+
+
+
+ Refresh=Morph
+
+
+ Destination: #refresh-morph
+
+ Navigate #refresh-morph
+
+
+
+
+
diff --git a/src/tests/fixtures/page_refresh.html b/src/tests/fixtures/page_refresh.html
new file mode 100644
index 000000000..a54d99f18
--- /dev/null
+++ b/src/tests/fixtures/page_refresh.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+ Turbo
+
+
+
+
+
+
+
+ Page to be refreshed
+
+
+ Frame to be morphed
+
+
+
+ Frame to be reloaded
+
+
+
+ Preserve me!
+
+
+
+
Element with Stimulus controller
+
+
+ Link to another page
+
+
+
+
diff --git a/src/tests/fixtures/page_refresh_replace.html b/src/tests/fixtures/page_refresh_replace.html
new file mode 100644
index 000000000..df4be0cfb
--- /dev/null
+++ b/src/tests/fixtures/page_refresh_replace.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ Turbo
+
+
+
+
+
+
+
+ Page to be refreshed
+
+
+ Frame to be morphed
+
+
+
+ Frame to be reloaded
+
+
+
+ Preserve me!
+
+
+
+
+
diff --git a/src/tests/fixtures/page_refresh_scroll_reset.html b/src/tests/fixtures/page_refresh_scroll_reset.html
new file mode 100644
index 000000000..2c0ed1e32
--- /dev/null
+++ b/src/tests/fixtures/page_refresh_scroll_reset.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+ Turbo
+
+
+
+
+
+
+
+ Page to be refreshed
+
+
+ Frame to be refreshed
+
+
+
+
+
diff --git a/src/tests/fixtures/page_refresh_stream_action.html b/src/tests/fixtures/page_refresh_stream_action.html
new file mode 100644
index 000000000..4fb80d1c8
--- /dev/null
+++ b/src/tests/fixtures/page_refresh_stream_action.html
@@ -0,0 +1,19 @@
+
+
+
+
+ Turbo Streams
+
+
+
+
+
+
+
+ Hello
+
+
+
diff --git a/src/tests/fixtures/page_refreshed.html b/src/tests/fixtures/page_refreshed.html
new file mode 100644
index 000000000..e8c97f923
--- /dev/null
+++ b/src/tests/fixtures/page_refreshed.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Turbo
+
+
+
+
+ Refreshed page
+
+
diff --git a/src/tests/fixtures/test.js b/src/tests/fixtures/test.js
index 9f71b536e..b461c971f 100644
--- a/src/tests/fixtures/test.js
+++ b/src/tests/fixtures/test.js
@@ -83,6 +83,7 @@
"turbo:frame-load",
"turbo:frame-render",
"turbo:frame-missing",
+ "turbo:before-frame-morph",
"turbo:reload"
])
diff --git a/src/tests/functional/frame_tests.js b/src/tests/functional/frame_tests.js
index bd06f2d15..f760ccf94 100644
--- a/src/tests/functional/frame_tests.js
+++ b/src/tests/functional/frame_tests.js
@@ -4,6 +4,7 @@ import {
attributeForSelector,
hasSelector,
listenForEventOnTarget,
+ locatorHasFocus,
nextAttributeMutationNamed,
noNextAttributeMutationNamed,
nextBeat,
@@ -882,6 +883,52 @@ test("test navigating a eager frame with a link[method=get] that does not fetch
assert.equal(pathname(page.url()), "/src/tests/fixtures/page_with_eager_frame.html")
})
+test("test driving a morph frame with link preserves focus", async ({ page }) => {
+ const frame = await page.locator("turbo-frame#refresh-morph")
+ const link = await frame.locator("a:first-of-type")
+
+ assert.include(await frame.textContent("h2"), "Source: #refresh-morph")
+
+ await link.focus()
+ await link.press("Enter")
+ await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render")
+
+ assert.ok(await locatorHasFocus(link), "restores focus after page loads")
+ assert.include(await frame.textContent("h2"), "Destination: #refresh-morph")
+ assert.equal(await frame.count("#input-in-morph"), 1)
+
+ await link.press("Enter")
+ await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render")
+
+ assert.ok(await locatorHasFocus(link), "restores focus after page loads")
+ assert.include(await frame.textContent("h2"), "Source: #refresh-morph")
+ assert.equal(await frame.count("#input-in-morph"), 1)
+})
+
+test("test driving a morph frame with form preserves focus", async ({ page }) => {
+ const frame = await page.locator("turbo-frame#refresh-morph")
+ const input = await frame.locator("#input-in-refresh-morph")
+
+ assert.include(await frame.textContent("h2"), "Source: #refresh-morph")
+
+ await input.type("hello")
+ await input.press("Enter")
+ await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render")
+
+ assert.ok(await locatorHasFocus(input), "restores focus after page loads")
+ assert.include(await frame.textContent("h2"), "Destination: #refresh-morph")
+ await expect(input).toHaveValue("hello")
+ assert.equal(await frame.count("#input-in-morph"), 1)
+
+ await input.press("Enter")
+ await nextEventOnTarget(page, "refresh-morph", "turbo:frame-render")
+
+ assert.ok(await locatorHasFocus(input), "restores focus after page loads")
+ assert.include(await frame.textContent("h2"), "Source: #refresh-morph")
+ await expect(input).toHaveValue("hello")
+ assert.equal(await frame.count("#input-in-morph"), 1)
+})
+
test("form submissions from frames clear snapshot cache", async ({ page }) => {
await page.evaluate(() => {
document.querySelector("h1").textContent = "Changed"
diff --git a/src/tests/functional/navigation_tests.js b/src/tests/functional/navigation_tests.js
index f18a7ea1e..c4263d0f3 100644
--- a/src/tests/functional/navigation_tests.js
+++ b/src/tests/functional/navigation_tests.js
@@ -195,7 +195,13 @@ test("test following a POST form clears cache", async ({ page }) => {
await page.click("#form-post-submit")
await nextBeat() // 301 redirect response
await nextBeat() // 200 response
+
+ assert.equal(await page.textContent("h1"), "One")
+
await page.goBack()
+ await nextBeat()
+
+ assert.equal(await page.textContent("h1"), "Navigation")
assert.notOk(await hasSelector(page, "some-cached-element"))
})
diff --git a/src/tests/functional/page_refresh_stream_action_tests.js b/src/tests/functional/page_refresh_stream_action_tests.js
new file mode 100644
index 000000000..22cf17e8d
--- /dev/null
+++ b/src/tests/functional/page_refresh_stream_action_tests.js
@@ -0,0 +1,55 @@
+import { test } from "@playwright/test"
+import { assert } from "chai"
+import { nextBeat } from "../helpers/page"
+
+test.beforeEach(async ({ page }) => {
+ await page.goto("/src/tests/fixtures/page_refresh_stream_action.html")
+})
+
+test("test refreshing the page", async ({ page }) => {
+ assert.match(await textContent(page), /Hello/)
+
+ await page.locator("#content").evaluate((content)=>content.innerHTML = "")
+ assert.notMatch(await textContent(page), /Hello/)
+
+ await page.click("#refresh button")
+ await nextBeat()
+
+ assert.match(await textContent(page), /Hello/)
+})
+
+test("don't refresh the page on self-originated request ids", async ({ page }) => {
+ assert.match(await textContent(page), /Hello/)
+
+ await page.locator("#content").evaluate((content)=>content.innerHTML = "")
+ page.evaluate(()=> { window.Turbo.recentRequests.add("123") })
+
+ await page.locator("#request-id").evaluate((input)=>input.value = "123")
+ await page.click("#refresh button")
+ await nextBeat()
+
+ assert.notMatch(await textContent(page), /Hello/)
+})
+
+test("fetch injects a Turbo-Request-Id with a UID generated automatically", async ({ page }) => {
+ const response1 = await fetchRequestId(page)
+ const response2 = await fetchRequestId(page)
+
+ assert.notEqual(response1, response2)
+
+ for (const response of [response1, response2]) {
+ assert.match(response, /.+-.+-.+-.+/)
+ }
+})
+
+async function textContent(page) {
+ const messages = await page.locator("#content")
+ return await messages.textContent()
+}
+
+async function fetchRequestId(page) {
+ return await page.evaluate(async () => {
+ const response = await window.fetch("/__turbo/request_id_header")
+ return response.text()
+ })
+}
diff --git a/src/tests/functional/page_refresh_tests.js b/src/tests/functional/page_refresh_tests.js
new file mode 100644
index 000000000..73c0f4c5f
--- /dev/null
+++ b/src/tests/functional/page_refresh_tests.js
@@ -0,0 +1,115 @@
+import { test, expect } from "@playwright/test"
+import {
+ nextAttributeMutationNamed,
+ nextBeat,
+ nextEventNamed,
+ nextEventOnTarget,
+ noNextEventNamed,
+ noNextEventOnTarget
+} from "../helpers/page"
+
+test("renders a page refresh with morphing", async ({ page }) => {
+ await page.goto("/src/tests/fixtures/page_refresh.html")
+
+ await page.click("#form-submit")
+ await nextEventNamed(page, "turbo:render", { renderMethod: "morph" })
+})
+
+test("doesn't morph when the turbo-refresh-method meta tag is not 'morph'", async ({ page }) => {
+ await page.goto("/src/tests/fixtures/page_refresh_replace.html")
+
+ await page.click("#form-submit")
+ expect(await noNextEventNamed(page, "turbo:render", { renderMethod: "morph" })).toBeTruthy()
+})
+
+test("doesn't morph when the navigation doesn't go to the same URL", async ({ page }) => {
+ await page.goto("/src/tests/fixtures/page_refresh.html")
+
+ await page.click("#link")
+ await expect(page.locator("h1")).toHaveText("One")
+
+ expect(await noNextEventNamed(page, "turbo:render", { renderMethod: "morph" })).toBeTruthy()
+})
+
+test("uses morphing to update remote frames marked with refresh='morph'", async ({ page }) => {
+ await page.goto("/src/tests/fixtures/page_refresh.html")
+
+ await page.click("#form-submit")
+ await nextEventNamed(page, "turbo:render", { renderMethod: "morph" })
+ await nextBeat()
+
+ // Only the frame marked with refresh="morph" uses morphing
+ expect(await nextEventOnTarget(page, "refresh-morph", "turbo:before-frame-morph")).toBeTruthy()
+ expect(await noNextEventOnTarget(page, "refresh-reload", "turbo:before-frame-morph")).toBeTruthy()
+})
+
+test("it preserves the scroll position when the turbo-refresh-scroll meta tag is 'preserve'", async ({ page }) => {
+ await page.goto("/src/tests/fixtures/page_refresh.html")
+
+ await page.evaluate(() => window.scrollTo(10, 10))
+ await assertPageScroll(page, 10, 10)
+
+ // not using page.locator("#form-submit").click() because it can reset the scroll position
+ await page.evaluate(() => document.getElementById("form-submit")?.click())
+ await nextEventNamed(page, "turbo:render", { renderMethod: "morph" })
+
+ await assertPageScroll(page, 10, 10)
+})
+
+test("it resets the scroll position when the turbo-refresh-scroll meta tag is 'reset'", async ({ page }) => {
+ await page.goto("/src/tests/fixtures/page_refresh_scroll_reset.html")
+
+ await page.evaluate(() => window.scrollTo(10, 10))
+ await assertPageScroll(page, 10, 10)
+
+ // not using page.locator("#form-submit").click() because it can reset the scroll position
+ await page.evaluate(() => document.getElementById("form-submit")?.click())
+ await nextEventNamed(page, "turbo:render", { renderMethod: "morph" })
+
+ await assertPageScroll(page, 0, 0)
+})
+
+test("it preserves data-turbo-permanent elements", async ({ page }) => {
+ await page.goto("/src/tests/fixtures/page_refresh.html")
+
+ await page.evaluate(() => {
+ const element = document.getElementById("preserve-me")
+ element.textContent = "Preserve me, I have a family!"
+ })
+
+ await expect(page.locator("#preserve-me")).toHaveText("Preserve me, I have a family!")
+
+ await page.click("#form-submit")
+ await nextEventNamed(page, "turbo:render", { renderMethod: "morph" })
+
+ await expect(page.locator("#preserve-me")).toHaveText("Preserve me, I have a family!")
+})
+
+test("it reloads data-controller attributes after a morph", async ({ page }) => {
+ await page.goto("/src/tests/fixtures/page_refresh.html")
+
+ await page.click("#form-submit")
+ await nextEventNamed(page, "turbo:render", { renderMethod: "morph" })
+
+ expect(
+ await nextAttributeMutationNamed(page, "stimulus-controller", "data-controller")
+ ).toEqual(null)
+
+ await nextBeat()
+
+ expect(
+ await nextAttributeMutationNamed(page, "stimulus-controller", "data-controller")
+ ).toEqual("test")
+})
+
+async function assertPageScroll(page, top, left) {
+ const [scrollTop, scrollLeft] = await page.evaluate(() => {
+ return [
+ document.documentElement.scrollTop || document.body.scrollTop,
+ document.documentElement.scrollLeft || document.body.scrollLeft
+ ]
+ })
+
+ expect(scrollTop).toEqual(top)
+ expect(scrollLeft).toEqual(left)
+}
diff --git a/src/tests/helpers/page.js b/src/tests/helpers/page.js
index 580327d25..a5753dced 100644
--- a/src/tests/helpers/page.js
+++ b/src/tests/helpers/page.js
@@ -68,11 +68,13 @@ export function nextBody(_page, timeout = 500) {
return sleep(timeout)
}
-export async function nextEventNamed(page, eventName) {
+export async function nextEventNamed(page, eventName, expectedDetail = {}) {
let record
while (!record) {
const records = await readEventLogs(page, 1)
- record = records.find(([name]) => name == eventName)
+ record = records.find(([name, detail]) => {
+ return name == eventName && Object.entries(expectedDetail).every(([key, value]) => detail[key] === value)
+ })
}
return record[1]
}
@@ -126,9 +128,11 @@ export async function noNextAttributeMutationNamed(page, elementId, attributeNam
return !records.some(([name, _, target]) => name == attributeName && target == elementId)
}
-export async function noNextEventNamed(page, eventName) {
- const records = await readEventLogs(page, 1)
- return !records.some(([name]) => name == eventName)
+export async function noNextEventNamed(page, eventName, expectedDetail = {}) {
+ const records = await readEventLogs(page)
+ return !records.some(([name, detail]) => {
+ return name === eventName && Object.entries(expectedDetail).every(([key, value]) => value === detail[key])
+ })
}
export async function noNextEventOnTarget(page, elementId, eventName) {
@@ -201,7 +205,11 @@ export function searchParams(url) {
}
export function selectorHasFocus(page, selector) {
- return page.locator(selector).evaluate((element) => element === document.activeElement)
+ return locatorHasFocus(page.locator(selector))
+}
+
+export function locatorHasFocus(locator) {
+ return locator.evaluate((element) => element === document.activeElement)
}
export function setLocalStorageFromEvent(page, eventName, storageKey, storageValue) {
diff --git a/src/tests/server.mjs b/src/tests/server.mjs
index 0628f2e30..0d35cb57d 100644
--- a/src/tests/server.mjs
+++ b/src/tests/server.mjs
@@ -4,7 +4,7 @@ import bodyParser from "body-parser"
import multer from "multer"
import path from "path"
import url from "url"
-import { fileURLToPath } from 'url'
+import { fileURLToPath } from "url"
import fs from "fs"
const __filename = fileURLToPath(import.meta.url)
@@ -51,6 +51,11 @@ router.get("/redirect", (request, response) => {
response.redirect(301, url.format({ pathname, query }))
})
+router.post("/refresh", (request, response) => {
+ const { sleep } = request.body
+ setTimeout(() => response.redirect("back"), parseInt(sleep || "0", 10))
+})
+
router.post("/reject/tall", (request, response) => {
const { status } = request.body
const fixture = path.join(__dirname, `../../src/tests/fixtures/422_tall.html`)
@@ -94,6 +99,28 @@ router.post("/messages", (request, response) => {
}
})
+router.post("/refreshes", (request, response) => {
+ const params = { ...request.body, ...request.query }
+ const { requestId } = params
+
+ if(acceptsStreams(request)){
+ response.type("text/vnd.turbo-stream.html; charset=utf-8")
+ response.send(renderPageRefresh(requestId))
+ } else {
+ response.sendStatus(201)
+ }
+})
+
+router.get("/request_id_header", (request, response) => {
+ const turboRequestHeader = request.get("X-Turbo-Request-Id")
+
+ if (turboRequestHeader) {
+ response.send(turboRequestHeader);
+ } else {
+ response.status(404).send("X-Turbo-Request header not found")
+ }
+})
+
router.post("/notfound", (request, response) => {
response.type("html").status(404).send("Not found
")
})
@@ -166,6 +193,12 @@ function renderMessageForTargets(content, id, targets) {
`
}
+function renderPageRefresh(requestId) {
+ return `
+
+ `
+}
+
function acceptsStreams(request) {
return !!request.accepts("text/vnd.turbo-stream.html")
}
diff --git a/src/tests/unit/limited_set_tests.js b/src/tests/unit/limited_set_tests.js
new file mode 100644
index 000000000..6fa67ba13
--- /dev/null
+++ b/src/tests/unit/limited_set_tests.js
@@ -0,0 +1,17 @@
+import { assert } from "@open-wc/testing"
+import { LimitedSet } from "../../core/drive/limited_set"
+
+test("add a limited number of elements", () => {
+ const set = new LimitedSet(3)
+ set.add(1)
+ set.add(2)
+ set.add(3)
+ set.add(4)
+
+ assert.equal(set.size, 3)
+
+ assert.notInclude(set, 1)
+ assert.include(set, 2)
+ assert.include(set, 3)
+ assert.include(set, 4)
+})
diff --git a/src/tests/unit/stream_element_tests.js b/src/tests/unit/stream_element_tests.js
index 05ecc8589..bd62c8aa5 100644
--- a/src/tests/unit/stream_element_tests.js
+++ b/src/tests/unit/stream_element_tests.js
@@ -2,6 +2,8 @@ import { StreamElement } from "../../elements"
import { nextAnimationFrame } from "../../util"
import { DOMTestCase } from "../helpers/dom_test_case"
import { assert } from "@open-wc/testing"
+import { nextBeat } from "../helpers/page"
+import * as Turbo from "../../index"
function createStreamElement(action, target, templateElement) {
const element = new StreamElement()
@@ -167,3 +169,30 @@ test("test action=before", async () => {
assert.ok(subject.find("h1#before"))
assert.isNull(element.parentElement)
})
+
+test("test action=refresh", async () => {
+ document.body.setAttribute("data-modified", "")
+ assert.ok(document.body.hasAttribute("data-modified"))
+
+ const element = createStreamElement("refresh")
+ subject.append(element)
+
+ await nextBeat()
+
+ assert.notOk(document.body.hasAttribute("data-modified"))
+})
+
+test("test action=refresh discarded when matching request id", async () => {
+ Turbo.recentRequests.add("123")
+
+ document.body.setAttribute("data-modified", "")
+ assert.ok(document.body.hasAttribute("data-modified"))
+
+ const element = createStreamElement("refresh")
+ element.setAttribute("request-id", "123")
+ subject.append(element)
+
+ await nextBeat()
+
+ assert.ok(document.body.hasAttribute("data-modified"))
+})
diff --git a/yarn.lock b/yarn.lock
index fa00b7d23..36a8bcdd8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1865,6 +1865,11 @@ iconv-lite@0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
+idiomorph@^0.0.9:
+ version "0.0.9"
+ resolved "https://registry.yarnpkg.com/idiomorph/-/idiomorph-0.0.9.tgz#938d5964031381b0713398fb283aa3754306fa1b"
+ integrity sha512-X7SGsldE5jQD+peLjNLAnIJDEZtGpuLrNRUBrTWMMnzrx9gwy5U+SCRhaifO2v2Z+8j09IY2J+EYaxHsOLTD0A==
+
ieee754@^1.1.13:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"