refactor: optimize codebase for React 19 compiler (v1.2.6)

React 19 compiler auto-optimizes memoization, making manual hooks unnecessary.

Changes:
- Remove ~60 useCallback/useMemo across 16 files
- Remove React.memo from nav-button and return-button
- Simplify hydration with useSyncExternalStore (privacy-provider)
- Add CHANGELOG.md for version tracking

No functional changes - internal optimization only.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-04 13:14:10 +00:00
parent a70a83dd9d
commit 757626c468
18 changed files with 571 additions and 617 deletions

62
CHANGELOG.md Normal file
View File

@@ -0,0 +1,62 @@
# Changelog
Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [1.2.6] - 2025-02-04
### Alterado
- Refatoração para otimização do React 19 compiler
- Removidos `useCallback` e `useMemo` desnecessários (~60 instâncias)
- Removidos `React.memo` wrappers desnecessários
- Simplificados padrões de hidratação com `useSyncExternalStore`
### Arquivos modificados
- `hooks/use-calculator-state.ts`
- `hooks/use-form-state.ts`
- `hooks/use-month-period.ts`
- `components/auth/signup-form.tsx`
- `components/contas/accounts-page.tsx`
- `components/contas/transfer-dialog.tsx`
- `components/lancamentos/table/lancamentos-filters.tsx`
- `components/sidebar/nav-main.tsx`
- `components/month-picker/nav-button.tsx`
- `components/month-picker/return-button.tsx`
- `components/privacy-provider.tsx`
- `components/dashboard/category-history-widget.tsx`
- `components/anotacoes/note-dialog.tsx`
- `components/categorias/category-dialog.tsx`
- `components/confirm-action-dialog.tsx`
- `components/orcamentos/budget-dialog.tsx`
## [1.2.5] - 2025-02-01
### Adicionado
- Widget de pagadores no dashboard
- Avatares atualizados para pagadores
## [1.2.4] - 2025-01-22
### Corrigido
- Preservar formatação nas anotações
- Layout do card de anotações
## [1.2.3] - 2025-01-22
### Adicionado
- Versão exibida na sidebar
- Documentação atualizada
## [1.2.2] - 2025-01-22
### Alterado
- Atualização de dependências
- Aplicada formatação no código

View File

