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.
Las tres ideas del router
Sección titulada «Las tres ideas del router»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.
-
Con
make devcorriendo, abre las herramientas de desarrollo en la pestaña de red y limpia el registro. -
Entra al detalle de una tarea tocando su título.
-
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:
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.
Por qué no usamos rutas con almohadilla
Sección titulada «Por qué no usamos rutas con almohadilla»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
/** * 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
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
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.
Comprobación
Sección titulada «Comprobación»Antes de pasar a la infraestructura, deberías poder responder:
- ¿Por qué
pathes un signal y no una variable normal? - ¿Qué pasaría si
main.tsno usarauntracked? - ¿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.