From ecd3137d34fe763aaebed602b5e1603459418407 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 10 May 2026 16:09:17 +0800 Subject: [PATCH] Extract sidebar callout state (#884) --- .../contexts/sidebar-callout-context.test.tsx | 224 ------------------ .../src/contexts/sidebar-callout-context.tsx | 188 +++++---------- .../contexts/sidebar-callout-state.test.ts | 136 +++++++++++ .../app/src/contexts/sidebar-callout-state.ts | 162 +++++++++++++ 4 files changed, 353 insertions(+), 357 deletions(-) delete mode 100644 packages/app/src/contexts/sidebar-callout-context.test.tsx create mode 100644 packages/app/src/contexts/sidebar-callout-state.test.ts create mode 100644 packages/app/src/contexts/sidebar-callout-state.ts diff --git a/packages/app/src/contexts/sidebar-callout-context.test.tsx b/packages/app/src/contexts/sidebar-callout-context.test.tsx deleted file mode 100644 index c24d5e902..000000000 --- a/packages/app/src/contexts/sidebar-callout-context.test.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React, { act, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const { theme } = vi.hoisted(() => ({ - theme: { - spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, - borderWidth: { 1: 1 }, - borderRadius: { md: 6 }, - fontSize: { xs: 11, sm: 13 }, - fontWeight: { medium: "500", semibold: "600" }, - colors: { - surface0: "#000", - foreground: "#fff", - foregroundMuted: "#aaa", - border: "#555", - destructive: "#f44", - }, - }, -})); - -const asyncStorage = vi.hoisted(() => ({ - values: new Map(), - getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null), - setItem: vi.fn(async (key: string, value: string) => { - asyncStorage.values.set(key, value); - }), -})); - -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: asyncStorage, -})); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, - }, - useUnistyles: () => ({ theme }), -})); - -vi.mock("lucide-react-native", () => { - const X = (props: Record) => React.createElement("span", props); - return { X }; -}); - -vi.stubGlobal("React", React); -vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - -import { - SidebarCalloutProvider, - type SidebarCalloutsApi, - SidebarCalloutViewport, - useSidebarCallouts, -} from "./sidebar-callout-context"; - -const apiSink: { current: SidebarCalloutsApi | null } = { current: null }; - -function handleApi(nextApi: SidebarCalloutsApi): void { - apiSink.current = nextApi; -} - -function CaptureApi({ onApi }: { onApi: (api: SidebarCalloutsApi) => void }) { - const api = useSidebarCallouts(); - onApi(api); - return null; -} - -describe("SidebarCalloutProvider", () => { - let root: Root | null = null; - let container: HTMLElement | null = null; - let api: SidebarCalloutsApi | null = null; - - beforeEach(async () => { - api = null; - apiSink.current = null; - asyncStorage.values.clear(); - asyncStorage.getItem.mockClear(); - asyncStorage.setItem.mockClear(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render( - - - - , - ); - await Promise.resolve(); - }); - api = apiSink.current; - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - api = null; - }); - - it("shows the highest-priority callout first, then reveals the next when dismissed", () => { - act(() => { - api?.show({ id: "onboarding", priority: 10, title: "Set up scripts" }); - api?.show({ id: "update", priority: 200, title: "Update available" }); - }); - - expect(container?.textContent).toContain("Update available"); - expect(container?.textContent).not.toContain("Set up scripts"); - - act(() => { - api?.dismiss("update"); - }); - - expect(container?.textContent).toContain("Set up scripts"); - expect(container?.textContent).not.toContain("Update available"); - }); - - it("replaces a callout by id without duplicating the queue item", () => { - act(() => { - api?.show({ id: "daemon", title: "Old daemon", description: "v1" }); - api?.show({ id: "daemon", title: "New daemon", description: "v2" }); - }); - - expect(container?.textContent).toContain("New daemon"); - expect(container?.textContent).toContain("v2"); - expect(container?.textContent).not.toContain("Old daemon"); - }); - - it("keeps API consumers from rerendering when callout state changes", () => { - const renders = vi.fn(); - function Producer() { - const callouts = useSidebarCallouts(); - renders(callouts); - useEffect(() => { - callouts.show({ id: "initial", title: "Initial" }); - }, [callouts]); - return null; - } - - act(() => { - root?.render( - - - - - , - ); - }); - api = apiSink.current; - const firstApi = renders.mock.calls[0]?.[0]; - - act(() => { - api?.show({ id: "later", priority: 10, title: "Later" }); - }); - - expect(renders).toHaveBeenCalledTimes(1); - expect(renders.mock.calls[0]?.[0]).toBe(firstApi); - }); - - it("unregisters only the registration returned by show", () => { - let unregisterOld: (() => void) | null = null; - act(() => { - unregisterOld = api?.show({ id: "update", title: "Old" }) ?? null; - api?.show({ id: "update", title: "New" }); - }); - - act(() => { - unregisterOld?.(); - }); - - expect(container?.textContent).toContain("New"); - }); - - it("persists dismissals by dismissal key", () => { - act(() => { - api?.show({ - id: "update", - dismissalKey: "desktop-update:available:1.2.3", - title: "Update available", - }); - }); - - expect(container?.textContent).toContain("Update available"); - - act(() => { - api?.dismiss("update"); - }); - - expect(container?.textContent).not.toContain("Update available"); - expect(asyncStorage.setItem).toHaveBeenCalledWith( - "@paseo:sidebar-callout-dismissals", - JSON.stringify(["desktop-update:available:1.2.3"]), - ); - - act(() => { - api?.show({ - id: "update", - dismissalKey: "desktop-update:available:1.2.3", - title: "Dismissed update", - }); - }); - - expect(container?.textContent).not.toContain("Dismissed update"); - - act(() => { - api?.show({ - id: "update", - dismissalKey: "desktop-update:available:1.2.4", - title: "New update", - }); - }); - - expect(container?.textContent).toContain("New update"); - }); -}); diff --git a/packages/app/src/contexts/sidebar-callout-context.tsx b/packages/app/src/contexts/sidebar-callout-context.tsx index e3c349e13..2e337aadd 100644 --- a/packages/app/src/contexts/sidebar-callout-context.tsx +++ b/packages/app/src/contexts/sidebar-callout-context.tsx @@ -8,27 +8,24 @@ import { useRef, useState, } from "react"; -import { - SidebarCallout, - type SidebarCalloutAction, - type SidebarCalloutProps, - type SidebarCalloutVariant, -} from "@/components/sidebar-callout"; +import { SidebarCallout, type SidebarCalloutProps } from "@/components/sidebar-callout"; import { useStableEvent } from "@/hooks/use-stable-event"; +import { + clearSidebarCallouts, + createSidebarCalloutState, + dismissSidebarCallout, + loadDismissedCalloutKeys, + parseDismissedCalloutKeys, + selectActiveSidebarCallout, + serializeDismissedCalloutKeys, + showSidebarCallout, + type SidebarCalloutEntry, + type SidebarCalloutOptions, + type SidebarCalloutState, + unregisterSidebarCallout, +} from "./sidebar-callout-state"; -export interface SidebarCalloutOptions { - id: string; - dismissalKey?: string; - title: string; - description?: ReactNode; - icon?: ReactNode; - variant?: SidebarCalloutVariant; - actions?: readonly SidebarCalloutAction[]; - dismissible?: boolean; - priority?: number; - onDismiss?: () => void; - testID?: string; -} +export type { SidebarCalloutOptions } from "./sidebar-callout-state"; export interface SidebarCalloutsApi { show: (callout: SidebarCalloutOptions) => () => void; @@ -36,96 +33,48 @@ export interface SidebarCalloutsApi { clear: () => void; } -type SidebarCalloutEntry = SidebarCalloutOptions & { - order: number; - priority: number; - token: number; -}; - const DISMISSED_CALLOUTS_STORAGE_KEY = "@paseo:sidebar-callout-dismissals"; const SidebarCalloutApiContext = createContext(null); const SidebarCalloutStateContext = createContext(null); -function normalizeDismissalKey(key: string | null | undefined): string | null { - const trimmed = key?.trim(); - return trimmed ? trimmed : null; -} - -function parseDismissedCalloutKeys(value: string | null): Set { - if (!value) { - return new Set(); - } - - try { - const parsed = JSON.parse(value) as unknown; - if (!Array.isArray(parsed)) { - return new Set(); - } - return new Set(parsed.filter((entry): entry is string => typeof entry === "string")); - } catch { - return new Set(); - } -} - function persistDismissedCalloutKeys(keys: ReadonlySet): void { - void AsyncStorage.setItem(DISMISSED_CALLOUTS_STORAGE_KEY, JSON.stringify([...keys])).catch( - (error) => { - console.error("[SidebarCallouts] Failed to persist dismissed callouts", error); - }, - ); -} - -function selectActiveCallout(input: { - callouts: readonly SidebarCalloutEntry[]; - dismissedKeys: ReadonlySet; - dismissalStorageLoaded: boolean; -}): SidebarCalloutEntry | null { - const visibleCallouts = input.callouts.filter((entry) => { - const dismissalKey = normalizeDismissalKey(entry.dismissalKey); - if (!dismissalKey) { - return true; - } - return input.dismissalStorageLoaded && !input.dismissedKeys.has(dismissalKey); + void AsyncStorage.setItem( + DISMISSED_CALLOUTS_STORAGE_KEY, + serializeDismissedCalloutKeys(keys), + ).catch((error) => { + console.error("[SidebarCallouts] Failed to persist dismissed callouts", error); }); - - if (visibleCallouts.length === 0) { - return null; - } - return ( - [...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null - ); } export function SidebarCalloutProvider({ children }: { children: ReactNode }) { - const [callouts, setCallouts] = useState([]); - const [dismissedKeys, setDismissedKeys] = useState>(new Set()); - const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false); - const calloutsRef = useRef([]); - const dismissedKeysRef = useRef>(new Set()); - const orderRef = useRef(0); - const tokenRef = useRef(0); + const [state, setState] = useState(createSidebarCalloutState); + const stateRef = useRef(state); + + function commitState(next: SidebarCalloutState): void { + stateRef.current = next; + setState(next); + } useEffect(() => { let mounted = true; - void AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY) - .then((value) => { - if (!mounted) { - return; - } - const nextKeys = parseDismissedCalloutKeys(value); - dismissedKeysRef.current = nextKeys; - setDismissedKeys(nextKeys); - return; - }) - .catch((error) => { + + async function loadDismissedKeys(): Promise { + let dismissedKeys: ReadonlySet; + try { + const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY); + dismissedKeys = parseDismissedCalloutKeys(value); + } catch (error) { console.error("[SidebarCallouts] Failed to load dismissed callouts", error); - }) - .finally(() => { - if (mounted) { - setDismissalStorageLoaded(true); - } - }); + dismissedKeys = stateRef.current.dismissedKeys; + } + + if (mounted) { + commitState(loadDismissedCalloutKeys(stateRef.current, dismissedKeys)); + } + } + + void loadDismissedKeys(); return () => { mounted = false; @@ -133,60 +82,33 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) { }, []); const show = useStableEvent((callout: SidebarCalloutOptions) => { - tokenRef.current += 1; - const token = tokenRef.current; - const current = calloutsRef.current; - const existing = current.find((entry) => entry.id === callout.id); - const nextEntry: SidebarCalloutEntry = { - ...callout, - priority: callout.priority ?? 0, - order: existing?.order ?? ++orderRef.current, - token, - }; - const next = existing - ? current.map((entry) => (entry.id === callout.id ? nextEntry : entry)) - : [...current, nextEntry]; - - calloutsRef.current = next; - setCallouts(next); + const result = showSidebarCallout(stateRef.current, callout); + commitState(result.state); return () => { - const updated = calloutsRef.current.filter( - (entry) => entry.id !== callout.id || entry.token !== token, + commitState( + unregisterSidebarCallout(stateRef.current, { id: callout.id, token: result.token }), ); - calloutsRef.current = updated; - setCallouts(updated); }; }); const dismiss = useStableEvent((id: string) => { - const dismissed = calloutsRef.current.find((entry) => entry.id === id) ?? null; - const next = calloutsRef.current.filter((entry) => entry.id !== id); - calloutsRef.current = next; - setCallouts(next); + const result = dismissSidebarCallout(stateRef.current, id); + commitState(result.state); - const dismissalKey = normalizeDismissalKey(dismissed?.dismissalKey); - if (dismissalKey) { - const nextKeys = new Set(dismissedKeysRef.current); - nextKeys.add(dismissalKey); - dismissedKeysRef.current = nextKeys; - setDismissedKeys(nextKeys); - persistDismissedCalloutKeys(nextKeys); + if (result.dismissalKey) { + persistDismissedCalloutKeys(result.state.dismissedKeys); } - dismissed?.onDismiss?.(); + result.dismissedCallout?.onDismiss?.(); }); const clear = useStableEvent(() => { - calloutsRef.current = []; - setCallouts([]); + commitState(clearSidebarCallouts(stateRef.current)); }); const api = useMemo(() => ({ show, dismiss, clear }), [clear, dismiss, show]); - const activeCallout = useMemo( - () => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }), - [callouts, dismissedKeys, dismissalStorageLoaded], - ); + const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]); return ( diff --git a/packages/app/src/contexts/sidebar-callout-state.test.ts b/packages/app/src/contexts/sidebar-callout-state.test.ts new file mode 100644 index 000000000..9b7d1a13a --- /dev/null +++ b/packages/app/src/contexts/sidebar-callout-state.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from "vitest"; +import { + clearSidebarCallouts, + createSidebarCalloutState, + dismissSidebarCallout, + loadDismissedCalloutKeys, + parseDismissedCalloutKeys, + selectActiveSidebarCallout, + serializeDismissedCalloutKeys, + showSidebarCallout, + unregisterSidebarCallout, +} from "./sidebar-callout-state"; + +describe("sidebar callout state", () => { + it("shows the highest-priority callout first, then reveals the next when dismissed", () => { + let state = createSidebarCalloutState(); + state = showSidebarCallout(state, { + id: "onboarding", + priority: 10, + title: "Set up scripts", + }).state; + state = showSidebarCallout(state, { + id: "update", + priority: 200, + title: "Update available", + }).state; + + expect(selectActiveSidebarCallout(state)?.title).toBe("Update available"); + + state = dismissSidebarCallout(state, "update").state; + + expect(selectActiveSidebarCallout(state)?.title).toBe("Set up scripts"); + }); + + it("replaces a callout by id without duplicating the queue item", () => { + let state = createSidebarCalloutState(); + state = showSidebarCallout(state, { + id: "daemon", + title: "Old daemon", + description: "v1", + }).state; + state = showSidebarCallout(state, { + id: "daemon", + title: "New daemon", + description: "v2", + }).state; + + expect(state.callouts).toMatchObject([ + { + id: "daemon", + title: "New daemon", + description: "v2", + }, + ]); + }); + + it("unregisters only the registration returned by show", () => { + let state = createSidebarCalloutState(); + const oldRegistration = showSidebarCallout(state, { id: "update", title: "Old" }); + state = oldRegistration.state; + state = showSidebarCallout(state, { id: "update", title: "New" }).state; + + state = unregisterSidebarCallout(state, { id: "update", token: oldRegistration.token }); + + expect(selectActiveSidebarCallout(state)?.title).toBe("New"); + }); + + it("persists dismissals by dismissal key and hides matching future callouts", () => { + const onDismiss = vi.fn(); + let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set()); + state = showSidebarCallout(state, { + id: "update", + dismissalKey: "desktop-update:available:1.2.3", + title: "Update available", + onDismiss, + }).state; + + const result = dismissSidebarCallout(state, "update"); + state = result.state; + + expect(result.dismissalKey).toBe("desktop-update:available:1.2.3"); + expect(serializeDismissedCalloutKeys(state.dismissedKeys)).toBe( + JSON.stringify(["desktop-update:available:1.2.3"]), + ); + expect(onDismiss).not.toHaveBeenCalled(); + result.dismissedCallout?.onDismiss?.(); + expect(onDismiss).toHaveBeenCalledOnce(); + + state = showSidebarCallout(state, { + id: "update", + dismissalKey: "desktop-update:available:1.2.3", + title: "Dismissed update", + }).state; + + expect(selectActiveSidebarCallout(state)).toBeNull(); + + state = showSidebarCallout(state, { + id: "update", + dismissalKey: "desktop-update:available:1.2.4", + title: "New update", + }).state; + + expect(selectActiveSidebarCallout(state)?.title).toBe("New update"); + }); + + it("waits for dismissal storage before showing dismissible callouts", () => { + let state = createSidebarCalloutState(); + state = showSidebarCallout(state, { + id: "update", + dismissalKey: "desktop-update:available:1.2.3", + title: "Update available", + }).state; + + expect(selectActiveSidebarCallout(state)).toBeNull(); + + state = loadDismissedCalloutKeys(state, new Set()); + + expect(selectActiveSidebarCallout(state)?.title).toBe("Update available"); + }); + + it("parses stored dismissal keys defensively", () => { + expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"])); + expect(parseDismissedCalloutKeys("{")).toEqual(new Set()); + expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set()); + }); + + it("clears visible callouts without dropping dismissal state", () => { + let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"])); + state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state; + + state = clearSidebarCallouts(state); + + expect(state.callouts).toEqual([]); + expect(state.dismissedKeys).toEqual(new Set(["dismissed"])); + }); +}); diff --git a/packages/app/src/contexts/sidebar-callout-state.ts b/packages/app/src/contexts/sidebar-callout-state.ts new file mode 100644 index 000000000..fa1da011a --- /dev/null +++ b/packages/app/src/contexts/sidebar-callout-state.ts @@ -0,0 +1,162 @@ +import type { ReactNode } from "react"; +import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout"; + +export interface SidebarCalloutOptions { + id: string; + dismissalKey?: string; + title: string; + description?: ReactNode; + icon?: ReactNode; + variant?: SidebarCalloutVariant; + actions?: readonly SidebarCalloutAction[]; + dismissible?: boolean; + priority?: number; + onDismiss?: () => void; + testID?: string; +} + +export interface SidebarCalloutEntry extends SidebarCalloutOptions { + order: number; + priority: number; + token: number; +} + +export interface SidebarCalloutState { + callouts: readonly SidebarCalloutEntry[]; + dismissedKeys: ReadonlySet; + dismissalStorageLoaded: boolean; + nextOrder: number; + nextToken: number; +} + +export function createSidebarCalloutState(): SidebarCalloutState { + return { + callouts: [], + dismissedKeys: new Set(), + dismissalStorageLoaded: false, + nextOrder: 0, + nextToken: 0, + }; +} + +export function normalizeDismissalKey(key: string | null | undefined): string | null { + const trimmed = key?.trim(); + return trimmed ? trimmed : null; +} + +export function parseDismissedCalloutKeys(value: string | null): Set { + if (!value) { + return new Set(); + } + + try { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) { + return new Set(); + } + return new Set(parsed.filter((entry): entry is string => typeof entry === "string")); + } catch { + return new Set(); + } +} + +export function serializeDismissedCalloutKeys(keys: ReadonlySet): string { + return JSON.stringify([...keys]); +} + +export function loadDismissedCalloutKeys( + state: SidebarCalloutState, + dismissedKeys: ReadonlySet, +): SidebarCalloutState { + return { + ...state, + dismissedKeys: new Set(dismissedKeys), + dismissalStorageLoaded: true, + }; +} + +export function showSidebarCallout( + state: SidebarCalloutState, + callout: SidebarCalloutOptions, +): { state: SidebarCalloutState; token: number } { + const token = state.nextToken + 1; + const existing = state.callouts.find((entry) => entry.id === callout.id); + const nextEntry: SidebarCalloutEntry = { + ...callout, + priority: callout.priority ?? 0, + order: existing?.order ?? state.nextOrder + 1, + token, + }; + const callouts = existing + ? state.callouts.map((entry) => (entry.id === callout.id ? nextEntry : entry)) + : [...state.callouts, nextEntry]; + + return { + state: { + ...state, + callouts, + nextOrder: existing ? state.nextOrder : state.nextOrder + 1, + nextToken: token, + }, + token, + }; +} + +export function unregisterSidebarCallout( + state: SidebarCalloutState, + input: { id: string; token: number }, +): SidebarCalloutState { + const callouts = state.callouts.filter( + (entry) => entry.id !== input.id || entry.token !== input.token, + ); + return callouts.length === state.callouts.length ? state : { ...state, callouts }; +} + +export function dismissSidebarCallout( + state: SidebarCalloutState, + id: string, +): { + state: SidebarCalloutState; + dismissedCallout: SidebarCalloutEntry | null; + dismissalKey: string | null; +} { + const dismissedCallout = state.callouts.find((entry) => entry.id === id) ?? null; + const callouts = state.callouts.filter((entry) => entry.id !== id); + const dismissalKey = normalizeDismissalKey(dismissedCallout?.dismissalKey); + const dismissedKeys = dismissalKey + ? new Set([...state.dismissedKeys, dismissalKey]) + : state.dismissedKeys; + + return { + state: { + ...state, + callouts, + dismissedKeys, + }, + dismissedCallout, + dismissalKey, + }; +} + +export function clearSidebarCallouts(state: SidebarCalloutState): SidebarCalloutState { + return { ...state, callouts: [] }; +} + +export function selectActiveSidebarCallout( + state: Pick, +): SidebarCalloutEntry | null { + const visibleCallouts = state.callouts.filter((entry) => { + const dismissalKey = normalizeDismissalKey(entry.dismissalKey); + if (!dismissalKey) { + return true; + } + return state.dismissalStorageLoaded && !state.dismissedKeys.has(dismissalKey); + }); + + if (visibleCallouts.length === 0) { + return null; + } + return ( + [...visibleCallouts].sort((a, b) => b.priority - a.priority || a.order - b.order)[0] ?? null + ); +}