-
Notifications
You must be signed in to change notification settings - Fork 428
Cache, state, lifecycle events, and the future of Turbolinks 3 #551
Comments
Thanks for laying it all out for us to discuss, Thibaut. In my opinion, (1) and (2) aren't good options because of the loss of state. We've always made it a point of emphasis that Turbolinks should mimic native browser behavior in every way possible. Losing the state when navigating through history would be a big contradiction to that. When the change was made to cache So I would favor any solution that included reverting to the old way of caching pages. If it can be done, (4) would be the ideal option. (I personally wouldn't have a huge problem with (5), except that it's probably not an option since it's already been announced/promoted and people seem enthusiastic about it. I think one of the reasons we resisted the idea of partial replacements for so long is because in the back of our minds we saw the potential of running into a caching issue like this. Hopefully we can find a way to make it work, but if we can't, dropping partial replacement might be our best move.) |
Thanks for your thoughts, Nick. I can give (4) a shot in the coming weeks. If we can pull it off (I only thought of it while writing this issue), it'd give us the best of both worlds. Regarding (5), we'd still get to keep half the features announced at RailsConf ( |
Thanks for digging into this so clearly. I think it's worth taking a shot On Mon, Jun 1, 2015 at 1:34 PM, Thibaut Courouble [email protected]
|
4 sounds achievable up front but I'm worried it may dramatically increase the complexity of our replacement logic and still have gotchas. I think the trickery will be around
Page A <div id="nav" data-turbolinks-permanent="true">
...
</div>
<ul id="resources">
<li>one</li>
<li>two</li>
</ul>
<div id="timer-banner" data-turbolinks-temporary="true">
...
</div> Page B <ul id="resources">
<li>three</li>
</ul>
<div id="something-else-conditionally-shown" data-turbolinks-temporary="true">
...
</div> Scenario 1: <div id="nav" data-turbolinks-permanent="true">
...
</div>
<ul id="resources">
<li>three</li>
</ul>
<div id="something-else-conditionally-shown" data-turbolinks-temporary="true">
...
</div>
<div id="timer-banner" data-turbolinks-temporary="true">
...
</div> Scenario 2: <div id="nav" data-turbolinks-permanent="true">
...
</div>
<ul id="resources">
<li>three</li>
</ul>
<div id="something-else-conditionally-shown" data-turbolinks-temporary="true">
...
</div> So now we want to get back from B to A and If I'm understanding " <div id="nav" data-turbolinks-permanent="true">
...
</div>
<ul id="resources">
<li>three</li>
</ul>
<!-- I'd expect this to go away -->
<div id="something-else-conditionally-shown" data-turbolinks-temporary="true">
...
</div>
<!-- I'd want this to come back -->
<div id="timer-banner" data-turbolinks-temporary="true">
...
</div> In this case it's all a flat node hierarchy, but you could imagine if this was nested we wouldn't really know where to put the nodes if we have no reference point (a handle with the same id in the current DOM). I think the biggest challenge here is that since we don't enforce any kind of DOM structuring requirements in the replacement body we don't know enough about the DOM structure to make an easy guess on where a reverse-applied node belongs. We may be able to achieve this if we started tracking parents and siblings but that's scary. Am I missing something big? |
@kristianpd In scenario 2) on transition from A->B, shouldn't |
@kristianpd As I understand popstate, the user can return to any state in history, not just pop last one from the stack. This means the user might be switching from some page C which has completely different body to A, after navigating A->B->C. So it's not just matter of reverting one transformation. |
Let's consider an example with partial replacement, if we implemented @Thibaut's proposal 4 (as I understand it): Page A: <body>
<nav>...</nav>
<main id=“container”>I’m page A</main>
<footer>…</footer>
</body> Page B: <body>
<nav>...</nav>
<main id=“container”>I’m page B</main>
<footer>…</footer>
</body> Page C: <body>
<p>Thank you</p>
</body> User performs following actions:
On transition from state 1. to 2. we cached whole body HTML and safely stored DOM node for When debugging problems with JS plugins I’d rather not have to reason about some elements on a page being re-constructed from serialized HTML and some re-attached. ProposalHow about we cache DOM nodes, but only for simple full-page transitions, with no In case of partial replacement we cache neither DOM nodes nor serialized HTML (@Thibaut's proposal 1.) On popstate URL will be fetched anew. This way "plain" web applications get speed up with Turbolinks without breaking "back" button behavior. And burden of solving problems falls on developers who opt-in to using partial replacement. And maybe if you're building something that more like an SPA, then you don't ever care about back button using cache and keeping input state that much? |
@WojtekKruszewski thanks for digging into this!
Good catch. This isn't a blocker, but again adds more complexity (we'd need to assign an incrementing ID to each state, so that when you pop |
This would be a way to keep the feature in place. The downside is that most people will probably make use of it, since it's so easy to do from the controller, and end up with slightly inconsistent back button behavior. |
Unless there are any objections I'll implement what @WojtekKruszewski suggested above (keep partial replacement, but remove pages that have been partial-replaced from the cache so we can go back to caching |
Fix turbolinks#551. Partial replacement broke the page cache by caching body.outerHTML (losing all DOM state in the process) instead the body element itself. This commit brings back the old behavior, with the following gotcha: when a partial replacement is performed, we remove the current page from the cache, since the body element will not change and there is no simple way for us to bring back the changed nodes. Additionally: - page:after-remove on body elements now triggers on cache eviction (since we don't want to lose DOM state) - to avoid triggering page:load on the same body elements more than once, a partial replacement now triggers the page:partial-load event
Fix turbolinks#551. Partial replacement broke the page cache by caching body.outerHTML (losing all DOM state in the process) instead of the body element itself. This commit brings back the old behavior, with the following gotcha: when a partial replacement is performed, we remove the current page from the cache, since the body element will not change and there is no simple way for us to bring back the changed nodes. Additionally: - page:after-remove on body elements now triggers on cache eviction (since we don't want to lose DOM state) - to avoid triggering page:load on the same body elements more than once, a partial replacement now triggers the page:partial-load event
@Thibaut I've seen that this has now been closed with a pull request that doesn't cache the body if there's a partial replacement but just wanted to query something about this. |
@amnesia7 You're describing how the cache worked before the fix for this issue. The problem is that as soon as we cache HTML (as a string), we lose all DOM state (not just event listeners but any data stored on the DOM elements themselves or which is only referenced through them). Most JS code is not written with "separating DOM transformations from DOM state" in mind. Writing code in such a way is hard and most of the time bad practice. We can't expect people to do that. |
Ah, good point. Glad you're on the ball. |
While working on the docs I realized that the partial replacement feature currently in master introduced significant issues with page caching and lifecycle events. Below is a summary of the problem and a list of possible solutions.
I apologize for the long description. This is a defining issue for Turbolinks 3 that needs careful consideration.
Problem
Say I have this:
After the page loads, the resulting markup is this:
Now say I:
Behavior in v2.5:
<body>
element is stored in the cache; its state (event listener and textarea value) is kept in memory<body>
is reassigned to the document, and:<div>
is presentfn
is still attached<body>
is evicted from the cache, jQuery stills hold a reference tofn
)Behavior in master:
<body>
element'souterHTML
is stored in the cache; its state is cleaned up<body>
is parsed and reassigned to the document, and:<div>
is presentfn
is not attached to click event(Side note: the reason why we now serialize the
<body>
element instead of keeping it in memory is that it isn't guaranteed to be replaced onTurbolinks.visit
anymore. If we didn't and you didTurbolinks.visit("#{currentUrl}?q=test", change: ['container'])
, thecontainer
element would be replaced and we'd have no way to bring the old one back unless we somehow managed to keep track of the diff across pages + the old nodes in memory.)Now, one way to get around this problem might be to bind to
page:change
(which also fires on history back/forward) instead ofpage:load
, but then here's what happens after popstate:fn
is attached<div>
is wrapped in another<div>
The DOM transformation is applied a second time.
So then we might do something like this:
Ignoring the fact that this isn't how people write JavaScript, here comes another problem: say I partial-replace another element on the page (with
Turbolinks.visit(url, change: ['id'])
):page:load
andpage:change
are triggered on thedocument
<div>
andfn
callback to the existingdata-rte
elementTo alleviate this problem, #537 started passing the affected nodes to these two events, allowing you to do this:
... which, in practice, is impossible to pull off (race conditions).
tl;dr: current master will break everyone's back button unless they write insanely complex JavaScript.
Paths forward
(1) Get rid of the cache entirely
This is what we do at Shopify / Turbograft.
Upsides:
Downsides:
Since a new page is loaded on history back/forward, we can keep using
page:load
the same way we would with Turbolinks 2.5 (no need to split DOM transformations / event listeners inpage:load
andpage:update
). For plugins that aren't idempotent, Turbograft also attaches the affected nodes to thepage:load
event (since it fires on partial replacement), so you'd end up with this:To help with the "loses state" issue, we could keep the values of form elements in memory and try to re-apply them on back/forward (each element would need a unique id).
(2) Cache before executing JS
Like (1) but we cache the untouched
<body>
'souterHTML
of each page (before any JS is executed on it), like an HTTP cache.Upsides:
Downsides:
render :view, change: :key, layout: false
and updating the URL at the same time wouldn't be possible since we can't make an untouched<body>
out of that (this was a requested feature that I was planning to address by makingrender :view, change: :key
update the current URL on GET requests; the use-case being that of a search form which updates a container and the URL at the same time)(3) Cache after executing JS (current master)
To make my example work without split callbacks, we'd need to write the code like this:
... and make one breaking change in Turbolinks: fire
page:load
on history back/forward.Upsides:
Downsides:
page:load
callbacks would need to be both idempotent and able to re-apply themselves on an already-transformed DOM(Side note: if we go with (1), (2) or (3), I would drop the transition cache, since its speed benefit is significantly reduced by the fact that we have to re-instantiate all the state on the page before loading the real new page.)
(4) Cache nodes and "reverse-apply" partial replacements
Like I briefly explained above, one solution might be to keep the changed nodes in memory (like Turbolinks 2.5 except it wouldn't always be the
<body>
). So for example when this happens:Turbolinks.visit(url, change: ['container'])
, we would keep the previouscontainer
elements in memory and put them back when you hit back.Upsides:
Downsides:
(5) Drop partial replacement, keep Turbograft separate
If neither (1) or (2) are an option, and nobody is up for exploring (4), I would go with this, since I don't think (3) is acceptable.
Again sorry for the wall of text. Please let me know what you think.
cc @dhh @reed @rafaelfranca @kristianpd @pushrax
The text was updated successfully, but these errors were encountered: