diff --git a/src/app/page.tsx b/src/app/page.tsx index fa32642..e8a4dfe 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -15,11 +15,11 @@ import { ProjectsSection } from "@/components/sections/projects"; import { PublicationsSection } from "@/components/sections/publications"; import { SkillsSection } from "@/components/sections/skills"; import { UnifiedFilterBar } from "@/components/unified-filter-bar"; -import { useFilter } from "@/contexts/filter"; +import { useFilterUI } from "@/contexts/filter"; const Home = () => { const [showFilter, setShowFilter] = useState(false); - const { setIsFilterBarVisible } = useFilter(); + const { setIsFilterBarVisible } = useFilterUI(); useEffect(() => { let skillsTop = Infinity; diff --git a/src/components/chat/use-chat-widget.ts b/src/components/chat/use-chat-widget.ts index fa928bc..1c5a5fc 100644 --- a/src/components/chat/use-chat-widget.ts +++ b/src/components/chat/use-chat-widget.ts @@ -3,7 +3,7 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { useChat } from "@ai-sdk/react"; -import { useFilter } from "@/contexts/filter"; +import { useFilterUI } from "@/contexts/filter"; export const getApiEndpoint = () => { if (process.env.NEXT_PUBLIC_CHAT_API_URL) { @@ -26,7 +26,7 @@ export function useChatWidget() { const [isOpen, setIsOpen] = useState(false); const [input, setInput] = useState(""); const [copiedId, setCopiedId] = useState(null); - const { isFilterBarVisible } = useFilter(); + const { isFilterBarVisible } = useFilterUI(); const { messages, sendMessage, status, error, stop } = useChat({ transport: new DefaultChatTransport({ api: getApiEndpoint() }), diff --git a/src/components/components.test.tsx b/src/components/components.test.tsx index 8bc495a..89683b0 100644 --- a/src/components/components.test.tsx +++ b/src/components/components.test.tsx @@ -38,6 +38,8 @@ vi.mock("@/contexts/search", () => ({ vi.mock("@/contexts/filter", () => ({ useFilter: () => ({ clearAll: mockClearAll, + }), + useFilterUI: () => ({ isFilterBarVisible: mockIsFilterBarVisible, }), })); diff --git a/src/components/unified-filter-bar.test.tsx b/src/components/unified-filter-bar.test.tsx index 872f6b1..aaf608c 100644 --- a/src/components/unified-filter-bar.test.tsx +++ b/src/components/unified-filter-bar.test.tsx @@ -6,11 +6,13 @@ import { UnifiedFilterBar } from "./unified-filter-bar"; vi.mock("@/contexts/filter", () => ({ useFilter: () => ({ - isFilterBarVisible: true, clearAll: vi.fn(), activeFiltersCount: 0, selected: {}, }), + useFilterUI: () => ({ + isFilterBarVisible: true, + }), })); vi.mock("@/contexts/search", () => ({ useSearch: () => ({ query: "", setQuery: vi.fn() }), diff --git a/src/components/unified-filter-bar.tsx b/src/components/unified-filter-bar.tsx index c9d63dc..cd6374a 100644 --- a/src/components/unified-filter-bar.tsx +++ b/src/components/unified-filter-bar.tsx @@ -37,15 +37,12 @@ const FilterDropdown = memo( ({ selected, setSelected, + activeCount, }: { selected: Record; setSelected: (category: string, selected: string[]) => void; + activeCount: number; }) => { - const activeCount = Object.values(selected).reduce( - (acc, curr) => acc + curr.length, - 0, - ); - return ( { const { query, setQuery } = useSearch(); - const { selected, setSelected, clearAll } = useFilter(); + const { selected, setSelected, clearAll, activeFiltersCount } = useFilter(); const hasFilters = query !== "" || Object.values(selected).some((v) => v.length > 0); @@ -105,7 +102,11 @@ export const UnifiedFilterBar = () => { className="rounded-l-2xl rounded-r-none border-r-0" autoFocus={false} /> - + {hasFilters && ( diff --git a/src/components/upArrow.tsx b/src/components/upArrow.tsx index 6b4a0b0..b89efc3 100644 --- a/src/components/upArrow.tsx +++ b/src/components/upArrow.tsx @@ -7,11 +7,11 @@ import { useEffect, useState } from "react"; import { ChevronUpIcon } from "@heroicons/react/24/outline"; import { Button, Tooltip } from "@heroui/react"; -import { useFilter } from "@/contexts/filter"; +import { useFilterUI } from "@/contexts/filter"; const ScrollToTopButton = () => { const [isVisible, setIsVisible] = useState(false); - const { isFilterBarVisible } = useFilter(); + const { isFilterBarVisible } = useFilterUI(); useEffect(() => { const toggleVisibility = () => { diff --git a/src/contexts/contexts.test.tsx b/src/contexts/contexts.test.tsx index 10afa36..134ec1f 100644 --- a/src/contexts/contexts.test.tsx +++ b/src/contexts/contexts.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, act, renderHook } from "@testing-library/react"; -import { useFilter, FilterProvider } from "./filter"; +import { useFilter, useFilterUI, FilterProvider } from "./filter"; import { useHeader, default as HeaderProvider } from "./header"; import { useSearch, useDebouncedSearch, SearchProvider } from "./search"; import { withSuspense } from "./suspense"; @@ -240,13 +240,8 @@ describe("React Contexts & Hooks", () => { ]); const TestComponent = () => { - const { - selected, - setSelected, - clearAll, - isFilterBarVisible, - setIsFilterBarVisible, - } = useFilter(); + const { selected, setSelected, clearAll } = useFilter(); + const { isFilterBarVisible, setIsFilterBarVisible } = useFilterUI(); return (
{selected.areas?.join(",")} @@ -306,5 +301,15 @@ describe("React Contexts & Hooks", () => { ); consoleErrorSpy.mockRestore(); }); + + it("should throw error if useFilterUI is called outside provider", () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + expect(() => renderHook(() => useFilterUI())).toThrow( + "useFilterUI must be used within a FilterProvider", + ); + consoleErrorSpy.mockRestore(); + }); }); }); diff --git a/src/contexts/filter.tsx b/src/contexts/filter.tsx index 428f668..dc0f35e 100644 --- a/src/contexts/filter.tsx +++ b/src/contexts/filter.tsx @@ -14,26 +14,45 @@ import { import { withSuspense } from "./suspense"; import { useUrlSync } from "./sync"; -type FilterContextType = { +type FilterStateContextType = { selected: Record; setSelected: (category: string, selected: string[]) => void; clearAll: () => void; + activeFiltersCount: number; +}; + +type FilterUIContextType = { isFilterBarVisible: boolean; setIsFilterBarVisible: (visible: boolean) => void; }; -export const FilterContext = createContext( +export const FilterStateContext = createContext< + FilterStateContextType | undefined +>(undefined); + +export const FilterUIContext = createContext( undefined, ); -export const useFilter: () => FilterContextType = () => { - const context = useContext(FilterContext); +// ⚡ Optimization: Split the Filter context into State and UI to prevent unnecessary re-renders. +// Components that only need filter selections (like portfolio sections) won't re-render +// when the filter bar visibility changes during scroll. +export const useFilter: () => FilterStateContextType = () => { + const context = useContext(FilterStateContext); if (!context) { throw new Error("useFilter must be used within a FilterProvider"); } return context; }; +export const useFilterUI: () => FilterUIContextType = () => { + const context = useContext(FilterUIContext); + if (!context) { + throw new Error("useFilterUI must be used within a FilterProvider"); + } + return context; +}; + const FilterContent = ({ children }: { children: ReactNode }) => { const searchParams = useSearchParams(); @@ -72,21 +91,35 @@ const FilterContent = ({ children }: { children: ReactNode }) => { useUrlSync(selected, updateUrl); - const contextValue = useMemo( + const activeFiltersCount = useMemo( + () => Object.values(selected).reduce((acc, curr) => acc + curr.length, 0), + [selected], + ); + + const stateValue = useMemo( () => ({ selected, setSelected, clearAll, + activeFiltersCount, + }), + [selected, setSelected, clearAll, activeFiltersCount], + ); + + const uiValue = useMemo( + () => ({ isFilterBarVisible, setIsFilterBarVisible, }), - [selected, setSelected, clearAll, isFilterBarVisible], + [isFilterBarVisible], ); return ( - - {children} - + + + {children} + + ); };