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:

  1. Seleciona A à esquerda, A à direita e B à esquerda. O número atual da esquerda é 2; o da direita continua 1.
  2. 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.
  3. Rejeita o A antigo da esquerda. A esquerda deve continuar esperando B sem erro.
  4. 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 catch de A mostra “A failed” na visualização de B.
  • A termina enquanto B carrega. O finally de 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?