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..4cc10a6e8
--- /dev/null
+++ b/src/core/drive/morph_renderer.js
@@ -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
+ }
+}
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..6cf25dfd5 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
@@ -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..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 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/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..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 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/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/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/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..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 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..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"