Ir al contenido

Timeline

Redux DevTools rebobina un store. La timeline de Wu rebobina la página: escrituras del store, eventos del bus y ciclo de vida de las apps, en todos los frameworks que haya. Arrastra el control deslizante y una app React se desmonta, una app Vue se remonta y el store compartido vuelve de golpe a lo que contenía 40 escrituras atrás.

Aclaremos la frontera. Lo que se rebobina es lo que fluye por el sustrato: el store, el bus de eventos, mount/unmount/update. Los efectos secundarios internos de una app no: un setTimeout dentro de una micro-app, un useState que nunca subió al store, una petición de red que ya está en vuelo. El viaje en el tiempo aquí trata de las costuras entre apps, no de sus interiores.

wu.timeline es un chunk perezoso. La fachada de wu.timeline existe desde el principio y dispara la carga en el primer uso.

import { wu } from 'wu-framework';
wu.timeline.record(); // arma el grabador (asíncronamente — ver más abajo)
const s = wu.timeline.status(); // responde de inmediato, sin forzar una carga

La fachada se divide con claridad:

Tipo Métodos Devuelve
Síncronos, encadenables record(), stop(), clear() La propia fachada
Asíncronos seek(), live(), stepBack(), stepForward(), export(), import(), ingest() Promise
Síncronos, baratos entries(), status(), loaded, instance Valores planos
wu.timeline.record().stop().record(); // el encadenado funciona
await wu.timeline.seek(120); // a este sí le haces await

Como el grabador es un chunk perezoso, record() se arma de forma asíncrona. Las escrituras del store que ocurran antes de que el chunk se resuelva no se registran en el diario.

wu.timeline.record();
store.set('user.name', 'Ada'); // puede estar o no en el diario

Si necesitas capturar la siguiente escritura sí o sí, precalienta el chunk primero:

await wu.timelineReady(); // resuelve el WuTimeline real
wu.timeline.record(); // ahora se arma de forma síncrona
store.set('user.name', 'Ada'); // registro garantizado

En el camino de la interfaz esto no es un problema: abrir el inspector carga el chunk, así que el botón de grabar se arma de inmediato. La fachada también rastrea la intención, de modo que un stop() emitido antes de que el chunk llegue cancela un record() pendiente, en lugar de producir una grabación sorpresa más tarde.

record(opts?: { snapshotEvery?: number; maxEntries?: number }): Timeline

Empieza a grabar. Reinicia el diario, toma una instantánea de referencia del store y de todas las apps montadas, y engancha taps al store y al bus de eventos.

Opción Por defecto Significado
snapshotEvery 64 Escrituras del store entre instantáneas completas de estado. Menos = búsquedas más rápidas, más memoria.
maxEntries 5000 Tope del diario. Las entradas más antiguas se pliegan sobre la línea base al superarlo.
wu.timeline.record({ snapshotEvery: 32, maxEntries: 2000 });

Casos límite. Llamar a record() mientras ya está grabando es un no-op idempotente — y las opciones nuevas se ignoran, porque la guarda se ejecuta antes de la fusión. Haz stop() y luego record() para cambiarlas. Las opciones persisten entre sesiones de grabación una vez asignadas.

stop(): Timeline
clear(): Timeline

stop() desengancha los taps y deja de registrar. El diario se conserva: buscar, exportar y desplazarse siguen funcionando sobre él.

clear() vacía el diario y vuelve a tomar una línea base del estado actual, pero no detiene la grabación. La grabación continúa sobre el diario nuevo.

wu.timeline.stop(); // congelar lo capturado
wu.timeline.clear(); // empezar de cero, sin dejar de grabar

Casos límite. stop() sobre una timeline que no está grabando es un no-op silencioso: sin evento, sin log. Ninguno de los dos métodos lanza.

seek(pos: number, opts?: { store?: boolean; apps?: boolean; props?: boolean }): Promise<Status>

