Un usuario pulsa el archivo A y después B. El título dice B, pero el editor muestra A. Ambas lecturas devolvieron los datos correctos. El problema es que A llegó cuando el usuario ya había cambiado y nada impidió mostrarlo.
Una cadena de llamadas —clic, carga, renderizado— no explica el fallo: ambos clics recorren las mismas funciones. Necesitas saber cuándo empieza y termina cada solicitud y cuál puede actualizar la pantalla. Este artículo analiza un ejemplo pequeño de JavaScript, incluidos los estados de error y carga que una corrección rápida suele olvidar.
Lee hasta el primer await
Este lector simplificado recibe load, que devuelve una promesa con el texto, y publish, que combina un parche con el estado visible. Al recibir ambos como argumentos no depende de ningún framework:
export function createUnsafeReader(load, publish) {
return async function select(file) {
publish({ file, text: '', error: null, loading: true });
try {
const text = await load(file);
publish({ text });
} catch (error) {
publish({ error: error.message });
} finally {
publish({ loading: false });
}
};
}
Antes de await, registra la selección y borra el texto anterior. Mientras espera puede empezar otra llamada a select. Después de await, nada comprueba si esta solicitud sigue correspondiendo a la pantalla.
La variable local file no cambia durante la suspensión, pero la vista compartida sí. Ese es el fallo: la función conserva los datos correctos para su llamada, pero ya no es la llamada que debería mostrarse.
Dibuja la cronología
Controla dos lecturas y deja que B termine antes que A:
| Paso | Evento | Selección | Texto visible | Cargando |
|---|---|---|---|---|
| 1 | Seleccionar A; empieza su lectura | A | Vacío | Sí |
| 2 | Seleccionar B; empieza su lectura | B | Vacío | Sí |
| 3 | B termina correctamente | B | Texto de B | No |
| 4 | A termina correctamente | B | Texto de A | No |
Ambas llamadas ejecutaron el mismo código. Por el orden, ganó la última lectura en terminar en lugar del último archivo seleccionado.
Antes de corregir, confirma qué debe hacer la interfaz. Un panel de vista previa debe mostrar la última selección. Una importación por lotes necesita todos los resultados y una cola de guardado puede necesitar escrituras en orden; descartar resultados antiguos sería incorrecto en ambos casos. La solución siguiente corresponde a una vista donde una selección sustituye a la anterior.
Reproduce sin temporizadores
Una prueba que espera 100 ms para A y 10 ms para B es lenta y depende de la planificación. Una promesa diferida permite decidir exactamente cuándo termina cada lectura:
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
Crea dos lecturas diferidas, selecciona ambos archivos, resuelve B y después A:
const a = deferred(),
b = deferred();
const state = {};
const select = createUnsafeReader(
(file) => (file === 'A' ? a : b).promise,
(patch) => Object.assign(state, patch),
);
const first = select('A'),
second = select('B');
b.resolve('B text');
await second;
a.resolve('A text');
await first;
assert.equal(state.file, 'B');
assert.equal(state.text, 'A text'); // Reproduces the defect.
La última aserción comprueba a propósito el resultado erróneo: demuestra que el fallo ocurre. La prueba del lector corregido espera el texto de B. Separar reproducción y corrección impide confundir una reproducción verde con que el código antiguo funcione.
Asigna un número a cada selección
Para una vista basta un contador. Increméntalo al iniciar la selección, conserva su valor en la llamada y compáralo antes de publicar:
export function createReader(load, publish) {
let latest = 0;
return async function select(file) {
const request = ++latest;
publish({ file, text: '', error: null, loading: true });
try {
const text = await load(file);
if (request === latest) publish({ text });
} catch (error) {
if (request === latest) publish({ error: error.message });
} finally {
if (request === latest) publish({ loading: false });
}
};
}
El contador pertenece a esta instancia de lector. Si fuera global, un clic en una ventana descartaría resultados de otra. Si se creara dentro de select, se reiniciaría en cada llamada y no distinguiría solicitudes.
Tampoco basta comparar rutas. Si el usuario elige A, B y de nuevo A, la primera y tercera solicitudes tienen la misma ruta, pero la primera puede contener una versión anterior del archivo. El número permite distinguirlas.
El ejemplo presupone que publish actualiza síncronamente y que load rechaza con un Error. Si el framework agrupa actualizaciones o desmonta componentes, o la API rechaza con otro valor, tendrás que gestionarlo también.
Prueba A → B → A con tres lecturas distintas
Asigna a cada invocación su propia promesa diferida. El fixture anterior dirige A a una promesa y B a otra; reutilizarlo para un segundo A ocultaría la diferencia entre la primera y tercera solicitudes.
Supón que load puede devolver instantáneas diferentes de la misma ruta, por ejemplo si el archivo se edita entre lecturas. La prueba proporciona expresamente «A version 1» y «A version 2». Son ejemplos didácticos controlados, no un incidente de producción de Yenpo. Si tu cargador devuelve siempre la misma instantánea inmutable, no verás textos distintos; comprueba su caché y lectura reales antes de aplicar esa explicación.
La descarga incluye un createPathOnlyReader deliberadamente incompleto. Mantiene selectedFile y protege éxito, error y finally con file === selectedFile. Las tres comprobaciones aceptan el primer A al volver a seleccionar A:
| Evento | Protección por ruta | Protección por número |
|---|---|---|
| Seleccionar A, B y A otra vez | Ruta actual A | Solicitud actual 3 |
| La tercera devuelve A versión 2 | Muestra versión 2 | Muestra versión 2 |
| B devuelve | Ignora B | Ignora solicitud 2 |
| La primera devuelve A versión 1 | Muestra versión 1 | Conserva versión 2 |
Las pruebas emparejadas ejecutan ese orden contra ambos lectores. reproduce: espera la versión equivocada para demostrar el fallo de la ruta; la prueba del número espera versión 2. Otras dos completan el primer A mientras el tercero está pendiente: un éxito antiguo debe mantener el texto vacío y la carga activa; un fallo antiguo debe dejar el error vacío. El fallo del A actual sí debe aparecer.
Mantén independientes dos lectores
Crea un lector por vista independiente, con su cierre latest y su destino publish. Reutiliza el lector para selecciones posteriores en la misma vista; recrearlo en cada clic pierde el contador compartido que invalida llamadas anteriores.
La prueba final crea lectores izquierdo y derecho con cargadores diferidos y estados separados y ejecuta:
- Selecciona A a la izquierda, A a la derecha y B a la izquierda. El número actual izquierdo es 2; el derecho sigue en 1.
- Resuelve A derecho mientras B izquierdo está pendiente. La derecha debe mostrar su resultado y dejar de cargar; la izquierda debe seguir vacía y cargando.
- Rechaza el A izquierdo antiguo. La izquierda debe seguir esperando B sin error.
- Resuelve B izquierdo. La izquierda debe mostrar B y la derecha conservar su A.
Mover latest al ámbito del módulo hace fallar el paso 2: la nueva selección izquierda invalidaría la derecha y la dejaría cargando. Compartir el objeto de estado también rompería el aislamiento, incluso con contadores separados. La prueba comprueba ambas vistas después de las finalizaciones intercaladas.
Los errores y la carga también pueden aparecer en la vista equivocada
Si solo proteges publish({ text }), desaparece el texto incorrecto pero quedan dos errores:
- A falla después de seleccionar B. El
catchde A muestra «A failed» en la vista de B. - A termina mientras B sigue cargando. El
finallyde A apaga el indicador de B.
Para detectar el segundo, completa A mientras B está pendiente y comprueba inmediatamente que loading sigue siendo true. Si solo miras al terminar B, no verás que el indicador se apagó antes.
Para el primero, rechaza A mientras B está pendiente y comprueba que B no muestra error y sigue cargando. Después resuelve B. Añade otra prueba donde falle la solicitud actual: una protección que silencie todos los errores pasaría la prueba del error antiguo, pero ocultaría fallos reales.
La regla es más estricta que «al final aparece el texto correcto»: solo la solicitud actual puede publicar algo al terminar, ya sea texto, un error o apagar el indicador de carga.
Cancelar no equivale a ignorar
Si el cargador admite cancelación, cancelar A ahorra trabajo. Aun así puede publicar después: el resultado podría estar en caché, la operación ignorar la señal o terminar justo al cancelar.
Usa cancelación para detener trabajo innecesario y el número para decidir si un resultado puede tocar la vista. Comprueba si el cargador respeta realmente AbortSignal; no toda API que devuelve promesas lo hace.
Ignorar un resultado antiguo tampoco deshace lo que haya hecho load. Aquí solo devuelve texto. Si un cargador real también escribe en una caché compartida o actualiza un modelo, rastrea esas escrituras aparte. El contador protege únicamente la vista.
Ejecuta las pruebas
Descarga async-selection.mjs y ejecútalo con Node.js 22 o posterior:
node --test async-selection.mjs
Las diez pruebas cubren la sobrescritura A → B, las protecciones de éxito/error/carga, las solicitudes repetidas A → B → A y dos lectores independientes. Las dos reproduce: afirman deliberadamente los resultados erróneos; las otras ocho comprueban el comportamiento previsto. Sin temporizadores ni dependencias.
Para aplicarlo a código real, encuentra el manejador de entrada, cada await o callback, el estado compartido que puede cambiar durante la espera y todas las escrituras al volver. Anota el orden junto a las ubicaciones del código.
En Yenpo puedes mantener abierto el archivo inicial y pedir a AI Reader que siga el estado a través de un await. Comprueba su respuesta en los recorridos de éxito, error y finally. Si termina en «la promesa se resuelve», pregunta: ¿qué solicitud tiene permiso para usar el resultado?