Pular para o conteúdo principal

v0.16: Busca em paralelo, Collection.move, Lazy

· 13 min para ler
Nathaniel Tucker
Creator of Reactive Data Client

Novos recursos:

Outras melhorias:

Collection.move facilita mover entidades entre Collections com uma única operação:

import { useController } from '@data-client/react';
import { TaskResource, type Task } from './TaskResource';

export default function TaskCard({ task }: { task: Task }) {
  const handleMove = () => ctrl.fetch(
    TaskResource.getList.move,
    { id: task.id },
    { id: task.id, status: task.status === 'backlog' ? 'in-progress' : 'backlog' },
  );
  const ctrl = useController();
  return (
    <div className="listItem">
      <span style={{ flex: 1 }}>{task.title}</span>
      <button onClick={handleMove}>
        {task.status === 'backlog' ? '\u25bc' : '\u25b2'}
      </button>
    </div>
  );
}
Resultado

Mudanças incompatíveis:

Atualize com um codemod automatizado para todas as mudanças incompatíveis:

npx jscodeshift -t https://dataclient.io/codemods/v0.16.js --extensions=ts,tsx,js,jsx src/

Collection.move​

Collection.move e RestEndpoint.move facilitam mover entidades entre collections com uma única requisição PATCH. A entidade é removida automaticamente das collections que correspondem ao seu estado antigo e adicionada às collections que correspondem aos novos valores do corpo da requisição.

Isso é útil para quadros kanban, reatribuição de grupos, mudanças de status ou qualquer fluxo em que um item se move entre listas filtradas.

const TaskResource = resource({
path: '/tasks/:id',
searchParams: {} as { status: string },
schema: Task,
});

// PATCH /tasks/3 - moves task from 'backlog' to 'in-progress'
await ctrl.fetch(
TaskResource.getList.move,
{ id: '3' },
{ id: '3', status: 'in-progress' },
);

Funciona tanto com parâmetros de caminho quanto com parâmetros de busca, e suporta collections de Array e Values.

Collection.moveWith()​

Collection.moveWith() constrói um schema de mover personalizado, análogo a addWith(). A função merge controla como as entidades são adicionadas à collection de destino (por exemplo, inserindo no início em vez de no final), enquanto o comportamento de remoção é derivado automaticamente. A função de merge unshift é fornecida por conveniência.

import { Collection, unshift } from '@data-client/rest';

class MyCollection extends Collection {
constructor(schema, options) {
super(schema, options);
this.move = this.moveWith(unshift);
}
}

Carregamento de dados em paralelo​

useFetch() agora retorna um thenable UsablePromise, tornando use(useFetch(endpoint, args)) um substituto direto de useSuspense() — ele suspende enquanto os dados carregam, retorna os dados desnormalizados quando estão em cache, lança em caso de erro e suspende novamente após uma invalidação.

Como useFetch() e use() são chamadas separadas, várias buscas começam em paralelo. Todas as chamadas a useFetch() executam antes que qualquer use() suspenda, então toda busca já está em andamento mesmo que o primeiro use() suspenda o componente. Isso evita a cascata sequencial que ocorre com várias chamadas a useSuspense(), em que a segunda busca não pode começar até que a primeira seja resolvida. #3755

import { use } from 'react';
import { useFetch } from '@data-client/react';
import { PostResource, CommentResource } from './Resources';

function PostWithComments({ id }: { id: number }) {
  // Both fetches start in parallel
  const postPromise = useFetch(PostResource.get, { id });
  const commentsPromise = useFetch(CommentResource.getList, { postId: id });

  // use() reads the results — if the first suspends,
  // the second fetch is already in-flight
  const post = use(postPromise);
  const comments = use(commentsPromise);

  return (
    <article>
      <h3>{post.title}</h3>
      <p>{post.body}</p>
      <h4>Comments</h4>
      {comments.map(comment => (
        <div key={comment.id} className="listItem">
          <strong>{comment.author}</strong>: {comment.text}
        </div>
      ))}
    </article>
  );
}
render(<PostWithComments id={1} />);
Resultado

Limite de profundidade de desnormalização e Lazy​

