-
Notifications
You must be signed in to change notification settings - Fork 474
Track refreshes per request and URL to support persistent modals with page refreshes #1440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Track refreshes per request and URL to support persistent modals with page refreshes #1440
Conversation
Track refreshes per request and URL to support persistent modals with page refreshes Previously, Turbo tracked only request IDs to prevent duplicate refreshes, which worked well for standard form submissions but failed when keeping modals/slideouts open while updating both modal and background content. This change tracks refreshes per request ID and URL combination, allowing page refreshes to process when submitting forms inside Turbo frames that redirect to the same frame's URL. The background content now updates correctly via broadcasts while the modal remains open with data-turbo-permanent. Fixes the scenario where redirecting to /conversations/1 inside a modal frame would update the modal but leave stale content behind it, even though the model broadcasts refreshes.
src/http/fetch.js
Outdated
| recentRequests.markUrlAsRefreshed(requestUID, response.url) | ||
| } | ||
|
|
||
| return response |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that I went with the approach of awaiting the fetch here since it's something that we want to do for every request (like we were doing for generating the requestUID and appending it to the headers).
An alternative is to add it to each FetchRequest's requestSucceededWithResponse delegate method, but that'd make things more complex and cause us to potentially miss some cases as new delegates are added.
| window.Turbo.session.recentRequests.markUrlAsRefreshed("123", currentUrl) | ||
| }) | ||
|
|
||
| await assertPageRefresh(page, "123", {shouldOccur: false}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Main change was:
-window.Turbo.session.recentRequests.add("123")
+window.Turbo.session.recentRequests.markUrlAsRefreshed("123", currentUrl)
})But I'm also doing a minor "refactor" to use the new assertPageRefresh function.
| assert.ok(subject.find("h1#before")) | ||
| assert.isNull(element.parentElement) | ||
| }) | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For these 2 existing page refresh tests:
test("test action=refresh", async () => {
test("test action=refresh discarded when matching request id", async () => {I did a minor "refactor" to use the new assertRefreshProcessed and triggerRefresh functions for clarity.
Added 4 additional ones.
| const { path } = request.body | ||
| response.redirect(303, path) | ||
| }) | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Existing /redirect added extraneous params to the redirected URL, such as enctype.
brunoprietog
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice change! This is a known issue, with people removing the request ID to avoid this caution.
src/http/fetch.js
Outdated
| // | ||
| // If it's within a Turbo frame, we don't consider it refreshed because the | ||
| // content outside the frame will be discarded. | ||
| if (response.ok && response.redirected && !options?.headers?.["Turbo-Frame"]) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be worth extracting this condition into a method. In any case, checking that the response is OK and is a redirect is not enough, since a page refresh can also occur when you receive 422 errors. It is not a redirect, but it does return to the same page. We are losing those.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for taking the time to review @brunoprietog, excited to get this in!
I have extracted it to separate functions to avoid polluting the main fetch logic. Given the additional logic for 422, it's much better to have it in a separate place. We could potentially extract it to a separate class, but not sure if it warrants its own extraction yet.
Regarding 422 errors, we weren't losing them, but rather we were unnecessarily processing page refreshes for that failed request, so thanks for the heads up and good call!
Since the server couldn't process the request, and whatever it rendered back is the freshest content, processing a page refresh would just display the same content (outside the modal, if any, or in the page if not using a modal). Afterwards, successfully processing the request will get us a new requestUID and thus we'll now update both the modal and the content behind it as intended.
I have added test cases for this as well as updated the inline Rails script adding 2 new test cases, which both fail without this PR, because while the previous implementation correctly didn't process refreshes in this case, when successfully making a new request, the content behind the modal was still stale.
src/core/drive/limited_map.js
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have to remove LimitedSet in that case, since this was the only place where it was used. And maybe move LimitedMap a bit further out? This feels like a primitive that is much less associated with something strictly related to Turbo Drive core. I know it was there before, but I personally would move it out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RecentRequestsTracker uses LimitedMap and LimitedSet internally to track the list of URLs for a given request. It's unlikely that a request will have more than 20 URLs, but it's rather defensive programming. Let me know if you think we should use a regular Set here.
I have moved both to src/util.js.
|
|
||
| function unprocessableEntity(response) { | ||
| return response.status === 422 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comments in the functions above make this look like a bigger change than it is. Wondering if I should just remove them since we don't usually add comments in the codebase like this.
|
Hey @brunoprietog, wanted to see if there's anything blocking this. Hopefully you've given it a spin in a 37Signals app. I've been running it in prod for a while now! Please let me know if there's anything else I can do to get this going 😺 |
|
@brunoprietog Happy new year! Would you have any news about this? On my side, I have 2 apps running since I wrote this (4 months ago):
The one without this change has to hack around with The one with this change works flawlessly. Hope you understand that it's a very high cognitive effort to come back to work on Turbo after a while, since it's rather a bit complex, tedious and time consuming to set up an environment where you can work on this locally with a real app, as well as having to load up everything in your mind again. Let me know what you need to move this along the pipeline, happy to help in any way! |
Description
This PR enables Turbo to handle the common pattern of keeping modals/slideouts open while refreshing the page behind them without extra code needed. When updating content in a persistent modal, any broadcasts from the server will correctly refresh the stale background content, making modern UI patterns work out-of-the-box.
This eliminates the need for manual Turbo Stream updates when building slideouts, dialogs, and inline editors that need to stay open while updating both their own content and the page behind them.
i.e. it makes the happy path, even happier.
Details
Turbo is currently focused on modals/slideouts that close when you submit them, because it assumes you'll redirect to the current page, getting the freshest content.
Imagine you have a standard Rails controller action:
And a Turbo frame that gets the conversation content when you click on one:
Redirecting to /conversations with standard body replacement
1.mov
Redirecting to /conversations with page refresh (morph)
2.mov
What happens though, if you want to leave the modal/slideout open, and want both the modal and the content behind it to be updated after you submit the form?
If you keep redirecting to the
conversations_path, the modal will be "closed", because the incoming HTML has an empty Turbo frame, so the modal content will be replaced by empty HTML.So you instead redirect to the conversation show path:
This will keep the modal "open" and update its contents, but the content behind it will no longer be updated:
3.mov
It is at this point that you have to reach out to Turbo streams to manually update the content behind the modal, and to quote @jorgemanrubia's A happier happy path in Turbo with morphing:
With this PR, the controller remains unchanged. Turbo is now smart enough to detect that a page refresh is needed and allows it to be processed if the server sends it, for example, with a model broadcasting page refreshes when updated with:
4.mov
Note that since the page is refreshed, the refresh would "close" the modal by replacing or morphing the Turbo frame contents with the empty HTML from the conversations page. So we just add
data-turbo-permanentto the modal:Inline Rails script reproduction
Apart from the Turbo tests in this PR (which more deeply test things, such as not processing duplicate page refreshes or not processing them when it doesn't have to), I have prepared an inline Rails script that you can save to a file, e.g.
rails.rbrun via:The tests all pass with this PR.
If you want to run them against Turbo latest release, just comment/uncomment these lines, which will make tests fail:
You can also comment out the tests and uncomment the "playground" test for you to test manually.
The inline Rails script is below:
Inline Rails script
How it works
Originally, Turbo only kept track of request ids.
Original approach
requestUIDrecentRequestsvariablerequestUID, Rails sets it inTurbo.current_request_idrequestUIDsetrequestIDis in recent requests, ignore refreshThis worked for normal form submission that redirected to the same page, since the server just redirected you there, there's no need to process a refresh, you already have the freshest content:
Scenario A: Redirect outside the modal to close it and update things outside
data-turbo-permanentTurbo frame as a modal/slideoutrequestUID"123"recentRequestsHowever, in the case of modals/slideouts within a Turbo frame that you want to keep open and update BOTH the modal content and the one behind the modal, this doesn't work:
Scenario B: Redirect to the modal to both update it and the content outside
data-turbo-permanentTurbo frame as a modal/slideoutrequestUID"123"requestUID"123"recentRequestsIn order to fix this, we now keep track of request ids and URLs.
New approach
requestUIDrecentRequestsvariablerequestUID, Rails sets it inTurbo.current_request_idrequestID, this prevents processing refreshes for redirects that the browser will follow and thus content will be freshrequestID, this prevents processing refreshes for failed requests, since whatever we have on screen or what was rendered from the server is the freshest content. Since the request failed, refreshing would just render the same content for anything else on screen (outside a modal, for example)requestUIDsetrequestIDis in recent requests for the current URL, ignore refreshThis fixes the persistent modal scenario:
Scenario B: Redirect to the modal to both update it and the content outside
requestUID"123"requestUID"123"requestUID"123" for URL /conversations, so it processes itrequestID"123" and URL /conversations are discardedThe first "normal" scenario mentioned above still works:
Scenario A: Redirect outside the modal to close it and update things outside
requestUID"123"requestUID"123"requestUID"123" for URL /conversations was already marked as refreshedrequestID"123" and URL /conversations are discardedHere's a scenario for failed requests:
Scenario C: Failed request with 422 to render errors, then successful request
Previous approach
requestUID"123"requestUID"123"requestID"123"requestID"123" are discardedrequestID"456"requestUID"456"recentRequestsThis leads to stale content behind the modal.
New approach
requestUID"123"requestUID"123"requestID"123" and URL /conversations because processing them would just render the same contentrequestID"123" and URL /conversations are discardedrequestID"456"requestUID"456"requestUID"456" for URL /conversations, so it processes itrequestID"456" and URL /conversations are discardedThe content on both the modal and behind it are fresh.
Note that this PR aims to NOT break existing behavior nor introduce unwanted additional page refreshes, but only progressively enhance the developer experience in a way that makes sense and it's expected:
e.g. "I'm redirecting to this modal to update it, and my model broadcasts refreshes, why is the page not being refreshed to update the content behind it?"
In order to do so, this PR adds test coverage for many different scenarios. It does so by introducing fixtures under
src/tests/fixtures/conversations/to be a seamless addition to the "messages" concept we use throughout, as well as making more sense to others, since it's an easier mental model to "open a modal for a conversation" than to "open modal for a message".