@@ -3,7 +3,6 @@
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react"; import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import { import {
type ReactNode, type ReactNode,
useCallback,
useEffect, useEffect,
useRef, useRef,
useState, useState,
@@ -121,24 +120,18 @@ export function NoteDialog({
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen; const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
const handleOpenChange = useCallback( const handleOpenChange = (v: boolean) => {
(v: boolean) => {
setDialogOpen(v); setDialogOpen(v);
if (!v) setErrorMessage(null); if (!v) setErrorMessage(null);
}, };
[setDialogOpen],
);
const handleKeyDown = useCallback( const handleKeyDown = (e: React.KeyboardEvent) => {
(e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
(e.currentTarget as HTMLFormElement).requestSubmit(); (e.currentTarget as HTMLFormElement).requestSubmit();
if (e.key === "Escape") handleOpenChange(false); if (e.key === "Escape") handleOpenChange(false);
}, };
[handleOpenChange],
);
const handleAddTask = useCallback(() => { const handleAddTask = () => {
const text = normalize(newTaskText); const text = normalize(newTaskText);
if (!text) return; if (!text) return;
@@ -151,32 +144,25 @@ export function NoteDialog({
updateField("tasks", [...(formState.tasks || []), newTask]); updateField("tasks", [...(formState.tasks || []), newTask]);
setNewTaskText(""); setNewTaskText("");
requestAnimationFrame(() => newTaskRef.current?.focus()); requestAnimationFrame(() => newTaskRef.current?.focus());
}, [newTaskText, formState.tasks, updateField]); };
const handleRemoveTask = useCallback( const handleRemoveTask = (taskId: string) => {
(taskId: string) => {
updateField( updateField(
"tasks", "tasks",
(formState.tasks || []).filter((t) => t.id !== taskId), (formState.tasks || []).filter((t) => t.id !== taskId),
); );
}, };
[formState.tasks, updateField],
);
const handleToggleTask = useCallback( const handleToggleTask = (taskId: string) => {
(taskId: string) => {
updateField( updateField(
"tasks", "tasks",
(formState.tasks || []).map((t) => (formState.tasks || []).map((t) =>
t.id === taskId ? { ...t, completed: !t.completed } : t, t.id === taskId ? { ...t, completed: !t.completed } : t,
), ),
); );
}, };
[formState.tasks, updateField],
);
const handleSubmit = useCallback( const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setErrorMessage(null); setErrorMessage(null);
@@ -228,20 +214,7 @@ export function NoteDialog({
toast.error(result.error); toast.error(result.error);
titleRef.current?.focus(); titleRef.current?.focus();
}); });
}, };
[
formState.title,
formState.description,
formState.type,
formState.tasks,
mode,
note,
setDialogOpen,
onlySpaces,
unchanged,
invalidLen,
],
);
return ( return (
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}> <Dialog open={dialogOpen} onOpenChange={handleOpenChange}>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react"; import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { type FormEvent, useMemo, useState } from "react"; import { type FormEvent, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -90,10 +90,7 @@ export function SignupForm({ className, ...props }: DivProps) {
const [loadingEmail, setLoadingEmail] = useState(false); const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false); const [loadingGoogle, setLoadingGoogle] = useState(false);
const passwordValidation = useMemo( const passwordValidation = validatePassword(password);
() => validatePassword(password),
[password],
);
async function handleSubmit(e: FormEvent<HTMLFormElement>) { async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();

View File

@@ -1,12 +1,6 @@
"use client"; "use client";
import { import { useEffect, useState, useTransition } from "react";
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
createCategoryAction, createCategoryAction,
@@ -76,14 +70,10 @@ export function CategoryDialog({
onOpenChange, onOpenChange,
); );
const initialState = useMemo( const initialState = buildInitialValues({
() =>
buildInitialValues({
category, category,
defaultType, defaultType,
}), });
[category, defaultType],
);
// Use form state hook for form management // Use form state hook for form management
const { formState, updateField, setFormState } = const { formState, updateField, setFormState } =
@@ -95,7 +85,7 @@ export function CategoryDialog({
setFormState(initialState); setFormState(initialState);
setErrorMessage(null); setErrorMessage(null);
} }
}, [dialogOpen, initialState, setFormState]); }, [dialogOpen, setFormState, category, defaultType]);
// Clear error when dialog closes // Clear error when dialog closes
useEffect(() => { useEffect(() => {
@@ -104,8 +94,7 @@ export function CategoryDialog({
} }
}, [dialogOpen]); }, [dialogOpen]);
const handleSubmit = useCallback( const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setErrorMessage(null); setErrorMessage(null);
@@ -141,9 +130,7 @@ export function CategoryDialog({
setErrorMessage(result.error); setErrorMessage(result.error);
toast.error(result.error); toast.error(result.error);
}); });
}, };
[category?.id, formState, initialState, mode, setDialogOpen, setFormState],
);
const title = mode === "create" ? "Nova categoria" : "Editar categoria"; const title = mode === "create" ? "Nova categoria" : "Editar categoria";
const description = const description =

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import type { VariantProps } from "class-variance-authority"; import type { VariantProps } from "class-variance-authority";
import { useCallback, useMemo, useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -49,22 +49,16 @@ export function ConfirmActionDialog({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const dialogOpen = open ?? internalOpen; const dialogOpen = open ?? internalOpen;
const setDialogOpen = useCallback( const setDialogOpen = (value: boolean) => {
(value: boolean) => {
if (open === undefined) { if (open === undefined) {
setInternalOpen(value); setInternalOpen(value);
} }
onOpenChange?.(value); onOpenChange?.(value);
}, };
[onOpenChange, open],
);
const resolvedPendingLabel = useMemo( const resolvedPendingLabel = pendingLabel ?? confirmLabel;
() => pendingLabel ?? confirmLabel,
[pendingLabel, confirmLabel],
);
const handleConfirm = useCallback(() => { const handleConfirm = () => {
if (!onConfirm) { if (!onConfirm) {
setDialogOpen(false); setDialogOpen(false);
return; return;
@@ -78,7 +72,7 @@ export function ConfirmActionDialog({
// Mantém o diálogo aberto para que o chamador trate o erro. // Mantém o diálogo aberto para que o chamador trate o erro.
} }
}); });
}, [onConfirm, setDialogOpen]); };
return ( return (
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}> <AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>

View File

@@ -3,7 +3,7 @@
import { RiAddCircleLine, RiBankLine } from "@remixicon/react"; import { RiAddCircleLine, RiBankLine } from "@remixicon/react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteAccountAction } from "@/app/(dashboard)/contas/actions"; import { deleteAccountAction } from "@/app/(dashboard)/contas/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog"; import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
@@ -47,8 +47,7 @@ export function AccountsPage({
const hasAccounts = accounts.length > 0; const hasAccounts = accounts.length > 0;
const orderedAccounts = useMemo(() => { const orderedAccounts = [...accounts].sort((a, b) => {
return [...accounts].sort((a, b) => {
// Coloca inativas no final // Coloca inativas no final
const aIsInactive = a.status?.toLowerCase() === "inativa"; const aIsInactive = a.status?.toLowerCase() === "inativa";
const bIsInactive = b.status?.toLowerCase() === "inativa"; const bIsInactive = b.status?.toLowerCase() === "inativa";
@@ -59,33 +58,32 @@ export function AccountsPage({
// Mesma ordem alfabética dentro de cada grupo // Mesma ordem alfabética dentro de cada grupo
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }); return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
}); });
}, [accounts]);
const handleEdit = useCallback((account: Account) => { const handleEdit = (account: Account) => {
setSelectedAccount(account); setSelectedAccount(account);
setEditOpen(true); setEditOpen(true);
}, []); };
const handleEditOpenChange = useCallback((open: boolean) => { const handleEditOpenChange = (open: boolean) => {
setEditOpen(open); setEditOpen(open);
if (!open) { if (!open) {
setSelectedAccount(null); setSelectedAccount(null);
} }
}, []); };
const handleRemoveRequest = useCallback((account: Account) => { const handleRemoveRequest = (account: Account) => {
setAccountToRemove(account); setAccountToRemove(account);
setRemoveOpen(true); setRemoveOpen(true);
}, []); };
const handleRemoveOpenChange = useCallback((open: boolean) => { const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open); setRemoveOpen(open);
if (!open) { if (!open) {
setAccountToRemove(null); setAccountToRemove(null);
} }
}, []); };
const handleRemoveConfirm = useCallback(async () => { const handleRemoveConfirm = async () => {
if (!accountToRemove) { if (!accountToRemove) {
return; return;
} }
@@ -99,19 +97,19 @@ export function AccountsPage({
toast.error(result.error); toast.error(result.error);
throw new Error(result.error); throw new Error(result.error);
}, [accountToRemove]); };
const handleTransferRequest = useCallback((account: Account) => { const handleTransferRequest = (account: Account) => {
setTransferFromAccount(account); setTransferFromAccount(account);
setTransferOpen(true); setTransferOpen(true);
}, []); };
const handleTransferOpenChange = useCallback((open: boolean) => { const handleTransferOpenChange = (open: boolean) => {
setTransferOpen(open); setTransferOpen(open);
if (!open) { if (!open) {
setTransferFromAccount(null); setTransferFromAccount(null);
} }
}, []); };
const removeTitle = accountToRemove const removeTitle = accountToRemove
? `Remover conta "${accountToRemove.name}"?` ? `Remover conta "${accountToRemove.name}"?`

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { transferBetweenAccountsAction } from "@/app/(dashboard)/contas/actions"; import { transferBetweenAccountsAction } from "@/app/(dashboard)/contas/actions";
import type { AccountData } from "@/app/(dashboard)/contas/data"; import type { AccountData } from "@/app/(dashboard)/contas/data";
@@ -62,15 +62,13 @@ export function TransferDialog({
const [period, setPeriod] = useState(currentPeriod); const [period, setPeriod] = useState(currentPeriod);
// Available destination accounts (exclude source account) // Available destination accounts (exclude source account)
const availableAccounts = useMemo( const availableAccounts = accounts.filter(
() => accounts.filter((account) => account.id !== fromAccountId), (account) => account.id !== fromAccountId,
[accounts, fromAccountId],
); );
// Source account info // Source account info
const fromAccount = useMemo( const fromAccount = accounts.find(
() => accounts.find((account) => account.id === fromAccountId), (account) => account.id === fromAccountId,
[accounts, fromAccountId],
); );
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {

View File

@@ -4,7 +4,7 @@ import {
RiBarChartBoxLine, RiBarChartBoxLine,
RiCloseLine, RiCloseLine,
} from "@remixicon/react"; } from "@remixicon/react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -49,11 +49,14 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
const [selectedCategories, setSelectedCategories] = useState<string[]>([]); const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const isFirstRender = useRef(true);
// Load selected categories from sessionStorage on mount // Load from sessionStorage on mount and save on changes
useEffect(() => { useEffect(() => {
setIsClient(true); setIsClient(true);
// Only load from storage on first render
if (isFirstRender.current) {
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED); const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
if (stored) { if (stored) {
try { try {
@@ -68,17 +71,15 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
// Invalid JSON, ignore // Invalid JSON, ignore
} }
} }
}, [data.allCategories]); isFirstRender.current = false;
} else {
// Save to sessionStorage when selection changes // Save to storage on subsequent changes
useEffect(() => {
if (isClient) {
sessionStorage.setItem( sessionStorage.setItem(
STORAGE_KEY_SELECTED, STORAGE_KEY_SELECTED,
JSON.stringify(selectedCategories), JSON.stringify(selectedCategories),
); );
} }
}, [selectedCategories, isClient]); }, [selectedCategories, data.allCategories]);
// Filter data to show only selected categories with vibrant colors // Filter data to show only selected categories with vibrant colors
const filteredCategories = useMemo(() => { const filteredCategories = useMemo(() => {

View File

@@ -6,14 +6,7 @@ import {
RiFilter3Line, RiFilter3Line,
} from "@remixicon/react"; } from "@remixicon/react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { import { type ReactNode, useEffect, useState, useTransition } from "react";
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -147,13 +140,10 @@ export function LancamentosFilters({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const getParamValue = useCallback( const getParamValue = (key: string) =>
(key: string) => searchParams.get(key) ?? FILTER_EMPTY_VALUE, searchParams.get(key) ?? FILTER_EMPTY_VALUE;
[searchParams],
);
const handleFilterChange = useCallback( const handleFilterChange = (key: string, value: string | null) => {
(key: string, value: string | null) => {
const nextParams = new URLSearchParams(searchParams.toString()); const nextParams = new URLSearchParams(searchParams.toString());
if (value && value !== FILTER_EMPTY_VALUE) { if (value && value !== FILTER_EMPTY_VALUE) {
@@ -167,9 +157,7 @@ export function LancamentosFilters({
scroll: false, scroll: false,
}); });
}); });
}, };
[pathname, router, searchParams],
);
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? ""); const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
const currentSearchParam = searchParams.get("q") ?? ""; const currentSearchParam = searchParams.get("q") ?? "";
@@ -191,7 +179,7 @@ export function LancamentosFilters({
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [searchValue, currentSearchParam, handleFilterChange]); }, [searchValue, currentSearchParam, handleFilterChange]);
const handleReset = useCallback(() => { const handleReset = () => {
const periodValue = searchParams.get("periodo"); const periodValue = searchParams.get("periodo");
const nextParams = new URLSearchParams(); const nextParams = new URLSearchParams();
if (periodValue) { if (periodValue) {
@@ -205,41 +193,29 @@ export function LancamentosFilters({
: pathname; : pathname;
router.replace(target, { scroll: false }); router.replace(target, { scroll: false });
}); });
}, [pathname, router, searchParams]); };
const pagadorSelectOptions = useMemo( const pagadorSelectOptions = pagadorOptions.map((option) => ({
() =>
pagadorOptions.map((option) => ({
value: option.slug, value: option.slug,
label: option.label, label: option.label,
avatarUrl: option.avatarUrl, avatarUrl: option.avatarUrl,
})), }));
[pagadorOptions],
);
const contaOptions = useMemo( const contaOptions = contaCartaoOptions
() =>
contaCartaoOptions
.filter((option) => option.kind === "conta") .filter((option) => option.kind === "conta")
.map((option) => ({ .map((option) => ({
value: option.slug, value: option.slug,
label: option.label, label: option.label,
logo: option.logo, logo: option.logo,
})), }));
[contaCartaoOptions],
);
const cartaoOptions = useMemo( const cartaoOptions = contaCartaoOptions
() =>
contaCartaoOptions
.filter((option) => option.kind === "cartao") .filter((option) => option.kind === "cartao")
.map((option) => ({ .map((option) => ({
value: option.slug, value: option.slug,
label: option.label, label: option.label,
logo: option.logo, logo: option.logo,
})), }));
[contaCartaoOptions],
);
const categoriaValue = getParamValue("categoria"); const categoriaValue = getParamValue("categoria");
const selectedCategoria = const selectedCategoria =
@@ -262,21 +238,18 @@ export function LancamentosFilters({
const [categoriaOpen, setCategoriaOpen] = useState(false); const [categoriaOpen, setCategoriaOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const hasActiveFilters = useMemo(() => { const hasActiveFilters =
return (
searchParams.get("transacao") || searchParams.get("transacao") ||
searchParams.get("condicao") || searchParams.get("condicao") ||
searchParams.get("pagamento") || searchParams.get("pagamento") ||
searchParams.get("pagador") || searchParams.get("pagador") ||
searchParams.get("categoria") || searchParams.get("categoria") ||
searchParams.get("contaCartao") searchParams.get("contaCartao");
);
}, [searchParams]);
const handleResetFilters = useCallback(() => { const handleResetFilters = () => {
handleReset(); handleReset();
setDrawerOpen(false); setDrawerOpen(false);
}, [handleReset]); };
return ( return (
<div className={cn("flex flex-wrap items-center gap-2", className)}> <div className={cn("flex flex-wrap items-center gap-2", className)}>

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react"; import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react";
import React from "react";
interface NavigationButtonProps { interface NavigationButtonProps {
direction: "left" | "right"; direction: "left" | "right";
@@ -9,8 +8,11 @@ interface NavigationButtonProps {
onClick: () => void; onClick: () => void;
} }
const NavigationButton = React.memo( export default function NavigationButton({
({ direction, disabled, onClick }: NavigationButtonProps) => { direction,
disabled,
onClick,
}: NavigationButtonProps) {
const Icon = direction === "left" ? RiArrowLeftSLine : RiArrowRightSLine; const Icon = direction === "left" ? RiArrowLeftSLine : RiArrowRightSLine;
return ( return (
@@ -25,9 +27,4 @@ const NavigationButton = React.memo(
<Icon className="text-primary" size={18} /> <Icon className="text-primary" size={18} />
</button> </button>
); );
}, }
);
NavigationButton.displayName = "NavigationButton";
export default NavigationButton;

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import React from "react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
interface ReturnButtonProps { interface ReturnButtonProps {
@@ -8,7 +7,7 @@ interface ReturnButtonProps {
onClick: () => void; onClick: () => void;
} }
const ReturnButton = React.memo(({ disabled, onClick }: ReturnButtonProps) => { export default function ReturnButton({ disabled, onClick }: ReturnButtonProps) {
return ( return (
<Button <Button
className="w-32 h-6 rounded-sm lowercase" className="w-32 h-6 rounded-sm lowercase"
@@ -20,8 +19,4 @@ const ReturnButton = React.memo(({ disabled, onClick }: ReturnButtonProps) => {
Ir para Mês Atual Ir para Mês Atual
</Button> </Button>
); );
}); }
ReturnButton.displayName = "ReturnButton";
export default ReturnButton;

View File

@@ -1,12 +1,6 @@
"use client"; "use client";
import { import { useEffect, useState, useTransition } from "react";
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
createBudgetAction, createBudgetAction,
@@ -79,14 +73,10 @@ export function BudgetDialog({
onOpenChange, onOpenChange,
); );
const initialState = useMemo( const initialState = buildInitialValues({
() =>
buildInitialValues({
budget, budget,
defaultPeriod, defaultPeriod,
}), });
[budget, defaultPeriod],
);
// Use form state hook for form management // Use form state hook for form management
const { formState, updateField, setFormState } = const { formState, updateField, setFormState } =
@@ -98,7 +88,7 @@ export function BudgetDialog({
setFormState(initialState); setFormState(initialState);
setErrorMessage(null); setErrorMessage(null);
} }
}, [dialogOpen, initialState, setFormState]); }, [dialogOpen, setFormState, budget, defaultPeriod]);
// Clear error when dialog closes // Clear error when dialog closes
useEffect(() => { useEffect(() => {
@@ -107,8 +97,7 @@ export function BudgetDialog({
} }
}, [dialogOpen]); }, [dialogOpen]);
const handleSubmit = useCallback( const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setErrorMessage(null); setErrorMessage(null);
@@ -165,9 +154,7 @@ export function BudgetDialog({
setErrorMessage(result.error); setErrorMessage(result.error);
toast.error(result.error); toast.error(result.error);
}); });
}, };
[budget?.id, formState, initialState, mode, setDialogOpen, setFormState],
);
const title = mode === "create" ? "Novo orçamento" : "Editar orçamento"; const title = mode === "create" ? "Novo orçamento" : "Editar orçamento";
const description = const description =

View File

@@ -1,7 +1,14 @@
"use client"; "use client";
import type React from "react"; import type React from "react";
import { createContext, useContext, useEffect, useState } from "react"; import {
createContext,
useContext,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
interface PrivacyContextType { interface PrivacyContextType {
privacyMode: boolean; privacyMode: boolean;
@@ -13,25 +20,41 @@ const PrivacyContext = createContext<PrivacyContextType | undefined>(undefined);
const STORAGE_KEY = "app:privacyMode"; const STORAGE_KEY = "app:privacyMode";
// Read from localStorage safely (returns false on server)
function getStoredValue(): boolean {
if (typeof window === "undefined") return false;
return localStorage.getItem(STORAGE_KEY) === "true";
}
// Subscribe to storage changes
function subscribeToStorage(callback: () => void) {
window.addEventListener("storage", callback);
return () => window.removeEventListener("storage", callback);
}
export function PrivacyProvider({ children }: { children: React.ReactNode }) { export function PrivacyProvider({ children }: { children: React.ReactNode }) {
const [privacyMode, setPrivacyMode] = useState(false); // useSyncExternalStore handles hydration safely
const [hydrated, setHydrated] = useState(false); const storedValue = useSyncExternalStore(
subscribeToStorage,
getStoredValue,
() => false, // Server snapshot
);
// Sincronizar com localStorage na montagem (evitar mismatch SSR/CSR) const [privacyMode, setPrivacyMode] = useState(storedValue);
const isFirstRender = useRef(true);
// Sync with stored value on mount
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY); if (isFirstRender.current) {
if (stored !== null) { setPrivacyMode(storedValue);
setPrivacyMode(stored === "true"); isFirstRender.current = false;
} }
setHydrated(true); }, [storedValue]);
}, []);
// Persistir mudanças no localStorage // Persist to localStorage when privacyMode changes
useEffect(() => { useEffect(() => {
if (hydrated) {
localStorage.setItem(STORAGE_KEY, String(privacyMode)); localStorage.setItem(STORAGE_KEY, String(privacyMode));
} }, [privacyMode]);
}, [privacyMode, hydrated]);
const toggle = () => { const toggle = () => {
setPrivacyMode((prev) => !prev); setPrivacyMode((prev) => !prev);

View File

@@ -63,12 +63,12 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const periodParam = searchParams.get(MONTH_PERIOD_PARAM); const periodParam = searchParams.get(MONTH_PERIOD_PARAM);
const isLinkActive = React.useCallback(
(url: string) => {
const normalizedPathname = const normalizedPathname =
pathname.endsWith("/") && pathname !== "/" pathname.endsWith("/") && pathname !== "/"
? pathname.slice(0, -1) ? pathname.slice(0, -1)
: pathname; : pathname;
const isLinkActive = (url: string) => {
const normalizedUrl = const normalizedUrl =
url.endsWith("/") && url !== "/" ? url.slice(0, -1) : url; url.endsWith("/") && url !== "/" ? url.slice(0, -1) : url;
@@ -77,23 +77,20 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
normalizedPathname === normalizedUrl || normalizedPathname === normalizedUrl ||
normalizedPathname.startsWith(`${normalizedUrl}/`) normalizedPathname.startsWith(`${normalizedUrl}/`)
); );
}, };
[pathname],
);
const buildHrefWithPeriod = React.useCallback( const buildHrefWithPeriod = (url: string) => {
(url: string) => {
if (!periodParam) { if (!periodParam) {
return url; return url;
} }
const [rawPathname, existingSearch = ""] = url.split("?"); const [rawPathname, existingSearch = ""] = url.split("?");
const normalizedPathname = const normalizedRawPathname =
rawPathname.endsWith("/") && rawPathname !== "/" rawPathname.endsWith("/") && rawPathname !== "/"
? rawPathname.slice(0, -1) ? rawPathname.slice(0, -1)
: rawPathname; : rawPathname;
if (!PERIOD_AWARE_PATHS.has(normalizedPathname)) { if (!PERIOD_AWARE_PATHS.has(normalizedRawPathname)) {
return url; return url;
} }
@@ -102,11 +99,9 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
const queryString = params.toString(); const queryString = params.toString();
return queryString return queryString
? `${normalizedPathname}?${queryString}` ? `${normalizedRawPathname}?${queryString}`
: normalizedPathname; : normalizedRawPathname;
}, };
[periodParam],
);
const activeLinkClasses = const activeLinkClasses =
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-dark! hover:text-primary!"; "data-[active=true]:bg-sidebar-accent data-[active=true]:text-dark! hover:text-primary!";

View File

@@ -1,5 +1,5 @@
import type { VariantProps } from "class-variance-authority"; import type { VariantProps } from "class-variance-authority";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { buttonVariants } from "@/components/ui/button"; import type { buttonVariants } from "@/components/ui/button";
import { import {
formatLocaleValue, formatLocaleValue,
@@ -26,9 +26,9 @@ export function useCalculatorState() {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const resetCopiedTimeoutRef = useRef<number | undefined>(undefined); const resetCopiedTimeoutRef = useRef<number | undefined>(undefined);
const currentValue = useMemo(() => Number(display), [display]); const currentValue = Number(display);
const resultText = useMemo(() => { const resultText = (() => {
if (display === "Erro") { if (display === "Erro") {
return null; return null;
} }
@@ -39,18 +39,17 @@ export function useCalculatorState() {
} }
return formatLocaleValue(normalized); return formatLocaleValue(normalized);
}, [currentValue, display]); })();
const reset = useCallback(() => { const reset = () => {
setDisplay("0"); setDisplay("0");
setAccumulator(null); setAccumulator(null);
setOperator(null); setOperator(null);
setOverwrite(false); setOverwrite(false);
setHistory(null); setHistory(null);
}, []); };
const inputDigit = useCallback( const inputDigit = (digit: string) => {
(digit: string) => {
// Check conditions before state updates // Check conditions before state updates
const shouldReset = overwrite || display === "Erro"; const shouldReset = overwrite || display === "Erro";
@@ -77,11 +76,9 @@ export function useCalculatorState() {
setOverwrite(false); setOverwrite(false);
setHistory(null); setHistory(null);
} }
}, };
[overwrite, display],
);
const inputDecimal = useCallback(() => { const inputDecimal = () => {
// Check conditions before state updates // Check conditions before state updates
const shouldReset = overwrite || display === "Erro"; const shouldReset = overwrite || display === "Erro";
@@ -108,10 +105,9 @@ export function useCalculatorState() {
setOverwrite(false); setOverwrite(false);
setHistory(null); setHistory(null);
} }
}, [overwrite, display]); };
const setNextOperator = useCallback( const setNextOperator = (nextOperator: Operator) => {
(nextOperator: Operator) => {
if (display === "Erro") { if (display === "Erro") {
reset(); reset();
return; return;
@@ -137,11 +133,9 @@ export function useCalculatorState() {
setOperator(nextOperator); setOperator(nextOperator);
setOverwrite(true); setOverwrite(true);
setHistory(null); setHistory(null);
}, };
[accumulator, currentValue, display, operator, overwrite, reset],
);
const evaluate = useCallback(() => { const evaluate = () => {
if (operator === null || accumulator === null || display === "Erro") { if (operator === null || accumulator === null || display === "Erro") {
return; return;
} }
@@ -161,9 +155,9 @@ export function useCalculatorState() {
setOperator(null); setOperator(null);
setOverwrite(true); setOverwrite(true);
setHistory(operation); setHistory(operation);
}, [accumulator, currentValue, display, operator]); };
const toggleSign = useCallback(() => { const toggleSign = () => {
setDisplay((prev) => { setDisplay((prev) => {
if (prev === "Erro") { if (prev === "Erro") {
return prev; return prev;
@@ -177,9 +171,9 @@ export function useCalculatorState() {
setOverwrite(false); setOverwrite(false);
setHistory(null); setHistory(null);
} }
}, [overwrite]); };
const deleteLastDigit = useCallback(() => { const deleteLastDigit = () => {
setHistory(null); setHistory(null);
// Check conditions before state updates // Check conditions before state updates
@@ -209,9 +203,9 @@ export function useCalculatorState() {
} else if (overwrite) { } else if (overwrite) {
setOverwrite(false); setOverwrite(false);
} }
}, [overwrite, display]); };
const applyPercent = useCallback(() => { const applyPercent = () => {
setDisplay((prev) => { setDisplay((prev) => {
if (prev === "Erro") { if (prev === "Erro") {
return prev; return prev;
@@ -221,9 +215,9 @@ export function useCalculatorState() {
}); });
setOverwrite(true); setOverwrite(true);
setHistory(null); setHistory(null);
}, []); };
const expression = useMemo(() => { const expression = (() => {
if (display === "Erro") { if (display === "Erro") {
return "Erro"; return "Erro";
} }
@@ -240,13 +234,12 @@ export function useCalculatorState() {
} }
return formatLocaleValue(display); return formatLocaleValue(display);
}, [accumulator, display, operator, overwrite]); })();
const buttons = useMemo(() => {
const makeOperatorHandler = (nextOperator: Operator) => () => const makeOperatorHandler = (nextOperator: Operator) => () =>
setNextOperator(nextOperator); setNextOperator(nextOperator);
return [ const buttons: CalculatorButtonConfig[][] = [
[ [
{ label: "C", onClick: reset, variant: "destructive" }, { label: "C", onClick: reset, variant: "destructive" },
{ label: "⌫", onClick: deleteLastDigit, variant: "default" }, { label: "⌫", onClick: deleteLastDigit, variant: "default" },
@@ -289,19 +282,9 @@ export function useCalculatorState() {
{ label: ",", onClick: inputDecimal }, { label: ",", onClick: inputDecimal },
{ label: "=", onClick: evaluate, variant: "default" }, { label: "=", onClick: evaluate, variant: "default" },
], ],
] satisfies CalculatorButtonConfig[][]; ];
}, [
applyPercent,
deleteLastDigit,
evaluate,
inputDecimal,
inputDigit,
reset,
setNextOperator,
toggleSign,
]);
const copyToClipboard = useCallback(async () => { const copyToClipboard = async () => {
if (!resultText) return; if (!resultText) return;
try { try {
@@ -320,9 +303,9 @@ export function useCalculatorState() {
error, error,
); );
} }
}, [resultText]); };
const pasteFromClipboard = useCallback(async () => { const pasteFromClipboard = async () => {
if (!navigator.clipboard?.readText) return; if (!navigator.clipboard?.readText) return;
try { try {
@@ -364,7 +347,7 @@ export function useCalculatorState() {
} catch (error) { } catch (error) {
console.error("Não foi possível colar o valor na calculadora.", error); console.error("Não foi possível colar o valor na calculadora.", error);
} }
}, []); };
useEffect(() => { useEffect(() => {
return () => { return () => {

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useState } from "react";
/** /**
* Hook for managing form state with type-safe field updates * Hook for managing form state with type-safe field updates
@@ -22,26 +22,23 @@ export function useFormState<T extends Record<string, any>>(initialValues: T) {
/** /**
* Updates a single field in the form state * Updates a single field in the form state
*/ */
const updateField = useCallback( const updateField = <K extends keyof T>(field: K, value: T[K]) => {
<K extends keyof T>(field: K, value: T[K]) => {
setFormState((prev) => ({ ...prev, [field]: value })); setFormState((prev) => ({ ...prev, [field]: value }));
}, };
[],
);
/** /**
* Resets form to initial values * Resets form to initial values
*/ */
const resetForm = useCallback(() => { const resetForm = () => {
setFormState(initialValues); setFormState(initialValues);
}, [initialValues]); };
/** /**
* Updates multiple fields at once * Updates multiple fields at once
*/ */
const updateFields = useCallback((updates: Partial<T>) => { const updateFields = (updates: Partial<T>) => {
setFormState((prev) => ({ ...prev, ...updates })); setFormState((prev) => ({ ...prev, ...updates }));
}, []); };
return { return {
formState, formState,

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react"; import { useMemo } from "react";
import { MONTH_NAMES } from "@/lib/utils/period"; import { MONTH_NAMES } from "@/lib/utils/period";
@@ -46,8 +46,7 @@ export function useMonthPeriod() {
}; };
}, [periodFromParams, defaultMonth, defaultYear, optionsMeses]); }, [periodFromParams, defaultMonth, defaultYear, optionsMeses]);
const buildHref = useCallback( const buildHref = (month: string, year: string | number) => {
(month: string, year: string | number) => {
const normalizedMonth = normalizeMonth(month); const normalizedMonth = normalizeMonth(month);
const normalizedYear = String(year).trim(); const normalizedYear = String(year).trim();
@@ -55,20 +54,15 @@ export function useMonthPeriod() {
params.set(PERIOD_PARAM, `${normalizedMonth}-${normalizedYear}`); params.set(PERIOD_PARAM, `${normalizedMonth}-${normalizedYear}`);
return `${pathname}?${params.toString()}`; return `${pathname}?${params.toString()}`;
}, };
[pathname, searchParams],
);
const replacePeriod = useCallback( const replacePeriod = (target: string) => {
(target: string) => {
if (!target) { if (!target) {
return; return;
} }
router.replace(target, { scroll: false }); router.replace(target, { scroll: false });
}, };
[router],
);
return { return {
monthNames: optionsMeses, monthNames: optionsMeses,

View File

@@ -1,6 +1,6 @@
{ {
"name": "opensheets", "name": "opensheets",
"version": "1.2.5", "version": "1.2.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",