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

Virtual DOM implementation #10

Merged
merged 1 commit into from May 5, 2015

Conversation

Projects
None yet
3 participants
@jgaskins
Member

jgaskins commented Apr 5, 2015

The virtual DOM is a pretty hot topic. I've found Matt Esch's virtual-dom library and wrapped it in some Opal bindings to let us re-render the app when needed. It supplies four main functions to do this:

  • h() creates the virtual nodes. They have a similar JS API to DOM elements.
  • createElement() turns virtual DOM nodes into actual DOM nodes.
  • diff() compares two trees and generates a set of patches.
  • patch() applies the patch set from the diff() function to a tree.
  • Remove jQuery dependency
  • Remove controllers
  • Remove views
  • Remove HTML components
  • Wrap virtual-dom library with Opal
  • Add components to build on top of virtual-dom's h(), createElement()
  • Adapt Clearwater::Application to understand virtual-dom components
  • Update the README to illustrate how to use these components
  • Add support to the virtual-dom wrapper for stateful components
    • Let components know where they are rendered in the DOM (something like React's data-react-id="0.0.1.2.$key" maybe?)
    • Add support for components updating themselves without updating the rest of the DOM for ludicrous speed

The reason I want to remove controllers and views is so that we don't have to support two different development styles. They don't seem to be compatible — in the old style, the Clearwater::Application had to set up event handlers on all a elements, whereas with the virtual DOM that is handled by the Link component.

I'll update the checklist above as I think of more things that are needed.

@jgaskins jgaskins added the enhancement label Apr 5, 2015

@aemadrid

This comment has been minimized.

Show comment
Hide comment
@aemadrid

aemadrid Apr 5, 2015

sounds very cool

aemadrid commented Apr 5, 2015

sounds very cool

@jgaskins jgaskins self-assigned this Apr 5, 2015

@krainboltgreene krainboltgreene added this to the v1.0.0 milestone Apr 6, 2015

@krainboltgreene

This comment has been minimized.

Show comment
Hide comment
@krainboltgreene

krainboltgreene Apr 6, 2015

Member

Let me know if you want to pair on this, I could use an introduction to the API.

Member

krainboltgreene commented Apr 6, 2015

Let me know if you want to pair on this, I could use an introduction to the API.

@jgaskins

This comment has been minimized.

Show comment
Hide comment
@jgaskins

jgaskins Apr 26, 2015

Member

I'm not so sure we need the last few checkbox items right now, to be honest. Rerendering the entire app every time hasn't been a bottleneck in anything I've built yet, so I'd like to go ahead and talk about merging this in.

Controllers/views and the old HTML-based components are gone

The concepts of components and controller/view don't seem to mesh really well, though I admit I haven't spent much time trying to make that happen. I'm happy to be wrong, but I can also say with reasonable confidence that HTML and the virtual DOM definitely do not mix. They're two different representations and you can't really combine them well.

Components are now the routing targets:

require 'clearwater/router'

router = Clearwater::Router.new do
  route 'blog' => BlogComponent.new
end

Component#outlet

The components have an outlet attribute that the router sets on these components when it adjusts the routing-target list based on the URL. This is exactly how controllers were linked up before; the components just absorbed that functionality.

Permanent vs ephemeral state

The main difference is that, while routed components are persisted (they're instantiated with the router), subcomponents instantiated by the routed components are not, so you can store state on routed components but you'll either need to pass that state down to instantiated subcomponents or store those components, as well:

require 'clearwater/component'

class BlogComponent
  include Clearwater::Component

  def initialize
    @title = 'My Blog'
  end

  def render
    div({id: 'blog'}, [
      blog_header,
      ArticleIndex.new,
    ])
  end

  def blog_header
    @blog_header ||= BlogHeader.new(@title)
  end
end

Every instance variable stored on this component will stick around for the life of the app. Notice how we store the blog's header component inside the @blog_header ivar. This makes sure that whatever state we give it will be there next time we render. However, since the ArticleIndex component referenced in the render method is reinstantiated on every render and no state is passed to it, its state is essentially thrown away, so any state it modifies (such as which articles were read) will be lost next time the app renders. If we want to keep that around, we need to store the component into one of our persistent (routed) components.

Event handling

In the old views and HTML-based components, we used event.on :click do … end somewhere inside the initializer. However, now it's done more like React:

class ArticleIndex
  include Clearwater::Component

  attributes :active

  def render
    ul({id: 'article-index'}, articles.map { |article| ArticleIndexItem.new(article, index: self) })
  end
end

class ArticleIndexItem
  include Clearwater::Component

  def initialize(article, index:)
    @article = article
    @index = index
  end

  def render
    li({ onclick: method(:set_active) }, [
      h4(nil, article.title),
    ])
  end

  def set_active
    @index.active = article.id
  end
end

One primary difference is that React fires an onChange every time you modify the value of an input or textarea node, whereas Clearwater relies on what the browser triggers — browsers won't trigger onchange until that form control loses focus. This may change in the future because I think React's onChange functionality is more aligned with what developers actually want.

Event handlers

The value you pass as the event handler (button({onclick: my_handler})) is usually a proc, lambda, or a bound method. In the example of the ArticleIndexItem above, I used a bound method.

However, we aren't limited to the usual proc/lambda-type things. Event handlers can be any object that responds to call:

class NewUserRegistration
  attr_reader :form

  def initialize(form)
    @form = form
  end

  def call
    # Data stores aren't a thing yet, but clap your hands if you believe.
    UserStore.create!(email: form.email, password: form.password)
  end
end

class RegistrationForm
  include Clearwater::Component

  attr_reader :email, :password

  def render
    form(({onsubmit: NewUserRegistration.new(self)}, [
      input(type: 'email', onkeyup: method(:set_email), placeholder: 'me@example.com'),
      input(type: 'password', onkeyup: method(:set_password), placeholder: 'password'),
      input(type: 'submit', value: 'Register'),
    ])
  end

  def set_email(event)
    @email = event.target.value
  end

  def set_password(event)
    @password = event.target.value
  end
end

I'm using bound methods here to set ivars on the form whenever the form inputs are updated, but the submit handler for the form is a service object that responds to call.

As you might have noticed from the bound-method handlers, the call message is supplied an argument that is the browser event (wrapped inside a Ruby binding provided by the opal-browser gem). You can omit the parameter in your method if you don't use it.

Attributes

Building on the model/view bindings that this PR deletes, components have the concept of "attributes". You define them in the component class body:

class ArticleIndex
  include Clearwater::Component

  attributes :active
end

Whenever you change this with article_index.active = foo, the app will be rerendered.

Render throttling

Because rerendering constantly can absolutely wreck performance and you may need to modify multiple attributes sequentially, changing several attributes rapid-fire will actually throttle rendering to at most once per 1/60th of a second. Changing the first attribute will eagerly fire a rerender, but subsequent attribute modifications will schedule at most one more to happen in 1/60th of a second or so (or whenever the current queue of callbacks is finished). This means that even if you change 10 attributes in a single callback, you will rerender at most twice.

Default rendering

If you don't specify a render method, the component will use the one provided by the Clearwater::Component module, which is completely empty. This helps you get your structure going without having to provide content. That was one of the frustrating things about the HTML view/template thing, was that you had to provide a template, so it slowed down my prototype development.

The time this helps the most is when you're getting your routes setup. You have to provide routing targets.

router = Clearwater::Router.new do
  route 'blog' => Blog.new do
    route ':id' => ArticleDisplay.new
  end

  route 'about' => About.new
  route 'contact' => ContactPage.new
end

But those all have to be valid Clearwater components, so we can stub them out:

class Blog
  include Clearwater::Component
end

class ArticleDisplay
  include Clearwater::Component
end

class About
  include Clearwater::Component
end

class ContactPage
  include Clearwater::Component
end

The Link component

This is one of my favorite things. With this PR, we no longer trap all link clicks and use some arcane incantation to determine whether or not to handle it in the framework or let it pass to the browser. Instead, we provide a Link component, which includes a built-in onclick handler that handles the pushState and rerendering. This seems trivial mechanically, but it means that the developer has a lot more control over what links get handled by the app and what we let the browser handle natively.

I came across this as a problem in the original link-trapping implementation because the app was only for the admin UI, but links to the homepage, which was not a Clearwater app, were still getting trapped. Using Link allows me to specify on a link-by-link basis which ones we handle. Inversion of Control FTW.

The Link API is exactly as you would use for an a tag, but you just use Link.new instead:

class ArticleIndexItem
  include Clearwater::Component

  def render
    li(nil,
      Link.new({href: "/blog/#{article.id}"}, article.title)
    )
  end
end
Member

jgaskins commented Apr 26, 2015

I'm not so sure we need the last few checkbox items right now, to be honest. Rerendering the entire app every time hasn't been a bottleneck in anything I've built yet, so I'd like to go ahead and talk about merging this in.

Controllers/views and the old HTML-based components are gone

The concepts of components and controller/view don't seem to mesh really well, though I admit I haven't spent much time trying to make that happen. I'm happy to be wrong, but I can also say with reasonable confidence that HTML and the virtual DOM definitely do not mix. They're two different representations and you can't really combine them well.

Components are now the routing targets:

require 'clearwater/router'

router = Clearwater::Router.new do
  route 'blog' => BlogComponent.new
end

Component#outlet

The components have an outlet attribute that the router sets on these components when it adjusts the routing-target list based on the URL. This is exactly how controllers were linked up before; the components just absorbed that functionality.

Permanent vs ephemeral state

The main difference is that, while routed components are persisted (they're instantiated with the router), subcomponents instantiated by the routed components are not, so you can store state on routed components but you'll either need to pass that state down to instantiated subcomponents or store those components, as well:

require 'clearwater/component'

class BlogComponent
  include Clearwater::Component

  def initialize
    @title = 'My Blog'
  end

  def render
    div({id: 'blog'}, [
      blog_header,
      ArticleIndex.new,
    ])
  end

  def blog_header
    @blog_header ||= BlogHeader.new(@title)
  end
end

Every instance variable stored on this component will stick around for the life of the app. Notice how we store the blog's header component inside the @blog_header ivar. This makes sure that whatever state we give it will be there next time we render. However, since the ArticleIndex component referenced in the render method is reinstantiated on every render and no state is passed to it, its state is essentially thrown away, so any state it modifies (such as which articles were read) will be lost next time the app renders. If we want to keep that around, we need to store the component into one of our persistent (routed) components.

Event handling

In the old views and HTML-based components, we used event.on :click do … end somewhere inside the initializer. However, now it's done more like React:

class ArticleIndex
  include Clearwater::Component

  attributes :active

  def render
    ul({id: 'article-index'}, articles.map { |article| ArticleIndexItem.new(article, index: self) })
  end
end

class ArticleIndexItem
  include Clearwater::Component

  def initialize(article, index:)
    @article = article
    @index = index
  end

  def render
    li({ onclick: method(:set_active) }, [
      h4(nil, article.title),
    ])
  end

  def set_active
    @index.active = article.id
  end
end

One primary difference is that React fires an onChange every time you modify the value of an input or textarea node, whereas Clearwater relies on what the browser triggers — browsers won't trigger onchange until that form control loses focus. This may change in the future because I think React's onChange functionality is more aligned with what developers actually want.

Event handlers

The value you pass as the event handler (button({onclick: my_handler})) is usually a proc, lambda, or a bound method. In the example of the ArticleIndexItem above, I used a bound method.

However, we aren't limited to the usual proc/lambda-type things. Event handlers can be any object that responds to call:

class NewUserRegistration
  attr_reader :form

  def initialize(form)
    @form = form
  end

  def call
    # Data stores aren't a thing yet, but clap your hands if you believe.
    UserStore.create!(email: form.email, password: form.password)
  end
end

class RegistrationForm
  include Clearwater::Component

  attr_reader :email, :password

  def render
    form(({onsubmit: NewUserRegistration.new(self)}, [
      input(type: 'email', onkeyup: method(:set_email), placeholder: 'me@example.com'),
      input(type: 'password', onkeyup: method(:set_password), placeholder: 'password'),
      input(type: 'submit', value: 'Register'),
    ])
  end

  def set_email(event)
    @email = event.target.value
  end

  def set_password(event)
    @password = event.target.value
  end
end

I'm using bound methods here to set ivars on the form whenever the form inputs are updated, but the submit handler for the form is a service object that responds to call.

As you might have noticed from the bound-method handlers, the call message is supplied an argument that is the browser event (wrapped inside a Ruby binding provided by the opal-browser gem). You can omit the parameter in your method if you don't use it.

Attributes

Building on the model/view bindings that this PR deletes, components have the concept of "attributes". You define them in the component class body:

class ArticleIndex
  include Clearwater::Component

  attributes :active
end

Whenever you change this with article_index.active = foo, the app will be rerendered.

Render throttling

Because rerendering constantly can absolutely wreck performance and you may need to modify multiple attributes sequentially, changing several attributes rapid-fire will actually throttle rendering to at most once per 1/60th of a second. Changing the first attribute will eagerly fire a rerender, but subsequent attribute modifications will schedule at most one more to happen in 1/60th of a second or so (or whenever the current queue of callbacks is finished). This means that even if you change 10 attributes in a single callback, you will rerender at most twice.

Default rendering

If you don't specify a render method, the component will use the one provided by the Clearwater::Component module, which is completely empty. This helps you get your structure going without having to provide content. That was one of the frustrating things about the HTML view/template thing, was that you had to provide a template, so it slowed down my prototype development.

The time this helps the most is when you're getting your routes setup. You have to provide routing targets.

router = Clearwater::Router.new do
  route 'blog' => Blog.new do
    route ':id' => ArticleDisplay.new
  end

  route 'about' => About.new
  route 'contact' => ContactPage.new
end

But those all have to be valid Clearwater components, so we can stub them out:

class Blog
  include Clearwater::Component
end

class ArticleDisplay
  include Clearwater::Component
end

class About
  include Clearwater::Component
end

class ContactPage
  include Clearwater::Component
end

The Link component

This is one of my favorite things. With this PR, we no longer trap all link clicks and use some arcane incantation to determine whether or not to handle it in the framework or let it pass to the browser. Instead, we provide a Link component, which includes a built-in onclick handler that handles the pushState and rerendering. This seems trivial mechanically, but it means that the developer has a lot more control over what links get handled by the app and what we let the browser handle natively.

I came across this as a problem in the original link-trapping implementation because the app was only for the admin UI, but links to the homepage, which was not a Clearwater app, were still getting trapped. Using Link allows me to specify on a link-by-link basis which ones we handle. Inversion of Control FTW.

The Link API is exactly as you would use for an a tag, but you just use Link.new instead:

class ArticleIndexItem
  include Clearwater::Component

  def render
    li(nil,
      Link.new({href: "/blog/#{article.id}"}, article.title)
    )
  end
end
@krainboltgreene

This comment has been minimized.

Show comment
Hide comment
@krainboltgreene

krainboltgreene Apr 26, 2015

Member

If routers target instantiated components, how do components get request payloads?

To clarify: Wouldn't it be more flexible to allow passing request payloads:

Clearwater::Router.new do
  route 'blog' => Blog
end

class Blog
  def initialize(payload)
    @id = payload["id"]
  end
end
Member

krainboltgreene commented Apr 26, 2015

If routers target instantiated components, how do components get request payloads?

To clarify: Wouldn't it be more flexible to allow passing request payloads:

Clearwater::Router.new do
  route 'blog' => Blog
end

class Blog
  def initialize(payload)
    @id = payload["id"]
  end
end
@jgaskins

This comment has been minimized.

Show comment
Hide comment
@jgaskins

jgaskins Apr 26, 2015

Member

Oh, a few more things I wanted to talk about:

React-style … er, style

You can specify the styles for your components programmatically, a la React:

class Foo
  include Clearwater::Component

  def render
    div({ style: random_background_color }, 'Hello, World!')
  end

  # Transition the background color each time the app gets rerendered.
  def random_background_color
    {
      background_color: "##{rand(16 ** 6).to_s(16)}",
      transition: 'all 500ms',
    }
  end
end

I'd kinda like to build some sort of Stylesheet class to wrap this, but before I do I want to work with it a bit more to tease out some patterns of use.

Notice the style attributes like background_color are snake-cased. You can specify camel-cased styles (backgroundColor) because that's what they get turned into (because that's what the API is for actual DOM nodes), but idiomatic Ruby uses underscores rather than caps.

Components as gems

With programmatic styles, you can gemify your components more easily because users of your components won't need to include your stylesheets in their apps. I've built a couple I'd like to use as examples.

Member

jgaskins commented Apr 26, 2015

Oh, a few more things I wanted to talk about:

React-style … er, style

You can specify the styles for your components programmatically, a la React:

class Foo
  include Clearwater::Component

  def render
    div({ style: random_background_color }, 'Hello, World!')
  end

  # Transition the background color each time the app gets rerendered.
  def random_background_color
    {
      background_color: "##{rand(16 ** 6).to_s(16)}",
      transition: 'all 500ms',
    }
  end
end

I'd kinda like to build some sort of Stylesheet class to wrap this, but before I do I want to work with it a bit more to tease out some patterns of use.

Notice the style attributes like background_color are snake-cased. You can specify camel-cased styles (backgroundColor) because that's what they get turned into (because that's what the API is for actual DOM nodes), but idiomatic Ruby uses underscores rather than caps.

Components as gems

With programmatic styles, you can gemify your components more easily because users of your components won't need to include your stylesheets in their apps. I've built a couple I'd like to use as examples.

@krainboltgreene

This comment has been minimized.

Show comment
Hide comment
@krainboltgreene

krainboltgreene Apr 26, 2015

Member

If I end up making a CSS gem like I did the HTML gem this will fit in very well.

Member

krainboltgreene commented Apr 26, 2015

If I end up making a CSS gem like I did the HTML gem this will fit in very well.

@jgaskins

This comment has been minimized.

Show comment
Hide comment
@jgaskins

jgaskins Apr 26, 2015

Member

If routers target instantiated components, how do components get request payloads?

They don't. Why do you ask?

Member

jgaskins commented Apr 26, 2015

If routers target instantiated components, how do components get request payloads?

They don't. Why do you ask?

@krainboltgreene

This comment has been minimized.

Show comment
Hide comment
@krainboltgreene

krainboltgreene Apr 26, 2015

Member

They don't. Why do you ask?

Shouldn't they? Since we don't have the concept of a Control anymore, how do we pass state between routes?

Member

krainboltgreene commented Apr 26, 2015

They don't. Why do you ask?

Shouldn't they? Since we don't have the concept of a Control anymore, how do we pass state between routes?

@jgaskins

This comment has been minimized.

Show comment
Hide comment
@jgaskins

jgaskins Apr 26, 2015

Member

Oh, do you mean how controllers had URL-based params? The components get that now, too. The router tells routed components that it is that component's router, so the component just calls router.params_for_path. It's exactly how it worked for controllers.

I thought you meant like HTTP POST payloads.

Member

jgaskins commented Apr 26, 2015

Oh, do you mean how controllers had URL-based params? The components get that now, too. The router tells routed components that it is that component's router, so the component just calls router.params_for_path. It's exactly how it worked for controllers.

I thought you meant like HTTP POST payloads.

@krainboltgreene

This comment has been minimized.

Show comment
Hide comment
@krainboltgreene

krainboltgreene Apr 26, 2015

Member

To be fair I do mean like HTTP POST payloads. I've been playing around with the idea that Earhart merges all data structures in an HTTP request: URL path pattern, Query, Headers, and Body. Merged right of course.

So basically the component is acting as presenter and the router is acting as router and control?

Member

krainboltgreene commented Apr 26, 2015

To be fair I do mean like HTTP POST payloads. I've been playing around with the idea that Earhart merges all data structures in an HTTP request: URL path pattern, Query, Headers, and Body. Merged right of course.

So basically the component is acting as presenter and the router is acting as router and control?

@jgaskins

This comment has been minimized.

Show comment
Hide comment
@jgaskins

jgaskins Apr 26, 2015

Member

Well, HTTP POST should probably only be happening as XHR. If a server is serving up the Clearwater app as the response for a POST request, I'd posit that that app is doing the wrong thing since the POST would be re-sent if the user refreshes the browser.

The idea is that the URL determines the app state, similar to how Ember works. Barring any permissions discrepancies, if I give you a link to any point inside a Clearwater app, it should show you what it's showing me.

Any other data from the request would need to be passed from the server to Clearwater, probably via gon or a similar construct. I don't think we'd have access to the request data from JavaScript otherwise.

Member

jgaskins commented Apr 26, 2015

Well, HTTP POST should probably only be happening as XHR. If a server is serving up the Clearwater app as the response for a POST request, I'd posit that that app is doing the wrong thing since the POST would be re-sent if the user refreshes the browser.

The idea is that the URL determines the app state, similar to how Ember works. Barring any permissions discrepancies, if I give you a link to any point inside a Clearwater app, it should show you what it's showing me.

Any other data from the request would need to be passed from the server to Clearwater, probably via gon or a similar construct. I don't think we'd have access to the request data from JavaScript otherwise.

@krainboltgreene

This comment has been minimized.

Show comment
Hide comment
@krainboltgreene

krainboltgreene Apr 26, 2015

Member

This probably bears further discussion, but frankly I'm just excited to use
the new vdom implementation.

On Sun, Apr 26, 2015 at 5:12 PM Jamie Gaskins notifications@github.com
wrote:

Well, HTTP POST should probably only be happening as XHR. If a server is
serving up the Clearwater app as the response for a POST request, I'd posit
that that app is doing the wrong thing since the POST would be re-sent if
the user refreshes the browser.

The idea is that the URL determines the app state, similar to how Ember
works. Barring any permissions discrepancies, if I give you a link to any
point inside a Clearwater app, it should show you what it's showing me.

Any other data from the request would need to be passed from the server to
Clearwater, probably via gon or a similar construct. I don't think we'd
have access to the request data from JavaScript otherwise.


Reply to this email directly or view it on GitHub
#10 (comment)
.

Member

krainboltgreene commented Apr 26, 2015

This probably bears further discussion, but frankly I'm just excited to use
the new vdom implementation.

On Sun, Apr 26, 2015 at 5:12 PM Jamie Gaskins notifications@github.com
wrote:

Well, HTTP POST should probably only be happening as XHR. If a server is
serving up the Clearwater app as the response for a POST request, I'd posit
that that app is doing the wrong thing since the POST would be re-sent if
the user refreshes the browser.

The idea is that the URL determines the app state, similar to how Ember
works. Barring any permissions discrepancies, if I give you a link to any
point inside a Clearwater app, it should show you what it's showing me.

Any other data from the request would need to be passed from the server to
Clearwater, probably via gon or a similar construct. I don't think we'd
have access to the request data from JavaScript otherwise.


Reply to this email directly or view it on GitHub
#10 (comment)
.

@jgaskins jgaskins referenced this pull request Apr 26, 2015

Merged

Sprucing up the README #13

@jgaskins

This comment has been minimized.

Show comment
Hide comment
@jgaskins

jgaskins Apr 29, 2015

Member

I created an example app by porting a React tutorial's app over to Clearwater's virtual DOM:

Demo: http://clearwater-mailbox.herokuapp.com
Code: https://github.com/jgaskins/clearwater_mailbox
Original React tutorial: http://blog.tryolabs.com/2015/04/07/react-examples-mailbox/

Since I've been able to at the very least duplicate the capability of what's on master in this branch, I'll work out the merge conflicts this evening and merge the PR.

Member

jgaskins commented Apr 29, 2015

I created an example app by porting a React tutorial's app over to Clearwater's virtual DOM:

Demo: http://clearwater-mailbox.herokuapp.com
Code: https://github.com/jgaskins/clearwater_mailbox
Original React tutorial: http://blog.tryolabs.com/2015/04/07/react-examples-mailbox/

Since I've been able to at the very least duplicate the capability of what's on master in this branch, I'll work out the merge conflicts this evening and merge the PR.

jgaskins added a commit that referenced this pull request May 5, 2015

@jgaskins jgaskins merged commit 0b2d4d1 into master May 5, 2015

@jgaskins jgaskins deleted the virtual-dom branch May 9, 2015

@jgaskins jgaskins referenced this pull request May 14, 2015

Closed

State changes #17

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment