Fix flaky reset password test with custom Turbo Stream visit action#1474
Fix flaky reset password test with custom Turbo Stream visit action#1474maebeale wants to merge 4 commits into
Conversation
The password change form submitted via Turbo Drive's fetch API, creating a race condition where the browser might not process the Set-Cookie header from the bypass_sign_in redirect response before making the follow-up GET request — causing a stale session with a mismatched authenticatable_salt and a redirect to /users/sign_in. Disable Turbo on the form so it uses standard HTML submission, which reliably processes Set-Cookie between redirect hops. Also reorder test assertions to check flash content before path. Closes #1473 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The "Log out and reset it." link is inside the password change form. Adding data-turbo="false" to the form also disabled Turbo for the link, preventing data-turbo-method and data-turbo-confirm from working. Fix by adding data-turbo="true" on the link to re-enable Turbo for it. Also fix change_password_flow_spec to use accept_confirm (wrapping the click) instead of accept_alert (after the click), and add JS-ready waits to both specs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace data-turbo="false" approach with a custom Turbo Stream action that separates session update from navigation. Turbo 8's fetch-based form submission has a race condition where the browser may not process the Set-Cookie header from a 303 redirect before making the follow-up GET request — causing Devise to invalidate the session due to a stale authenticatable_salt. The new turbo_stream_visit helper renders a 200 with a Turbo Stream tag that triggers client-side Turbo.visit(), ensuring the cookie is set before navigation begins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| // Usage in controller: | ||
| // render turbo_stream: turbo_stream_visit(root_path) | ||
| Turbo.StreamActions.visit = function () { | ||
| Turbo.visit(this.getAttribute("url")) |
There was a problem hiding this comment.
This custom Turbo Stream action is the client-side counterpart to turbo_stream_visit in ApplicationController. When the server responds with <turbo-stream action="visit" url="/">, this handler triggers Turbo.visit() — ensuring the browser has already processed the Set-Cookie header from the 200 response before navigation begins.
| # (e.g. bypass_sign_in after password change) to ensure the browser | ||
| # processes the Set-Cookie header before navigation begins. | ||
| def turbo_stream_visit(url) | ||
| %(<turbo-stream action="visit" url="#{ERB::Util.html_escape(url)}"></turbo-stream>).html_safe |
There was a problem hiding this comment.
This helper generates a raw Turbo Stream tag rather than using the turbo_stream builder because we need a custom action (visit) not in the standard set. The ERB::Util.html_escape prevents XSS if the URL somehow contains user input.
| format.turbo_stream do | ||
| flash[:notice] = "Your Password was updated." | ||
| render turbo_stream: turbo_stream_visit(root_path) | ||
| end |
There was a problem hiding this comment.
The key change: instead of redirect_to (which Turbo follows via fetch, racing against Set-Cookie processing), we respond with a 200 containing a Turbo Stream that triggers client-side navigation. The browser reliably processes Set-Cookie on the 200 response before the stream action fires.
Document the turbo_stream_visit pattern as the preferred approach for session-modifying form submissions, and explicitly warn against using data-turbo="false" which breaks descendant Turbo features. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
jmilljr24
left a comment
There was a problem hiding this comment.
I need to look into it more but these changes make me pause for a second.
Do we actually have an issue with updating passwords or it just the test being flaky? I don't love the idea of adding special code just to make a test past when the application works.
I can see how this change is intended to work but it seems like we are reinventing the wheel. My gut says that a devise password update with turbo and keeping the user logged in is a pretty normal flow.
Here is the default update action from the devise controller. We of course have our own action with some custom stuff like ahoy but for the most part there is nothing special in devise.
def update
self.resource = resource_class.to_adapter.get!(send(:"current_#{resource_name}").to_key)
prev_unconfirmed_email = resource.unconfirmed_email if resource.respond_to?(:unconfirmed_email)
resource_updated = update_resource(resource, account_update_params)
yield resource if block_given?
if resource_updated
set_flash_message_for_update(resource, prev_unconfirmed_email)
bypass_sign_in resource, scope: resource_name if sign_in_after_change_password?
respond_with resource, location: after_update_path_for(resource)
else
clean_up_passwords resource
set_minimum_password_length
respond_with resource
end
end
Maybe respond_with from the responders gem. But again, I'm wonder if this is just a test issue not an app issue.
i tagged you in to get your thoughts bc i wasn't sold either. yeah, the test is often flaky and so just trying to get us with a more reliable suite. |
Thanks for diving into it! My vote would be disable this specific test for now and open an issue for it. Maybe someone will pick it up. We know the password change works and would surface pretty quickly from user feedback if it wasn't. And it seems like the only thing in question regarding the test is the redirect after updating, not the update itself. |
|
ok, @jmilljr24 i'm going to close this pr but leave the test in and see how flaky it actually is. |
Closes #1473
What is the goal of this PR and why is this important?
reset_password_person_spec.rbwhere the user ends up at/users/sign_ininstead of/after changing their passwordreset_password_person_spec.rbandchange_password_flow_spec.rbHow did you approach the change?
Password change form (flaky redirect to sign_in):
fetch()-based form submission withredirect: "follow"has a race condition where the browser may not process theSet-Cookieheader from a 303 redirect before making the follow-up GET request — causing Devise to invalidate the session due to a staleauthenticatable_salt(which changes when the password changes viaupdate_with_password)visitaction that separates session update from navigation: the server responds with a 200 (not a redirect) containing a<turbo-stream action="visit">tag, the browser processes theSet-Cookieheader, then the custom action triggersTurbo.visit()client-sideturbo_stream_visit(url)helper inApplicationControllergenerates the stream tag;Turbo.StreamActions.visitinturbo-events.jshandles it client-side"Log out and reset it." link (confirm dialog test failures):
change_password_flow_spec.rbto useaccept_confirm(wrapping the click) instead ofaccept_alert(after the click)have_css("[data-controller='password-toggle']")) to both specs to ensure Turbo is initialized before clicking the confirm linkUI Testing Checklist
Anything else to add?
visitaction is a general solution for the Turbo/session race condition — prefer it overdata-turbo="false"which disables Turbo for all descendant elements🤖 Generated with Claude Code