Turbolinks: Have you thought about using virtual DOM diffing?

Created on 29 Sep 2016  Â·  16Comments  Â·  Source: turbolinks/turbolinks

(Preface: I know, I know… a virtual DOM? It might be a little overkill and probably not even close to being on the roadmap, but here me out anyways just for the sake of discussion.)

While developing a site a few weeks back, I wanted button hover states/animations to persist between page transitions. Using vanilla Turbolinks, I was experiencing 'jumpy' transitions (gif below). Although I could have used something like React to accomplish it, I thought that was a bit overkill for such a simple site. So, I figured I'd try out using Turbolinks with a virtual DOM.

I've been experimenting with a small monkey patch using the nifty virtual-dom and vdom-virtualize libraries compiled with browserify-rails:

#= require turbolinks

diff = require "virtual-dom/diff"
patch = require "virtual-dom/patch"
virtualize = require "vdom-virtualize"

Turbolinks.SnapshotRenderer::assignNewBody = ->

  # Virtualize the current body
  curBody = virtualize document.body

  # Virtualize the new body
  newBody = virtualize @newBody

  # Diff the 2 virtual DOMs, creating a set of patches
  patches = diff curBody, newBody

  # Apply the resulting patches to the current DOM
  patch document.body, patches

It's obviously quite hacky, but it's enough to experiment with. And however hacky it may be, the results are pretty awesome. See the gifs below:

Before

After

I thought I'd see what the thoughts were on something like this and maybe start a discussion on whether or not this kind of feature would be useful to other Turbolinks users.

enhancement

Most helpful comment

Inspired by @ezekg's code sample, I wanted to try the even simpler 'Virtual-DOM'-less DOM patcher morphdom.

Added to the Gemfile:

gem 'rails-assets-morphdom', source: 'https://rails-assets.org'

And monkey patched like @ezekg's:

//= require morphdom

Turbolinks.SnapshotRenderer::assignNewBody = ->
  morphdom(document.body,@newBody,{})

The advantage over the typical VirtualDOM solutions is weight. GZipped & minified it adds about 2.5kb to the project. Which I find acceptable (in contrast to the >50k increase VirtualDOM solutions add).

All 16 comments

Hey @ezekg, thanks for bringing this up. I love how small your implementation is.

I don’t see this coming to Turbolinks core anytime soon, but what I _would_ really like to do is add hooks to the render events so you can implement a custom renderer yourself without monkey-patching. Probably something along the lines of calling event.preventDefault() in turbolinks:before-render, and then calling back into Turbolinks when your render is complete.

Please feel free to open a pull request if you’re interested in exploring custom rendering hooks.

One of the reasons it probably isn't a fit for Turbolinks core is that VDOM implementations tend to be pretty heavy compared to Turbolinks itself. Turbolinks itself is 34kB, and most VDOM libs are 50kb+.

Custom renderers sound dope.

@sstephenson, that seems like a smart idea. I'll explore putting together a pull request whenever I have time. And I agree, @nateberkopec, they are pretty large. Just figured I'd start the discussion.

Another thought - dom diffing probably will fix a lot of the problems people are having with data-turbolinks-permanent right now. See #177.

How about this? Isn't this doing the same thingamajig? https://github.com/reactjs/react-magic

@emilebosch kind of, https://github.com/ssorallen/turbo-react is a better turbolinks comparable

@nateberkopec Just tried it out but doesn't seem to work with

gem 'turbolinks', '~> 5.0.0'
gem 'turbo_react-rails'

Global not defined error? Like this one? https://github.com/ssorallen/turbo-react/issues/30

Dunno. It was always more of a demo/experiment, you'll have to take it up with them.

@nateberkopec Alright got it work by simply pasting the dist file. https://github.com/ssorallen/turbo-react/tree/master/public/dist. Seems to work a lot quicker though, without flickering artifacts i'd love to have custom renderers.

+1 for this

Inspired by @ezekg's code sample, I wanted to try the even simpler 'Virtual-DOM'-less DOM patcher morphdom.

Added to the Gemfile:

gem 'rails-assets-morphdom', source: 'https://rails-assets.org'

And monkey patched like @ezekg's:

//= require morphdom

Turbolinks.SnapshotRenderer::assignNewBody = ->
  morphdom(document.body,@newBody,{})

