wandres.dev
ROUTING Y SPA · Browser.application

Browser.application: interceptar el clic y poseer la navegación

Cuando un programa se declara Browser.application ocurre algo que ningún otro constructor provoca: el navegador deja de decidir qué pasa al pulsar un enlace. El clic, que en cualquier página corriente desencadena una petición de documento y la destrucción del estado actual, se convierte en un valor de tipo UrlRequest que llega a update como un mensaje más, y el cambio efectivo de la dirección se convierte en otro. Esta lección desarrolla esa intercepción hasta el final: por qué el runtime distingue entre enlaces internos y externos con dos constructores y qué obliga esa distinción a decidir explícitamente, por qué la petición y el cambio son dos eventos separados y no uno solo, cómo cierra el ciclo Nav.pushUrl al provocar el onUrlChange que actualizará el modelo, y qué obligaciones asume quien acepta este poder: no romper el botón de retroceso, no convertir enlaces legítimos en callejones sin salida y mantener la coherencia entre la barra de direcciones y lo que se ve en pantalla. El fondo del asunto es que aquí Elm no añade una funcionalidad sino que traslada al programa una responsabilidad que antes tenía el navegador, y lo hace con la única condición de que esa responsabilidad quede escrita en el tipo.

⏱ 17 min

Hay una frontera invisible que separa una página web de una aplicación web, y no está donde solemos ponerla. No la marca la cantidad de interactividad, ni el uso de peticiones asíncronas, ni la complejidad del estado: la marca quién decide qué ocurre cuando el usuario pulsa un enlace. En una página, esa decisión pertenece al navegador, que descarta todo lo que había en memoria, pide un documento nuevo y lo pinta desde cero; el programa no participa porque, hablando con propiedad, el programa muere en ese instante. En una aplicación de una sola página, esa decisión pertenece al programa, que sobrevive al clic y responde a él cambiando su propio estado. Browser.application es exactamente el mecanismo por el que Elm traslada esa decisión, y lo hace con la coherencia que cabe esperar del lenguaje: no ofrece un evento global al que suscribirse ni un objeto de enrutador que gestione la travesía, sino que convierte el clic en un valor de un tipo que tú declaras y lo entrega a update como cualquier otro mensaje. A partir de ese momento la navegación deja de ser una fuerza de la naturaleza que le ocurre a tu programa y pasa a ser un caso más de tu función de actualización.

🎯 Al terminar esta lección sabrás
  • Explicar qué intercepta Browser.application y en qué se diferencia un clic interceptado de una navegación real del navegador.
  • Distinguir los dos constructores de UrlRequest y decidir con criterio qué hacer con cada uno.
  • Separar la petición de navegación del cambio efectivo de URL y describir el ciclo que los une.
  • Enumerar las obligaciones que asume quien toma el control: historial coherente, retroceso funcional y enlaces que sigan siendo enlaces.

El clic deja de ser una navegación y pasa a ser un mensaje

En cuanto un programa se declara application, el runtime instala un interceptor sobre los clics en elementos a del documento. Cuando el usuario pulsa uno, el navegador no navega: el runtime construye un valor de tipo UrlRequest y llama a la función que tú entregaste en onUrlRequest para convertirlo en un mensaje de tu vocabulario. Ese mensaje llega a update como llegaría el resultado de una petición o la pulsación de un botón, y allí decides. La consecuencia inmediata es que el estado no se pierde: el modelo, las suscripciones activas y todo lo que hubiera en memoria siguen ahí, porque el documento nunca se ha destruido.

El tipo que describe la petición tiene dos constructores, y esa dualidad no es un detalle de implementación sino el reflejo de una distinción real que el programa está obligado a tratar. Un enlace cuyo destino pertenece al mismo origen es candidato a resolverse dentro de la aplicación; un enlace que apunta a otro dominio no puede resolverse internamente de ninguna manera, porque el programa no tiene nada que mostrar allí. El runtime no adivina cuál es cuál según tu intención: aplica la regla del origen y te entrega el resultado etiquetado.

type UrlRequest
    = Internal Url
    | External String


type Msg
    = ClicEnEnlace Browser.UrlRequest
    | CambioDeUrl Url


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ClicEnEnlace peticion ->
            case peticion of
                Browser.Internal url ->
                    ( model, Nav.pushUrl model.key (Url.toString url) )

                Browser.External destino ->
                    ( model, Nav.load destino )

        CambioDeUrl url ->
            ( { model | ruta = Route.parse url }, Cmd.none )
