From 736672007bd2e77a0e5c520a83948caaab36bffc Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Thu, 4 Dec 2025 10:14:18 +0100 Subject: [PATCH] Refactor components to use memoization and callbacks for performance improvements --- apps/web/src/components/ActivityItem.tsx | 5 +- .../web/src/components/BillingConsumption.tsx | 32 +-- apps/web/src/components/BillingLimits.tsx | 24 +-- apps/web/src/components/DashboardLayout.tsx | 30 ++- .../src/components/EmailEditor/Toolbar.tsx | 199 ++++++++++++------ .../src/components/SegmentFilterBuilder.tsx | 118 ++++++----- apps/web/src/components/WorkflowBuilder.tsx | 16 +- 7 files changed, 260 insertions(+), 164 deletions(-) diff --git a/apps/web/src/components/ActivityItem.tsx b/apps/web/src/components/ActivityItem.tsx index 4608eb8..cf32c14 100644 --- a/apps/web/src/components/ActivityItem.tsx +++ b/apps/web/src/components/ActivityItem.tsx @@ -1,5 +1,6 @@ import {Badge, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui'; import type {Activity} from './ActivityFeed'; +import {memo} from 'react'; import { AlertCircle, Calendar, @@ -293,7 +294,7 @@ function getActivityConfig(activity: Activity): ActivityConfig { } } -export function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) { +export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) { const config = getActivityConfig(activity); const Icon = config.icon; const timestamp = new Date(activity.timestamp); @@ -358,4 +359,4 @@ export function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) ); -} +}); diff --git a/apps/web/src/components/BillingConsumption.tsx b/apps/web/src/components/BillingConsumption.tsx index fe58cfb..3c23eca 100644 --- a/apps/web/src/components/BillingConsumption.tsx +++ b/apps/web/src/components/BillingConsumption.tsx @@ -2,6 +2,7 @@ import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert} from ' import {AlertCircle, TrendingUp} from 'lucide-react'; import {useBillingConsumption} from '../lib/hooks/useBillingConsumption'; import {useConfig} from '../lib/hooks/useConfig'; +import {useCallback} from 'react'; interface BillingConsumptionProps { projectId: string; @@ -15,6 +16,22 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt // Always call the hook to satisfy Rules of Hooks const {consumptionData, isLoading, error} = useBillingConsumption(projectId, hasSubscription && billingEnabled); + // Define callbacks BEFORE any conditional returns (Rules of Hooks) + const formatCurrency = useCallback((amount: number, currency: string) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency.toUpperCase(), + }).format(amount / 100); + }, []); + + const formatDate = useCallback((dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }, []); + if (!billingEnabled) { // If billing is globally disabled, hide the card entirely return null; @@ -79,21 +96,6 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt return null; } - const formatCurrency = (amount: number, currency: string) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency.toUpperCase(), - }).format(amount / 100); - }; - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); - }; - return ( diff --git a/apps/web/src/components/BillingLimits.tsx b/apps/web/src/components/BillingLimits.tsx index 77a7ae8..0736307 100644 --- a/apps/web/src/components/BillingLimits.tsx +++ b/apps/web/src/components/BillingLimits.tsx @@ -1,4 +1,4 @@ -import {useEffect, useState} from 'react'; +import {memo, useEffect, useMemo, useState} from 'react'; import {useForm} from 'react-hook-form'; import {zodResolver} from '@hookform/resolvers/zod'; import {BillingLimitSchemas} from '@plunk/shared'; @@ -270,24 +270,24 @@ interface UsageDisplayProps { usage: CategoryLimit; } -function UsageDisplay({category, usage}: UsageDisplayProps) { - const getStatusColor = () => { +const UsageDisplay = memo(function UsageDisplay({category, usage}: UsageDisplayProps) { + const statusColor = useMemo(() => { if (usage.isBlocked) return 'text-red-600'; if (usage.isWarning) return 'text-orange-600'; return 'text-green-600'; - }; + }, [usage.isBlocked, usage.isWarning]); - const getProgressColor = () => { + const progressColor = useMemo(() => { if (usage.isBlocked) return 'bg-red-600'; if (usage.isWarning) return 'bg-orange-500'; return 'bg-green-600'; - }; + }, [usage.isBlocked, usage.isWarning]); - const getStatusIcon = () => { + const statusIcon = useMemo(() => { if (usage.isBlocked) return ; if (usage.isWarning) return ; return ; - }; + }, [usage.isBlocked, usage.isWarning]); const limitText = usage.limit === null ? 'Unlimited' : usage.limit.toLocaleString(); @@ -300,8 +300,8 @@ function UsageDisplay({category, usage}: UsageDisplayProps) { {usage.usage.toLocaleString()} / {limitText} emails this month

-
- {getStatusIcon()} +
+ {statusIcon} {usage.limit === null ? 'Unlimited' : `${Math.round(usage.percentage)}%`} @@ -310,7 +310,7 @@ function UsageDisplay({category, usage}: UsageDisplayProps) { {usage.limit !== null && ( <> - + {usage.isBlocked && ( @@ -338,4 +338,4 @@ function UsageDisplay({category, usage}: UsageDisplayProps) { )}
); -} +}); diff --git a/apps/web/src/components/DashboardLayout.tsx b/apps/web/src/components/DashboardLayout.tsx index afd81e9..3c002f9 100644 --- a/apps/web/src/components/DashboardLayout.tsx +++ b/apps/web/src/components/DashboardLayout.tsx @@ -19,7 +19,7 @@ import { import Image from 'next/image'; import Link from 'next/link'; import {useRouter} from 'next/router'; -import {useEffect, useRef, useState} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; interface DashboardLayoutProps { children: React.ReactNode; @@ -87,7 +87,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) { } }, [showProjectMenu, showUserMenu]); - const handleLogout = async () => { + const handleLogout = useCallback(async () => { try { // Call the logout endpoint to clear the cookie await network.fetch('GET', '/auth/logout'); @@ -112,7 +112,21 @@ export function DashboardLayout({children}: DashboardLayoutProps) { await mutateUser(null, false); await router.push('/auth/login'); } - }; + }, [mutateUser, router]); + + const handleToggleProjectMenu = useCallback(() => { + setShowProjectMenu(prev => !prev); + }, []); + + const handleToggleUserMenu = useCallback(() => { + setShowUserMenu(prev => !prev); + }, []); + + const handleLogoutClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + void handleLogout(); + }, [handleLogout]); return (
@@ -128,7 +142,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
- ))} + {group.colors.map(color => { + const handleColorClick = () => { + setColor(color); + setShowColorPicker(false); + }; + return ( + + ); + })}
))} @@ -414,17 +501,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage variant="ghost" size="icon" onMouseDown={e => e.preventDefault()} - onClick={() => { - if (editor.isActive('link')) { - // Get the current link URL and show the input to edit it - const previousUrl = editor.getAttributes('link').href || ''; - setLinkUrl(previousUrl); - setShowLinkInput(true); - } else { - setShowLinkInput(!showLinkInput); - setLinkUrl(''); - } - }} + onClick={handleToggleLinkInput} data-active={editor.isActive('link')} className="h-8 w-8 data-[active=true]:bg-neutral-200" > diff --git a/apps/web/src/components/SegmentFilterBuilder.tsx b/apps/web/src/components/SegmentFilterBuilder.tsx index 4e19e3e..3549683 100644 --- a/apps/web/src/components/SegmentFilterBuilder.tsx +++ b/apps/web/src/components/SegmentFilterBuilder.tsx @@ -1,7 +1,7 @@ import {Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui'; import type {FilterCondition, FilterGroup, SegmentFilter, SegmentFilterOperator} from '@plunk/types'; import {Plus, Trash2, GripVertical, Check, ChevronsUpDown, Search} from 'lucide-react'; -import {useState, useEffect} from 'react'; +import {useState, useEffect, useMemo, useCallback, memo} from 'react'; import {network} from '../lib/network'; const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [ @@ -115,7 +115,7 @@ interface FilterRowProps { availableFields: FieldOption[]; } -function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps) { +const FilterRow = memo(function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); @@ -123,13 +123,13 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps const needsUnit = ['within', 'triggeredWithin'].includes(filter.operator); // Get field type from available fields - const fieldOption = availableFields.find(f => f.value === filter.field); + const fieldOption = useMemo(() => availableFields.find(f => f.value === filter.field), [availableFields, filter.field]); const fieldType = fieldOption?.type || 'string'; const isEventOrEmailActivity = fieldType === 'event' || fieldType === 'email'; - // Get operators based on field type - const getOperators = () => { + // Get operators based on field type (memoized) + const operators = useMemo(() => { if (isEventOrEmailActivity) { return EVENT_OPERATORS; } @@ -151,9 +151,9 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps return STANDARD_OPERATORS.filter(op => ['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value) ); - }; + }, [fieldType, isEventOrEmailActivity]); - const handleFieldChange = (value: string) => { + const handleFieldChange = useCallback((value: string) => { const selectedField = availableFields.find(f => f.value === value); const newFieldType = selectedField?.type || 'string'; const isEvent = newFieldType === 'event' || newFieldType === 'email'; @@ -208,10 +208,10 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps setOpen(false); setSearch(''); - }; + }, [availableFields, filter.operator, fieldType, onChange]); // Helper to get default value based on field type - const getDefaultValueForType = (type: string) => { + const getDefaultValueForType = useCallback((type: string) => { switch (type) { case 'boolean': return true; @@ -222,10 +222,10 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps default: return ''; } - }; + }, []); // Helper to get valid operators for a field type - const getOperatorsForType = (type: string, isEvent: boolean) => { + const getOperatorsForType = useCallback((type: string, isEvent: boolean) => { if (isEvent) { return EVENT_OPERATORS; } @@ -246,34 +246,38 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps return STANDARD_OPERATORS.filter(op => ['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value) ); - }; + }, []); // Get label for selected field - const getFieldLabel = () => { + const getFieldLabel = useCallback(() => { const field = availableFields.find(f => f.value === filter.field); return field?.label || filter.field; - }; + }, [availableFields, filter.field]); - // Group fields by category for display - const groupedFields = availableFields.reduce>((acc, field) => { - if (!acc[field.category]) { - acc[field.category] = []; - } - acc[field.category]!.push(field); - return acc; - }, {}); + // Group fields by category for display (memoized to avoid expensive reduce on every render) + const groupedFields = useMemo(() => { + return availableFields.reduce>((acc, field) => { + if (!acc[field.category]) { + acc[field.category] = []; + } + acc[field.category]!.push(field); + return acc; + }, {}); + }, [availableFields]); - // Filter fields based on search - const filteredGroups = Object.entries(groupedFields).reduce((acc, [category, fields]) => { - const filtered = fields.filter(f => - f.label.toLowerCase().includes(search.toLowerCase()) || - f.value.toLowerCase().includes(search.toLowerCase()) - ); - if (filtered.length > 0) { - acc[category] = filtered; - } - return acc; - }, {} as Record); + // Filter fields based on search (memoized to avoid expensive filter on every keystroke) + const filteredGroups = useMemo(() => { + return Object.entries(groupedFields).reduce((acc, [category, fields]) => { + const filtered = fields.filter(f => + f.label.toLowerCase().includes(search.toLowerCase()) || + f.value.toLowerCase().includes(search.toLowerCase()) + ); + if (filtered.length > 0) { + acc[category] = filtered; + } + return acc; + }, {} as Record); + }, [groupedFields, search]); return (
@@ -391,7 +395,7 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps - {getOperators().map(op => ( + {operators.map(op => ( {op.label} @@ -474,7 +478,7 @@ function FilterRow({filter, onChange, onRemove, availableFields}: FilterRowProps
); -} +}); interface FilterGroupComponentProps { group: FilterGroup; @@ -485,28 +489,28 @@ interface FilterGroupComponentProps { } function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFields}: FilterGroupComponentProps) { - const addFilter = () => { + const addFilter = useCallback(() => { onChange({ ...group, filters: [...group.filters, {field: 'email', operator: 'contains', value: ''}], }); - }; + }, [group, onChange]); - const updateFilter = (index: number, filter: SegmentFilter) => { + const updateFilter = useCallback((index: number, filter: SegmentFilter) => { onChange({ ...group, filters: group.filters.map((f, i) => (i === index ? filter : f)), }); - }; + }, [group, onChange]); - const removeFilter = (index: number) => { + const removeFilter = useCallback((index: number) => { onChange({ ...group, filters: group.filters.filter((_, i) => i !== index), }); - }; + }, [group, onChange]); - const addNestedCondition = () => { + const addNestedCondition = useCallback(() => { onChange({ ...group, conditions: { @@ -514,19 +518,19 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi groups: [{filters: [{field: 'email', operator: 'contains', value: ''}]}], }, }); - }; + }, [group, onChange]); - const updateNestedCondition = (condition: FilterCondition) => { + const updateNestedCondition = useCallback((condition: FilterCondition) => { onChange({ ...group, conditions: condition, }); - }; + }, [group, onChange]); - const removeNestedCondition = () => { + const removeNestedCondition = useCallback(() => { const {conditions, ...rest} = group; onChange(rest); - }; + }, [group, onChange]); const bgColors = ['bg-white', 'bg-blue-50/50', 'bg-purple-50/50', 'bg-green-50/50']; const borderColors = ['border-neutral-300', 'border-blue-300', 'border-purple-300', 'border-green-300']; @@ -547,7 +551,7 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
{group.filters.map((filter, index) => ( - updateFilter(index, f)} onRemove={() => removeFilter(index)} availableFields={availableFields} /> + updateFilter(index, f)} onRemove={() => removeFilter(index)} availableFields={availableFields} /> ))} {group.conditions && ( @@ -593,33 +597,33 @@ interface FilterConditionComponentProps { } function FilterConditionComponent({condition, onChange, depth = 0, availableFields}: FilterConditionComponentProps) { - const addGroup = () => { + const addGroup = useCallback(() => { onChange({ ...condition, groups: [...condition.groups, {filters: [{field: 'email', operator: 'contains', value: ''}]}], }); - }; + }, [condition, onChange]); - const updateGroup = (index: number, group: FilterGroup) => { + const updateGroup = useCallback((index: number, group: FilterGroup) => { onChange({ ...condition, groups: condition.groups.map((g, i) => (i === index ? group : g)), }); - }; + }, [condition, onChange]); - const removeGroup = (index: number) => { + const removeGroup = useCallback((index: number) => { onChange({ ...condition, groups: condition.groups.filter((_, i) => i !== index), }); - }; + }, [condition, onChange]); - const toggleLogic = () => { + const toggleLogic = useCallback(() => { onChange({ ...condition, logic: condition.logic === 'AND' ? 'OR' : 'AND', }); - }; + }, [condition, onChange]); return (
@@ -640,7 +644,7 @@ function FilterConditionComponent({condition, onChange, depth = 0, availableFiel
{condition.groups.map((group, index) => ( -
+
{index > 0 && (
{condition.logic}
diff --git a/apps/web/src/components/WorkflowBuilder.tsx b/apps/web/src/components/WorkflowBuilder.tsx index a52f7c4..11c5c2f 100644 --- a/apps/web/src/components/WorkflowBuilder.tsx +++ b/apps/web/src/components/WorkflowBuilder.tsx @@ -357,10 +357,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr template: step.template, config: step.config, onEdit: () => handleEditStep(step.id), - onDelete: () => { - setStepToDelete(step.id); - setShowDeleteDialog(true); - }, + onDelete: () => handleDeleteStepClick(step.id), }, }; }); @@ -420,7 +417,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr }); return nodes; - }, [steps]); + }, [steps, handleEditStep, handleDeleteStepClick]); // Convert transitions to React Flow edges const rawEdges: Edge[] = useMemo(() => { @@ -625,11 +622,16 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr [addStepContext, workflowId, onUpdate], ); - const handleEditStep = (stepId: string) => { + const handleEditStep = useCallback((stepId: string) => { // This will be handled by the parent component const event = new CustomEvent('workflow-edit-step', {detail: {stepId}}); window.dispatchEvent(event); - }; + }, []); + + const handleDeleteStepClick = useCallback((stepId: string) => { + setStepToDelete(stepId); + setShowDeleteDialog(true); + }, []); // Get all steps that will be affected by deleting a step (the step itself + all downstream steps) const getAffectedSteps = useCallback(