Disassembling Rails — How does ActionText deal with file upload?

(This post is based on Rails 6.0.0.rc2) TBH, I wasn’t that interested in the ActionText component, because it's very unlikely that I'll use it either at work or on my side projects. But recently I took some time to study it in order to solve https://github.com/rails/rails/issues/36177. It's kind of a rabbit hole and there aren't many materials about ActionText online at this point. So I decided to write down what I found and I hope this can help those who just started to try out ActionText. Also, because in this post I'll talk about uploading files, this means that some of it will also cover ActiveStorage component (mostly about direct upload).

I will separate the whole workflow in following steps

  1. Upload

(I also found a gotcha during my research, let me show you at the end of this post 😉)

Upload

This part doesn’t have much to do with ActionText. But I think it’s also very fun (really?) to know how the images get into the text area 😄

  1. Rails’ JS library will first send a post request to `/rails/active_storage/direct_uploads` (this endpoint is configurable) with file metadata like
{
"blob"=>{
"filename"=>"goby-logo.png",
"content_type"=>"image/png", "byte_size"=>5109,
"checksum"=>"H4+4U/vWyxzVUOmMLG9Ghg=="
},
"direct_upload"=>{
"blob"=>{
"filename"=>"goby-logo.png",
"content_type"=>"image/png",
"byte_size"=>5109,
"checksum"=>"H4+4U/vWyxzVUOmMLG9Ghg=="
}
}
}

2. Rails will then create an ActiveStorage::Blob record and return a direct upload url and signed id of that blob.

3. Once received the direct upload url, the JS library will handle the file upload.

4. Meanwhile, the image will be inserted in the Trix editor's text area and wrapped in a figure element. The metadata of uploaded file will be put inside the figure's data-trix-attachment attribute:

<figure 
data-trix-attachment={
“contentType”:”image/png”,
”filename”:”goby-logo.png”,
”filesize”:5109,
”height”:142,
”sgid”:”BAh7CEkiCGdpZAY6BkVUSSI8Z2lkOi8vYWN0aW9udGV4dG5wbHVzMS9BY3RpdmVTdG9yYWdlOjpCbG9iLzM_ZXhwaXJlc19pbgY7AFRJIgxwdXJwb3NlBjsAVEkiD2F0dGFjaGFibGUGOwBUSSIPZXhwaXJlc19hdAY7AFQw—4e7f74086b3917849b01b3136fd6465220a11804”,
”url”:”http://localhost:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBDQT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--9ec5e5aa73562eb16a1b06ad42c83e1a9d94c3dc/goby-logo.png”,
”width”:142
}>

Till this point, the user can see the uploaded image on the page, the file is uploaded to the storage and we have the image’s blob record in database 🎉.

However, Rails still doesn’t know where to attach the blob record until we save the text content and in next step.

Parse, Transform and Attach

After receiving the text content, we can’t just save it into the database because there still are works haven’t been done, like:

  • The image’s blob record is an orphan right now, we need to find a way to attach it to something (an ActionText::RichText record).

And in order to do the above things, we need to parse the content first!

Parse

