Hotwire Turbo: Drive, Frames y Streams para proyectos en Ruby on Rails

Hotwire Turbo: Drive, Frames y Streams para proyectos en Ruby on Rails

El creador de Rails, DHH (David Heinemeir Hansson), ha mostrado siempre descontento hacia JavaScript y TypeScript en particular, en los últimos años. Él recomienda que las dificultades para aprender otro lenguaje, el cual te desenfoca de seguir trabajando en tus reglas de negocio, pueden ser una desventaja a lo largo del tiempo. Por lo tanto, en su compañía Basecamp, ha creado un framework que usa JavaScript internamente a través de Ruby, así tú no tienes que hacerlo. La meta es enfocarse principalmente en tu código Backend (Ruby en este caso), tu lógica de negocio, en lugar de configuraciones complejas con JavaScript, especialmente desde una perspectiva para hacer SPA (Single Page Applications).

Brindé una presentación acerca de este tema en Mayo de 2024 en el Meetup mensual de Montreal.rb. Ya que a algunas personas no les gusta mirar videos o diapositivas, o bien prefieren una versión escrita, esta es la versión resumida. Puedes guardar el enlace y regresar si quieres repasar algunos conceptos. Sin embargo, este post está muy relacionado con la aplicación RoR demo que preparé para la charla, descárgala y pruébala. Apuesto que los conceptos serán más claros una vez que pruebes y ejecutes la aplicación.

Links de interés

¿Por qué evitar usar JavaScript? Mi opinión como desarrollador actual en NodeJS

Puedo decir que es verdad, como un desarrollador frecuente en TypeScript, verás que cada día te encuentras en el dilema de cómo organizar o agregar tipos para escribir la lógica del negocio. Diría que TypeScript no tiene una curva de aprendizaje sencilla para un desarrollador junior, especialmente comenzando desde la fase de build.

Ya que TypeScript no es soportado en el navegador ni en el runtime por defecto de Node, tienes que aprender cómo configurar tu entorno de desarrollo, enfocándote en los pasos de build y watch para soportar TypeScript. Hay muchos proyectos preconfigurados, pero aún así, no hay muchas opciones para el desarrollador principiante que quiera comenzar a probar TypeScript. Y no quiero comenzar a escribir acerca de aprender sobre tuplas, genéricos, etc.

Así que, sacrificamos velocidad de desarrollo para agregar tipado fuerte en JavaScript. No me malinterpretes, estoy de acuerdo que a largo plazo es mejor tener un tipado fuerte, especialmente para objetos y complejos en JSON. Pero como punto inicial, puede desmotivar a los principiantes de continuar con el aprendizaje sobre programación, escribiendo código para el negocio para resolver problemas del mundo exterior, y después en el futuro pueden volverse expertos en mejorar su conocimiento sobre las herramientas. Deben evitar autocrearse problemas innecesarios.

¿Por qué Turbo?

Turbo es la solución para crear experiencias SPA utilizando principalmente tu conocimiento en Ruby. Puedes escribir aplicaciones que pueden reemplazar muchas de las características que un SPA tendría, como la carga rápida de páginas y cambiar partes específicas del DOM.

Una de las ventajas de las versiones modernas de Ruby on Rails, el framework web para crear aplicaciones web en Ruby, es que viene con Turbo instalado por defecto.

Turbo Drive: Precargar las páginas

Uno de los factores clave para elegir una Single Page Application (SPA, Aplicación de una sola página) es la velocidad con la que se carga cada página. Después de que la primera solicitud se envía al servidor, este devuelve todo el JavaScript necesario para ejecutarse en el navegador. Aquí es donde Turbo Drive entra en juego. Ayuda a precargar cualquier página de un enlace que quieras visitar mientras mantiene el historial al mismo tiempo.

Por supuesto, puedes deshabilitar esta funcionalidad para enlaces específicos, lo cual es útil si hay enlaces que realmente sean pesados de cargar. De lo contrario, ya que se cargan de manera asíncrona, proporcionará a tus clientes la experiencia de una navegación rápida.

Recargar archivos de estilos o de JavaScript cuando cambien

Dado que frecuentemente harás cambios a tu código JavaScript, puedes decirle a Turbo Drive que recargue cualquier código JavaScript o Estilo CSS cuando se haga un cambio en cualquiera de ellos. Asegúrate de agregar las etiquetas HTML requeridas.

<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>

