Skip to content

Commit a8455e0

Browse files
Copilothotlong
andauthored
fix(console): migrate useFavorites to React Context (FavoritesProvider) to fix star toggle reactivity
Agent-Logs-Url: https://github.com/objectstack-ai/objectui/sessions/26bb233a-d5c2-4042-8361-e6c14d430389 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 0bd9d92 commit a8455e0

File tree

5 files changed

+261
-107
lines changed

5 files changed

+261
-107
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Home page star/favorite not reactive** (`@object-ui/console`): Migrated `useFavorites` from standalone hook to React Context (`FavoritesProvider`) so all consumers (HomePage, AppCard, AppSidebar, UnifiedSidebar) share a single state instance. Previously, each component calling `useFavorites()` created independent state, so toggling a favorite in AppCard did not trigger re-render in HomePage. localStorage persistence is retained as the storage layer.
13+
1014
### Changed
1115

1216
- **Merged ObjectManagerPage into MetadataManagerPage pipeline** (`@object-ui/console`): Removed the standalone `ObjectManagerPage` component. Object management is now fully handled by the generic `MetadataManagerPage` (list view) and `MetadataDetailPage` (detail view) pipeline. The object type config in `metadataTypeRegistry` uses `listComponent: ObjectManagerListAdapter` for the custom list UI and `pageSchemaFactory: buildObjectDetailPageSchema` for the detail page, eliminating redundant page code and centralizing all metadata management through a single architecture.

