From 62f2fbfec702d0fd67d769233c82c0f6547c2519 Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Mon, 23 Mar 2026 11:36:37 -0400 Subject: [PATCH 1/2] feat favorites infrastructure --- src/hooks/useFavorites.test.ts | 124 +++++++++++++++++++++++++++++++++ src/hooks/useFavorites.ts | 90 ++++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/hooks/useFavorites.test.ts create mode 100644 src/hooks/useFavorites.ts diff --git a/src/hooks/useFavorites.test.ts b/src/hooks/useFavorites.test.ts new file mode 100644 index 000000000..57118826b --- /dev/null +++ b/src/hooks/useFavorites.test.ts @@ -0,0 +1,124 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { type FavoriteItem, useFavorites } from "./useFavorites"; + +const pipeline: FavoriteItem = { + type: "pipeline", + id: "p1", + name: "My Pipeline", +}; +const run: FavoriteItem = { type: "run", id: "r1", name: "My Run" }; + +beforeEach(() => { + localStorage.clear(); +}); + +describe("useFavorites", () => { + it("starts with no favorites", () => { + const { result } = renderHook(() => useFavorites()); + expect(result.current.favorites).toEqual([]); + }); + + it("adds a favorite", () => { + const { result } = renderHook(() => useFavorites()); + + act(() => { + result.current.addFavorite(pipeline); + }); + + expect(result.current.favorites).toEqual([pipeline]); + }); + + it("does not add a duplicate favorite", () => { + const { result } = renderHook(() => useFavorites()); + + act(() => { + result.current.addFavorite(pipeline); + result.current.addFavorite(pipeline); + }); + + expect(result.current.favorites).toHaveLength(1); + }); + + it("removes a favorite", () => { + const { result } = renderHook(() => useFavorites()); + + act(() => { + result.current.addFavorite(pipeline); + result.current.addFavorite(run); + }); + + act(() => { + result.current.removeFavorite("pipeline", "p1"); + }); + + expect(result.current.favorites).toEqual([run]); + }); + + it("toggles a favorite on and off", () => { + const { result } = renderHook(() => useFavorites()); + + act(() => { + result.current.toggleFavorite(pipeline); + }); + expect(result.current.favorites).toEqual([pipeline]); + + act(() => { + result.current.toggleFavorite(pipeline); + }); + expect(result.current.favorites).toEqual([]); + }); + + it("correctly reports isFavorite", () => { + const { result } = renderHook(() => useFavorites()); + + act(() => { + result.current.addFavorite(pipeline); + }); + + expect(result.current.isFavorite("pipeline", "p1")).toBe(true); + expect(result.current.isFavorite("run", "r1")).toBe(false); + }); + + it("does not confuse items of different types with the same id", () => { + const pipelineItem: FavoriteItem = { + type: "pipeline", + id: "1", + name: "Pipeline", + }; + const runItem: FavoriteItem = { type: "run", id: "1", name: "Run" }; + const { result } = renderHook(() => useFavorites()); + + act(() => { + result.current.addFavorite(pipelineItem); + }); + + expect(result.current.isFavorite("pipeline", "1")).toBe(true); + expect(result.current.isFavorite("run", "1")).toBe(false); + + act(() => { + result.current.addFavorite(runItem); + }); + + expect(result.current.favorites).toHaveLength(2); + }); + + it("reacts to storage events from other windows", () => { + const { result } = renderHook(() => useFavorites()); + + act(() => { + // In a real browser, another tab both updates localStorage and fires the + // storage event. jsdom only does the latter, so we simulate both. + localStorage.setItem("Home/favorites", JSON.stringify([pipeline])); + window.dispatchEvent( + new StorageEvent("storage", { + key: "Home/favorites", + newValue: JSON.stringify([pipeline]), + }), + ); + }); + + expect(result.current.favorites).toEqual([pipeline]); + }); +}); diff --git a/src/hooks/useFavorites.ts b/src/hooks/useFavorites.ts new file mode 100644 index 000000000..17c79bc37 --- /dev/null +++ b/src/hooks/useFavorites.ts @@ -0,0 +1,90 @@ +import { useCallback, useSyncExternalStore } from "react"; + +import { getStorage } from "@/utils/typedStorage"; + +const FAVORITES_STORAGE_KEY = "Home/favorites"; + +export type FavoriteType = "pipeline" | "run"; + +export interface FavoriteItem { + type: FavoriteType; + id: string; + name: string; +} + +type FavoritesStorageMapping = { + [FAVORITES_STORAGE_KEY]: FavoriteItem[]; +}; + +const storage = getStorage< + typeof FAVORITES_STORAGE_KEY, + FavoritesStorageMapping +>(); + +// useSyncExternalStore requires getSnapshot to return a stable reference. +// We cache the last parsed value and only update it when the raw JSON changes. +let cachedJson: string | null = null; +let cachedFavorites: FavoriteItem[] = []; + +function readFavorites(): FavoriteItem[] { + const json = localStorage.getItem(FAVORITES_STORAGE_KEY); + if (json === cachedJson) return cachedFavorites; + cachedJson = json; + cachedFavorites = json ? (JSON.parse(json) as FavoriteItem[]) : []; + return cachedFavorites; +} + +function subscribe(callback: () => void) { + const handler = (event: StorageEvent) => { + if (event.key === FAVORITES_STORAGE_KEY) callback(); + }; + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); +} + +export function useFavorites() { + // useSyncExternalStore keeps the hook reactive to localStorage changes, + // including changes dispatched by typedStorage from the same window. + const favorites = useSyncExternalStore(subscribe, readFavorites, () => []); + + const addFavorite = useCallback((item: FavoriteItem) => { + const current = readFavorites(); + const alreadyExists = current.some( + (f) => f.type === item.type && f.id === item.id, + ); + if (!alreadyExists) { + storage.setItem(FAVORITES_STORAGE_KEY, [...current, item]); + } + }, []); + + const removeFavorite = useCallback((type: FavoriteType, id: string) => { + const current = readFavorites(); + storage.setItem( + FAVORITES_STORAGE_KEY, + current.filter((f) => !(f.type === type && f.id === id)), + ); + }, []); + + const toggleFavorite = useCallback((item: FavoriteItem) => { + const current = readFavorites(); + const exists = current.some( + (f) => f.type === item.type && f.id === item.id, + ); + if (exists) { + storage.setItem( + FAVORITES_STORAGE_KEY, + current.filter((f) => !(f.type === item.type && f.id === item.id)), + ); + } else { + storage.setItem(FAVORITES_STORAGE_KEY, [...current, item]); + } + }, []); + + const isFavorite = useCallback( + (type: FavoriteType, id: string) => + favorites.some((f) => f.type === type && f.id === id), + [favorites], + ); + + return { favorites, addFavorite, removeFavorite, toggleFavorite, isFavorite }; +} From ce9e2da34d97f62ca9e7f3debb914f30e1729c4f Mon Sep 17 00:00:00 2001 From: mbeaulne Date: Wed, 1 Apr 2026 13:40:29 -0400 Subject: [PATCH 2/2] fix: safe JSON parse and consolidate toggleFavorite logic in useFavorites --- react-compiler.config.js | 1 + src/hooks/useFavorites.test.ts | 121 +++++++----------- src/hooks/useFavorites.ts | 99 ++++---------- .../libraries/storage.ts | 16 ++- 4 files changed, 84 insertions(+), 153 deletions(-) diff --git a/react-compiler.config.js b/react-compiler.config.js index 1062f5da5..7de445db7 100644 --- a/react-compiler.config.js +++ b/react-compiler.config.js @@ -29,6 +29,7 @@ export const REACT_COMPILER_ENABLED_DIRS = [ "src/hooks/useHandleEdgeSelection.ts", "src/hooks/useEdgeSelectionHighlight.ts", "src/hooks/useRunSearchParams.ts", + "src/hooks/useFavorites.ts", "src/components/shared/Tags", "src/components/shared/Submitters/Oasis/components", diff --git a/src/hooks/useFavorites.test.ts b/src/hooks/useFavorites.test.ts index 57118826b..74da9a04b 100644 --- a/src/hooks/useFavorites.test.ts +++ b/src/hooks/useFavorites.test.ts @@ -1,5 +1,9 @@ -import { act, renderHook } from "@testing-library/react"; -import { beforeEach, describe, expect, it } from "vitest"; +import "fake-indexeddb/auto"; + +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { LibraryDB } from "@/providers/ComponentLibraryProvider/libraries/storage"; import { type FavoriteItem, useFavorites } from "./useFavorites"; @@ -10,78 +14,67 @@ const pipeline: FavoriteItem = { }; const run: FavoriteItem = { type: "run", id: "r1", name: "My Run" }; -beforeEach(() => { - localStorage.clear(); +afterEach(async () => { + await LibraryDB.favorites.clear(); }); describe("useFavorites", () => { - it("starts with no favorites", () => { + it("starts with no favorites", async () => { const { result } = renderHook(() => useFavorites()); - expect(result.current.favorites).toEqual([]); + await waitFor(() => { + expect(result.current.favorites).toEqual([]); + }); }); - it("adds a favorite", () => { + it("adds a favorite", async () => { const { result } = renderHook(() => useFavorites()); - - act(() => { - result.current.addFavorite(pipeline); + await result.current.addFavorite(pipeline); + await waitFor(() => { + expect(result.current.favorites).toEqual([pipeline]); }); - - expect(result.current.favorites).toEqual([pipeline]); }); - it("does not add a duplicate favorite", () => { + it("does not add a duplicate favorite", async () => { const { result } = renderHook(() => useFavorites()); - - act(() => { - result.current.addFavorite(pipeline); - result.current.addFavorite(pipeline); + await result.current.addFavorite(pipeline); + await result.current.addFavorite(pipeline); + await waitFor(() => { + expect(result.current.favorites).toHaveLength(1); }); - - expect(result.current.favorites).toHaveLength(1); }); - it("removes a favorite", () => { + it("removes a favorite", async () => { const { result } = renderHook(() => useFavorites()); - - act(() => { - result.current.addFavorite(pipeline); - result.current.addFavorite(run); - }); - - act(() => { - result.current.removeFavorite("pipeline", "p1"); + await result.current.addFavorite(pipeline); + await result.current.addFavorite(run); + await result.current.removeFavorite("pipeline", "p1"); + await waitFor(() => { + expect(result.current.favorites).toEqual([run]); }); - - expect(result.current.favorites).toEqual([run]); }); - it("toggles a favorite on and off", () => { + it("toggles a favorite on and off", async () => { const { result } = renderHook(() => useFavorites()); - - act(() => { - result.current.toggleFavorite(pipeline); + await result.current.toggleFavorite(pipeline); + await waitFor(() => { + expect(result.current.favorites).toEqual([pipeline]); }); - expect(result.current.favorites).toEqual([pipeline]); - - act(() => { - result.current.toggleFavorite(pipeline); + await result.current.toggleFavorite(pipeline); + await waitFor(() => { + expect(result.current.favorites).toEqual([]); }); - expect(result.current.favorites).toEqual([]); }); - it("correctly reports isFavorite", () => { + it("correctly reports isFavorite", async () => { const { result } = renderHook(() => useFavorites()); - - act(() => { - result.current.addFavorite(pipeline); + await result.current.addFavorite(pipeline); + await waitFor(() => { + expect(result.current.isFavorite("pipeline", "p1")).toBe(true); + expect(result.current.isFavorite("run", "r1")).toBe(false); }); - - expect(result.current.isFavorite("pipeline", "p1")).toBe(true); - expect(result.current.isFavorite("run", "r1")).toBe(false); }); - it("does not confuse items of different types with the same id", () => { + it("does not confuse items of different types with the same id", async () => { const pipelineItem: FavoriteItem = { type: "pipeline", id: "1", @@ -90,35 +83,15 @@ describe("useFavorites", () => { const runItem: FavoriteItem = { type: "run", id: "1", name: "Run" }; const { result } = renderHook(() => useFavorites()); - act(() => { - result.current.addFavorite(pipelineItem); + await result.current.addFavorite(pipelineItem); + await waitFor(() => { + expect(result.current.isFavorite("pipeline", "1")).toBe(true); + expect(result.current.isFavorite("run", "1")).toBe(false); }); - expect(result.current.isFavorite("pipeline", "1")).toBe(true); - expect(result.current.isFavorite("run", "1")).toBe(false); - - act(() => { - result.current.addFavorite(runItem); + await result.current.addFavorite(runItem); + await waitFor(() => { + expect(result.current.favorites).toHaveLength(2); }); - - expect(result.current.favorites).toHaveLength(2); - }); - - it("reacts to storage events from other windows", () => { - const { result } = renderHook(() => useFavorites()); - - act(() => { - // In a real browser, another tab both updates localStorage and fires the - // storage event. jsdom only does the latter, so we simulate both. - localStorage.setItem("Home/favorites", JSON.stringify([pipeline])); - window.dispatchEvent( - new StorageEvent("storage", { - key: "Home/favorites", - newValue: JSON.stringify([pipeline]), - }), - ); - }); - - expect(result.current.favorites).toEqual([pipeline]); }); }); diff --git a/src/hooks/useFavorites.ts b/src/hooks/useFavorites.ts index 17c79bc37..fef644f4a 100644 --- a/src/hooks/useFavorites.ts +++ b/src/hooks/useFavorites.ts @@ -1,90 +1,35 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { useLiveQuery } from "dexie-react-hooks"; -import { getStorage } from "@/utils/typedStorage"; +import { + type FavoriteItem, + type FavoriteType, + LibraryDB, +} from "@/providers/ComponentLibraryProvider/libraries/storage"; -const FAVORITES_STORAGE_KEY = "Home/favorites"; +export type { FavoriteItem, FavoriteType }; -export type FavoriteType = "pipeline" | "run"; - -export interface FavoriteItem { - type: FavoriteType; - id: string; - name: string; -} - -type FavoritesStorageMapping = { - [FAVORITES_STORAGE_KEY]: FavoriteItem[]; -}; - -const storage = getStorage< - typeof FAVORITES_STORAGE_KEY, - FavoritesStorageMapping ->(); - -// useSyncExternalStore requires getSnapshot to return a stable reference. -// We cache the last parsed value and only update it when the raw JSON changes. -let cachedJson: string | null = null; -let cachedFavorites: FavoriteItem[] = []; - -function readFavorites(): FavoriteItem[] { - const json = localStorage.getItem(FAVORITES_STORAGE_KEY); - if (json === cachedJson) return cachedFavorites; - cachedJson = json; - cachedFavorites = json ? (JSON.parse(json) as FavoriteItem[]) : []; - return cachedFavorites; -} +export function useFavorites() { + const favorites = useLiveQuery(() => LibraryDB.favorites.toArray(), []) ?? []; -function subscribe(callback: () => void) { - const handler = (event: StorageEvent) => { - if (event.key === FAVORITES_STORAGE_KEY) callback(); + const addFavorite = async (item: FavoriteItem) => { + // put is an upsert — compound PK [type+id] prevents duplicates + await LibraryDB.favorites.put(item); }; - window.addEventListener("storage", handler); - return () => window.removeEventListener("storage", handler); -} - -export function useFavorites() { - // useSyncExternalStore keeps the hook reactive to localStorage changes, - // including changes dispatched by typedStorage from the same window. - const favorites = useSyncExternalStore(subscribe, readFavorites, () => []); - const addFavorite = useCallback((item: FavoriteItem) => { - const current = readFavorites(); - const alreadyExists = current.some( - (f) => f.type === item.type && f.id === item.id, - ); - if (!alreadyExists) { - storage.setItem(FAVORITES_STORAGE_KEY, [...current, item]); - } - }, []); + const removeFavorite = async (type: FavoriteType, id: string) => { + await LibraryDB.favorites.delete([type, id]); + }; - const removeFavorite = useCallback((type: FavoriteType, id: string) => { - const current = readFavorites(); - storage.setItem( - FAVORITES_STORAGE_KEY, - current.filter((f) => !(f.type === type && f.id === id)), - ); - }, []); + const isFavorite = (type: FavoriteType, id: string) => + favorites.some((f) => f.type === type && f.id === id); - const toggleFavorite = useCallback((item: FavoriteItem) => { - const current = readFavorites(); - const exists = current.some( - (f) => f.type === item.type && f.id === item.id, - ); - if (exists) { - storage.setItem( - FAVORITES_STORAGE_KEY, - current.filter((f) => !(f.type === item.type && f.id === item.id)), - ); + const toggleFavorite = async (item: FavoriteItem) => { + if (isFavorite(item.type, item.id)) { + await removeFavorite(item.type, item.id); } else { - storage.setItem(FAVORITES_STORAGE_KEY, [...current, item]); + await addFavorite(item); } - }, []); - - const isFavorite = useCallback( - (type: FavoriteType, id: string) => - favorites.some((f) => f.type === type && f.id === id), - [favorites], - ); + }; return { favorites, addFavorite, removeFavorite, toggleFavorite, isFavorite }; } diff --git a/src/providers/ComponentLibraryProvider/libraries/storage.ts b/src/providers/ComponentLibraryProvider/libraries/storage.ts index 78996c0ba..a6e8f002a 100644 --- a/src/providers/ComponentLibraryProvider/libraries/storage.ts +++ b/src/providers/ComponentLibraryProvider/libraries/storage.ts @@ -1,4 +1,4 @@ -import Dexie, { type EntityTable } from "dexie"; +import Dexie, { type EntityTable, type Table } from "dexie"; import { icons } from "lucide-react"; const DB_NAME = "oasis-app"; @@ -31,15 +31,27 @@ export interface StoredLibrary extends StoredLibraryFolder { knownDigests: string[]; } +export type FavoriteType = "pipeline" | "run"; + +export interface FavoriteItem { + type: FavoriteType; + id: string; + name: string; +} + export const LibraryDB = new Dexie(DB_NAME) as Dexie & { component_libraries: EntityTable; + favorites: Table; }; /** - * Initialize the database with the favorite components * Each version should be declared in DEXIE_EPOCH + {number}, starting from 1 */ LibraryDB.version(DEXIE_EPOCH + 1).stores({ // id - primary key; name is unique index component_libraries: "id, &name", }); + +LibraryDB.version(DEXIE_EPOCH + 2).stores({ + favorites: "[type+id]", +});