Ir para o conteúdo

Routing

O módulo routing do tempest-react-sdk embrulha o React Router em modo declarativo (^7 || ^8) e devolve uma superfície única de import: você declara sua árvore de rotas com defineRoutes, monta tudo com um único <AppRouter> e importa cada primitivo (Link, Outlet, useNavigate, …) direto do SDK. Em cima disso, o SDK adiciona o que todo app Tempest repete na mão: code-splitting com retry automático em chunk velho, guards declarativos de rota e um <Suspense> já pronto. Esta página te leva do zero a uma árvore com layout aninhado, lazy loading e rotas protegidas.

react-router é peer dependency — instale no seu app

npm install react-router

O SDK não empacota o react-router junto, porque ele guarda contexto React: uma cópia aninhada em tempest-react-sdk/node_modules seria um <Router> diferente do seu, e todo hook do SDK estouraria com useNavigate() may be used only in the context of a <Router>. O range aceito é ^7 || ^8 — a superfície re-exportada aqui é idêntica nos dois majors, e nenhum deles usa react-router-dom (os bindings de DOM vêm no próprio react-router).

Por que o SDK é dono do roteamento agora

Antes, cada app criava seu próprio <Suspense>, escrevia o seu helper de guard e reinventava o retry de chunk. Isso gerava divergência entre os apps Tempest e import paths espalhados.

Com o módulo routing você ganha:

  • Uma só superfície de import. Tudo vem de "tempest-react-sdk" — componentes, hooks e os primitivos re-exportados do React Router. Seu app instala o react-router (peer), mas importa de um lugar só.
  • Declarativo. Você descreve o que são as rotas (uma árvore de objetos), não como montá-las imperativamente.
  • Baterias inclusas. <AppRouter> já constrói o router, o <Suspense> e as <Routes>. defineRoutes te dá tipagem. Guards e lazy loading são campos da própria rota.

Primitivos re-exportados

O SDK re-exporta os primitivos declarativos do React Router para você importar tudo do mesmo lugar: BrowserRouter, HashRouter, MemoryRouter, Routes, Route, Outlet, Navigate, Link, NavLink, useNavigate, useParams, useSearchParams, useLocation, useMatch, useRouteError e redirect.

Importar direto de react-router também funciona e resolve para a mesma cópia — o re-export existe por conveniência, não por isolamento. Isso torna a adoção incremental possível: um app que já usa react-router direto pode passar a usar o SDK sem reescrever import nenhum.

Construindo a árvore com defineRoutes

defineRoutes é um helper de identidade: ele recebe um array de TempestRouteObject e devolve o mesmo array, mas com tipagem completa. Você ganha autocomplete e checagem de tipos na árvore sem precisar anotar nada na mão.

// routes.tsx
import { defineRoutes } from "tempest-react-sdk";
import { Home } from "@/pages/Home";
import { Login } from "@/pages/Login";

export const routes = defineRoutes([
  {
    path: "/",
    element: <Home />,
  },
  {
    path: "login",
    element: <Login />,
  },
]);

Cada TempestRouteObject aceita:

Campo Tipo Descrição
path string Segmento de URL da rota.
index boolean Rota índice do pai. Mutuamente exclusivo com path.
element ReactNode O que renderizar quando a rota casa.
lazy () => Promise<{ default: ComponentType }> Carrega o componente sob demanda (code-split). Retry automático em chunk velho.
children TempestRouteObject[] Rotas aninhadas.
guard boolean \| (() => RouteGuardResult) false redireciona, true renderiza, "pending" segura a decisão.
guardFallback ReactNode Renderizado enquanto o guard responde "pending". Padrão: o fallback do <AppRouter>.
redirectTo string Destino do redirect do guard. Padrão "/".
caseSensitive boolean Faz o match de path diferenciar maiúsculas/minúsculas.

A página do lazy pode declarar props

ComponentType ali é literal: qualquer componente serve, tenha ele prop obrigatória, opcional ou nenhuma. Uma página ({ id }: Props) => … entra na árvore sem cast. Props que a rota precise passar continuam saindo do element ou de um contexto — o lazy monta o componente sem argumentos.

Depois é só passar a árvore para o <AppRouter>:

