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 oreact-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>.defineRouteste 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
falseredireciona quem tem a permissão, num flash, toda vez que a rota é aberta a frio (F5 direto na URL, link externo); - responder
truerenderiza 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 oindex.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
routingembrulha o React Router v8 declarativo e te dá uma superfície única de import — tudo vem de"tempest-react-sdk". defineRoutestipa sua árvore;<AppRouter routes={...} />monta router +<Suspense>+<Routes>sozinho.- Use
index: truepara a rota padrão de um layout epathpara as demais — nunca os dois juntos. - Layouts aninhados renderizam filhos no
<Outlet>; navegue com<Link>. lazyfaz code-split com retry automático em chunk velho; mostre ofallbackdo 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çãouse…para o ESLint aceitar. - Permissão que resolve de forma assíncrona responde
"pending": a decisão fica segura e oguardFallback(ou ofallbackdo<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"+initialEntriesem testes.