TIL: Turbo Stream and personalised content

Hotwire and Turbo are great for very quickly and easily adding real-time updating of webpages without requiring the browser to reload the whole page.

But if the information you want to stream back from your server to the client has anything specific to the current user — like using the name "You" instead of "James" — you might hit an issue. So far I haven't found this written up on the web anywhere, so hopefully this will help someone else with the same problem.

Let's take this example straight from the documentation:

<!-- app/views/messages/_message.html.erb -->
<div id="<%= dom_id message %>">
<%= message.content %>
</div>

<!-- app/views/messages/index.html.erb -->
<h1>All the messages</h1>
<%= render partial: "messages/message", collection: @messages %>
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
def index
@messages = Message.all
end

def create
message = Message.create!(params.require(:message).permit(:content))

respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.append(:messages, partial: "messages/message",
locals: { message: message })
end

format.html { redirect_to messages_url }
end
end
end

What this demonstrates is that when, from the index page, we submit a request to create a new message using turbo streams, rather than redirecting back to the index page, instead it will append the HTML for the message, rendered using the _message.html.erb partial, without a full page reload. Pretty neat.

Let's expand this example in an uncontroversial way. As well as including the message content attribute, I also want to include the sender's name, which we can get because we've also associated our Message model with a User. Here's the new partial:

<!-- app/views/messages/_message.html.erb -->
<div id="<%= dom_id message %>">
<span class="from"><%= message.user.name %></span>:
<%= message.content %>
</div>

This works just fine too.

But now imagine we want to render the string "You" if the message was sent by you, the current user (we can assume we have a method called current_user to check if this is true). Now our partial becomes:

<!-- app/views/messages/_message.html.erb -->
<div id="<%= dom_id message %>">
<span class="from">
<% if message.user == current_user %>
You
<% else %>
<%= message.user.name %>
</span>:
<%= message.content %>
</div>

(Certainly I'd extract this logic into a helper, but let's not bother with that kind of housekeeping for the moment.)

Now, when you load up the index page of messages, you see what you might expect — a list of people and contents, some of them rendered as "You".

But then you post a new message, and while the message itself appears, appended to the bottom, it's not from "You". It's from "James". What happened to the "You" logic? You reload the page, and it's "You" again. Maybe it was some kind of non-deterministic bug? So you post another message, and again, it's from "James", not "You".

And this continues. Loading the whole page renders everything as you want, but the turbo-stream messages never seems to get it right.

Turbo stream can never know the current user, and that's a good thing

Firstly, this is why:

Partials used for turbo streaming have to be free of global references, as they’re rendered by the ApplicationRenderer, not within the context of a specific request.

What this means is when the method call to turbo_stream.append actually goes about rendering a partial, it does so without any references to your specific request, and so it doesn't know what the "current user" should be. It's as if nobody is logged in.

And this is actually a good thing, because let's imagine for a second that my friend Patrick is also on the index page when I'm posting my message. My request hits the controller, and it renders the partial for the message, and broadcasts that to all connected clients. If that partial had been rendered with me as the current user, then the "from" part of the partial would become the string "You", and not only would I see "You" as the sender, but Patrick would also see "You" (implying him!) as the sender too!

This is because it's the same rendered string that gets sent to all of the clients connected to each turbo stream. Each client doesn't get their own renderer, knowing who they are.

OK, but I want it to say "you" so what can I do about it?

I've had to solve this in a couple of applications now, where I wanted to highlight when a message or other piece of data was strongly related to the user viewing it.

There's no practical way to solve this server-side, at least not without doubling the number of turbo stream connections and rendering through one back to the current user, and another to all other clients (and then, on the client side, building the logic so that turbo knows to ignore messages in the "group" stream where one from the personal stream supercedes it).

Instead, we have to fix this on the client side. Here's what I've come up with. Firstly, a Stimulus controller:

// app/javascript/controllers/personalization_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
static targets = ["name"]
static values = { userId: Number }

nameTargetConnected(target) {
if (target.dataset.userId == this.userIdValue) {
target.innerHTML = "You"
}
}
}

Next, in our main application layout, we enable this controller and tell it the user id for current_user:

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html lang="en">
<!-- head element stuff -->

<body data-controller="personalization" data-personalization-user-id-value="<%= current_user.id %>">
<%= yield %>

<!-- etc... -->
</body>
</html>

Penultimately, a helper:

# app/helpers/application_helper.rb
def user_name(user)
display_name = (user == current_user) ? 'You' : user.name

content_tag(:span, display_name, data: { personalization_target: 'name', user_id: user.id })
end

And then finally, change our partial to use that helper:

<!-- app/views/messages/_message.html.erb -->
<div id="<%= dom_id message %>">
<span class="from"><%= user_name message.user %></span>:
<%= message.content %>
</div>

When we are rendering everything from the server, it all works as you'd expect; the helper renders "You" correctly and our messages are correctly displayed.

When our partial is inserted via turbo, the stimulus controller running across the whole page notices the new name target, and then invokes its nameTargetConnected() function on that element, which compares the userId data attribute on that HTML element with the user ID the page itself knows belongs to the current user. If they are the same, the contents of that element are replaced with "You", right there on the client side, but only for the client where the user ID does match.