// App.tsx
import { AppRouter } from "tempest-react-sdk";
import { routes } from "@/routes";

export function App() {
  return <AppRouter routes={routes} />;
}

O <AppRouter> monta sozinho o router, o <Suspense> e as <Routes> a partir da árvore. Props disponíveis:

Prop Tipo Padrão Descrição
routes TempestRouteObject[] — A árvore de rotas (obrigatória).
router "browser" \| "hash" \| "memory" "browser" Qual tipo de router usar.
basename string — Prefixo de path comum a todas as rotas.
initialEntries string[] — Histórico inicial — só para o router "memory".
fallback ReactNode — Fallback do <Suspense> enquanto um chunk lazy carrega.

index vs path

Toda rota com children precisa decidir o que mostrar quando a URL casa exatamente com o pai. Essa é a rota index: ela não tem path próprio, apenas marca index: true.

// routes.tsx
import { defineRoutes } from "tempest-react-sdk";
import { RootLayout } from "@/layouts/RootLayout";
import { Home } from "@/pages/Home";
import { About } from "@/pages/About";

export const routes = defineRoutes([
  {
    path: "/",
    element: <RootLayout />,
    children: [
      { index: true, element: <Home /> },
      { path: "about", element: <About /> },
    ],
  },
]);

Aqui, abrir / renderiza o <RootLayout> com o <Home> dentro; abrir /about renderiza o <RootLayout> com o <About> dentro.

index e path são mutuamente exclusivos

Uma rota é índice (index: true) ou tem path, nunca os dois. Marcar os dois é um erro de configuração.

Layouts aninhados com Outlet

A rota pai renderiza o layout; os filhos renderizam dentro dele através do <Outlet>. O <Outlet> é o ponto onde o React Router injeta a rota filha que casou.

// RootLayout.tsx
import { Link, Outlet } from "tempest-react-sdk";

export function RootLayout() {
  return (
    <div>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/dashboard">Dashboard</Link>
      </nav>
      <Outlet />
    </div>
  );
}

A <nav> fica visível em todas as rotas filhas; o <Outlet> troca de conteúdo conforme a URL. Use <Link> (também re-exportado pelo SDK) para navegar sem recarregar a página.

Navegação programática

Para navegar a partir de código (depois de um submit, por exemplo), use useNavigate: const navigate = useNavigate(); navigate("/dashboard");.

Lazy loading + o fallback do Suspense

Páginas pesadas não precisam entrar no bundle inicial. Use o campo lazy para carregar o componente sob demanda. Ele recebe uma função que faz import() dinâmico e devolve o módulo com default.

// routes.tsx
import { defineRoutes } from "tempest-react-sdk";
import { RootLayout } from "@/layouts/RootLayout";
import { Home } from "@/pages/Home";

export const routes = defineRoutes([
  {
    path: "/",
    element: <RootLayout />,
    children: [
      { index: true, element: <Home /> },
      {
        path: "dashboard",
        lazy: () => import("@/pages/Dashboard"),
      },
    ],
  },
]);

Como o chunk demora um instante para baixar, o <AppRouter> envolve tudo num <Suspense>. Passe um fallback para mostrar algo enquanto carrega:

// App.tsx
import { AppRouter } from "tempest-react-sdk";
import { routes } from "@/routes";

export function App() {
  return <AppRouter routes={routes} fallback={<p>Loading…</p>} />;
}

Retry automático em chunk velho

Quando você faz um novo deploy, os nomes dos chunks mudam. Um usuário com a aba aberta há horas pode pedir um chunk que não existe mais e tomar um erro de import. O lazy do SDK detecta esse caso e tenta recarregar automaticamente — você não precisa escrever esse retry na mão.

Guards: protegendo rotas

Quase todo app tem rotas que só usuários autenticados podem ver. O campo guard resolve isso direto na árvore: quando o valor é falsy, o <AppRouter> renderiza um redirect no lugar do element.

Forma booleana

Se a condição já está disponível como valor, passe um booleano:

// routes.tsx
import { defineRoutes } from "tempest-react-sdk";
import { Dashboard } from "@/pages/Dashboard";

const isAuthenticated = false;

