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

View File

@@ -3,7 +3,6 @@
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
@@ -121,24 +120,18 @@ export function NoteDialog({
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
const handleOpenChange = useCallback(
(v: boolean) => {
setDialogOpen(v);
if (!v) setErrorMessage(null);
},
[setDialogOpen],
);
const handleOpenChange = (v: boolean) => {
setDialogOpen(v);
if (!v) setErrorMessage(null);
};
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
(e.currentTarget as HTMLFormElement).requestSubmit();
if (e.key === "Escape") handleOpenChange(false);
},
[handleOpenChange],
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
(e.currentTarget as HTMLFormElement).requestSubmit();
if (e.key === "Escape") handleOpenChange(false);
};
const handleAddTask = useCallback(() => {
const handleAddTask = () => {
const text = normalize(newTaskText);
if (!text) return;
@@ -151,97 +144,77 @@ export function NoteDialog({
updateField("tasks", [...(formState.tasks || []), newTask]);
setNewTaskText("");
requestAnimationFrame(() => newTaskRef.current?.focus());
}, [newTaskText, formState.tasks, updateField]);
};
const handleRemoveTask = useCallback(
(taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).filter((t) => t.id !== taskId),
);
},
[formState.tasks, updateField],
);
const handleRemoveTask = (taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).filter((t) => t.id !== taskId),
);
};
const handleToggleTask = useCallback(
(taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).map((t) =>
t.id === taskId ? { ...t, completed: !t.completed } : t,
),
);
},
[formState.tasks, updateField],
);
const handleToggleTask = (taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).map((t) =>
t.id === taskId ? { ...t, completed: !t.completed } : t,
),
);
};
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
const payload = {
title: normalize(formState.title),
description: formState.description.trim(),
type: formState.type,
tasks: formState.tasks,
};
const payload = {
title: normalize(formState.title),
description: formState.description.trim(),
type: formState.type,
tasks: formState.tasks,
};
if (onlySpaces || invalidLen) {
setErrorMessage("Preencha os campos respeitando os limites.");
titleRef.current?.focus();
return;
}
if (onlySpaces || invalidLen) {
setErrorMessage("Preencha os campos respeitando os limites.");
titleRef.current?.focus();
return;
}
if (mode === "update" && !note?.id) {
const msg = "Não foi possível identificar a anotação a ser editada.";
setErrorMessage(msg);
toast.error(msg);
return;
}
if (mode === "update" && !note?.id) {
const msg = "Não foi possível identificar a anotação a ser editada.";
setErrorMessage(msg);
toast.error(msg);
return;
}
if (unchanged) {
toast.info("Nada para atualizar.");
return;
}
if (unchanged) {
toast.info("Nada para atualizar.");
return;
}
startTransition(async () => {
let result;
if (mode === "create") {
result = await createNoteAction(payload);
} else {
if (!note?.id) {
const msg = "ID da anotação não encontrado.";
setErrorMessage(msg);
toast.error(msg);
return;
}
result = await updateNoteAction({ id: note.id, ...payload });
}
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
startTransition(async () => {
let result;
if (mode === "create") {
result = await createNoteAction(payload);
} else {
if (!note?.id) {
const msg = "ID da anotação não encontrado.";
setErrorMessage(msg);
toast.error(msg);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
titleRef.current?.focus();
});
},
[
formState.title,
formState.description,
formState.type,
formState.tasks,
mode,
note,
setDialogOpen,
onlySpaces,
unchanged,
invalidLen,
],
);
result = await updateNoteAction({ id: note.id, ...payload });
}
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
titleRef.current?.focus();
});
};
return (
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>

View File

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

View File

@@ -1,12 +1,6 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createCategoryAction,
@@ -76,14 +70,10 @@ export function CategoryDialog({
onOpenChange,
);
const initialState = useMemo(
() =>
buildInitialValues({
category,
defaultType,
}),
[category, defaultType],
);
const initialState = buildInitialValues({
category,
defaultType,
});
// Use form state hook for form management
const { formState, updateField, setFormState } =
@@ -95,7 +85,7 @@ export function CategoryDialog({
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
}, [dialogOpen, setFormState, category, defaultType]);
// Clear error when dialog closes
useEffect(() => {
@@ -104,46 +94,43 @@ export function CategoryDialog({
}
}, [dialogOpen]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (mode === "update" && !category?.id) {
const message = "Categoria inválida.";
setErrorMessage(message);
toast.error(message);
if (mode === "update" && !category?.id) {
const message = "Categoria inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = {
name: formState.name.trim(),
type: formState.type,
icon: formState.icon.trim(),
};
startTransition(async () => {
const result =
mode === "create"
? await createCategoryAction(payload)
: await updateCategoryAction({
id: category?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
const payload = {
name: formState.name.trim(),
type: formState.type,
icon: formState.icon.trim(),
};
startTransition(async () => {
const result =
mode === "create"
? await createCategoryAction(payload)
: await updateCategoryAction({
id: category?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[category?.id, formState, initialState, mode, setDialogOpen, setFormState],
);
setErrorMessage(result.error);
toast.error(result.error);
});
};
const title = mode === "create" ? "Nova categoria" : "Editar categoria";
const description =

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import {
RiBarChartBoxLine,
RiCloseLine,
} 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 { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -49,36 +49,37 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [isClient, setIsClient] = 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(() => {
setIsClient(true);
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const validCategories = parsed.filter((id) =>
data.allCategories.some((cat) => cat.id === id),
);
setSelectedCategories(validCategories.slice(0, 5));
// Only load from storage on first render
if (isFirstRender.current) {
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const validCategories = parsed.filter((id) =>
data.allCategories.some((cat) => cat.id === id),
);
setSelectedCategories(validCategories.slice(0, 5));
}
} catch (_e) {
// Invalid JSON, ignore
}
} catch (_e) {
// Invalid JSON, ignore
}
}
}, [data.allCategories]);
// Save to sessionStorage when selection changes
useEffect(() => {
if (isClient) {
isFirstRender.current = false;
} else {
// Save to storage on subsequent changes
sessionStorage.setItem(
STORAGE_KEY_SELECTED,
JSON.stringify(selectedCategories),
);
}
}, [selectedCategories, isClient]);
}, [selectedCategories, data.allCategories]);
// Filter data to show only selected categories with vibrant colors
const filteredCategories = useMemo(() => {

View File

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

View File

@@ -1,7 +1,6 @@
"use client";
import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react";
import React from "react";
interface NavigationButtonProps {
direction: "left" | "right";
@@ -9,25 +8,23 @@ interface NavigationButtonProps {
onClick: () => void;
}
const NavigationButton = React.memo(
({ direction, disabled, onClick }: NavigationButtonProps) => {
const Icon = direction === "left" ? RiArrowLeftSLine : RiArrowRightSLine;
export default function NavigationButton({
direction,
disabled,
onClick,
}: NavigationButtonProps) {
const Icon = direction === "left" ? RiArrowLeftSLine : RiArrowRightSLine;
return (
<button
onClick={onClick}
className="text-month-picker-foreground transition-all duration-200 cursor-pointer rounded-lg p-1 hover:bg-month-picker-foreground/10 focus:outline-hidden focus:ring-2 focus:ring-month-picker-foreground/30 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
disabled={disabled}
aria-label={`Navegar para o mês ${
direction === "left" ? "anterior" : "seguinte"
}`}
>
<Icon className="text-primary" size={18} />
</button>
);
},
);
NavigationButton.displayName = "NavigationButton";
export default NavigationButton;
return (
<button
onClick={onClick}
className="text-month-picker-foreground transition-all duration-200 cursor-pointer rounded-lg p-1 hover:bg-month-picker-foreground/10 focus:outline-hidden focus:ring-2 focus:ring-month-picker-foreground/30 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
disabled={disabled}
aria-label={`Navegar para o mês ${
direction === "left" ? "anterior" : "seguinte"
}`}
>
<Icon className="text-primary" size={18} />
</button>
);
}

View File

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

View File

@@ -1,12 +1,6 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createBudgetAction,
@@ -79,14 +73,10 @@ export function BudgetDialog({
onOpenChange,
);
const initialState = useMemo(
() =>
buildInitialValues({
budget,
defaultPeriod,
}),
[budget, defaultPeriod],
);
const initialState = buildInitialValues({
budget,
defaultPeriod,
});
// Use form state hook for form management
const { formState, updateField, setFormState } =
@@ -98,7 +88,7 @@ export function BudgetDialog({
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
}, [dialogOpen, setFormState, budget, defaultPeriod]);
// Clear error when dialog closes
useEffect(() => {
@@ -107,67 +97,64 @@ export function BudgetDialog({
}
}, [dialogOpen]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (mode === "update" && !budget?.id) {
const message = "Orçamento inválido.";
setErrorMessage(message);
toast.error(message);
if (mode === "update" && !budget?.id) {
const message = "Orçamento inválido.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.categoriaId.length === 0) {
const message = "Selecione uma categoria.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.period.length === 0) {
const message = "Informe o período.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.amount.length === 0) {
const message = "Informe o valor limite.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = {
categoriaId: formState.categoriaId,
period: formState.period,
amount: formState.amount,
};
startTransition(async () => {
const result =
mode === "create"
? await createBudgetAction(payload)
: await updateBudgetAction({
id: budget?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
if (formState.categoriaId.length === 0) {
const message = "Selecione uma categoria.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.period.length === 0) {
const message = "Informe o período.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.amount.length === 0) {
const message = "Informe o valor limite.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = {
categoriaId: formState.categoriaId,
period: formState.period,
amount: formState.amount,
};
startTransition(async () => {
const result =
mode === "create"
? await createBudgetAction(payload)
: await updateBudgetAction({
id: budget?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[budget?.id, formState, initialState, mode, setDialogOpen, setFormState],
);
setErrorMessage(result.error);
toast.error(result.error);
});
};
const title = mode === "create" ? "Novo orçamento" : "Editar orçamento";
const description =

View File

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

View File

@@ -63,50 +63,45 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
const searchParams = useSearchParams();
const periodParam = searchParams.get(MONTH_PERIOD_PARAM);
const isLinkActive = React.useCallback(
(url: string) => {
const normalizedPathname =
pathname.endsWith("/") && pathname !== "/"
? pathname.slice(0, -1)
: pathname;
const normalizedUrl =
url.endsWith("/") && url !== "/" ? url.slice(0, -1) : url;
const normalizedPathname =
pathname.endsWith("/") && pathname !== "/"
? pathname.slice(0, -1)
: pathname;
// Verifica se é exatamente igual ou se o pathname começa com a URL
return (
normalizedPathname === normalizedUrl ||
normalizedPathname.startsWith(`${normalizedUrl}/`)
);
},
[pathname],
);
const isLinkActive = (url: string) => {
const normalizedUrl =
url.endsWith("/") && url !== "/" ? url.slice(0, -1) : url;
const buildHrefWithPeriod = React.useCallback(
(url: string) => {
if (!periodParam) {
return url;
}
// Verifica se é exatamente igual ou se o pathname começa com a URL
return (
normalizedPathname === normalizedUrl ||
normalizedPathname.startsWith(`${normalizedUrl}/`)
);
};
const [rawPathname, existingSearch = ""] = url.split("?");
const normalizedPathname =
rawPathname.endsWith("/") && rawPathname !== "/"
? rawPathname.slice(0, -1)
: rawPathname;
const buildHrefWithPeriod = (url: string) => {
if (!periodParam) {
return url;
}
if (!PERIOD_AWARE_PATHS.has(normalizedPathname)) {
return url;
}
const [rawPathname, existingSearch = ""] = url.split("?");
const normalizedRawPathname =
rawPathname.endsWith("/") && rawPathname !== "/"
? rawPathname.slice(0, -1)
: rawPathname;
const params = new URLSearchParams(existingSearch);
params.set(MONTH_PERIOD_PARAM, periodParam);
if (!PERIOD_AWARE_PATHS.has(normalizedRawPathname)) {
return url;
}
const queryString = params.toString();
return queryString
? `${normalizedPathname}?${queryString}`
: normalizedPathname;
},
[periodParam],
);
const params = new URLSearchParams(existingSearch);
params.set(MONTH_PERIOD_PARAM, periodParam);
const queryString = params.toString();
return queryString
? `${normalizedRawPathname}?${queryString}`
: normalizedRawPathname;
};
const activeLinkClasses =
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-dark! hover:text-primary!";