Mueve la página a la posición pos del diario. pos es un índice — el número de entradas del diario aplicadas, de 0 a length. No es una marca de tiempo.

Opción Por defecto Qué restaura
store true Rematerializa el estado del store en esa posición.
apps true Fuerza el desmontaje de las apps que no estaban montadas entonces y remonta las que sí.
props true Vuelve a empujar las props vivas que cada app tenía en esa posición.
await wu.timeline.seek(0); // volver a la línea base
await wu.timeline.seek(120); // 120 entradas dentro
await wu.timeline.seek(9e9); // recortado al final — igual que live()

Casos límite. seek() nunca se rechaza. Las búsquedas se serializan a través de una cadena interna, y cualquier fallo se registra y se traga, resolviendo con el estado actual. Los fallos por app durante la búsqueda se capturan y se avisan individualmente: un diario importado que referencia apps que esta página nunca registró avisa y continúa.

La entrada se recorta y se convierte: NaN, null, undefined y las cadenas no numéricas pasan todas a 0; los negativos se recortan a 0; pasarse de largo se recorta a length; los decimales se truncan hacia abajo. Las propias escrituras del store hechas por la búsqueda se excluyen del diario, así que no hay bucle de realimentación.

live(): Promise<Status>
stepBack(): Promise<Status>
stepForward(): Promise<Status>

Búsquedas de conveniencia. live() salta al final del diario, y los dos pasos mueven una entrada.

await wu.timeline.stepBack();
await wu.timeline.stepBack();
await wu.timeline.live(); // de vuelta al presente

Casos límite. Los tres calculan su destino de forma perezosa, en el momento en que la búsqueda realmente se ejecuta — de lo contrario, dos stepBack() seguidos resolverían al mismo índice, y un live() compitiendo con una búsqueda en cola podría dejarte varado en el pasado. stepBack() en la posición 0 y stepForward() al final se recortan sin daño (siguen reaplicando el estado y emitiendo timeline:seek).

Esto es lo más importante que hay que entender de la timeline, y el lugar donde vive su limitación honesta.

Mientras estés posicionado en el pasado, las escrituras nuevas no se registran. Se ejecutan con normalidad contra el store real y la página real —la timeline no las bloquea— pero no se añade nada al diario, no se trunca nada y nada se bifurca. Cuando vuelves a live(), la búsqueda rematerializa el estado del final del diario y esas escrituras se descartan.

Recibes un aviso por sesión de grabación:

[WuTimeline] Writes while positioned in the past are not journaled —
they will be discarded when you return to live().

Es la misma política que usa Redux DevTools. La alternativa —diarios con ramas— implica reconciliar historiales divergentes entre apps desplegadas de forma independiente, que es un problema distinto y mucho más grande.

status(): {
loaded: boolean;
recording: boolean;
live: boolean;
position: number;
length: number;
site: string;
lamport: number;
snapshots: number;
}
Campo Significado
loaded ¿Está resuelto el chunk real del grabador? false desde el stub de la fachada.
recording ¿Están instalados los taps?
live ¿Es position === length?
position Índice actual del diario.
length Total de entradas.
site Id de esta réplica: un UUID, o un fallback de tiempo más aleatorio.
lamport Contador del reloj lógico.
snapshots Número de instantáneas, no el array.
const s = wu.timeline.status();
if (s.recording && !s.live) {
console.log(`Viendo la entrada ${s.position} de ${s.length}`);
}

Casos límite. Es seguro consultarlo en bucle: nunca fuerza la carga del chunk. Antes de que el chunk se resuelva devuelve un stub congelado con loaded: false, length: 0, site: null.

entries(): Array<Entry>

Una copia superficial del diario. Cada entrada lleva l (contador de Lamport), site y t (Date.now()), además de:

Escrituras del store{ kind: 'store', seq, path, value }. Un path vacío significa un reemplazo del estado completo.