Quando os schemas têm relações bidirecionais entre tipos de entidade (por exemplo, Department → Building → Department → ...), a desnormalização percorre cada entidade única do grafo conectado. Com conjuntos de dados pequenos isso passa despercebido, mas em escala — milhares de entidades interconectadas — a recursão excede a pilha de chamadas do JS e lança RangeError: Maximum call stack size exceeded. #3822

Dois recursos complementares resolvem isso: um limite de profundidade como rede de segurança e Lazy para controle preciso, campo a campo, de quais relações são resolvidas de forma antecipada.

Limite de profundidade de desnormalização​

A desnormalização agora impõe um limite de profundidade padrão de 64 saltos entre entidades. Entidades além do limite são retornadas com ids não resolvidos em vez de objetos totalmente desnormalizados. Um console.error é emitido em modo de desenvolvimento quando o limite é atingido.

O limite pode ser configurado por Entity com static maxEntityDepth:

class Department extends Entity {
static maxEntityDepth = 16;
}

Isso evita travamentos, mas é um instrumento grosseiro — corta todos os caminhos profundos, inclusive os legítimos e não cíclicos. Para um controle refinado, use Lazy.

Lazy​

Lazy envolve um schema para pular a desnormalização antecipada de campos de relação específicos. Durante a desnormalização da entidade pai, o campo mantém seu valor normalizado bruto (chaves primárias). A relação pode então ser resolvida sob demanda via useQuery usando o acessor .query.

import { Entity, Lazy } from '@data-client/rest';

class Department extends Entity {
id = '';
name = '';
buildings: string[] = [];

static schema = {
buildings: new Lazy([Building]),
};
}

Diferente do limite de profundidade, Lazy é uma adesão direcionada, por relação. Apenas os campos que você marca como lazy pulam a resolução antecipada — o resto do schema é desnormalizado normalmente. Isso também melhora o desempenho ao adiar o trabalho em relações que nem sempre são necessárias, e oferece isolamento de memoização — mudanças em entidades lazy não invalidam a forma desnormalizada do pai.

Veja a documentação de Lazy para todos os detalhes de uso e #3828 para a discussão de design.

Guia de migração​

Esta atualização exige atualizar todas as versões dos pacotes simultaneamente.

npm install --save @data-client/react@^0.16.0 @data-client/rest@^0.16.0 @data-client/endpoint@^0.16.0 @data-client/core@^0.16.0 @data-client/vue@^0.16.0 @data-client/test@^0.16.0 @data-client/img@^0.16.0

Um codemod automatizado trata das três mudanças incompatíveis abaixo — sintaxe de caminho, truthiness de useFetch() e imports do namespace schema:

npx jscodeshift -t https://dataclient.io/codemods/v0.16.js --extensions=ts,tsx,js,jsx src/

path-to-regexp v8​

RestEndpoint.path agora usa o path-to-regexp v8, que muda a sintaxe de parâmetros opcionais, parâmetros repetidos e caracteres especiais.