Barra de Progreso de Carga en Turbo

Además, puedes habilitar la barra de carga de nuevo, como en versiones anteriores de Rails. Solo necesitas hacer unas modificaciones a tu código.

Dado que la barra de carga aparecerá en páginas que tomen más de 500 milisegundos para cargar, podemos poner el mínimo a 0. De esta manera, siempre se mostrará.

Vamos a usar nuestro archivo principal de JS para mostrarla.

application.js
import { Turbo } from "@hotwired/turbo-rails";
// Reduce demora de la barra de carga de 500ms a 0ms
Turbo.setProgressBarDelay(0);

También puedes modificar el color de la barra de carga. Simplemente actualiza la clase .turbo-progress-bar.

.turbo-progress-bar {
height: 5px;
background-color: red;
}

Uso desde JavaScript

También puedes usar las funciones y propiedades de Turbo Drive desde JavaScript. Aquí tienes un ejemplo.

import { Turbo } from "@hotwired/turbo-rails";
// Navegando hacia adelante
Turbo.visit(location);
Turbo.visit(location, { action: "navigate" });
// Reemplazando el tope del historial
Turbo.visit(location, { action: "replace" });
// Restaurar - Restaurar (Reservado para uso interno, NO USAR)
Turbo.visit(location, { action: "restore" });

Uso desde HTML

Y aquí un par de ejemplos de código HTML. Compatible con ERB.

<!-- Navegando hacia otro link -->
<a href="/edit">Edit</a>
<!-- Reemplazando todo el historial -->
<a href="/edit" data-turbo-action="replace">Edit</a>

Morphing

Podemos especificar cómo se actualiza la página y cómo se renueva el contenido utilizando el siguiente código.

<head>
...
<meta name="turbo-refresh-method" content="morph" />
</head>

Preservación de desplazamiento vertical

También podemos preservar la posición del desplazamiento vertical durante la navegación. Un ejemplo sería volver atrás, a un artículo largo. Como usuario, me gustaría volver a la misma posición donde dejé la lectura antes de visitar el enlace en la publicación.

<head>
...
<meta name="turbo-refresh-scroll" content="preserve" />
</head>

Excluir contenido del morphing

Esta es una característica muy interesante si quieres mantener secciones excluidas del morphing. Digamos, por ejemplo, para mantener mensajes de alerta o información que persiste entre páginas.

<div data-turbo-permanent>...</div>

Métodos de ayuda de la gema de Ruby

Además, cuando usamos la gema de Turbo Drive, podemos aprovechar los helpers y funciones que la gema nos proporciona. Aquí una lista de ejemplo con algunos de los métodos más utilizados.

<% turbo_exempts_page_from_cache %>
<% turbo_exempts_page_from_cache_tag %>
<% turbo_page_requires_reload %>
<% turbo_page_requires_reload_tag %>
<% turbo_refresh_method_tag(method = :replace) %>
<% turbo_refresh_scroll_tag(scroll = :reset) %>
<% turbo_refreshes_with(method: :replace, scroll: :reset) %>

Escribir Pruebas para Turbo Drive

Para probar tu código, puedes utilizar el mismo enfoque que usarías para hacer pruebas en tu código Ruby on Rails.

Para la mayoría de las aplicaciones CRUD allá afuera, necesitas asegurarte de que tu aplicación está ejecutando las 4 acciones principales (Create - Crear, Update - Actualizar, Read - Leer, Delete - Eliminar).

Mira el siguiente código, no hay nada nuevo acerca de Turbo Drive.

jobs_controller_test.rb
class JobsControllerTest < ActionDispatch::IntegrationTest
test "deberia listar" do
get jobs_path
assert_response :success
end
test "deberia crear un trabajo" do
job_count_first = Job.count
post jobs_path, params: { job: { name: 'Nuevo Trabajo', status: :active, tag_id: tags(:tag_ruby).id } }
job_count_last = Job.count
assert_equal job_count_first + 1, job_count_last
assert_redirected_to job_path(Job.last)
end
# ... otros tests
end

Turbo Frames: carga de páginas dentro de una misma página, comportamiento de los componentes

Trabajar con Turbo Frames es similar a lidiar con componentes de un Framework Frontend.

Puedes definir los parciales en otra vista y después usar la vista padre para cargarlos.

Una vez que el cambio está hecho, va a lanzar una orden de refresco o recargarlo dentro del mismo marco.

