If you run a Rails app that is using Turbo Show archive.org snapshot , you might observe that your integration tests are unstable depending on the load of your machine. We have a card "Fixing flaky E2E tests" that explains various reasons for that in detail.
Turbo currently ships with three modules:
- Turbo Drive accelerates links and form submissions by negating the need for full page reloads.
- Turbo Frames decompose pages into independent contexts, which scope navigation and can be lazily loaded.
- Turbo Streams deliver page changes over WebSocket, SSE or in response to form submissions using just HTML and a set of CRUD-like actions.
..which are all examples of CapybaraLockstep's limitations Show archive.org snapshot .
To stabilize asynchronous work of Turbo, we have to find good
events
Show archive.org snapshot
to wrap calls to startWork
and stopWork
around them. The framework currently does not define symmetric events for each type, so my controller needed one hack or two.
Stimulus controller
import { Controller, Application } from "@hotwired/stimulus"
// See https://turbo.hotwired.dev/reference/events
class CapybaraLockstepController extends Controller {
connect() {
this.lockCounter = 0
this.boundLockTurboStreamRendering = this.lockTurboStreamRendering.bind(this)
this.boundStartLock = this.startLock.bind(this)
this.boundStopLock = this.stopLock.bind(this)
// Stream rendering
document.addEventListener("turbo:before-stream-render", this.boundLockTurboStreamRendering)
// Form submission
document.addEventListener("turbo:submit-start", this.boundStartLock)
document.addEventListener("turbo:submit-end", this.boundStopLock)
// Network activity
document.addEventListener("turbo:before-fetch-request", this.boundStartLock)
document.addEventListener("turbo:before-fetch-response", this.boundStopLock)
document.addEventListener("turbo:fetch-request-error", this.boundStopLock)
// Frame rendering
document.addEventListener("turbo:before-frame-render", this.boundStartLock)
document.addEventListener("turbo:frame-render", this.boundStopLock)
}
disconnect() {
// Stream rendering
document.removeEventListener("turbo:before-stream-render", this.boundLockTurboStreamRendering)
// Form submission
document.removeEventListener("turbo:submit-start", this.boundStartLock)
document.removeEventListener("turbo:submit-end", this.boundStopLock)
// Network activity
document.removeEventListener("turbo:before-fetch-request", this.boundStartLock)
document.removeEventListener("turbo:before-fetch-response", this.boundStopLock)
document.removeEventListener("turbo:fetch-request-error", this.boundStopLock)
// Frame rendering
document.removeEventListener("turbo:before-frame-render", this.boundStartLock)
document.removeEventListener("turbo:frame-render", this.boundStopLock)
}
lockTurboStreamRendering(event) {
const originalRender = event.detail.render
this.startLock(event)
event.detail.render = (streamElement) => {
originalRender(streamElement)
this.stopLock({type: "turbo:after-stream-render"})
}
}
startLock(event) {
this.lockCounter++;
window.CapybaraLockstep?.startWork(event.type);
}
stopLock(event) {
if (this.lockCounter > 0) {
// Turbo does not currently offer perfectly symmetrical event pairs.
// We must ensure that stopWork is not called more often than startWork
this.lockCounter--
window.CapybaraLockstep?.stopWork(event.type)
}
}
}
const application = Application.start()
application.register("capybara-lockstep", CapybaraLockstepController)
DOM
Active the controller on the body
tag of every layout.
<body data-controller="capybara-lockstep">
Posted by Michael Leimstädtner to makandra dev (2025-09-12 11:32)