diff --git a/package.json b/package.json
index 03b07fa02..214ddee8e 100644
--- a/package.json
+++ b/package.json
@@ -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",
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..c3379be2b
--- /dev/null
+++ b/src/core/drive/morph_renderer.js
@@ -0,0 +1,97 @@
+import Idiomorph from "idiomorph"
+import { dispatch } from "../../util"
+import { urlsAreEqual } from "../url"
+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()
+
+ dispatch("turbo:morph", {
+ detail: {
+ currentElement: this.currentElement,
+ newElement: this.newElement
+ }
+ })
+ }
+
+ #morphElements(currentElement, newElement, morphStyle = "outerHTML") {
+ this.isMorphingTurboFrame = this.#remoteFrameReplacement(currentElement, newElement)
+
+ Idiomorph.morph(currentElement, newElement, {
+ morphStyle: morphStyle,
+ callbacks: {
+ beforeNodeAdded: this.#shouldAddElement,
+ beforeNodeMorphed: this.#shouldMorphElement,
+ beforeNodeRemoved: this.#shouldRemoveElement
+ }
+ })
+ }
+
+ #shouldAddElement = (node) => {
+ return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id))
+ }
+
+ #shouldMorphElement = (oldNode, newNode) => {
+ if (!(oldNode instanceof HTMLElement) || this.isMorphingTurboFrame) {
+ return true
+ }
+ else if (oldNode.hasAttribute("data-turbo-permanent")) {
+ return false
+ } else {
+ return !this.#remoteFrameReplacement(oldNode, newNode)
+ }
+ }
+
+ #remoteFrameReplacement = (oldNode, newNode) => {
+ return this.#isRemoteFrame(oldNode) && this.#isRemoteFrame(newNode) && urlsAreEqual(oldNode.getAttribute("src"), newNode.getAttribute("src"))
+ }
+
+ #shouldRemoveElement = (node) => {
+ return this.#shouldMorphElement(node)
+ }
+
+ #reloadRemoteFrames() {
+ this.#remoteFrames().forEach((frame) => {
+ if (this.#isRemoteFrame(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) => {
+ dispatch("turbo:before-frame-morph", {
+ target: currentElement,
+ detail: { currentElement, newElement }
+ })
+ this.#morphElements(currentElement, newElement.children, "innerHTML")
+ }
+
+ #isRemoteFrame(node) {
+ return node instanceof HTMLElement && node.nodeName.toLowerCase() === "turbo-frame" && node.getAttribute("src")
+ }
+
+ #remoteFrames() {
+ return Array.from(document.querySelectorAll('turbo-frame[src]')).filter(frame => {
+ return !frame.closest('[data-turbo-permanent]')
+ })
+ }
+}
diff --git a/src/core/drive/navigator.js b/src/core/drive/navigator.js
index 00a743b8b..54b01e8ee 100644
--- a/src/core/drive/navigator.js
+++ b/src/core/drive/navigator.js
@@ -99,7 +99,9 @@ export class Navigator {
} else {
await this.view.renderPage(snapshot, false, true, this.currentVisit)
}
- this.view.scrollToTop()
+ if(!snapshot.shouldPreserveScrollPosition) {
+ this.view.scrollToTop()
+ }
this.view.clearSnapshotCache()
}
}
diff --git a/src/core/drive/page_snapshot.js b/src/core/drive/page_snapshot.js
index 8b5a6e9d1..58a5a75a9 100644
--- a/src/core/drive/page_snapshot.js
+++ b/src/core/drive/page_snapshot.js
@@ -74,6 +74,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 1b95bb1d1..0508d0960 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, PageRenderer.renderElement, isPreview, willRender)
if (!renderer.shouldRender) {
this.forceReloaded = true
@@ -51,6 +55,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/preloader.js b/src/core/drive/preloader.js
index 23871a530..ff9d871d6 100644
--- a/src/core/drive/preloader.js
+++ b/src/core/drive/preloader.js
@@ -1,4 +1,5 @@
import { PageSnapshot } from "./page_snapshot"
+import { fetch } from "../../http/fetch"
export class Preloader {
selector = "a[data-turbo-preload]"
diff --git a/src/core/drive/visit.js b/src/core/drive/visit.js
index 7fc494c19..4e7796302 100644
--- a/src/core/drive/visit.js
+++ b/src/core/drive/visit.js
@@ -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 {
diff --git a/src/core/frames/frame_controller.js b/src/core/frames/frame_controller.js
index 1cab2902e..5940ba761 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)
diff --git a/src/core/index.js b/src/core/index.js
index 440d67302..27a2ad15c 100644
--- a/src/core/index.js
+++ b/src/core/index.js
@@ -3,12 +3,11 @@ 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 { fetch } from "../http/fetch"
const session = new Session()
const { cache, navigator } = session
-export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer }
-
-export { StreamActions } from "./streams/stream_actions"
+export { navigator, session, cache, PageRenderer, PageSnapshot, FrameRenderer, fetch }
/**
* Starts the main session.
diff --git a/src/core/renderer.js b/src/core/renderer.js
index 694a2004c..56e73983e 100644
--- a/src/core/renderer.js
+++ b/src/core/renderer.js
@@ -79,4 +79,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 6b3d550f0..928e03aae 100644
--- a/src/core/session.js
+++ b/src/core/session.js
@@ -16,6 +16,7 @@ import { clearBusyState, dispatch, findClosestRecursively, getVisitAction, markA
import { PageView } from "./drive/page_view"
import { FrameElement } from "../elements/frame_element"
import { Preloader } from "./drive/preloader"
+import { LimitedSet } from "./drive/limited_set"
import { Cache } from "./cache"
export class Session {
@@ -35,6 +36,7 @@ export class Session {
frameRedirector = new FrameRedirector(this, document.documentElement)
streamMessageRenderer = new StreamMessageRenderer()
cache = new Cache(this)
+ recentRequests = new LimitedSet(20)
drive = true
enabled = true
@@ -93,6 +95,14 @@ export class Session {
}
}
+ refresh(url, requestId) {
+ const isRecentRequest = requestId && this.recentRequests.has(requestId)
+ if (!isRecentRequest) {
+ this.cache.exemptPageFromPreview()
+ this.visit(url, { action: "replace" })
+ }
+ }
+
connectStreamSource(source) {
this.streamObserver.connectStreamSource(source)
}
@@ -265,9 +275,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) {
@@ -330,8 +340,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..064e94ca4 100644
--- a/src/core/streams/stream_actions.js
+++ b/src/core/streams/stream_actions.js
@@ -1,3 +1,5 @@
+import { session } from "../"
+
export const StreamActions = {
after() {
this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling))
@@ -30,5 +32,9 @@ export const StreamActions = {
targetElement.innerHTML = ""
targetElement.append(this.templateContent)
})
+ },
+
+ refresh() {
+ session.refresh(this.baseURI, this.requestId)
}
}
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/elements/stream_element.js b/src/elements/stream_element.js
index f73084cc2..b9bcf35f0 100644
--- a/src/elements/stream_element.js
+++ b/src/elements/stream_element.js
@@ -143,6 +143,13 @@ export class StreamElement extends HTMLElement {
return this.getAttribute("targets")
}
+ /**
+ * Reads the request-id attribute
+ */
+ get requestId() {
+ return this.getAttribute("request-id")
+ }
+
#raise(message) {
throw new Error(`${this.description}: ${message}`)
}
diff --git a/src/http/fetch.js b/src/http/fetch.js
new file mode 100644
index 000000000..c82ea081e
--- /dev/null
+++ b/src/http/fetch.js
@@ -0,0 +1,13 @@
+import { uuid } from "../util"
+
+export function fetch(url, options = {}) {
+ const modifiedHeaders = new Headers(options.headers || {})
+ const requestUID = uuid()
+ window.Turbo.session.recentRequests.add(requestUID)
+ modifiedHeaders.append("X-Turbo-Request-Id", requestUID)
+
+ return window.fetch(url, {
+ ...options,
+ headers: modifiedHeaders
+ })
+}
diff --git a/src/http/fetch_request.js b/src/http/fetch_request.js
index 9b61b8b49..3d79f24ae 100644
--- a/src/http/fetch_request.js
+++ b/src/http/fetch_request.js
@@ -1,6 +1,7 @@
import { FetchResponse } from "./fetch_response"
import { expandURL } from "../core/url"
import { dispatch } from "../util"
+import { fetch } from "./fetch"
export function fetchMethodFromString(method) {
switch (method.toLowerCase()) {
diff --git a/src/index.js b/src/index.js
index 07e3e097d..c3f27878e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -7,6 +7,7 @@ import * as Turbo from "./core"
window.Turbo = Turbo
Turbo.start()
+export { StreamActions } from "./core/streams/stream_actions"
export * from "./core"
export * from "./elements"
export * from "./http"
diff --git a/src/tests/fixtures/frame_refresh_morph.html b/src/tests/fixtures/frame_refresh_morph.html
new file mode 100644
index 000000000..70bf85601
--- /dev/null
+++ b/src/tests/fixtures/frame_refresh_morph.html
@@ -0,0 +1,3 @@
+
+ Loaded morphed frame
+
diff --git a/src/tests/fixtures/page_refresh.html b/src/tests/fixtures/page_refresh.html
new file mode 100644
index 000000000..ec28092a4
--- /dev/null
+++ b/src/tests/fixtures/page_refresh.html
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+ Turbo
+
+
+
+
+
+
+
+ Page to be refreshed
+
+
+ Frame to be morphed
+
+
+
+ Preserve me!
+
+
+ Frame to be preserved
+
+
+
+
+
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..b46a1c327
--- /dev/null
+++ b/src/tests/fixtures/page_refresh_replace.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+ Turbo
+
+
+
+
+
+
+
+ Page to be refreshed
+
+
+ Frame to be morphed
+
+
+
+ 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/remote_permanent_frame.html b/src/tests/fixtures/remote_permanent_frame.html
new file mode 100644
index 000000000..a1be6c573
--- /dev/null
+++ b/src/tests/fixtures/remote_permanent_frame.html
@@ -0,0 +1,3 @@
+
+ Loaded permanent frame
+
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/page_refresh_stream_action_tests.js b/src/tests/functional/page_refresh_stream_action_tests.js
new file mode 100644
index 000000000..cbdeb272a
--- /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.session.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.Turbo.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..eb02128d0
--- /dev/null
+++ b/src/tests/functional/page_refresh_tests.js
@@ -0,0 +1,154 @@
+import { test, expect } from "@playwright/test"
+import { assert } from "chai"
+import {
+ hasSelector,
+ nextBeat,
+ nextBody,
+ nextEventNamed,
+ nextEventOnTarget,
+ noNextEventOnTarget,
+ noNextEventNamed
+} 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", 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, "remote-frame", "turbo:before-frame-morph")).toBeTruthy()
+ await expect(page.locator("#remote-frame")).toHaveText("Loaded morphed frame")
+})
+
+test("don't refresh frames contained in [data-turbo-permanent] elements", 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 noNextEventOnTarget(page, "refresh-reload", "turbo:before-frame-morph")).toBeTruthy()
+})
+
+test("remote frames are excluded from full page morphing", async ({ page }) => {
+ await page.goto("/src/tests/fixtures/page_refresh.html")
+
+ await page.evaluate(() => document.getElementById("remote-frame").setAttribute("data-modified", "true"))
+
+ await page.click("#form-submit")
+ await nextEventNamed(page, "turbo:render", { renderMethod: "morph" })
+ await nextBeat()
+
+ await expect(page.locator("#remote-frame")).toHaveAttribute("data-modified", "true")
+ await expect(page.locator("#remote-frame")).toHaveText("Loaded morphed frame")
+})
+
+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 preserves data-turbo-permanent elements that don't match when their ids do", 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!"
+ document.getElementById("container").append(element)
+ })
+
+ 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("renders unprocessable entity responses with morphing", async ({ page }) => {
+ await page.goto("/src/tests/fixtures/page_refresh.html")
+
+ await page.click("#reject form.unprocessable_entity input[type=submit]")
+ await nextEventNamed(page, "turbo:render", { renderMethod: "morph" })
+ await nextBody(page)
+
+ const title = await page.locator("h1")
+ assert.equal(await title.textContent(), "Unprocessable Entity", "renders the response HTML")
+ assert.notOk(await hasSelector(page, "#frame form.reject"), "replaces entire page")
+})
+
+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..ccc75e654 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) {
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 c84731c84..8ca8c48f3 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("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.session.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..b0e316fa0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1865,6 +1865,10 @@ iconv-lite@0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
+"idiomorph@https://github.com/basecamp/idiomorph#rollout-build":
+ version "0.0.8"
+ resolved "https://github.com/basecamp/idiomorph#e906820368e4c9c52489a3336b8c3826b1bf6de5"
+
ieee754@^1.1.13:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"