The advantage over the typical VirtualDOM solutions is weight. GZipped & minified it adds about 2.5kb to the project. Which I find acceptable (in contrast to the >50k increase VirtualDOM solutions add).

I've been playing with the morphdom solution here today and it looks really promising. However, because of how LITTLE it does, it's not actually a drop-in replacement for standard Turbolinks behavior. For example, using Foundation menus morphdom will reset data attributes and class names on the menu elements, effectively breaking them both functionally and visually, but yet Foundation will give this error message: "Tried to initialize dropdown-menu on an element that already has a Foundation plugin." and won't fully reset the menu, leaving it in its broken state.

So somewhat in the same way we have to re-learn how to write or plugin initialization JavaScript for Turbolinks to play nicely, we need to re-learn again how to make morphdom play nicely with all that also. After a bunch of tinkering, I haven't yet figured out how to do that.

Ideally, elements that are "initialized" but a plugin would not be reset, and would not need to be re-initialized. That seems to be the point of the benefit of this Issue in the first place: things not needing change are left alone. Anyone have any ideas how to communicate to morphdom which elements should be left alone in cases like this?

Maybe we're getting a bit off topic here, but on the other hand it may be relevant to the discussion what problems may arise / and how we 'should' deal with those.

@glennfu I upgraded at the same time to the latest version of foundation-rails actually, and while I'm still getting the "Tried to initialize responsive-toggle on an element that already has a Foundation plugin."-warning, it still seems to work perfectly here. I do recall, however, having to work around a few issues. I think I might have added a bit of extra CSS here and there to make e.g. the responsive-toggler work.... but sadly I haven't really documented all these steps.

More on a conceptual level, I think the state of the page should be fully represented by the URL ... hence if Foundation, or any other plugin, modifies the state of the HTML, this state should be transferred over to the next it should be represented in the URL as well (think e.g. about tabs using anchor links), or it should be robust enough to deal with DOM changes (like how we 'learned' to register click-event listeners to the body instead of an element deep down).

When both sides (server and client) transfer state via the URL there can and should be an agreement on the presence of the HTML-elements needed to make something fully work on the front-end (think of a later-to-be-used overlay div that a script used to add on init, should be prerendered already with the HTML)

Alternatively, the script should be made in such a way that it can handle any DOM change. In case of the modal dialog needing a page covering overlay div: On the click event the html can also be temporarily added to the DOM client side, and destroyed on closing the dialog.

Maybe of help to anyone: Came across https://github.com/choojs/nanomorph, which is an alternative to the earlier mentioned morphdom (also small and (non-virtual) dom-based), which actually claims to copy events. Will revisit this issue probably not before early next year.

All solutions above break restoration visits from cache (back button) because snapshot clone is deferred here so that the body is stored in cache after being updated.

It works with more monkey patching

import Turbolinks from 'turbolinks';
import nanomorph from 'nanomorph';

Turbolinks.SnapshotRenderer.prototype.assignNewBody = function () {
  nanomorph(document.body, this.newBody);

  // or replace only some elements manually
  // const docRoot = document.body.querySelector('.page');
  // const newRoot = this.newBody.querySelector('.page');
  // docRoot.replaceWith(newRoot);
};

const originalCacheSnapshot = Turbolinks.Controller.prototype.cacheSnapshot;
const originalDefer = Turbolinks.defer;
const noDefer = (callback) => callback();
Turbolinks.Controller.prototype.cacheSnapshot = function () {
  Turbolinks.defer = noDefer;
  originalCacheSnapshot.call(this, ...arguments);
  Turbolinks.defer = originalDefer;
};

Turbolinks.start();

@webkonstantin do you have a more detailed explanation of why restoration visits get broken with nanomorph and we need to eliminate the defer?

Reason I'm asking is because when we eliminate the defer as in your example, then disconnect() on Stimulus and other js relying on mutation observer (Trix) fail to work well .. which was one of the main goals for introducing the defer as explained in the PR.

In those cases, it's important to fall back to listening on the turbolinks:before-cache event to teardown any setup logic.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

wayneashleyberry picture wayneashleyberry  Â·  16Comments

pmaojo picture pmaojo  Â·  15Comments

parasharrk picture parasharrk  Â·  12Comments

RathanakSreang picture RathanakSreang  Â·  17Comments

gbuesing picture gbuesing  Â·  14Comments