Extract sidebar callout state (#884)

This commit is contained in:
Mohamed Boudra
2026-05-10 08:09:17 +00:00
committed by GitHub
parent f881f9ae32
commit ecd3137d34
4 changed files with 353 additions and 357 deletions
@@ -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<string, string>(),
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<string, unknown>) => 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(
<SidebarCalloutProvider>
<CaptureApi onApi={handleApi} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
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(
<SidebarCalloutProvider>
<Producer />
<CaptureApi onApi={handleApi} />
<SidebarCalloutViewport />
</SidebarCalloutProvider>,
);
});
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");
});
});
@@ -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<SidebarCalloutsApi | null>(null);
const SidebarCalloutStateContext = createContext<SidebarCalloutEntry | null>(null);
function normalizeDismissalKey(key: string | null | undefined): string | null {
const trimmed = key?.trim();
return trimmed ? trimmed : null;
}
function parseDismissedCalloutKeys(value: string | null): Set<string> {
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<string>): 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<string>;
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<SidebarCalloutEntry[]>([]);
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
const [dismissalStorageLoaded, setDismissalStorageLoaded] = useState(false);
const calloutsRef = useRef<SidebarCalloutEntry[]>([]);
const dismissedKeysRef = useRef<Set<string>>(new Set());
const orderRef = useRef(0);
const tokenRef = useRef(0);
const [state, setState] = useState<SidebarCalloutState>(createSidebarCalloutState);
const stateRef = useRef<SidebarCalloutState>(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<void> {
let dismissedKeys: ReadonlySet<string>;
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<SidebarCalloutsApi>(() => ({ show, dismiss, clear }), [clear, dismiss, show]);
const activeCallout = useMemo(
() => selectActiveCallout({ callouts, dismissedKeys, dismissalStorageLoaded }),
[callouts, dismissedKeys, dismissalStorageLoaded],
);
const activeCallout = useMemo(() => selectActiveSidebarCallout(state), [state]);
return (
<SidebarCalloutApiContext.Provider value={api}>
@@ -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"]));
});
});
@@ -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<string>;
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<string> {
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>): string {
return JSON.stringify([...keys]);
}
export function loadDismissedCalloutKeys(
state: SidebarCalloutState,
dismissedKeys: ReadonlySet<string>,
): 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<SidebarCalloutState, "callouts" | "dismissedKeys" | "dismissalStorageLoaded">,
): 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
);
}