Ejemplo de un Turbo Frame

Un Turbo Frame declarado sólo como HTML se verá como el siguiente código.

<turbo-frame id="my-refreshing-frame" refresh="morph"> ... </turbo-frame>

Propiedades de un Turbo Frame

Los Turbo Frames nos permiten modificar su comportamiento con las siguientes propiedades:

  • src: una URL con un path que controla la navegación del elemento.
  • loading: Tiene dos valores eager y lazy. loading="eager" cargará el frame inmediatamente, mientras que loading="lazy" cargará el frame cuando se vuelva visible.
  • busy: Un atributo booleano que indica si el frame está actualmente cargando. Es gestionado por Turbo.
  • disabled: Se utiliza para desactivar la navegación en el frame.
  • complete: Un atributo booleano que indica si el frame ha terminado de cargar. Gestionado por Turbo.
  • autoscroll: Un atributo booleano que indica si el frame debería hacer autoscroll al tope de la página después de cargar. Gestionado por Turbo.

Además, ya que Turbo puede ser utilizado desde JavaScript, recuerda que tienes acceso a todas estas propiedades también.

  • FrameElement.src
  • FrameElement.disabled
  • FrameElement.loading
  • FrameElement.loaded
  • FrameElement.complete
  • FrameElement.autoscroll
  • FrameElement.isActive
  • FrameElement.isPreview

Usando la gema

Si vas a utilizar la gema, también puedes hacer uso de los helpers para Turbo Frame.

<%= turbo_frame_tag "tray", src: tray_path(tray) %>
# => <turbo-frame id="tray" src="http://example.com/trays/1"></turbo-frame>
<%= turbo_frame_tag tray, src: tray_path(tray) %>
# => <turbo-frame id="tray_1" src="http://example.com/trays/1"></turbo-frame>
<%= turbo_frame_tag "tray", src: tray_path(tray), target: "_top" %>
# => <turbo-frame id="tray" target="_top" src="http://example.com/trays/1"></turbo-frame>
<%= turbo_frame_tag "tray", target: "other_tray" %>
# => <turbo-frame id="tray" target="other_tray"></turbo-frame>
<%= turbo_frame_tag "tray", src: tray_path(tray), loading: "lazy" %>
# => <turbo-frame id="tray" src="http://example.com/trays/1" loading="lazy"></turbo-frame>
<%= turbo_frame_tag "tray" do %>
<div>My tray frame!</div>
<% end %>
# => <turbo-frame id="tray"><div>My tray frame!</div></turbo-frame>
<%= turbo_frame_tag [user_id, "tray"], src: tray_path(tray) %>
# => <turbo-frame id="1_tray" src="http://example.com/trays/1"></turbo-frame>

Dos Turbo Frames dentro de una misma página

Podemos utilizar múltiples Turbo Frames dentro de una misma página. Voy a utilizar una versión hecha con etiquetas HTML para generar dos frames en la misma vista. Los objetivos a resolver son:

  • Mostrar un formulario en <turbo-frame/> que almacena etiquetas, el cual será tomado desde un parcial.
  • Mostrar el listado de etiquetas a un lado, el cual será refrescado cuando se envíe el formulario.
  • Evitar refrescar la página.
app/views/tag_frame/index.html.erb
<turbo-frame id="<%= TagFrameController::TAG_FRAME_FORM_ID %>" src="<%= new_tag_frame_path(@tag) %>">
</turbo-frame>
<turbo-frame id="<%= TagFrameController::TAG_FRAME_ID %>">
<!-- código para mostrar la tabla -->
</turbo-frame>

El primer <turbo-frame/> muestra una propiedad src, que es la vista/parcial de la que estamos tomando el contenido. En este caso, es la ruta que renderiza la vista new. Aquí está el contenido.

app/views/tag_frame/new.html.erb
<turbo-frame id="<%= TagFrameController::TAG_FRAME_FORM_ID %>">
<%= form_with model: @tag, url: tag_frame_index_path, data: { turbo_frame: TagFrameController::TAG_FRAME_ID } do |form| %>
<div class="mb-2">
<%= form.label :name %>
<%= form.text_field :name %>
</div>
<%= form.submit "Crear etiqueta", class: "block mt-7 py-3 bg-black rounded text-white text-center w-full" %>
<% end %>
</turbo-frame>

