Ir al contenido

El router

Módulo corto, y el más importante de los tres de la aplicación. Al salir de aquí vas a saber exactamente qué va a fallar cuando esto llegue a S3.

La ruta es un signal. path es la única fuente de verdad, y las vistas nunca leen location.pathname directamente. Eso permite que todo lo demás sea reactivo sin casos especiales.

route es un computed que parsea. Convierte una cadena en una unión discriminada: { name: 'tarea', id: string } en lugar de expresiones regulares repartidas por la app.

Los clics se interceptan en el documento. Un solo listener, delegado, que busca el a[data-link] más cercano. No hay que registrar nada al crear cada enlace.

Paso 5.1 · Comprobar que la navegación no toca el servidor

Sección titulada «Paso 5.1 · Comprobar que la navegación no toca el servidor»

Objetivo: ver que el router resuelve en el cliente.

  1. Con make dev corriendo, abre las herramientas de desarrollo en la pestaña de red y limpia el registro.

  2. Entra al detalle de una tarea tocando su título.

  3. Mira la pestaña de red.

Qué deberías ver: la URL cambió a /tareas/3f2b1a4c-... y no hubo ninguna petición de documento. El router interceptó el clic, actualizó la History API y montó la vista.

Paso 5.2 · Comprobar que respeta el cmd-clic

Sección titulada «Paso 5.2 · Comprobar que respeta el cmd-clic»

Objetivo: ver un detalle que los routers hechos a mano suelen romper.

Haz cmd-clic (o ctrl-clic en Windows y Linux) sobre el título de una tarea.

Qué deberías ver: se abre en una pestaña nueva, como cualquier enlace normal.

Eso funciona porque el listener sale temprano si hay una tecla modificadora:

src/lib/router.ts
if (
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}

Sin esas comprobaciones, el router capturaría el clic y no haría nada. Es de esas cosas que no rompen la aplicación pero la vuelven irritante de usar.

Paso 5.3 · Recarga la ruta, y recuerda que funcionó

Sección titulada «Paso 5.3 · Recarga la ruta, y recuerda que funcionó»

Objetivo: guardar en la memoria que esto funciona en local, para que el contraste duela después.

Estando en el detalle de una tarea, recarga con F5.

Qué deberías ver: funciona. La página carga y muestra la tarea.

Funciona porque el servidor de desarrollo de Vite hace el trabajo por ti: cuando le piden una ruta que no existe como archivo, devuelve index.html y deja que el router resuelva.

Existe una alternativa que evita el problema: rutas del tipo /#/tareas/abc. El navegador nunca pide esa ruta al servidor, así que S3 siempre sirve index.html y no hay nada que configurar.

No la usamos por tres razones. Las URL quedan peores para compartir y para posicionamiento, el fragmento no se envía al servidor, lo que impide cualquier tratamiento en el borde, y sobre todo: la configuración correcta en CloudFront son dos bloques, y esconder el problema no enseña nada.

Ver el router completo
src/lib/router.ts
/**
* Router de una SPA en unas sesenta líneas, sobre la History API.
*
* Este archivo es el que hace necesaria la configuración de CloudFront del
* módulo 5: el navegador puede pedir /tareas/abc directamente, pero en S3 no
* existe ningún objeto con ese nombre.
*/
import { computed, signal } from '@preact/signals-core';
/** Ruta actual, sin el prefijo de despliegue. Es la única fuente de verdad. */
export const path = signal(normalizar(location.pathname));
export type Route =
| { readonly name: 'tablero' }
| { readonly name: 'tarea'; readonly id: string }
| { readonly name: 'acerca' }
| { readonly name: 'desconocida' };
/** La ruta derivada. Las vistas leen esto, nunca location.pathname. */
export const route = computed<Route>(() => {
const actual = path.value;
if (actual === '/') return { name: 'tablero' };
if (actual === '/acerca') return { name: 'acerca' };
const tarea = /^\/tareas\/([A-Za-z0-9_-]+)$/.exec(actual);
if (tarea !== null) return { name: 'tarea', id: tarea[1] as string };
return { name: 'desconocida' };
});
function normalizar(pathname: string): string {
const base = import.meta.env.BASE_URL.replace(/\/+$/, '');
const sinBase = base !== '' && pathname.startsWith(base)
? pathname.slice(base.length)
: pathname;
const limpio = sinBase.replace(/\/+$/, '');
return limpio === '' ? '/' : limpio;
}
/** Navega sin recargar la página. */
export function navegar(destino: string, options: { replace?: boolean } = {}): void {
const base = import.meta.env.BASE_URL.replace(/\/+$/, '');
const url = `${base}${destino}`;
if (options.replace === true) history.replaceState({}, '', url);
else history.pushState({}, '', url);
path.value = normalizar(destino);
scrollTo({ top: 0 });
}
/**
* Arranca el router: sincroniza con el botón atrás e intercepta los enlaces
* internos marcados con `data-link`.
*/
export function iniciarRouter(): void {
addEventListener('popstate', () => {
path.value = normalizar(location.pathname);
});
document.addEventListener('click', (event) => {
if (event.defaultPrevented) return;
// Respetamos clic con modificadores y clic de rueda: el usuario quiere
// abrir en otra pestaña, no navegar aquí.
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const target = event.target;
if (!(target instanceof Element)) return;
const enlace = target.closest('a[data-link]');
if (!(enlace instanceof HTMLAnchorElement)) return;
const destino = enlace.getAttribute('href');
if (destino === null || !destino.startsWith('/')) return;
event.preventDefault();
navegar(destino);
});
}

