Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/components/chat/use-chat-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -26,7 +26,7 @@ export function useChatWidget() {
const [isOpen, setIsOpen] = useState(false);
const [input, setInput] = useState("");
const [copiedId, setCopiedId] = useState<string | null>(null);
const { isFilterBarVisible } = useFilter();
const { isFilterBarVisible } = useFilterUI();

const { messages, sendMessage, status, error, stop } = useChat({
transport: new DefaultChatTransport({ api: getApiEndpoint() }),
Expand Down
2 changes: 2 additions & 0 deletions src/components/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ vi.mock("@/contexts/search", () => ({
vi.mock("@/contexts/filter", () => ({
useFilter: () => ({
clearAll: mockClearAll,
}),
useFilterUI: () => ({
isFilterBarVisible: mockIsFilterBarVisible,
}),
}));
Expand Down
4 changes: 3 additions & 1 deletion src/components/unified-filter-bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() }),
Expand Down
15 changes: 8 additions & 7 deletions src/components/unified-filter-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,12 @@ const FilterDropdown = memo(
({
selected,
setSelected,
activeCount,
}: {
selected: Record<string, string[]>;
setSelected: (category: string, selected: string[]) => void;
activeCount: number;
}) => {
const activeCount = Object.values(selected).reduce(
(acc, curr) => acc + curr.length,
0,
);

return (
<Filter
activeCount={activeCount}
Expand Down Expand Up @@ -78,7 +75,7 @@ FilterDropdown.displayName = "FilterDropdown";

export const UnifiedFilterBar = () => {
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);
Expand All @@ -105,7 +102,11 @@ export const UnifiedFilterBar = () => {
className="rounded-l-2xl rounded-r-none border-r-0"
autoFocus={false}
/>
<FilterDropdown selected={selected} setSelected={setSelected} />
<FilterDropdown
selected={selected}
setSelected={setSelected}
activeCount={activeFiltersCount}
/>
</div>

{hasFilters && (
Expand Down
4 changes: 2 additions & 2 deletions src/components/upArrow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
21 changes: 13 additions & 8 deletions src/contexts/contexts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div>
<span data-testid="areas">{selected.areas?.join(",")}</span>
Expand Down Expand Up @@ -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();
});
});
});
51 changes: 42 additions & 9 deletions src/contexts/filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,45 @@ import {
import { withSuspense } from "./suspense";
import { useUrlSync } from "./sync";

type FilterContextType = {
type FilterStateContextType = {
selected: Record<string, string[]>;
setSelected: (category: string, selected: string[]) => void;
clearAll: () => void;
activeFiltersCount: number;
};

type FilterUIContextType = {
isFilterBarVisible: boolean;
setIsFilterBarVisible: (visible: boolean) => void;
};

export const FilterContext = createContext<FilterContextType | undefined>(
export const FilterStateContext = createContext<
FilterStateContextType | undefined
>(undefined);

export const FilterUIContext = createContext<FilterUIContextType | undefined>(
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();

Expand Down Expand Up @@ -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 (
<FilterContext.Provider value={contextValue}>
{children}
</FilterContext.Provider>
<FilterStateContext.Provider value={stateValue}>
<FilterUIContext.Provider value={uiValue}>
{children}
</FilterUIContext.Provider>
</FilterStateContext.Provider>
);
};

Expand Down
Loading