🔗

Internal

Mismo origen. Lo habitual es empujar la URL al historial con pushUrl, que provocará después el mensaje de cambio. Nada obliga a hacerlo: puedes ignorarlo o pedir confirmación.

🚪

External

Otro origen. Solo cabe abandonar la aplicación con Nav.load, o no hacer nada. Si lo ignoras en silencio, el enlace deja de funcionar y el usuario no sabrá por qué.

📨

onUrlRequest

Traduce la intención de navegar a un mensaje tuyo. Se dispara antes de que nada cambie: aquí la URL de la barra sigue siendo la anterior.

🔁

onUrlChange

Traduce el cambio consumado a otro mensaje. Se dispara también con el botón de retroceso, con el de avance y al cargar la página por primera vez.

Dos eventos porque son dos cosas distintas

La primera reacción de casi todo el mundo al ver la firma es preguntarse por qué hacen falta dos manejadores si el clic acaba cambiando la URL de todos modos. La respuesta es que no todos los cambios de URL vienen de un clic y no todos los clics acaban en un cambio de URL. Cuando el usuario pulsa el botón de retroceso, no hay petición ninguna: el navegador ya ha cambiado la dirección y solo notifica el hecho consumado, así que se dispara onUrlChange y nunca onUrlRequest. Cuando tu programa navega por su cuenta tras guardar un formulario, tampoco hay clic. Y en sentido contrario, una petición interna puede rechazarse —porque hay cambios sin guardar, porque el usuario no tiene permiso— y entonces hay onUrlRequest sin onUrlChange.

⚠️
La URL no cambia sola: el ciclo lo cierras tú

Este es el punto donde tropieza casi todo el mundo la primera vez. Recibir Internal url no modifica la barra de direcciones ni actualiza nada; es solo una notificación de intención. Si en esa rama devuelves Cmd.none, el enlace simplemente no hará nada. Es Nav.pushUrl quien altera el historial, y es esa alteración la que provoca que el runtime dispare onUrlChange con la nueva dirección. De ahí se sigue una regla de higiene que ahorra muchas horas: no actualices la ruta del modelo en la rama del clic. Hazlo únicamente al recibir el mensaje de cambio, porque ese mensaje llega por todos los caminos posibles —clic, retroceso, avance y navegación programática— mientras que el otro solo llega por uno.

init : () -> Url -> Nav.Key -> ( Model, Cmd Msg )
init _ url key =
    ( { key = key, ruta = Route.parse url }, Cmd.none )


main : Program () Model Msg
main =
    Browser.application
        { init = init
        , update = update
        , subscriptions = \_ -> Sub.none
        , view = view
        , onUrlRequest = ClicEnEnlace
        , onUrlChange = CambioDeUrl
        }

Conviene mirar también el lado de la vista, porque es donde se decide si la intercepción tiene algo que interceptar. Un application devuelve un Document msg, de modo que el título de la pestaña se calcula a partir de la ruta actual y viaja con cada cambio de pantalla sin ningún trabajo adicional. Y los enlaces se escriben como enlaces de verdad, con su destino en el atributo correspondiente, construido a partir de un valor del dominio en lugar de una cadena tecleada a mano. No hace falta ningún manejador de clic: el runtime ya está escuchando, y el enlace conserva mientras tanto todo lo que un enlace sabe hacer por sí solo.

view : Model -> Browser.Document Msg
view model =
    { title = Route.titulo model.ruta
    , body =
        [ nav []
            [ a [ href (Route.toString Route.Inicio) ] [ text "Inicio" ]
            , a [ href (Route.toString Route.Articulos) ] [ text "Articulos" ]
            , a [ href "https://elm-lang.org" ] [ text "Elm" ]
            ]
        , main_ [] [ verPagina model ]
        ]
    }
flowchart TD
C[Clic en enlace] --> R[onUrlRequest]
R --> I[Internal]
R --> E[External]
I --> P[pushUrl al historial]
E --> L[load abandona la app]
P --> H[Historial modificado]
B[Boton atras] --> H
N[Navegacion programatica] --> H
H --> U[onUrlChange]
U --> M[Update de la ruta en el Model]
style R fill:#89b4fa,color:#11111b
style U fill:#a6e3a1,color:#11111b
style M fill:#cba6f7,color:#11111b

Quien toma el control asume las obligaciones

