Eric / Brooklyn

Random 5

Operator as Methods

Web Accounts

Honeypot Bots

Stripe and Tap

Git Save

Whitespace Problems

Ruby Exits

Appending in Javascript

Sendgrid Ban

Clean URLs

Integer Division

Multi-tab Websockets

Bad Content

JS Data Chutzpah

Responsive tables

Concerns

Cookies

Emoji Bits

Git

Ruby vs. Ruby

Extending Devise

Rails UJS

ENV Variables

See All

HTML Whitespace problems

Mar 2021

Lessons from HTML passed Over The Wire in Rails

Our app is a web chat - so a variety of data needs to be broadcasted directly to a user in real time.

This is handled via WebSockets - instant sending and receiving of data. Rails implementation of WS is called Action Cable.

Incoming Requests

For this chat application, users can request chats with pros.

So a pro will receive an incoming request on her dashboard.

Before request:

After request:

As mentioned above, we need to send this data directly to the pro, without the pro needing to refresh her page.

Let’s look in the Controller that handles chat sessions.

ChatSessions Controller

When a user requests a chat, we route to the create action of the ChatSessionsController

# ChatSessionsController

def create
	...
	# append new student to pro buddy list
	SessionChannel.broadcast_to(
	    @pro,
	    action: 'create',
	    new_student: new_student(@chat_session),
	    ... more stuff ...
	  )
	...
end

private

def new_student(session)
	ApplicationController.renderer.render(partial: 'shared/list_user_pro_dash', locals: { session: session, session_status: 'active' })
end

The SessionChannel is the Action Cable channel where we handle chat sessions throughout their lifecycle (request, accept, cancel, end).

In this case, we are broadcasting an HTML partial (list_user_pro_dash.html.erb) directly to the @pro’s dashboard.

This partial generates the HTML we see in the image above — a link with the username of the incoming request.

Receiving the Partial

The Javascript component of the SessionChannel lives in session_channel.js, and we receive the data that was broadcasted from the controller.

#session_channel.js

import consumer from "./consumer"

consumer.subscriptions.create("SessionChannel", {
  connected() {},
  disconnected() {},
  received(data) {
	  if (data.action == 'create'){
	  // append data.new_student to correct location in DOM
	 }
})

Appending data.new_student

data.new_student is a string that contains the HTML corresponding to this requested chat. Just a minute ago, this was rendered via ApplicationController.renderer in our ChatSessionsController.

To append it to the pro’s dashboard, we could do something like this:

const new_student = data.new_student

// active sessions live in #active element
const active = document.getElementById('active')
active.append(new_student)

However, this won’t work because new_student is a string (and not a Node element), so append will treat it like a string.

In other words, we will see HTML, instead of experiencing HTML.

In other words, actually, in images, our view will look something like:

instead of what we want:

Strings or Nodes

So at this point, we either need to turn our HTML string into a Node object (because append can also append nodes) or somehow ask Javascript to interpret our string as HTML.

The latter option probably would have been the easiest. All we would have to do is something like this:

const new_student = data.new_student

// active sessions live in #active element
const active = document.getElementById('active')
active.insertAdjacentHTML('beforeend', new_student)

insertAdjacentHTML will parse a string as HTML.

However, I decided to stick with append to see if we could get it to work.

To do this, we do a little hack: we create an element, then fill it with our HTML string. At this point, our string magically becomes a node object. And we can extract it from our temporary element (temp_obj) via firstChild.

// create temp Node object
const temp_obj = document.createElement('div')
temp_obj.innerHTML = data.new_student
const new_student = temp_obj.firstChild

// append to list
const active = document.getElementById('active')
active.append(new_student)

At this point we have a working solution. We turn a string of HTML from our Controller into a node, and then append it to the correct element.

The Application Breaks

Initially, the partial we append (new_student) looked something like this:

<li>Stuff in here</li>

Then, we make a seemingly innocuous change to the partial.

<% if something? %>
	<li>Stuff in here</li>
<% else %>
	<li>Different stuff in here</li>
<% end %>

Now the application is broken.

Huh?

It seemed bizarre that the addition of a conditional in a partial on the backend would break the broadcasting elsewhere in the site.

It turns out that the broadcast wasn’t broken, but our Javascript was unable to deal with the new HTML string from our partial.

What’s new in this partial?

It would seem that the partial should render a virtually identical result — a simple <li> element containing some info.

However, there is one difference. As is standard in code syntax, we indent the nested line inside of a conditional.

And because of this, a whitespace is created before our <li> element.

FirstChild

Now back to our Javascript. Our data.new_student now contains a single whitespace, and then an <li> element.

// temporary object
const temp_obj = document.createElement('div')
temp_obj.innerHTML = data.new_student
const new_student = temp_obj.firstChild

const active = document.getElementById('active')
active.append(new_student)

When we temporarily created the element and then grab the firstChild, we are actually grabbing the whitespace instead of the <li>.

So, we are appending something, but we are appending whitespace.

Fixes

At this point we have a couple of fixes.

The easiest would be to use firstElementChild instead of firstChild, as that will grab the <li> and ignore text(whitespace).

Alternatively, we can be more specific than firstElementChild and instead look directly for an <li> element.

// temporary element
const wrapper = document.createElement('div')
wrapper.innerHTML = data.new_student
const new_student = wrapper.querySelector("li")

const active = document.getElementById('active')
active.appendChild(new_student)

The End

Be careful when using firstChild as whitespace could potentially find its way to the front of the child you were initially targeting.