Sintaxe v6Sintaxe v8Descrição
/:optional?{/:optional}Parâmetro opcional
/:repeat+/*repeatRepetição de um ou mais (tipado como string[])
/:repeat*{/*repeat}Repetição de zero ou mais (tipado como string[])
/:with-dash/:"with-dash"Nome de parâmetro entre aspas
/:id(\d+)/:idRegex personalizada removida

Parâmetros opcionais​

O sufixo ? foi removido. Envolva o segmento opcional (incluindo seu prefixo) em {}.

Before
const ep = new RestEndpoint({
path: '/:group/things/:number?',
});
After
const ep = new RestEndpoint({
path: '/:group/things{/:number}',
});

Vários segmentos opcionais com prefixos diferentes:

Before
const ep = new RestEndpoint({
path: '/:attr1?{-:attr2}?{-:attr3}?',
});
After
const ep = new RestEndpoint({
path: '{/:attr1}{-:attr2}{-:attr3}',
});

Parâmetros repetidos​

Os sufixos + (um ou mais) e * (zero ou mais) foram removidos. Use a sintaxe de curinga *name, envolvida em {} para zero ou mais.

Parâmetros curinga agora são tipados como string[] (arrays) em vez de string | number, já que representam vários segmentos de caminho.

Before
const ep = new RestEndpoint({
path: '/files/:path+',
});
ep({ path: 'docs/reports/q4' });
const optEp = new RestEndpoint({
path: '/files/:path*',
});
After
const ep = new RestEndpoint({
path: '/files/*path',
});
ep({ path: ['docs', 'reports', 'q4'] });
const optEp = new RestEndpoint({
path: '/files{/*path}',
});

Nomes de parâmetros entre aspas​

Nomes de parâmetros com caracteres especiais (hifens, pontos) agora precisam estar entre aspas duplas:

Before
const ep = new RestEndpoint({
path: '/:with-dash',
});
After
const ep = new RestEndpoint({
path: '/:"with-dash"',
});

Regex personalizada removida​

Padrões de regex inline como /:id(\d+) não são mais suportados. Remova a regex — valide no servidor em vez disso.

Before
const ep = new RestEndpoint({
path: '/users/:id(\\d+)',
});
After
const ep = new RestEndpoint({
path: '/users/:id',
});

Escapando caracteres especiais​

Caracteres como {}()[]+?!:*\\ precisam ser escapados com \\ quando usados como literais no padrão de caminho. Esses caracteres são reservados pelo path-to-regexp e seu uso sem escape lança um erro Unexpected token. Uma query string literal no padrão ainda precisa de um ? escapado, por exemplo '/resource\\?per_page=50'.

Quando os segmentos de query usam parâmetros nomeados, prefira grupos explícitos em vez de um único ? escapado:

Before
const ep = new RestEndpoint({
path: '/search\\?q=:q',
});
After
const ep = new RestEndpoint({
path: '/search?{q=:q}{&page=:page}',
});

Encontrando caminhos para migrar​

Procure estes padrões nos valores de path: do seu código:

  • ? depois de um :param — parâmetros opcionais a converter
  • + ou * depois de um :param — parâmetros repetidos a converter
  • (\ depois de um :param — regex personalizada a remover
  • Nomes de parâmetros com - ou . — precisam de aspas

Veja RestEndpoint.path para a documentação completa da sintaxe v8, ou os erros do path-to-regexp para explicações detalhadas. A migração assistida por IA também está disponível:

npx skills add reactive/data-client --skill path-to-regexp-v8-migration

useFetch() retorna UsablePromise​

useFetch() agora retorna um thenable UsablePromise com dados desnormalizados, tratamento de erros e uma propriedade .resolved. Isso o torna diretamente compatível com React.use() — use(useFetch(endpoint, args)) se comporta de forma idêntica a useSuspense().

Antes, useFetch() retornava undefined quando os dados eram válidos. Código que verificava se houve uma busca pela truthiness agora precisa usar .resolved:

Before
const promise = useFetch(PostResource.get, { id });
if (promise) {
// fetch was triggered
}
After
const promise = useFetch(PostResource.get, { id });
if (!promise.resolved) {
// fetch is in-flight
}
use(promise); // suspends or returns denormalized data

Veja Carregamento de dados em paralelo acima para saber como isso habilita buscas concorrentes que evitam cascatas sequenciais. #3752, #3755

Imports diretos de schema​

As classes de schema agora podem ser importadas diretamente de @data-client/endpoint (ou de @data-client/rest, que as reexporta), sem exigir o namespace schema. Isso oferece uma sintaxe de import mais limpa, mantendo total compatibilidade retroativa.

Before
import { schema } from '@data-client/endpoint';

const myUnion = new schema.Union(
{ users: User, groups: Group },
'type',
);
After
import { Union } from '@data-client/endpoint';

const myUnion = new Union(
{ users: User, groups: Group },
'type',
);

Todas as classes de schema estão disponíveis como exports diretos: Union, Invalidate, Collection, Query, Values, All e Lazy. O export do namespace schema continua disponível por compatibilidade retroativa.

Suporte à atualização​

Como sempre, se tiver qualquer problema ou dúvida, fique à vontade para entrar no nosso Chat ou registrar um bug