Um usuário clica no arquivo A e depois em B. O título mostra B, mas o editor exibe A. Ambas as leituras retornaram os dados certos. O resultado de A chegou depois que o usuário mudou de seleção, e nada impediu sua exibição.
Uma cadeia de chamadas — clicar, carregar, renderizar — não explica o bug, porque ambos os cliques percorrem as mesmas funções. É preciso saber quando cada requisição começou e terminou e qual pode atualizar a tela. Este artigo usa um exemplo JavaScript pequeno, incluindo erro e carregamento, que uma correção apressada costuma esquecer.
Leia até o primeiro await
Este é um leitor simplificado. load retorna uma promise com o texto do arquivo, e publish mescla uma atualização no estado visível. Ambos são injetados, sem dependência de 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 do await, a função registra a seleção e limpa o texto antigo. Enquanto aguarda, outra chamada select pode começar. Depois do await, nada confere se a requisição ainda corresponde à tela.
A variável local file não muda durante a suspensão, mas a visualização compartilhada muda. Esse é o bug: a função tem os dados certos para sua chamada, mas ela já não deve ser exibida.
Desenhe a linha do tempo
Use duas leituras controláveis e deixe B terminar antes de A:
| Etapa | Evento | Arquivo selecionado | Texto visível | Carregando |
|---|---|---|---|---|
| 1 | Seleciona A; inicia leitura A | A | vazio | sim |
| 2 | Seleciona B; inicia leitura B | B | vazio | sim |
| 3 | B tem sucesso | B | texto B | não |
| 4 | A tem sucesso | B | texto A | não |
Ambas executaram o mesmo código. Pela ordem, venceu a última leitura concluída, quando deveria vencer o último arquivo selecionado.
Antes de escolher a correção, confira o comportamento esperado. Um painel único deve mostrar a última seleção. Uma importação em lote precisa de todos os resultados, e uma fila de gravação pode exigir conclusão ordenada; descartar resultados antigos seria errado para ambas. A correção abaixo vale para uma visualização em que a nova seleção substitui a antiga.
Reproduza sem temporizadores
Esperar 100 ms por A e 10 ms por B torna o teste lento e dependente do escalonamento. Uma promise adiada permite controlar exatamente quando cada leitura termina:
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
Crie duas leituras adiadas, selecione ambos os arquivos, resolva B e depois 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.
A última asserção verifica propositalmente o resultado quebrado: demonstra que o bug acontece. O teste do leitor corrigido espera o texto B. Separar reprodução e correção evita confundir reprodução verde com prova de que o código antigo funciona.
Numere cada seleção
Para uma visualização, basta um contador. Incremente ao iniciar a seleção, guarde o valor naquela chamada e compare 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 });
}
};
}
O contador pertence à instância do leitor. Se for global, um clique em uma janela descartará resultados de outra. Se for criado dentro de select, reinicia a cada chamada e não distingue requisições.
Comparar caminhos também não basta. Se o usuário escolhe A, B e A novamente, a primeira e a terceira requisições têm o mesmo caminho, mas a primeira pode conter uma versão antiga do arquivo. O número as distingue.
O exemplo presume que publish atualiza o estado sincronamente e load rejeita com Error. Se o framework agrupa atualizações, desmonta componentes ou a API rejeita outro tipo, será preciso tratar isso também.
Teste A → B → A com três leituras separadas
Dê a cada chamada sua própria promise adiada. A fixture anterior encaminha A para uma promise e B para outra; reutilizá-la para o segundo A esconderia a diferença entre primeira e terceira requisições.
Suponha que load retorne estados diferentes para o mesmo caminho: o arquivo pode ser editado entre leituras. Aqui, o teste fornece explicitamente “A version 1” e “A version 2”. São exemplos controlados, não relatos de produção do Yenpo. Se o carregador sempre retorna o mesmo estado imutável, não haverá textos diferentes; confira seu cache e comportamento real antes de aplicar a explicação.
O download inclui createPathOnlyReader, deliberadamente incompleto. Rastreia selectedFile e protege sucesso, erro e finally com file === selectedFile. As três verificações ainda aceitam o primeiro A quando A é selecionado novamente:
| Evento | Proteção só por caminho | Proteção por número |
|---|---|---|
| Seleciona A, B e A novamente | Caminho atual é A | Requisição atual é 3 |
| A terceira retorna A versão 2 | Mostra A versão 2 | Mostra A versão 2 |
| B retorna | Ignora B | Ignora requisição 2 |
| A primeira retorna A versão 1 | Mostra A versão 1 | Mantém A versão 2 |
Os testes pareados executam essa ordem nos dois leitores. O teste reproduce: espera a versão errada para provar a falha da proteção por caminho; o teste por número espera versão 2. Outros dois concluem o primeiro A com o terceiro ainda pendente: sucesso antigo deve manter texto vazio e carregamento ativo; falha antiga deve manter erro vazio. A falha do A atual ainda precisa aparecer.
Mantenha dois leitores independentes
Crie um leitor para cada visualização independente, com sua própria closure latest e destino publish. Reutilize-o para seleções seguintes na mesma visualização; recriá-lo a cada clique perde o contador compartilhado que invalida chamadas antigas.
O teste final cria leitores esquerdo e direito com carregadores adiados e estados separados e executa:
- Seleciona A à esquerda, A à direita e B à esquerda. O número atual da esquerda é 2; o da direita continua 1.
- Resolve A da direita com B da esquerda pendente. A direita deve exibir o resultado e parar de carregar; a esquerda deve ficar vazia e carregando.
- Rejeita o A antigo da esquerda. A esquerda deve continuar esperando B sem erro.
- Resolve B da esquerda. Ela deve mostrar B, e a direita deve manter seu próprio A.
Mover latest para o escopo do módulo faz a etapa 2 falhar: a seleção nova da esquerda invalidaria a requisição da direita, deixando-a carregando. Compartilhar o objeto de estado também quebraria o isolamento, mesmo com contadores separados. O teste confere ambas após conclusões intercaladas.
Erros e carregamento também podem chegar ao lugar errado
Se proteger apenas publish({ text }), o texto errado desaparece, mas dois bugs permanecem:
- A falha após B ser selecionado. O
catchde A mostra “A failed” na visualização de B. - A termina enquanto B carrega. O
finallyde A desliga o indicador de B.
Para detectar o segundo, conclua A com B ainda pendente e confira imediatamente que loading continua true. Se só examinar depois de B terminar, não verá o indicador desligar cedo.
Para o primeiro, rejeite A com B pendente e confira que B não mostra erro e continua carregando. Depois resolva B. Adicione também um teste de falha da requisição atual: uma proteção que engole todos os erros passaria no teste antigo e ocultaria falhas reais.
A regra é mais forte que “o texto certo aparece no fim”: só a requisição atual pode publicar algo ao terminar, seja texto, erro ou desligamento do indicador.
Cancelar não é o mesmo que ignorar
Se o carregador oferece cancelamento, cancelar A economiza trabalho. Mas A ainda pode publicar depois: o resultado pode estar em cache, a operação pode ignorar o sinal ou terminar no mesmo instante do cancelamento.
Use cancelamento para interromper trabalho desnecessário. Use o número para decidir se o resultado pode alterar a visualização. Confira se o carregador respeita AbortSignal; nem toda API que retorna promise o faz.
Ignorar um resultado antigo também não desfaz o que load fez pelo caminho. O carregador do exemplo só retorna texto. Se um real também escreve em cache compartilhado ou atualiza um modelo, rastreie essas gravações separadamente. O contador protege apenas a visualização.
Execute os testes
Baixe async-selection.mjs e execute com Node.js 22 ou posterior:
node --test async-selection.mjs
Os dez testes cobrem sobrescrita A → B, proteção de sucesso/erro/carregamento, repetição A → B → A e dois leitores independentes. Os dois reproduce: afirmam deliberadamente resultados quebrados; os outros oito conferem o comportamento pretendido. Sem temporizadores nem dependências.
Para aplicar em código real, encontre o manipulador de entrada, cada await ou callback, o estado compartilhado que pode mudar durante a espera e cada gravação no retorno. Anote a ordem dos eventos junto às posições do código.
No Yenpo, mantenha o arquivo de entrada aberto e peça ao AI Reader para rastrear estado através de um await. Confira os caminhos de sucesso, erro e finally. Se a resposta parar em “a promise resolve”, pergunte: qual requisição pode usar o resultado?