Skip to content
Merged
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
16 changes: 14 additions & 2 deletions src/contexts/GitStatusContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from "react";
import type { WorkspaceMetadata, GitStatus } from "@/types/workspace";
import { useStableReference, compareMaps, compareGitStatus } from "@/hooks/useStableReference";
import { parseGitShowBranchForStatus } from "@/utils/git/parseGitStatus";
import {
GIT_STATUS_SCRIPT,
Expand Down Expand Up @@ -45,9 +46,20 @@ interface FetchState {
}

export function GitStatusProvider({ workspaceMetadata, children }: GitStatusProviderProps) {
const [gitStatus, setGitStatus] = useState<Map<string, GitStatus | null>>(new Map());
// Internal state holds the raw results from git status checks
const [gitStatusResults, setGitStatusResults] = useState<Map<string, GitStatus | null>>(
new Map()
);
const fetchCache = useRef<Map<string, FetchState>>(new Map());

// Stabilize Map identity - only return new Map when values actually change
// This prevents unnecessary re-renders in components using useGitStatus()
const gitStatus = useStableReference(
() => gitStatusResults,
(prev, next) => compareMaps(prev, next, compareGitStatus),
[gitStatusResults]
);

// Helper: Check if project should be fetched
const shouldFetch = useCallback((projectName: string): boolean => {
const cached = fetchCache.current.get(projectName);
Expand Down Expand Up @@ -252,7 +264,7 @@ export function GitStatusProvider({ workspaceMetadata, children }: GitStatusProv

if (!isActive) return; // Don't update state if unmounted

setGitStatus(new Map(results));
setGitStatusResults(new Map(results));
};

// Run immediately on mount or when workspaces change
Expand Down
180 changes: 180 additions & 0 deletions src/hooks/useStableReference.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Tests for stable reference utilities.
*
* Note: Hook tests (useStableReference) are omitted because they require jsdom setup.
* The comparator functions are the critical logic and are thoroughly tested here.
* The hook itself is a thin wrapper around useMemo and useRef with manual testing.
*/
import { compareMaps, compareRecords, compareArrays, compareGitStatus } from "./useStableReference";
import type { GitStatus } from "@/types/workspace";

describe("compareMaps", () => {
it("returns true for empty maps", () => {
expect(compareMaps(new Map(), new Map())).toBe(true);
});

it("returns true for maps with same entries", () => {
const prev = new Map([
["a", 1],
["b", 2],
]);
const next = new Map([
["a", 1],
["b", 2],
]);
expect(compareMaps(prev, next)).toBe(true);
});

it("returns false for maps with different sizes", () => {
const prev = new Map([["a", 1]]);
const next = new Map([
["a", 1],
["b", 2],
]);
expect(compareMaps(prev, next)).toBe(false);
});

it("returns false for maps with different keys", () => {
const prev = new Map([["a", 1]]);
const next = new Map([["b", 1]]);
expect(compareMaps(prev, next)).toBe(false);
});

it("returns false for maps with different values", () => {
const prev = new Map([["a", 1]]);
const next = new Map([["a", 2]]);
expect(compareMaps(prev, next)).toBe(false);
});

it("supports custom value equality function", () => {
const prev = new Map([["a", { id: 1 }]]);
const next = new Map([["a", { id: 1 }]]);

// Default comparison (reference equality) returns false
expect(compareMaps(prev, next)).toBe(false);

// Custom comparison (by id) returns true
expect(compareMaps(prev, next, (a, b) => a.id === b.id)).toBe(true);
});
});

describe("compareRecords", () => {
it("returns true for empty records", () => {
expect(compareRecords({}, {})).toBe(true);
});

it("returns true for records with same entries", () => {
const prev = { a: 1, b: 2 };
const next = { a: 1, b: 2 };
expect(compareRecords(prev, next)).toBe(true);
});

it("returns false for records with different sizes", () => {
const prev = { a: 1 };
const next = { a: 1, b: 2 };
expect(compareRecords(prev, next)).toBe(false);
});

it("returns false for records with different keys", () => {
const prev = { a: 1 };
const next = { b: 1 };
expect(compareRecords(prev, next)).toBe(false);
});

it("returns false for records with different values", () => {
const prev = { a: 1 };
const next = { a: 2 };
expect(compareRecords(prev, next)).toBe(false);
});

it("supports custom value equality function", () => {
const prev = { a: { id: 1 } };
const next = { a: { id: 1 } };

// Default comparison (reference equality) returns false
expect(compareRecords(prev, next)).toBe(false);

// Custom comparison (by id) returns true
expect(compareRecords(prev, next, (a, b) => a.id === b.id)).toBe(true);
});
});

describe("compareArrays", () => {
it("returns true for empty arrays", () => {
expect(compareArrays([], [])).toBe(true);
});

it("returns true for arrays with same elements", () => {
expect(compareArrays([1, 2, 3], [1, 2, 3])).toBe(true);
});

it("returns false for arrays with different lengths", () => {
expect(compareArrays([1, 2], [1, 2, 3])).toBe(false);
});

it("returns false for arrays with different values", () => {
expect(compareArrays([1, 2, 3], [1, 2, 4])).toBe(false);
});

it("returns false for arrays with same values in different order", () => {
expect(compareArrays([1, 2, 3], [3, 2, 1])).toBe(false);
});

it("supports custom value equality function", () => {
const prev = [{ id: 1 }, { id: 2 }];
const next = [{ id: 1 }, { id: 2 }];

// Default comparison (reference equality) returns false
expect(compareArrays(prev, next)).toBe(false);

// Custom comparison (by id) returns true
expect(compareArrays(prev, next, (a, b) => a.id === b.id)).toBe(true);
});
});

describe("compareGitStatus", () => {
it("returns true for two null values", () => {
expect(compareGitStatus(null, null)).toBe(true);
});

it("returns false when one is null and the other is not", () => {
const status: GitStatus = { ahead: 0, behind: 0, dirty: false };
expect(compareGitStatus(null, status)).toBe(false);
expect(compareGitStatus(status, null)).toBe(false);
});

it("returns true for identical git status", () => {
const a: GitStatus = { ahead: 1, behind: 2, dirty: true };
const b: GitStatus = { ahead: 1, behind: 2, dirty: true };
expect(compareGitStatus(a, b)).toBe(true);
});

it("returns false when ahead differs", () => {
const a: GitStatus = { ahead: 1, behind: 2, dirty: false };
const b: GitStatus = { ahead: 2, behind: 2, dirty: false };
expect(compareGitStatus(a, b)).toBe(false);
});

it("returns false when behind differs", () => {
const a: GitStatus = { ahead: 1, behind: 2, dirty: false };
const b: GitStatus = { ahead: 1, behind: 3, dirty: false };
expect(compareGitStatus(a, b)).toBe(false);
});

it("returns false when dirty differs", () => {
const a: GitStatus = { ahead: 1, behind: 2, dirty: false };
const b: GitStatus = { ahead: 1, behind: 2, dirty: true };
expect(compareGitStatus(a, b)).toBe(false);
});

it("returns true for clean status (all zeros)", () => {
const a: GitStatus = { ahead: 0, behind: 0, dirty: false };
const b: GitStatus = { ahead: 0, behind: 0, dirty: false };
expect(compareGitStatus(a, b)).toBe(true);
});
});

// Hook integration tests would require jsdom setup with bun.
// The comparator functions above are the critical logic and are thoroughly tested.
// The hook itself is tested manually through its usage in useUnreadTracking,
// useWorkspaceAggregators, and GitStatusContext.
149 changes: 149 additions & 0 deletions src/hooks/useStableReference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { useRef, useMemo, type DependencyList } from "react";
import type { GitStatus } from "@/types/workspace";

/**
* Compare two Maps for deep equality (same keys and values).
* Uses === for value comparison by default.
*
* @param prev Previous Map
* @param next Next Map
* @param valueEquals Optional custom value equality function
* @returns true if Maps are equal, false otherwise
*/
export function compareMaps<K, V>(
prev: Map<K, V>,
next: Map<K, V>,
valueEquals: (a: V, b: V) => boolean = (a, b) => a === b
): boolean {
if (prev.size !== next.size) return false;

for (const [key, value] of next) {
if (!prev.has(key)) return false;
if (!valueEquals(prev.get(key)!, value)) return false;
}

return true;
}

/**
* Compare two Records for deep equality (same keys and values).
* Uses === for value comparison by default.
*
* @param prev Previous Record
* @param next Next Record
* @param valueEquals Optional custom value equality function
* @returns true if Records are equal, false otherwise
*/
export function compareRecords<V>(
prev: Record<string, V>,
next: Record<string, V>,
valueEquals: (a: V, b: V) => boolean = (a, b) => a === b
): boolean {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(next);

if (prevKeys.length !== nextKeys.length) return false;

for (const key of nextKeys) {
if (!(key in prev)) return false;
if (!valueEquals(prev[key], next[key])) return false;
}

return true;
}

/**
* Compare two Arrays for deep equality (same length and values).
* Uses === for value comparison by default.
*
* @param prev Previous Array
* @param next Next Array
* @param valueEquals Optional custom value equality function
* @returns true if Arrays are equal, false otherwise
*/
export function compareArrays<V>(
prev: V[],
next: V[],
valueEquals: (a: V, b: V) => boolean = (a, b) => a === b
): boolean {
if (prev.length !== next.length) return false;

for (let i = 0; i < next.length; i++) {
if (!valueEquals(prev[i], next[i])) return false;
}

return true;
}

/**
* Compare two GitStatus objects for equality.
* Used to stabilize git status Map identity when values haven't changed.
*
* @param a Previous GitStatus
* @param b Next GitStatus
* @returns true if GitStatus objects are equal, false otherwise
*/
export function compareGitStatus(a: GitStatus | null, b: GitStatus | null): boolean {
if (a === null && b === null) return true;
if (a === null || b === null) return false;

return a.ahead === b.ahead && a.behind === b.behind && a.dirty === b.dirty;
}

/**
* Hook to stabilize reference identity for computed values.
*
* Returns the previous reference if the new value is deeply equal to the previous value,
* preventing unnecessary re-renders in components that depend on reference equality.
*
* Common use case: Stabilizing Map/Record/Array identities in useMemo when the
* underlying values haven't actually changed.
*
* @example
* ```typescript
* // Stabilize a Map<string, boolean>
* const unreadStatus = useStableReference(
* () => {
* const map = new Map<string, boolean>();
* for (const [id, state] of workspaceStates) {
* map.set(id, calculateUnread(state));
* }
* return map;
* },
* compareMaps,
* [workspaceStates]
* );
* ```
*
* @param factory Function that creates the new value
* @param comparator Function to check equality between prev and next values
* @param deps Dependency list for useMemo
* @returns Stable reference to the value
*/
export function useStableReference<T>(
factory: () => T,
comparator: (prev: T, next: T) => boolean,
deps: DependencyList
): T {
const ref = useRef<T | undefined>(undefined);

return useMemo(() => {
const next = factory();

// First render or no previous value
if (ref.current === undefined) {
ref.current = next;
return next;
}

// Compare with previous value
if (comparator(ref.current, next)) {
return ref.current; // Maintain identity
}

// Value changed, update ref and return new value
ref.current = next;
return next;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
Loading