| null = null;
+ const handleCallback = vi.fn(() => {
+ if (handleCallbackInFlight) return handleCallbackInFlight;
- const callbackPromise = Promise.resolve(baseHandleCallback()).finally(() => {
- handleCallbackInFlight = null;
- });
- handleCallbackInFlight = callbackPromise;
- return callbackPromise;
+ const callbackPromise = Promise.resolve(baseHandleCallback()).finally(() => {
+ handleCallbackInFlight = null;
});
+ handleCallbackInFlight = callbackPromise;
+ return callbackPromise;
+ });
- const { handleCallback: _ignoredHandleCallback, ...otherOverrides } = overrides ?? {};
+ const { handleCallback: _ignoredHandleCallback, ...otherOverrides } = overrides ?? {};
- return {
- getState: vi.fn(() => state),
- login: vi.fn(),
- logout: vi.fn(),
- getAuthUrl: vi.fn(),
- checkAuthStatus: vi.fn().mockResolvedValue({
- isAuthenticated: false,
- error: null,
- isReady: true,
- }),
- refreshTokens: vi.fn(),
- ready: vi.fn(() => Promise.resolve()),
- configure: vi.fn(),
- addEventListener: vi.fn(() => () => {}),
- getAuthHeaders: vi.fn(),
- fetch: vi.fn(),
- getAppUri: vi.fn(() => "https://api.test.com"),
- ...otherOverrides,
- handleCallback,
- } as EnhancedAuthClient;
- };
+ return {
+ getState: vi.fn(() => state),
+ login: vi.fn(),
+ logout: vi.fn(),
+ getAuthUrl: vi.fn(),
+ checkAuthStatus: vi.fn().mockResolvedValue({
+ isAuthenticated: false,
+ error: null,
+ isReady: true,
+ }),
+ refreshTokens: vi.fn(),
+ ready: vi.fn(() => Promise.resolve()),
+ configure: vi.fn(),
+ addEventListener: vi.fn(() => () => {}),
+ getAuthHeaders: vi.fn(),
+ fetch: vi.fn(),
+ getAppUri: vi.fn(() => "https://api.test.com"),
+ ...otherOverrides,
+ handleCallback,
+ } as EnhancedAuthClient;
+};
+
+const AuthSuspenseContent = () => {
+ const { isAuthenticated } = useAuthSuspense();
+ return Content Loaded: {isAuthenticated ? "authenticated" : "not authenticated"}
;
+};
+
+const makeBaseClient = (mockHandleCallback: ReturnType) => ({
+ handleCallback: mockHandleCallback,
+ getState: vi.fn(() => ({
+ isAuthenticated: false,
+ error: null,
+ isReady: false,
+ })),
+ login: vi.fn(),
+ logout: vi.fn(),
+ getAuthUrl: vi.fn(),
+ checkAuthStatus: vi.fn(),
+ refreshTokens: vi.fn(),
+ ready: vi.fn(() => Promise.resolve()),
+ configure: vi.fn(),
+ addEventListener: vi.fn(() => () => {}),
+ getAuthHeaders: vi.fn(),
+ fetch: vi.fn(),
+ getAuthHeadersForQuery: vi.fn(),
+});
+describe("AuthProvider", () => {
describe("useEnsureAuthInitialized", () => {
it("should initialize auth status on mount", async () => {
const state = {
@@ -554,32 +576,16 @@ describe("AuthProvider", () => {
ready: mockReady,
});
- let suspenseTriggered = false;
-
- const TestComponent = () => {
- try {
- useAuthSuspense();
- return Content Loaded
;
- } catch (error) {
- if (error instanceof Promise) {
- suspenseTriggered = true;
- throw error;
- }
- throw error;
- }
- };
-
render(
Loading...}>
-
+
,
);
- // Initially should show loading
+ // Initially should show loading, which proves the hook suspended.
expect(screen.getByText("Loading...")).toBeDefined();
- expect(suspenseTriggered).toBe(true);
});
it("should resolve suspense when auth becomes ready", async () => {
@@ -608,15 +614,10 @@ describe("AuthProvider", () => {
ready: mockReady,
});
- const TestComponent = () => {
- const { isAuthenticated } = useAuthSuspense();
- return Content Loaded: {isAuthenticated ? "authenticated" : "not authenticated"}
;
- };
-
render(
Loading...}>
-
+
,
);
@@ -652,22 +653,17 @@ describe("AuthProvider", () => {
};
const mockClient = createMockAuthClient(state);
- const TestComponent = () => {
- const { isAuthenticated } = useAuthSuspense();
- return Loaded: {isAuthenticated ? "authenticated" : "unauthenticated"}
;
- };
-
render(
Suspense Loading...}>
-
+
,
);
// Should show unauthenticated state immediately (not suspended)
await waitFor(() => {
- expect(screen.getByText("Loaded: unauthenticated")).toBeDefined();
+ expect(screen.getByText("Content Loaded: not authenticated")).toBeDefined();
});
});
});
@@ -1012,26 +1008,6 @@ describe("AuthProvider", () => {
});
describe("createAuthClient", () => {
- const makeBaseClient = (mockHandleCallback: ReturnType) => ({
- handleCallback: mockHandleCallback,
- getState: vi.fn(() => ({
- isAuthenticated: false,
- error: null,
- isReady: false,
- })),
- login: vi.fn(),
- logout: vi.fn(),
- getAuthUrl: vi.fn(),
- checkAuthStatus: vi.fn(),
- refreshTokens: vi.fn(),
- ready: vi.fn(() => Promise.resolve()),
- configure: vi.fn(),
- addEventListener: vi.fn(() => () => {}),
- getAuthHeaders: vi.fn(),
- fetch: vi.fn(),
- getAuthHeadersForQuery: vi.fn(),
- });
-
it("calls handleCallback immediately when URL contains OAuth callback parameters", () => {
window.history.replaceState({}, "", "/?code=auth-code-123");
diff --git a/packages/core/src/contexts/scroll-container-context.test.tsx b/packages/core/src/contexts/scroll-container-context.test.tsx
index d70920c33..5c81fdf2e 100644
--- a/packages/core/src/contexts/scroll-container-context.test.tsx
+++ b/packages/core/src/contexts/scroll-container-context.test.tsx
@@ -1,6 +1,6 @@
-import { render, cleanup, waitFor } from "@testing-library/react";
+import { renderHook, cleanup, waitFor } from "@testing-library/react";
import { describe, it, expect, afterEach, vi } from "vitest";
-import { useEffect, type RefObject } from "react";
+import { useEffect, type ReactNode } from "react";
import { SidebarLayout } from "@/components/sidebar";
import { AppShell } from "@/components/appshell";
import { defineModule } from "@/resource";
@@ -13,63 +13,61 @@ afterEach(() => {
window.history.replaceState({}, "", "/");
});
-// Captures the ref returned by the hook so assertions can inspect its `current`
-// after the layout has mounted (refs populate during commit, before effects).
-let captured: RefObject | null = null;
-
-function Probe() {
- const ref = useAppShellScrollContainer();
- captured = ref;
- return probe
;
-}
-
-const renderInsideLayout = () => {
- captured = null;
+const insideLayoutWrapper = () => {
window.history.pushState({}, "", "/dashboard");
- return render(
- },
- component: () => ,
- resources: [],
- }),
- ]}
- >
-
- ,
- );
+ return function InsideLayoutWrapper({ children }: { children: ReactNode }) {
+ return (
+ },
+ component: () => <>{children}>,
+ resources: [],
+ }),
+ ]}
+ >
+
+
+ );
+ };
};
describe("useAppShellScrollContainer", () => {
it("marks the content scroll region with data-appshell-scroll-container", async () => {
- renderInsideLayout();
+ renderHook(() => useAppShellScrollContainer(), {
+ wrapper: insideLayoutWrapper(),
+ });
+
await waitFor(() => {
expect(document.querySelector("[data-appshell-scroll-container]")).not.toBeNull();
});
});
it("resolves to the content scroll region element for pages inside the layout", async () => {
- renderInsideLayout();
+ const { result } = renderHook(() => useAppShellScrollContainer(), {
+ wrapper: insideLayoutWrapper(),
+ });
+
// Assert inside waitFor: StrictMode's mount→unmount→remount detaches and
// reattaches the ref, so `current` is briefly null right after first paint.
await waitFor(() => {
const el = document.querySelector("[data-appshell-scroll-container]");
expect(el).not.toBeNull();
- expect(captured?.current).toBe(el);
+ expect(result.current.current).toBe(el);
});
});
it("exposes an element that dispatches scroll events to listeners", async () => {
- renderInsideLayout();
+ renderHook(() => useAppShellScrollContainer(), {
+ wrapper: insideLayoutWrapper(),
+ });
+
await waitFor(() =>
expect(document.querySelector("[data-appshell-scroll-container]")).not.toBeNull(),
);
- // Read the element from the DOM (stable, in-document) rather than through
- // the ref, whose `current` toggles during the StrictMode remount.
const el = document.querySelector("[data-appshell-scroll-container]")!;
const onScroll = vi.fn();
el.addEventListener("scroll", onScroll);
@@ -78,13 +76,10 @@ describe("useAppShellScrollContainer", () => {
el.removeEventListener("scroll", onScroll);
});
- it("returns a ref whose current is null when used outside a SidebarLayout", async () => {
- captured = null;
- render();
- await waitFor(() => expect(document.querySelector('[data-testid="probe"]')).not.toBeNull());
+ it("returns a ref whose current is null when used outside a SidebarLayout", () => {
+ const { result } = renderHook(() => useAppShellScrollContainer());
- expect(captured).not.toBeNull();
- expect(captured!.current).toBeNull();
+ expect(result.current.current).toBeNull();
});
});
@@ -94,42 +89,23 @@ describe("useAppShellScrollContainer", () => {
describe("useAppShellScrollContainer (consumer subscription pattern)", () => {
it("lets a page attach a scroll listener via an effect", async () => {
const onScroll = vi.fn();
- captured = null;
- window.history.pushState({}, "", "/dashboard");
-
- function Subscriber() {
- const scrollRef = useAppShellScrollContainer();
- useEffect(() => {
- const el = scrollRef.current;
- if (!el) return;
- el.addEventListener("scroll", onScroll, { passive: true });
- return () => el.removeEventListener("scroll", onScroll);
- }, [scrollRef]);
- return sub
;
- }
-
- render(
- },
- component: () => ,
- resources: [],
- }),
- ]}
- >
-
- ,
+ const { result } = renderHook(
+ () => {
+ const scrollRef = useAppShellScrollContainer();
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ el.addEventListener("scroll", onScroll, { passive: true });
+ return () => el.removeEventListener("scroll", onScroll);
+ }, [scrollRef]);
+ return scrollRef;
+ },
+ { wrapper: insideLayoutWrapper() },
);
- await waitFor(() =>
- expect(document.querySelector('[data-testid="subscriber"]')).not.toBeNull(),
- );
+ await waitFor(() => expect(result.current.current).not.toBeNull());
- const el = document.querySelector("[data-appshell-scroll-container]")!;
- el.dispatchEvent(new Event("scroll"));
+ result.current.current!.dispatchEvent(new Event("scroll"));
expect(onScroll).toHaveBeenCalledTimes(1);
});
});
diff --git a/packages/core/src/contexts/theme-context.test.tsx b/packages/core/src/contexts/theme-context.test.tsx
index 2067106d7..2052a3993 100644
--- a/packages/core/src/contexts/theme-context.test.tsx
+++ b/packages/core/src/contexts/theme-context.test.tsx
@@ -1,7 +1,8 @@
-import { cleanup, render, act, waitFor } from "@testing-library/react";
+import { cleanup, renderHook, act, waitFor } from "@testing-library/react";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ReactNode } from "react";
-import { ThemeProvider, useTheme } from "./theme-context";
+import { ThemeProvider, useTheme, type ColorTheme } from "./theme-context";
/** happy-dom / Node can omit a full `localStorage`; ThemeProvider persists via it. */
function installLocalStorageStub() {
@@ -52,6 +53,10 @@ function installMatchMediaStub(matches: boolean) {
});
}
+const wrapper =
+ (props?: { defaultColorTheme?: ColorTheme }) =>
+ ({ children }: { children: ReactNode }) => {children};
+
let storageMap: Map;
beforeAll(() => {
@@ -69,52 +74,36 @@ afterEach(() => {
cleanup();
});
-function Probe() {
- const { theme, resolvedTheme } = useTheme();
- return (
-
- {theme}
- {resolvedTheme}
-
- );
-}
-
describe("ThemeProvider — storage validation", () => {
it("falls back to defaultColorTheme for an unrecognized stored color theme", () => {
storageMap.set("appshell-ui-theme", "totally-not-a-mode");
- const { getByTestId } = render(
-
-
- ,
- );
+ const { result } = renderHook(() => useTheme(), {
+ wrapper: wrapper({ defaultColorTheme: "dark" }),
+ });
- expect(getByTestId("color-theme").textContent).toBe("dark");
+ expect(result.current.theme).toBe("dark");
});
it("useTheme returns color theme values", () => {
storageMap.set("appshell-ui-theme", "dark");
- const { getByTestId } = render(
-
-
- ,
- );
+ const { result } = renderHook(() => useTheme(), {
+ wrapper: wrapper(),
+ });
- expect(getByTestId("color-theme").textContent).toBe("dark");
- expect(getByTestId("resolvedTheme").textContent).toBe("dark");
+ expect(result.current.theme).toBe("dark");
+ expect(result.current.resolvedTheme).toBe("dark");
});
it("reads a valid stored color theme and applies it as the html class", async () => {
storageMap.set("appshell-ui-theme", "dark");
- const { getByTestId } = render(
-
-
- ,
- );
+ const { result } = renderHook(() => useTheme(), {
+ wrapper: wrapper(),
+ });
- expect(getByTestId("color-theme").textContent).toBe("dark");
+ expect(result.current.theme).toBe("dark");
await waitFor(() => {
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
@@ -126,14 +115,12 @@ describe("ThemeProvider — system color theme resolution", () => {
installMatchMediaStub(true);
storageMap.set("appshell-ui-theme", "system");
- const { getByTestId } = render(
-
-
- ,
- );
+ const { result } = renderHook(() => useTheme(), {
+ wrapper: wrapper(),
+ });
- expect(getByTestId("color-theme").textContent).toBe("system");
- expect(getByTestId("resolvedTheme").textContent).toBe("dark");
+ expect(result.current.theme).toBe("system");
+ expect(result.current.resolvedTheme).toBe("dark");
await waitFor(() => {
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
@@ -143,13 +130,11 @@ describe("ThemeProvider — system color theme resolution", () => {
installMatchMediaStub(false);
storageMap.set("appshell-ui-theme", "system");
- const { getByTestId } = render(
-
-
- ,
- );
+ const { result } = renderHook(() => useTheme(), {
+ wrapper: wrapper(),
+ });
- expect(getByTestId("resolvedTheme").textContent).toBe("light");
+ expect(result.current.resolvedTheme).toBe("light");
await waitFor(() => {
expect(document.documentElement.classList.contains("light")).toBe(true);
});
@@ -159,22 +144,19 @@ describe("ThemeProvider — system color theme resolution", () => {
installMatchMediaStub(false);
storageMap.set("appshell-ui-theme", "system");
- const { getByTestId } = render(
-
-
- ,
- );
+ const { result } = renderHook(() => useTheme(), {
+ wrapper: wrapper(),
+ });
- expect(getByTestId("resolvedTheme").textContent).toBe("light");
+ expect(result.current.resolvedTheme).toBe("light");
- // Simulate OS dark mode toggle
act(() => {
for (const listener of matchMediaListeners) {
listener({ matches: true } as MediaQueryListEvent);
}
});
- expect(getByTestId("resolvedTheme").textContent).toBe("dark");
+ expect(result.current.resolvedTheme).toBe("dark");
await waitFor(() => {
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
@@ -184,11 +166,9 @@ describe("ThemeProvider — system color theme resolution", () => {
describe("provider guards", () => {
it("throws when useTheme is called outside ThemeProvider", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
- const TP = () => {
- useTheme();
- return null;
- };
- expect(() => render()).toThrow(/useTheme must be used within a ThemeProvider/);
+ expect(() => renderHook(() => useTheme())).toThrow(
+ /useTheme must be used within a ThemeProvider/,
+ );
spy.mockRestore();
});
});
diff --git a/packages/core/src/hooks/use-override-breadcrumb.test.tsx b/packages/core/src/hooks/use-override-breadcrumb.test.tsx
index 53440fbb6..ed2ae2477 100644
--- a/packages/core/src/hooks/use-override-breadcrumb.test.tsx
+++ b/packages/core/src/hooks/use-override-breadcrumb.test.tsx
@@ -1,93 +1,82 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect } from "vitest";
+import { type ReactNode } from "react";
import { MemoryRouter } from "react-router";
import { BreadcrumbOverrideProvider, useBreadcrumbOverride } from "@/contexts/breadcrumb-context";
import { useOverrideBreadcrumb } from "./use-override-breadcrumb";
-let contextSnapshot: Map | undefined;
-
-const Spy = () => {
- contextSnapshot = useBreadcrumbOverride().overrides;
- return null;
-};
-
const wrapper =
(path: string) =>
- ({ children }: { children: React.ReactNode }) => (
+ ({ children }: { children: ReactNode }) => (
-
- {children}
-
-
+ {children}
);
+const renderOverride = (path: string, title: string | undefined) =>
+ renderHook(
+ ({ title: currentTitle }: { title: string | undefined }) => {
+ useOverrideBreadcrumb(currentTitle);
+ return useBreadcrumbOverride().overrides;
+ },
+ {
+ initialProps: { title },
+ wrapper: wrapper(path),
+ },
+ );
+
describe("useOverrideBreadcrumb", () => {
it("registers an override when title is provided", async () => {
- renderHook(() => useOverrideBreadcrumb("Order #123"), {
- wrapper: wrapper("/orders/123"),
- });
+ const { result } = renderOverride("/orders/123", "Order #123");
+
await waitFor(() => {
- expect(contextSnapshot?.get("/orders/123")).toBe("Order #123");
+ expect(result.current.get("/orders/123")).toBe("Order #123");
});
});
it("does not register when title is undefined", async () => {
- renderHook(() => useOverrideBreadcrumb(undefined), {
- wrapper: wrapper("/orders/123"),
- });
+ const { result } = renderOverride("/orders/123", undefined);
+
await waitFor(() => {
- expect(contextSnapshot?.has("/orders/123")).toBe(false);
+ expect(result.current.has("/orders/123")).toBe(false);
});
});
it("updates override when title changes", async () => {
- const { rerender } = renderHook(
- ({ title }: { title: string | undefined }) => useOverrideBreadcrumb(title),
- {
- initialProps: { title: "Order #123" },
- wrapper: wrapper("/orders/123"),
- },
- );
+ const { result, rerender } = renderOverride("/orders/123", "Order #123");
+
await waitFor(() => {
- expect(contextSnapshot?.get("/orders/123")).toBe("Order #123");
+ expect(result.current.get("/orders/123")).toBe("Order #123");
});
rerender({ title: "Order #456" });
await waitFor(() => {
- expect(contextSnapshot?.get("/orders/123")).toBe("Order #456");
+ expect(result.current.get("/orders/123")).toBe("Order #456");
});
});
it("cleans up override on unmount", async () => {
- const { unmount } = renderHook(() => useOverrideBreadcrumb("Order #123"), {
- wrapper: wrapper("/orders/123"),
- });
+ const { result, unmount } = renderOverride("/orders/123", "Order #123");
+
await waitFor(() => {
- expect(contextSnapshot?.get("/orders/123")).toBe("Order #123");
+ expect(result.current.get("/orders/123")).toBe("Order #123");
});
- // unmount tears down the entire tree (including the Spy), so we cannot
- // observe the state change via contextSnapshot. Verifying that unmount
- // completes without error confirms the cleanup effect ran.
+ // Unmount tears down the provider too, so the observable contract here is
+ // simply that cleanup completes without throwing.
expect(() => unmount()).not.toThrow();
});
it("removes override when title changes from string to undefined", async () => {
- const { rerender } = renderHook(
- ({ title }: { title: string | undefined }) => useOverrideBreadcrumb(title),
- {
- initialProps: { title: "Order #123" as string | undefined },
- wrapper: wrapper("/orders/123"),
- },
- );
+ const { result, rerender } = renderOverride("/orders/123", "Order #123");
+
await waitFor(() => {
- expect(contextSnapshot?.get("/orders/123")).toBe("Order #123");
+ expect(result.current.get("/orders/123")).toBe("Order #123");
});
rerender({ title: undefined });
await waitFor(() => {
- expect(contextSnapshot?.has("/orders/123")).toBe(false);
+ expect(result.current.has("/orders/123")).toBe(false);
});
});
});