export const routes = defineRoutes([
  {
    path: "dashboard",
    element: <Dashboard />,
    guard: isAuthenticated,
    redirectTo: "/login",
  },
]);

Forma de função (auth store)

Na prática, o estado de auth vive numa store. Passe uma função que lê a store na hora da renderização — assim o guard sempre vê o valor atual:

// routes.tsx
import { defineRoutes } from "tempest-react-sdk";
import { useAuth } from "@/stores/auth"; // store baseada em createAuthStore
import { RootLayout } from "@/layouts/RootLayout";
import { Home } from "@/pages/Home";
import { Login } from "@/pages/Login";

export const routes = defineRoutes([
  {
    path: "/",
    element: <RootLayout />,
    children: [
      { index: true, element: <Home /> },
      { path: "login", element: <Login /> },
      {
        path: "dashboard",
        lazy: () => import("@/pages/Dashboard"),
        guard: () => useAuth.getState().isAuthenticated,
        redirectTo: "/login",
      },
    ],
  },
]);

O guard roda na renderização — leia a store via getState() ou um hook

A função do guard é avaliada durante a renderização da rota. Por isso ela precisa ler o estado naquele momento: use useAuth.getState().isAuthenticated (leitura imperativa, fora do React) ou chame um hook direto na função do guard, que roda dentro do ciclo de renderização — veja a seção seguinte. Não capture o valor uma vez fora da função: você congelaria o estado de auth no carregamento inicial.

Quando guard é falsy, o usuário é redirecionado para redirectTo (padrão "/"). No exemplo acima, quem não está autenticado e tenta abrir /dashboard cai em /login.

Forma de função assíncrona: o terceiro estado "pending"

Um guard de duas respostas não consegue falar sobre permissão que ainda não resolveu — e é o caso do próprio useCan do SDK, que devolve { allowed, isLoading }. Com só true/false, o guard precisa responder algo enquanto a checagem está no ar, e as duas respostas possíveis estão erradas:

  • responder false redireciona quem tem a permissão, num flash, toda vez que a rota é aberta a frio (F5 direto na URL, link externo);
  • responder true renderiza a tela para quem não tem, até a resposta chegar.

Por isso o guard aceita uma terceira resposta:

type RouteGuardResult = boolean | "pending";

"pending" segura a decisão e renderiza o guardFallback no lugar — sem redirecionar, sem vazar a tela:

// routes.tsx
import { defineRoutes, useCan } from "tempest-react-sdk";
import { Spinner } from "tempest-react-sdk";
import { RootLayout } from "@/layouts/RootLayout";
import { Home } from "@/pages/Home";

export const routes = defineRoutes([
  {
    path: "/",
    element: <RootLayout />,
    children: [
      { index: true, element: <Home /> },
      {
        path: "financeiro",
        lazy: () => import("@/pages/Financeiro"),
        redirectTo: "/",
        guardFallback: <Spinner size="lg" />,
        guard: function useFinanceGuard() {
          const { allowed, isLoading } = useCan({ action: "read", resource: "finance" });
          return isLoading ? "pending" : allowed;
        },
      },
    ],
  },
]);

Nomeie a função use… — sem isso o ESLint reprova

O guard roda dentro da renderização, então ele pode chamar hooks. Mas a regra react-hooks/rules-of-hooks só aceita hook dentro de função cujo nome começa com use (ou de componente em PascalCase). Escrito como arrow anônima, o exemplo acima falha o lint com:

React Hook "useCan" is called in function "guard" that is neither a React
function component nor a custom React Hook function.

A forma que passa é a expressão de função nomeada, exatamente como no exemplo:

guard: function useFinanceGuard() { … }   // ✅ passa
guard: () => { … useCan() … }             // ❌ erro de lint

Guard sem hook (() => useAuth.getState().isAuthenticated) continua podendo ser arrow anônima — getState() não é hook.

Um spinner cobre lazy e pending

Se a rota não define guardFallback, o SDK usa o fallback que você já passou ao <AppRouter> — o mesmo que cobre o carregamento de chunk do lazy. As duas coisas são "ainda não está pronto", e na maioria dos apps merecem o mesmo spinner.

Cada rota guardada é remontada, e isso é de propósito