Sesenta líneas. normalizar() maneja el prefijo de despliegue y las barras finales, para que /acerca, /acerca/ y /acerca// sean la misma ruta.

Ver el montaje de vistas y por qué untracked es obligatorio
src/main.ts
import { effect, untracked } from '@preact/signals-core';
import './styles.css';
import { createScope, type Scope } from './lib/reactive';
import { iniciarRouter, route, type Route } from './lib/router';
import { acerca } from './views/acerca';
import { detalle } from './views/detalle';
import { noEncontrada } from './views/no-encontrada';
import { tablero } from './views/tablero';
const raiz = document.querySelector('#app');
if (!(raiz instanceof HTMLElement)) {
throw new Error('Falta el contenedor #app en index.html');
}
const contenedor = raiz;
iniciarRouter();
let vigente: Scope | null = null;
/**
* El único efecto de nivel superior de la aplicación: cuando cambia la ruta,
* desecha la vista anterior y monta la nueva.
*/
effect(() => {
const actual = route.value;
// untracked es imprescindible aquí. Montar una vista crea sus propios
// efectos y lee signals de estado; sin aislar ese trabajo, esas lecturas se
// volverían dependencias de este efecto y agregar una tarea remontaría la
// vista completa, perdiendo el foco del formulario en cada tecleo.
untracked(() => {
vigente?.dispose();
const scope = createScope();
vigente = scope;
contenedor.replaceChildren(construir(actual, scope));
});
});
function construir(actual: Route, scope: Scope): HTMLElement {
switch (actual.name) {
case 'tablero':
return tablero(scope);
case 'tarea':
return detalle(actual.id, scope);
case 'acerca':
return acerca();
case 'desconocida':
return noEncontrada();
}
}
// La versión se pinta una sola vez: no cambia durante la vida de la página.
// En el workshop este es el indicador de que el despliegue llegó al CDN.
const version = document.querySelector('#version');
if (version instanceof HTMLElement) {
version.textContent = `${__APP_VERSION__} · ${__APP_COMMIT__}`;
}

Un único efecto de nivel superior: cuando cambia la ruta, desecha la vista anterior y monta la nueva.

untracked no es opcional. Montar una vista crea sus propios efectos y lee signals de estado. Sin aislar ese trabajo, esas lecturas se convertirían en dependencias del efecto de montaje, y agregar una tarea destruiría y reconstruiría la vista completa. Es lo que comprobaste en el paso 4.2.

Fíjate también en el switch sobre actual.name. Como Route es una unión discriminada y la función declara que devuelve HTMLElement, si mañana agregas una ruta y olvidas su caso, el typecheck falla. Exhaustividad verificada por el compilador, gratis.

Ver la vista de detalle
src/views/detalle.ts
import { bindClass, bindText, h, on, type Scope } from '../lib/reactive';
import { navegar } from '../lib/router';
import { alternar, buscar, eliminar } from '../state/tasks';
/**
* Vista de detalle. Su única razón de existir en este workshop es forzar una
* segunda ruta con parámetro: al recargar /tareas/<id> en el navegador, S3
* responde 403 porque ese objeto no existe. Ese es el problema que se resuelve
* en el módulo de CloudFront.
*/
export function detalle(id: string, scope: Scope): HTMLElement {
const seccion = h('section', { class: 'detalle' });
const titulo = h('h1', {});
bindText(scope, titulo, () => buscar(id)?.title ?? 'Tarea no encontrada');
const estado = h('p', { class: 'estado' });
bindText(scope, estado, () => {
const tarea = buscar(id);
if (tarea === undefined) return 'Esta tarea ya no existe.';
return tarea.done ? 'Completada' : 'Pendiente';
});
const creada = h('p', { class: 'meta' });
bindText(scope, creada, () => {
const tarea = buscar(id);
if (tarea === undefined) return '';
return `Creada el ${new Date(tarea.createdAt).toLocaleString('es-PE')}`;
});
const alternarBtn = h('button', { type: 'button' });
bindText(scope, alternarBtn, () =>
buscar(id)?.done === true ? 'Marcar pendiente' : 'Marcar hecha',
);
on(scope, alternarBtn, 'click', () => alternar(id));
const borrar = h('button', { type: 'button', class: 'peligro' }, 'Borrar tarea');
on(scope, borrar, 'click', () => {
eliminar(id);
navegar('/');
});
const acciones = h('div', { class: 'acciones' }, alternarBtn, borrar);
bindClass(scope, acciones, 'oculto', () => buscar(id) === undefined);
seccion.append(
h('a', { href: '/', 'data-link': '', class: 'volver' }, '← Volver al tablero'),
titulo,
estado,
creada,
acciones,
);
return seccion;
}

Esta vista existe por una razón concreta del taller: forzar una segunda ruta con parámetro, que es la que va a romper en S3.

Antes de pasar a la infraestructura, deberías poder responder:

  • ¿Por qué path es un signal y no una variable normal?
  • ¿Qué pasaría si main.ts no usara untracked?
  • ¿Qué va a responder S3 cuando recargues /tareas/abc, y por qué 403 y no 404?

Si las tres están claras, vamos a crear la infraestructura.