apps/console/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const HomeLayout = lazy(() => import('./pages/home/HomeLayout').then(m => ({ def
6363
import { ThemeProvider } from './components/theme-provider';
6464
import { ConsoleToaster } from './components/ConsoleToaster';
6565
import { NavigationProvider } from './context/NavigationContext';
66+
import { FavoritesProvider } from './context/FavoritesProvider';
6667

6768
/**
6869
* ConnectedShell
@@ -520,6 +521,7 @@ export function App() {
520521
<ConditionalAuthWrapper authUrl="/api/v1/auth">
521522
<PreviewBanner />
522523
<NavigationProvider>
524+
<FavoritesProvider>
523525
<BrowserRouter basename={import.meta.env.BASE_URL?.replace(/\/$/, '') || '/'}>
524526
<Suspense fallback={<LoadingScreen />}>
525527
<Routes>
@@ -571,6 +573,7 @@ export function App() {
571573
</Routes>
572574
</Suspense>
573575
</BrowserRouter>
576+
</FavoritesProvider>
574577
</NavigationProvider>
575578
</ConditionalAuthWrapper>
576579
</ThemeProvider>

apps/console/src/__tests__/Favorites.test.tsx

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/**
2-
* Tests for useFavorites hook
2+
* Tests for useFavorites hook (via FavoritesProvider context)
33
*/
44
import { describe, it, expect, beforeEach, vi } from 'vitest';
55
import { renderHook, act } from '@testing-library/react';
6+
import type { ReactNode } from 'react';
67
import { useFavorites } from '../hooks/useFavorites';
8+
import { FavoritesProvider } from '../context/FavoritesProvider';
79

810
// Mock localStorage
911
const localStorageMock = (() => {
@@ -18,19 +20,24 @@ const localStorageMock = (() => {
1820

1921
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
2022

23+
/** Wrapper that provides the FavoritesProvider for renderHook */
24+
function wrapper({ children }: { children: ReactNode }) {
25+
return <FavoritesProvider>{children}</FavoritesProvider>;
26+
}
27+
2128
describe('useFavorites', () => {
2229
beforeEach(() => {
2330
localStorageMock.clear();
2431
vi.clearAllMocks();
2532
});
2633

2734
it('starts with empty favorites when localStorage is empty', () => {
28-
const { result } = renderHook(() => useFavorites());
35+
const { result } = renderHook(() => useFavorites(), { wrapper });
2936
expect(result.current.favorites).toEqual([]);
3037
});
3138

3239
it('adds a favorite item', () => {
33-
const { result } = renderHook(() => useFavorites());
40+
const { result } = renderHook(() => useFavorites(), { wrapper });
3441

3542
act(() => {
3643
result.current.addFavorite({
@@ -48,7 +55,7 @@ describe('useFavorites', () => {
4855
});
4956

5057
it('does not add duplicate favorites', () => {
51-
const { result } = renderHook(() => useFavorites());
58+
const { result } = renderHook(() => useFavorites(), { wrapper });
5259

5360
act(() => {
5461
result.current.addFavorite({
@@ -73,7 +80,7 @@ describe('useFavorites', () => {
7380
});
7481

7582
it('removes a favorite', () => {
76-
const { result } = renderHook(() => useFavorites());
83+
const { result } = renderHook(() => useFavorites(), { wrapper });
7784

7885
act(() => {
7986
result.current.addFavorite({
@@ -92,7 +99,7 @@ describe('useFavorites', () => {
9299
});
93100

94101
it('toggles a favorite on and off', () => {
95-
const { result } = renderHook(() => useFavorites());
102+
const { result } = renderHook(() => useFavorites(), { wrapper });
96103
const item = {
97104
id: 'dashboard:sales',
98105
label: 'Sales Dashboard',
@@ -116,7 +123,7 @@ describe('useFavorites', () => {
116123
});
117124

118125
it('checks if an item is a favorite', () => {
119-
const { result } = renderHook(() => useFavorites());
126+
const { result } = renderHook(() => useFavorites(), { wrapper });
120127

121128
expect(result.current.isFavorite('object:contact')).toBe(false);
122129

@@ -134,7 +141,7 @@ describe('useFavorites', () => {
134141
});
135142

136143
it('limits to max 20 favorites', () => {
137-
const { result } = renderHook(() => useFavorites());
144+
const { result } = renderHook(() => useFavorites(), { wrapper });
138145

139146
for (let i = 0; i < 25; i++) {
140147
act(() => {
@@ -151,7 +158,7 @@ describe('useFavorites', () => {
151158
});
152159

153160
it('clears all favorites', () => {
154-
const { result } = renderHook(() => useFavorites());
161+
const { result } = renderHook(() => useFavorites(), { wrapper });
155162

156163
act(() => {
157164
result.current.addFavorite({
@@ -170,7 +177,7 @@ describe('useFavorites', () => {
170177
});
171178

172179
it('persists favorites to localStorage', () => {
173-
const { result } = renderHook(() => useFavorites());
180+
const { result } = renderHook(() => useFavorites(), { wrapper });
174181

175182
act(() => {
176183
result.current.addFavorite({
@@ -186,4 +193,46 @@ describe('useFavorites', () => {
186193
expect.any(String),
187194
);
188195
});
196+
197+
it('two hooks sharing the same provider see each other\'s mutations (cross-component reactivity)', () => {
198+
// Both hooks are called within the same render, sharing the same provider.
199+
// This simulates the real scenario where AppCard (consumer A) toggles a favorite
200+
// and HomePage (consumer B) should immediately see the updated state.
201+
const { result } = renderHook(
202+
() => ({ hookA: useFavorites(), hookB: useFavorites() }),
203+
{ wrapper },
204+
);
205+
206+
// Hook A adds a favorite
207+
act(() => {
208+
result.current.hookA.addFavorite({
209+
id: 'app:crm',
210+
label: 'CRM',
211+
href: '/apps/crm',
212+
type: 'object',
213+
});
214+
});
215+
216+
// Hook B (simulating HomePage reading favorites) must see the update
217+
expect(result.current.hookB.favorites).toHaveLength(1);
218+
expect(result.current.hookB.isFavorite('app:crm')).toBe(true);
219+
220+
// Hook B removes the favorite
221+
act(() => {
222+
result.current.hookB.removeFavorite('app:crm');
223+
});
224+
225+
// Hook A must see the removal
226+
expect(result.current.hookA.favorites).toHaveLength(0);
227+
expect(result.current.hookA.isFavorite('app:crm')).toBe(false);
228+
});
229+
230+
it('throws when used outside FavoritesProvider', () => {
231+
// Suppress the expected React error boundary console output
232+
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
233+
expect(() => renderHook(() => useFavorites())).toThrow(
234+
'useFavorites must be used within a FavoritesProvider',
235+
);
236+
spy.mockRestore();
237+
});
189238
});
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* FavoritesProvider
3+
*
4+
* React Context + Provider for shared favorites state across all consumers
5+
* (HomePage, AppCard, AppSidebar, UnifiedSidebar, StarredApps).
6+
*
7+
* Replaces the standalone `useFavorites` hook pattern — all callers now share
8+
* a single state instance so that toggling a star in AppCard immediately
9+
* reflects in HomePage's Starred section and sidebar.
10+
*
11+
* Persistence: localStorage (key: "objectui-favorites", max 20 items).
12+
*
13+
* TODO: Migrate persistence to server-side storage via the adapter/API layer
14+
* (e.g. PUT /api/user/preferences) so favorites sync across devices and browsers.
15+
* The provider should accept an optional `persistenceAdapter` prop that implements
16+
* `load(): Promise<FavoriteItem[]>` and `save(items: FavoriteItem[]): Promise<void>`.
17+
* When the adapter is provided, localStorage should be used only as a fallback
18+
* during the initial load while the server response is in-flight.
19+
*
20+
* @module
21+
*/
22+
23+
import {
24+
createContext,
25+
useCallback,
26+
useContext,
27+
useEffect,
28+
useMemo,
29+
useState,
30+
type ReactNode,
31+
} from 'react';
32+
33+
// ---------------------------------------------------------------------------
34+
// Types
35+
// ---------------------------------------------------------------------------
36+
37+
export interface FavoriteItem {
38+
/** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */
39+
id: string;
40+
label: string;
41+
href: string;
42+
type: 'object' | 'dashboard' | 'page' | 'report';
43+
/** ISO timestamp of when the item was favorited */
44+
favoritedAt: string;
45+
}
46+
47+
interface FavoritesContextValue {
48+
favorites: FavoriteItem[];
49+
addFavorite: (item: Omit<FavoriteItem, 'favoritedAt'>) => void;
50+
removeFavorite: (id: string) => void;
51+
toggleFavorite: (item: Omit<FavoriteItem, 'favoritedAt'>) => void;
52+
isFavorite: (id: string) => boolean;
53+
clearFavorites: () => void;
54+
}
55+
56+
// ---------------------------------------------------------------------------
57+
// Storage helpers
58+
// ---------------------------------------------------------------------------
59+
60+
const STORAGE_KEY = 'objectui-favorites';
61+
const MAX_FAVORITES = 20;
62+
63+
function loadFavorites(): FavoriteItem[] {
64+
try {
65+
const raw = localStorage.getItem(STORAGE_KEY);
66+
return raw ? JSON.parse(raw) : [];
67+
} catch {
68+
return [];
69+
}
70+
}
71+
72+
function saveFavorites(items: FavoriteItem[]) {
73+
try {
74+
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
75+
} catch {
76+
// Storage full — silently ignore
77+
}
78+
}
79+
80+
// ---------------------------------------------------------------------------
81+
// Context
82+
// ---------------------------------------------------------------------------
83+
84+
const FavoritesContext = createContext<FavoritesContextValue | null>(null);
85+
86+
// ---------------------------------------------------------------------------
87+
// Provider
88+
// ---------------------------------------------------------------------------
89+
90+
interface FavoritesProviderProps {
91+
children: ReactNode;
92+
}
93+
94+
export function FavoritesProvider({ children }: FavoritesProviderProps) {
95+
const [favorites, setFavorites] = useState<FavoriteItem[]>(loadFavorites);
96+
97+
// Re-sync from storage on mount (handles cases where storage was written
98+
// before this provider was mounted, e.g. on initial page load).
99+
useEffect(() => {
100+
setFavorites(loadFavorites());
101+
}, []);
102+
103+
const addFavorite = useCallback(
104+
(item: Omit<FavoriteItem, 'favoritedAt'>) => {
105+
setFavorites(prev => {
106+
if (prev.some(f => f.id === item.id)) return prev;
107+
const updated = [
108+
{ ...item, favoritedAt: new Date().toISOString() },
109+
...prev,
110+
].slice(0, MAX_FAVORITES);
111+
saveFavorites(updated);
112+
return updated;
113+
});
114+
},
115+
[],
116+
);
117+
118+
const removeFavorite = useCallback((id: string) => {
119+
setFavorites(prev => {
120+
const updated = prev.filter(f => f.id !== id);
121+
saveFavorites(updated);
122+
return updated;
123+
});
124+
}, []);
125+
126+
const toggleFavorite = useCallback(
127+
(item: Omit<FavoriteItem, 'favoritedAt'>) => {
128+
setFavorites(prev => {
129+
const exists = prev.some(f => f.id === item.id);
130+
const updated = exists
131+
? prev.filter(f => f.id !== item.id)
132+
: [{ ...item, favoritedAt: new Date().toISOString() }, ...prev].slice(
133+
0,
134+
MAX_FAVORITES,
135+
);
136+
saveFavorites(updated);
137+
return updated;
138+
});
139+
},
140+
[],
141+
);
142+
143+
const clearFavorites = useCallback(() => {
144+
setFavorites([]);
145+
saveFavorites([]);
146+
}, []);
147+
148+
const value = useMemo<FavoritesContextValue>(
149+
() => ({
150+
favorites,
151+
addFavorite,
152+
removeFavorite,
153+
toggleFavorite,
154+
// Inlined here so useMemo sees the freshest `favorites` without needing
155+
// a separate useCallback([favorites]) entry in the deps array.
156+
isFavorite: (id: string) => favorites.some(f => f.id === id),
157+
clearFavorites,
158+
}),
159+
// addFavorite / removeFavorite / toggleFavorite / clearFavorites are all
160+
// stable (useCallback with [] deps) and never cause extra re-renders.
161+
[favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites],
162+
);
163+
164+
return (
165+
<FavoritesContext.Provider value={value}>
166+
{children}
167+
</FavoritesContext.Provider>
168+
);
169+
}
170+
171+
// ---------------------------------------------------------------------------
172+
// Hook
173+
// ---------------------------------------------------------------------------
174+
175+
/**
176+
* Access shared favorites state.
177+
*
178+
* Must be used inside `<FavoritesProvider>`.
179+
*/
180+
export function useFavorites(): FavoritesContextValue {
181+
const ctx = useContext(FavoritesContext);
182+
if (!ctx) {
183+
throw new Error('useFavorites must be used within a FavoritesProvider');
184+
}
185+
return ctx;
186+
}

0 commit comments

Comments
 (0)