When an ActionText::RichText object is initialized, the text content will be used to initialize an ActionText::Content object (let's just call it content object in the later sections).

# rails/actiontext/app/models/action_text/rich_text.rb 
module ActionText
class RichText < ActiveRecord::Base
serialize :body, ActionText::Content
end
end

That content object holds the content as a tree structure, so it’ll be easier to search/replace elements inside it.

Transform

  1. ActionText will go through the content tree and look for elements that matches [data-trix-attachment] selector.
<action-text-attachment 
sgid="BAh7CEkiCGdpZAY6BkVUSSI8Z2lkOi8vYWN0aW9udGV4dG5wbHVzMS9BY3RpdmVTdG9yYWdlOjpCbG9iLzQ_ZXhwaXJlc19pbgY7AFRJIgxwdXJwb3NlBjsAVEkiD2F0dGFjaGFibGUGOwBUSSIPZXhwaXJlc19hdAY7AFQw--683e065a43885a4d71de889936f184999aa4e208"
content-type="image/png"
url="http://localhost:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBDUT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--0e0e2e03765b64517af21a00ca5cc9771b2b2984/goby-logo.png"
filename="goby-logo.png"
filesize="5109"
width="142"
height="142"
presentation="gallery">
</action-text-attachment>

Attach

Now it’s time to attach image blobs on our rich text record! But how should we find all the attachments? It turns out the content object has a method call attachables, which returns all the action-text-attachment element it holds (by going through the tree and collect them).

And there actually are 3 types of attachments in ActionText:

  1. Assets uploaded via ActiveStorage

ActionText will find all the these attachables, grep ActiveStorage::Blob and attach them on the ActionText::RichText record before saving it.

# rails/actiontext/app/models/action_text/rich_text.rb 
module ActionText
class RichText < ActiveRecord::Base
before_save do
self.embeds = body.attachables.grep(ActiveStorage::Blob).uniq if body.present?
end
end
end

Signed Global IDs

I’m not sure if you’ve noticed that I missed a very important step. Some of you may still wonder: When did the attachable became a blob record?

To explain this, we need to first understand the sgid attribute displayed in the action-text-attachment node earlier.

The name sgid means Signed Global IDs. What does that mean exactly? Well, it means

  1. The value is an Global Identification across the app.
sgid = ActiveStorage::Blob.find(id).to_sgid(for: "attachable").to_s
SignedGlobalID.parse(sgid, for: "attachable").model_id #=> "1"
GlobalID::Locator.locate_signed sgid, for: "attachable" #=> #<ActiveStorage::Blob id: 1.....>

For more detail about global id or signed global ids, you should checkout the globalid gem.

So this is why ActionText can find your attachment blob without having an id or a model name.

# rails/actiontext/lib/action_text/attachable.rb 
module ActionText
module Attachable
class << self
def from_attachable_sgid(sgid, options = {})
method = sgid.is_a?(Array) ? :locate_many_signed : :locate_signed
record = GlobalID::Locator.public_send(method, sgid, options.merge(for: LOCATOR_NAME))
record || raise(ActiveRecord::RecordNotFound)
end
end
end
end

Render

After understanding how the text content is processed and stored, it’s pretty easy to guess how would ActionText render the content:

  1. Parse the content into a tree

And here comes the gotcha I mentioned at the beginning of this post: n+1 queries!!!!!

Although ActionText provides some scopes like

Message.with_rich_text_content_and_embeds

to eager load the contents plus their blobs. You will still find your application creating n+1 queries when rendering you text content, why?

It’s because it uses sgid to find the blob records!

# rails/actiontext/lib/action_text/attachable.rb 
module ActionText
module Attachable
class << self
def from_attachable_sgid(sgid, options = {})
method = sgid.is_a?(Array) ? :locate_many_signed : :locate_signed
record = GlobalID::Locator.public_send(method, sgid, options.merge(for: LOCATOR_NAME))
record || raise(ActiveRecord::RecordNotFound)
end
end
end
end

It’s essentially like

ActiveStorage::Blob.find(decoded_id)

Since this is done individually when going through each attachment element, it gets 0 benefit from the eager loading scope. In fact, in some cases, it’s even worse to use those scopes because it creates extra queries to eager load the data we couldn’t use. (This is actually the issue that https://github.com/rails/rails/issues/36177 reports)

Conclusion

After studying and playing with ActionText for hours, I think it's a well designed component and I would say it's definitely the best choice when you need a rich text editor. However, I also think there's some room for improvements, like the n+1 queries issue mentioned earlier. And finally, I'd love to hear some feedbacks from those who's already started using it!! Let's make this component more mature and easy to use 😄

Creator of Goby language(https://github.com/goby-lang/goby), also a Rails/Ruby developer, Rails contributor. Love open source, cats and boxing.