Como puedes ver, tenemos una propiedad data: { turbo_frame: TagFrameController::TAG_FRAME_ID } en el formulario. Esto significa que, una vez procesado el formulario, se renderizará el contenido en ese frame objetivo. Así, nuestro Backend se verá como sigue:

class TagFrameController < ApplicationController
TAG_FRAME_ID = "tags-frame"
TAG_FRAME_FORM_ID = "tags-frame-form"
def index
@tags = Tag.all
@tag = Tag.new
end
def create
@tag = Tag.new(tag_params)
if @tag.save
redirect_to tag_frame_index_path
else
redirect_to new_tag_frame_path
end
end
def new
@tag = Tag.new
end
private
def tag_params
params.require(:tag).permit(:name)
end
end

Algunas cosas interesantes sobre Turbo Frames

No cubrí esto en mi presentación, pero puedes realizar acciones adicionales con Turbo Frames, incluyendo características interesantes como:

  • Carga diferida (lazy loading) de frames
  • Almacenamiento en caché
  • Protección contra falsificación de solicitudes en sitios cruzados (Cross-Site Request Forgery, CSRF)
  • Navegación desde un frame

Realización de pruebas en Turbo Frames

Las pruebas funcionarán de la misma manera que para cualquier aplicación regular en Rails. Pero, en este caso, verifica que estamos utilizando la misma URL para ejecutar las acciones de crear y listar, ya que ambas acciones se ejecutarán desde esa misma ruta.

class TagFrameControllerTest < ActionDispatch::IntegrationTest
test "deberia listar" do
get tag_frame_index_path
assert_response :success
end
test "deberia crear una etiqueta" do
post tag_frame_index_path, params: { tag: { name: 'Nueva etiqueta' } }
assert_redirected_to tag_frame_index_path
end
# ... otros tests
end

Turbo Streams: Carga Sólo los Datos Modificados

En Turbo Frame, renderizas grandes bloques de vistas. En Turbo Stream, solo renderizas los datos que realmente necesitas, especificando su comportamiento, cómo agregarla, removerla, reemplazarla, etc.

Esta característica puede ser combinada opcionalmente con Websockets. Por lo tanto, ¡cualquier nueva información almacenada en tu base de datos aparecerá en tiempo real para todos tus usuarios!

Sin embargo, la documentación de Turbo Stream afirma lo siguiente:

Es una buena práctica comenzar tu diseño sin Turbo Streams. Desarrolla tu aplicación completa como si Turbo Streams no estuviera disponible, luego, agrégalo a medida que creces. Esto significa que no dependerás fuertemente de las actualizaciones para flujos que necesiten funcionar con aplicaciones nativas o en algún otro lugar sin ellas.

Además, si lo usas en Ruby on Rails, puedes aprovechar Action Cable y Active Jobs para renderizar el contenido según sea necesario. Para este ejemplo, voy a usar la gema en un proyecto de Ruby on Rails.

Funcionamiento Adicional al Renderizar

Si deseas desencadenar efectos secundarios cuando realizas un renderizado en Turbo Stream, tal vez quieras utilizar controladores Stimulus. Para eso, necesitarás trabajar con archivos JavaScript.

Acciones

Con Turbo Stream, tenemos ocho acciones (o comportamientos) disponibles. Estas especifican cómo se agregará tu contenido al contenido renderizado previamente.

Estas acciones son:

  • append : Agrega el contenido dado al final del elemento especificado por el objetivo.
  • prepend : Agrega el contenido dado al comienzo del elemento especificado por el objetivo.
  • replace: Reemplaza el contenido completo del elemento objetivo con el contenido dado.
  • update: Reemplaza el elemento objetivo con el contenido dado.
  • remove: Elimina el elemento objetivo del DOM.
  • before: Inserta el contenido dado inmediatamente antes del elemento objetivo.
  • after : Inserta el contenido dado inmediatamente después del elemento objetivo.
  • morph : Reemplaza el contenido usando la técnica de morph en el elemento objetivo.
  • refresh : Refresca el contenido dentro del elemento objetivo.

Estructura de un Render en Turbo Stream

<turbo-stream action="append" target="dom_id">
<template> Contenido para añadir al contenedor designado con el ID dom_id </template>
</turbo-stream>

Ahora, para permitir que Turbo Streams inserte contenido en tu HTML, debes pre-renderizar un elemento con un ID objetivo, como dom_id, por ejemplo.

Esto significa que, si quieres reemplazar o transformar un elemento específico, tu HTML debería verse así:

<tr id="<%= dom_id job_application %>">
...
</tr>

Pero si queremos anteponer, añadir o realizar cualquier acción similar al elemento padre, necesitamos agregar un identificador único.

<tbody id="<%= JobApplicationController::JOB_APPLICATION_STREAM_ID %>">
...
</tbody>

Luego, si queremos crear un registro nuevo y agregarlo a nuestra tabla actual, el código del controlador se verá así:

job_application_controller.rb
class JobApplicationController < ApplicationController
#...
def create
@job_application = Application.new(job_application_params)
@job_application.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
# Renderizando la lista
turbo_stream.prepend(JOB_APPLICATION_STREAM_ID, partial: 'job_application/job_application', locals: { job_application: @job_application }),
# Ya que es un array, puedes agregar otro turbo stream aquí
]
end
end
end
#...
private
def job_application_params
params.require(:application).permit(:job_id, :name, :cover_letter)
end
end

Esta es la razón por la cual necesitas un ID de un elemento padre. Veamos un ejemplo donde necesitamos remover un elemento específico de la tabla.

job_application_controller.rb
class JobApplicationController < ApplicationController
#...
def destroy
@job_application = Application.find(params[:id])
@job_application.destroy
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.remove(@job_application)
end
end
end
end

El uso de la gema Ruby nos proporciona varias ventajas y ofrece métodos ya establecidos para interactuar con Turbo.

Escribir Pruebas para Turbo Stream

Necesitamos ser muy específicos sobre el tipo de respuesta que esperamos del controlador. De acuerdo al caso, necesitaríamos especificar a nuestra herramienta de pruebas que estamos esperando un objeto “stream”.

Para cambiar esa manera de hacer pruebas, agregamos as: :turbo_stream al final de nuestra ruta de llamada. Mira el siguiente ejemplo.

job_application_controller_test.rb
class JobApplicationControllerTest < ActionDispatch::IntegrationTest
test "deberia crear la aplicacion de trabajo con turbo stream" do
job_application_count = Application.count
post job_application_index_path, params: { application: { job_id: jobs(:job_javascript_sr_developer).id, name: 'John Doe', cover_letter: 'Soy un desarrollador excelente' } }, as: :turbo_stream
job_application_count_after = Application.count
assert_equal job_application_count + 1, job_application_count_after
assert_response :success
end
# ... otros tests
end

Conclusiones

Recuerda que la mayoría del contenido de este artículo se ha resumido para ser presentado en diapositivas durante una hora. Por supuesto, no voy a cubrir cada aspecto, pero la meta es mostrarte los conceptos básicos para que puedas comenzar tu propia aventura practicando y aprendiendo Turbo, y usarlo en tu próximo proyecto.

Usa Turbo cuando tengas la oportunidad, especialmente si trabajas con muchos programadores de Ruby talentosos. El cambio de contexto y conocimiento será minimizado y el tiempo se dedicará a crear más características para los usuarios en lugar de investigar cómo transpilar correctamente tu código TypeScript y qué herramientas usar.

¿Recomendaría a los programadores dejar de aprender sobre JavaScript porque tenemos herramientas como Turbo? No del todo. En cambio, les animaría a continuar aprendiendo, pero una vez que hayan creado algo, ya sea en el trabajo o en sus propios proyectos. JavaScript se usa ampliamente en todo el mundo y probablemente, en cualquier trabajo que estés buscando, requerirán algún conocimiento básico o intermedio de JavaScript. Además, recuerda que necesitarás hacer pasos adicionales con Turbo; tarde o temprano tendrás que trabajar con Controladores Stimulus, los cuales están escritos en JavaScript.

Mis posts no son generados por la IA, sin embargo, podrían estar corregidos por ella. El primer borrador siempre es de mi creación

Tags

Autor

Escrito por Helmer Davila

En otros lenguajes

Emmène ton appli au niveau supérieur, sans JavaScript

Hotwire Turbo: Drive, Frames et Streams pour des projets en Ruby on Rails

Taking your app to the next level, without JavaScript

Hotwire Turbo: Drive, Frames and Streams for Ruby on Rail projects

Posts relacionados

Rails 6 + MySQL + PHPMyAdmin

Rails 6: corriendo sobre Docker con PHPMyAdmin

Usando Docker para crear un entorno en contenedores

Rails 7 con Ruby 3, MySQL 8 y Redis en Docker Alpine