Interceptar la navegación es apropiarse de un comportamiento que el usuario conoce, que funciona igual en todos los sitios que visita y que lleva treinta años sin cambiar. Toda la ergonomía de la web descansa en un puñado de expectativas muy sólidas: el botón de retroceso deshace el último paso, una dirección copiada de la barra lleva a otra persona al mismo sitio, abrir un enlace en una pestaña nueva funciona, y un enlace que se ve como un enlace navega. Un application que no las respete no está siendo creativo, está incumpliendo un contrato implícito que el usuario no negoció.

💡
Los modificadores del teclado ya están resueltos

Conviene saber qué no hay que programar. El runtime no intercepta el clic cuando el usuario mantiene pulsada una tecla modificadora, ni cuando usa el botón central, ni cuando el enlace declara un destino de ventana distinto: en esos casos deja pasar la navegación nativa y no genera ningún UrlRequest. Es decir, abrir en pestaña nueva sigue funcionando sin escribir una línea. Esto es un argumento fuerte a favor de usar enlaces reales con su destino en el atributo correspondiente en lugar de nodos con manejadores de clic simulados: el enlace auténtico conserva el menú contextual, el destino en la barra de estado, la accesibilidad por teclado y el comportamiento de las pestañas, y además sigue siendo navegable si el programa aún no ha arrancado.

La obligación más delicada es la del enlace externo. Si tu update no hace nada con el constructor externo, todos los enlaces salientes de la aplicación quedan muertos sin ningún aviso: no hay error, no hay excepción, no hay nada en la consola. El compilador te obligó a escribir la rama, pero no puede obligarte a que la rama haga algo sensato, y esta es una frontera útil para entender el alcance real de la exhaustividad. El sistema de tipos garantiza que has considerado el caso; que lo hayas resuelto bien sigue siendo asunto tuyo.

Interceptar es asumir la deuda del comportamiento que sustituyes

La tentación al descubrir este mecanismo es leerlo como una capacidad, y lo es, pero leerlo solo así lleva a construir aplicaciones que técnicamente funcionan y resultan hostiles. Lo que en realidad ocurre al declarar un application es una transferencia de deuda. El navegador venía prestando gratis un conjunto de servicios que nadie escribió en el proyecto y que sin embargo el usuario da por descontados: una pila de historial coherente con lo que ha visto, direcciones que identifican estados y por tanto pueden compartirse, un botón de retroceso que siempre deshace el último paso, una indicación visible de que algo se está cargando, un punto de restauración cuando la conexión falla. En el instante en que interceptas el clic, todos esos servicios pasan a tu presupuesto, y la mayoría de las aplicaciones de una sola página mal construidas no fallan por errores de programación sino por deudas de este balance que nadie contabilizó: el retroceso que salta tres pantallas porque se empujó al historial cada tecleo de un buscador, la dirección que no restaura nada porque el estado real vivía fuera de la URL, el enlace que no se puede abrir en otra pestaña porque no era un enlace sino un nodo con un manejador. La distinción entre Internal y External es una primera factura hecha explícita —el compilador te fuerza a decir qué haces con la navegación que no puedes resolver—, pero es solo la primera. La pregunta que conviene hacerse antes de escribir la vista no es qué puedo hacer ahora que controlo la navegación, sino qué le estaba dando el navegador a mi usuario que ahora tengo que darle yo. Quien la responde antes construye aplicaciones que se sienten como la web; quien la descubre después construye aplicaciones donde el botón de retroceso da miedo.

⚔️ Toma el control sin romper la web
  1. Monta un application mínimo con dos rutas y comprueba que al pulsar un enlace interno el modelo conserva un contador que habías incrementado antes.
  2. Devuelve Cmd.none en la rama interna y observa qué ocurre exactamente; explica por qué el enlace parece averiado y qué eslabón del ciclo falta.
  3. Añade un enlace a otro dominio y prueba las dos opciones de la rama externa; razona qué le comunica cada una al usuario.
  4. Instrumenta ambos manejadores para registrar cuál se dispara y en qué orden con un clic, con el botón de retroceso y con una navegación programática; deduce de ahí por qué son dos eventos.
  5. Sustituye un enlace real por un nodo con manejador de clic y enumera todo lo que se pierde: pestaña nueva, menú contextual, teclado y barra de estado.
  6. Escribe la lista de servicios que el navegador dejó de prestarte al declarar el application y decide, para cada uno, si tu programa ya lo ha pagado.