O React Router renderiza o elemento da rota casada na mesma posição da árvore. Sem uma chave distinta por rota, o React reconciliaria duas rotas diferentes sobre a mesma instância do guard, e o estado de hook da rota anterior sobreviveria na seguinte — um guard que lê useState("de-a") continuaria lendo "de-a" depois de navegar para a rota cujo guard inicializa "de-b". O <AppRouter> dá uma chave por objeto de rota justamente para isso; existe um teste nomeado que falha se ela sair.

RouteGuard standalone

Às vezes você quer proteger uma parte da UI sem que ela seja uma rota — ou prefere o guard explícito no JSX. Para isso existe o <RouteGuard>: ele renderiza os children quando when é verdadeiro, senão emite um <Navigate>.

// ProtectedDashboard.tsx
import { RouteGuard } from "tempest-react-sdk";
import { useAuth } from "@/stores/auth";
import { Dashboard } from "@/pages/Dashboard";

export function ProtectedDashboard() {
  const isAuthenticated = useAuth((state) => state.isAuthenticated);

  return (
    <RouteGuard when={isAuthenticated} redirectTo="/login" replace>
      <Dashboard />
    </RouteGuard>
  );
}

Props do <RouteGuard>:

Prop Tipo Padrão Descrição
when RouteGuardResult — true renderiza os children, false redireciona, "pending" segura.
fallback ReactNode null Renderizado enquanto when é "pending".
redirectTo string "/" Destino quando when é falso.
replace boolean true Substitui a entrada no histórico em vez de empilhar.
children ReactNode — O que proteger.

Mesma regra de leitura de estado

Aqui você está num componente React, então leia a store com o hook (useAuth((state) => state.isAuthenticated)) para re-renderizar quando o auth mudar. O guard da árvore roda dentro da renderização também, então ele tem as duas opções: getState() para leitura imperativa simples, ou hooks — desde que a função seja nomeada use…, como na seção de "pending" acima.

Escolhendo o tipo de router

O <AppRouter> aceita três tipos via a prop router:

  • "browser" (padrão) — usa a History API; URLs limpas (/dashboard). É o que você quer em produção.
  • "hash" — URLs com # (/#/dashboard). Útil quando o servidor não consegue fazer fallback de todas as rotas para o index.html.
  • "memory" — histórico em memória, sem tocar a URL do navegador. Ideal para testes e ambientes não-DOM.

Em testes, combine "memory" com initialEntries para começar numa rota específica:

// App.test.tsx
import { render, screen } from "@testing-library/react";
import { AppRouter } from "tempest-react-sdk";
import { routes } from "@/routes";

test("renders the dashboard route", () => {
  render(
    <AppRouter
      routes={routes}
      router="memory"
      initialEntries={["/dashboard"]}
      fallback={<p>Loading…</p>}
    />,
  );

  expect(screen.getByRole("heading", { name: /dashboard/i })).toBeInTheDocument();
});

initialEntries é só do router memory

initialEntries define o histórico inicial e só faz sentido com router="memory". Nos routers browser/hash a rota inicial vem da própria URL do navegador.

Recap

  • O módulo routing embrulha o React Router v8 declarativo e te dá uma superfície única de import — tudo vem de "tempest-react-sdk".
  • defineRoutes tipa sua árvore; <AppRouter routes={...} /> monta router + <Suspense> + <Routes> sozinho.
  • Use index: true para a rota padrão de um layout e path para as demais — nunca os dois juntos.
  • Layouts aninhados renderizam filhos no <Outlet>; navegue com <Link>.
  • lazy faz code-split com retry automático em chunk velho; mostre o fallback do Suspense enquanto carrega.
  • Proteja rotas com guard (booleano ou função) + redirectTo, ou com <RouteGuard when={...}> no JSX. O guard roda na renderização, então pode chamar hooks — nomeie a função use… para o ESLint aceitar.
  • Permissão que resolve de forma assíncrona responde "pending": a decisão fica segura e o guardFallback (ou o fallback do <AppRouter>) aparece, em vez de redirecionar quem tem acesso ou vazar a tela para quem não tem.
  • Escolha o router com a prop router: "browser" em produção, "memory" + initialEntries em testes.