Lazy Loading Lots of Comments HOTWire Tutorial #5

As we continue in building out the HOTWire Hacker News Progressive Web App, it’s time to look at loading the comments. This is an interesting data problem, because each item has comments, and each comment has comments, and you can go recursively until you run out. The original HNWPA pushed all the comment loading into a background worker, and then used ActionCable to send the results back to the front end. This was okay, but there was a race condition where the background job might finish loading all the comments before the WebSocket connection was running. The fix was to send the load comments command over a web socket, and then wait for the response. If there were only a few comments, it felt pretty quick. But when it was a popular item, with a large discussion, it could take a while.

I think the comment threads are a good candidate to experiment with recursive lazy loading. First, when you visit the comment page for an item, it displays the item’s information, and then it loads the comments lazily. This means going to the Hacker News API to load each individual comment. Once the comment is displayed, each of its sub comments are lazily loaded. The repeats until there are no more descendant comments.

This tutorial introduces a Comment model. It’s not very different from the Item model in terms of the data it stores, but it’s used differently, so I decided to break it into a separate table. This is different from the original HNPWA app, which used items throughout, and made caching partials interesting.

Creating the Comment model

Here is the comment model generation:

rails g model Comment by:string hn_id:bigint:index parent_id:bigint:index text:text dead:boolean time:datetime location:integer

It uses the parent’s hn_id and it’s location to determine where it falls in order on the page. It has a populate helper method that pulls out the relevant data from the JSON response. It has_one parent through the parent_id, and it has_many kids through those sub comment’s parent_id.

Here is the model, app/models/comment.rb:

class Comment < ApplicationRecord
  has_one :hn_parent, class_name: 'Comment', primary_key: 'parent_id', foreign_key: 'hn_id'
  has_many :kids, class_name: "Comment", primary_key: 'hn_id', foreign_key: 'parent_id'

  def populate(json) 
    if json.nil?
      return
    end
    self.hn_id = json['id'] if json['id']
    self.by = json['by'] if json['by']
    self.parent_id = json['parent'] if json['parent']
    self.text = json['text'] if json['text']
    self.dead = json['dead'] if json['dead']
    self.time = DateTime.strptime("#{json['time']}",'%s') if json['time']
  end
end

The Item class now gets a has_many relationship based on the it’s children’s parent_id. Here is the first three lines of app/models/item.rb:

class Item < ApplicationRecord
  has_one :top_item
  has_many :kids, class_name: "Comment", primary_key: 'hn_id', foreign_key: 'parent_id'

Routes

The routes.rb file will be updated to add Comment as a sub resource of Item:

  resources :items do
    resources :comments
  end

Comments Controller

The item/show.html.erb will lazy load the comments of an item at comments#index. Then, any child comments will be directly loaded at comments#show. Each of these methods is similar, loading the requested resource, either an Item or a Comment, and then loading any descendant comments. There is a helper method called load_kids which attempts to load in any children comments, should they exist. Since there are many HTTP requests to the same server, this controller uses the HTTP.persistent option to keep up a connection to the server while the request is progressing.

class CommentsController < ApplicationController 

  def index
    @item = Item.find_by_hn_id params[:item_id]

    http = HTTP.persistent "https://hacker-news.firebaseio.com"
    item_json = JSON.parse http.get("/v0/item/#{@item.hn_id}.json").to_s

    if item_json.nil?
      return
    end
    @item.populate(item_json)
    @item.save
    load_kids(http, @item.hn_id, item_json)
  end

  def show 
    http = HTTP.persistent "https://hacker-news.firebaseio.com"
    @item = Item.find_by_hn_id params[:item_id]
    @comment = Comment.find_by_hn_id params[:id]
    item_json = JSON.parse http.get("/v0/item/#{@comment.hn_id}.json").to_s
    if item_json.nil?
      return
    end
    @comment.populate(item_json)
    @comment.save
    load_kids(http, @comment.hn_id, item_json)
  end

  private 

  def load_kids(http, parent_id, item_json)
    if item_json and item_json.has_key? 'kids'
      item_json['kids'].each_with_index do |kid_hn_id, kid_location|

        kid_json = JSON.parse http.get("/v0/item/#{kid_hn_id}.json").to_s
        if kid_json.nil?
          next
        end

        kid = Comment.where(hn_id: kid_hn_id).first_or_create
        kid.location = kid_location
        kid.parent_id = parent_id
        kid.populate(kid_json)
        kid.save
      end
    end
  end
end

Views

The app/views/items/show.html is going to use turbo-frame to replace the comments:

<header class="bg-red-600 py-1 px-3 mb-11 text-white flex">
  <p class="text-2xl flex-grow">
    <%= link_to "Top Stories", top_path %></p>
</header>
<%= render @item %>
<%= turbo_frame_tag "comments", src: item_comments_path(@item.hn_id) %>

The comments page will use a turbo-frame to tell turbo where the comments are on the HTML page. However, if this page is loaded outside of the items#index frame, it will show the Item object. Here is app/view/comments/index.html.erb:

<header class="bg-red-600 py-1 px-3 text-white flex">
  <p class="text-2xl flex-grow">
    <%= link_to "Top Stories", top_path %></p>
</header>
<%= render @item %>
<%= turbo_frame_tag "comments" do %> 
  <%= render partial: 'comments', locals: { item: @item, comments: @item.kids.order(:location) } %>
<% end %>

The difference between these two pages comes down to the src directive on items/show.html.erb and actually rendering the comments in comments/index.html.erb.

The individual comment page is simpler. It renders the comment, inside a turbo-frame that has the id of the comment. Here is app/views/comments/show.html.erb:

<%= turbo_frame_tag dom_id(@comment) do %>
  <%= render @comment %>
<% end %>

The app/views/comments/_comments.html.erb partial loops through the list of comments, and sets up a turbo-frame with a src tag that will call out to the comment.

<div class="flex flex-col">
  <% comments.each do |comment| %>
    <%= turbo_frame_tag dom_id(comment), src: item_comment_path(item.hn_id, comment.hn_id) %>
  <% end %>
</div>

Finally, the app/views/comments/_comment.html.erb will display the text, the author username, the published date, and any children in a sub-rendering of the _comments.html.erb partial.

<% unless comment.dead %>
  <div class="w-full">
    <div class="border-l border-b pl-4 mb-4">
      <%= sanitize comment.text %>
      <footer class="border-t flex justify-between">
        <p class="py-1 px-3">
          <a href="/user/<%= comment.by %>"><strong><%= comment.by %></strong></a>
        </p>
        <p class="py-1 px-3">
          <%= comment.time.strftime('%c') %>
        </p>
      </footer>
    </div>
    <div class="w-11/12 float-right">
      <%= render partial: 'comments', locals: { item: @item, comments: comment.kids.order(:location) } %>
    </div>
  </div>
<% end %>

HHNPWA vs. HNPWA

This is a bigger improvement over the previous comment loading process in the HNPWA. The previous implementation used a fragile WebSocket connection, and sometimes the comments wouldn’t load. The newer version uses fetch() when the page is loaded, so there won’t be a delay such as when the WebSocket connection was setup. Another advantage is that the comments are able to load piecemeal. The WebSocket method was only able to load the all the comments at once. It had a progress indicator, but I think loading the comments sooner rather than later feels faster and more interactive.

Next Steps

Caching optimizations are the obvious next steps. Stay tuned for tutorial #6!

Subscribe for updates as this project takes shape. You can see all the code at Github: https://github.com/OnRailsBlog/hhnpwa.

Previous Tutorials

Leave a Reply