Eventos del bus{ kind: 'event', name, appName }, con container añadido para app:mounted y props para app:updated.

const writes = wu.timeline.entries().filter(e => e.kind === 'store');
console.log(writes.map(e => e.path));

Casos límite. El array es una copia, pero los objetos de entrada son referencias compartidas: seguro para ordenar o filtrar, no seguro para mutar. Antes de que el chunk cargue devuelve [].

Dos detalles que conviene conocer. El diario registra todos los eventos del bus salvo los timeline:*, pero solo app:mounted, app:unmounted y app:updated se interpretan durante la reproducción — el resto se registran pero son inertes. Y las entradas de app:updated guardan las props acumuladas absolutas, no el delta: un delta no puede expresar la eliminación de una clave que solo apareció después.

export(): Promise<Journal>
import(data: Journal): Promise<Status>

Serializa una sesión y reprodúcela en otro sitio — el flujo de “adjunta la grabación al informe de error”.

const journal = await wu.timeline.export();
await fetch('/bug-reports', { method: 'POST', body: JSON.stringify(journal) });
// En otra máquina:
await wu.timeline.import(journal);
await wu.timeline.seek(0);
await wu.timeline.stepForward(); // recorrer lo que hizo el usuario

La forma del diario:

{
format: 'wu-timeline/1',
wu: '2.7.2',
site: '',
exportedAt: 1730000000000,
baselineApps: [{ name, container, props }],
entries: [ /* … */ ],
snapshots: [{ at, state }],
}

Casos límite. import() es el único método que lanza: [WuTimeline] Unsupported journal format: <format> cuando data.format no es 'wu-timeline/1'. Detiene primero la grabación, te deja al final del diario importado (position === length), conserva tu propio id de site en lugar del del exportador, y no impone maxEntries. Los valores se clonan con structuredClone donde esté disponible, cayendo a JSON y, finalmente, a la referencia cruda — así que un diario con valores no clonables guarda referencias vivas en vez de reventar.

ingest(entries: Entry[]): Promise<Status>

Fusiona un diario remoto con este, deduplicado y con orden total por (lamport, site). La primitiva que hay debajo de la sincronización multijugador.

socket.on('journal', (remote) => wu.timeline.ingest(remote));

Casos límite. ingest() reinicia todas las instantáneas y ancla la reproducción en {}. La convergencia necesita una base compartida: dos réplicas que divergen y vuelven a fusionarse deben acabar en el mismo estado, y una instantánea tomada del estado local de una réplica rompería eso. La consecuencia práctica es que los diarios que pretendas sincronizar deben grabarse desde una base compartida, o empezar con una entrada de reemplazo completo — de lo contrario, ingerirlos produce un estado al que le falta todo lo que contenía la línea base. ingest() nunca lanza y no emite ningún evento.

Evento Payload
timeline:record { site }
timeline:stop { length }
timeline:seek { position, length, live }
timeline:import { length, site }

Todos se emiten con history: false, para que arrastrar el control deslizante no expulse todos los eventos reales de apps del historial acotado del bus, y el grabador ignora sus propios eventos timeline:* para que nunca se autorregistren.

  • Solo el sustrato. El estado interno de las apps, los timers y las peticiones en vuelo no rebobinan.
  • Sin bifurcación. Las escrituras hechas en el pasado se descartan silenciosamente al volver a live.
  • record() se arma de forma asíncrona salvo que hagas antes await wu.timelineReady().
  • La reproducción remonta apps. Buscar a través de una frontera de montaje fuerza desmontajes y remontajes, lo cual no es gratis y reinicia todo lo que la app no subió al store.
  • ingest() descarta las instantáneas, lo que hace que los diarios largos sean más lentos de recorrer tras una fusión.
  • El reloj de Lamport ordena; las marcas de tiempo no. El campo t se registra para mostrarlo, pero nunca se usa para ordenar.