diff --git a/README.md b/README.md
index 114efae8..ad81312a 100644
--- a/README.md
+++ b/README.md
@@ -187,6 +187,8 @@ This project uses a multi-tool testing strategy. Note that **Jest is not used**
> reference — when to use Vitest vs. node:test vs. Playwright, a map of every
> `package.json` test script, the `tests/` layout, coverage and gate expectations, and a
> "how to add a test" recipe per runner.
+> For React component testing conventions (render helpers, accessible queries, mocking
+> hooks and context, a11y assertions), see [docs/COMPONENT_TESTING.md](docs/COMPONENT_TESTING.md).
#### Running Tests
diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx
index fcdb1a1b..179c1e59 100644
--- a/app/dashboard/layout.tsx
+++ b/app/dashboard/layout.tsx
@@ -1,5 +1,6 @@
import { WhatsNewProvider } from "@/lib/context/WhatsNewContext";
import WhatsNewPanel from "@/components/Dashboard/WhatsNewPanel";
+import DashboardHeader from "@/components/Dashboard/DashboardHeader";
import PrimaryNav from "@/components/Nav/PrimaryNav";
import SubNav from "@/components/Nav/SubNav";
@@ -13,6 +14,7 @@ export default function DashboardLayout({
+
{/*
pt-20 for PrimaryNav (80px)
+ pt-16 for SubNav (64px) = 144px
diff --git a/components/Dashboard/DashboardHeader.tsx b/components/Dashboard/DashboardHeader.tsx
index 07e253dd..256271f5 100644
--- a/components/Dashboard/DashboardHeader.tsx
+++ b/components/Dashboard/DashboardHeader.tsx
@@ -5,8 +5,12 @@ import { ArrowLeft, Star, Activity, Settings, Sparkles } from "lucide-react";
import { useWhatsNew } from "@/lib/context/WhatsNewContext";
import WhatsNewBadge from "@/components/Dashboard/WhatsNewBadge";
import { usePrefersReducedMotion } from "@/lib/hooks/usePrefersReducedMotion";
+import { useTitle } from "@/lib/hooks/useTitle";
+import QuickRefreshButton from "@/components/QuickRefreshButton";
const DashboardHeader = () => {
+ useTitle("Financial Dashboard", { depth: 0 });
+
const { toggle } = useWhatsNew();
const prefersReducedMotion = usePrefersReducedMotion();
@@ -54,6 +58,9 @@ const DashboardHeader = () => {
Insights
+ {/* Quick Refresh — refetches all active widgets without resetting filters */}
+
+
{/* What's New Button */}
**Audience:** Contributors adding or modifying React components.
+> For the full multi-runner reference (Vitest vs. node:test vs. Playwright), see
+> [docs/testing.md](testing.md). For general standards and the testing pyramid, see
+> [docs/TESTING_STANDARDS.md](TESTING_STANDARDS.md).
+
+---
+
+## Table of Contents
+
+1. [Toolchain at a glance](#toolchain-at-a-glance)
+2. [Where tests live](#where-tests-live)
+3. [Writing your first component test](#writing-your-first-component-test)
+4. [Rendering with providers](#rendering-with-providers)
+5. [Querying the DOM](#querying-the-dom)
+6. [Simulating user interaction](#simulating-user-interaction)
+7. [Testing hooks](#testing-hooks)
+8. [Async behaviour](#async-behaviour)
+9. [i18n in tests](#i18n-in-tests)
+10. [Mocking context and external modules](#mocking-context-and-external-modules)
+11. [Accessibility assertions](#accessibility-assertions)
+12. [What not to test](#what-not-to-test)
+13. [Common mistakes](#common-mistakes)
+14. [Running component tests](#running-component-tests)
+
+---
+
+## Toolchain at a glance
+
+| Tool | Role |
+| --- | --- |
+| **Vitest** | Test runner and `expect` assertions |
+| **@testing-library/react** | `render`, `renderHook`, `screen`, `within` |
+| **@testing-library/user-event** | Realistic pointer/keyboard simulation |
+| **@testing-library/jest-dom** | Extra matchers: `toBeInTheDocument`, `toHaveTextContent`, etc. |
+| **jsdom** | Browser-like DOM environment (configured in `vitest.config.mjs`) |
+
+`@testing-library/jest-dom` matchers are loaded globally by `vitest.setup.ts` — no import required.
+
+---
+
+## Where tests live
+
+Component tests can live in two places:
+
+| Location | When to use |
+| --- | --- |
+| **Co-located** — `components/Foo/Foo.test.tsx` | Tightly coupled tests for a single component |
+| **`tests/unit/components/`** | Tests that span multiple components or need shared fixtures |
+
+The Vitest glob `components/**/*.test.tsx` picks up co-located tests automatically.
+
+Example structure:
+```
+components/
+ Dashboard/
+ DashboardHeader.tsx
+ DashboardHeader.test.tsx ← co-located
+ QuickRefreshButton.tsx
+
+tests/unit/components/
+ QuickRefreshButton.test.tsx ← in unit tree (also picked up)
+```
+
+---
+
+## Writing your first component test
+
+Here is the minimal template for a Vitest component test:
+
+```tsx
+// components/Foo/Foo.test.tsx
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import Foo from "./Foo";
+
+describe("Foo", () => {
+ it("renders the label", () => {
+ render( );
+ expect(screen.getByText("Hello")).toBeInTheDocument();
+ });
+});
+```
+
+A real example from the codebase — `components/QuickRefreshButton.tsx`:
+
+```tsx
+// tests/unit/components/QuickRefreshButton.test.tsx
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+const refetchQueries = vi.fn();
+
+vi.mock("@tanstack/react-query", () => ({
+ useQueryClient: () => ({ refetchQueries }),
+}));
+
+vi.mock("@/lib/i18n/client", () => ({
+ useClientTranslator: () => ({
+ t: (key: string) => {
+ if (key === "quickRefresh.label") return "Quick Refresh";
+ if (key === "quickRefresh.button") return "Refresh";
+ return key;
+ },
+ }),
+}));
+
+import QuickRefreshButton from "@/components/QuickRefreshButton";
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("QuickRefreshButton", () => {
+ it("renders the refresh button", () => {
+ render( );
+ expect(screen.getByRole("button", { name: "Quick Refresh" })).toBeInTheDocument();
+ });
+
+ it("triggers refetch on click", async () => {
+ render( );
+ await userEvent.setup().click(screen.getByRole("button", { name: "Quick Refresh" }));
+ expect(refetchQueries).toHaveBeenCalledWith({ type: "active" });
+ });
+});
+```
+
+Key points:
+- Mock external dependencies (`react-query`, `i18n`) at the top using `vi.mock`.
+- Call `cleanup()` in `afterEach` to unmount components between tests.
+- Call `vi.clearAllMocks()` to reset call counts between tests.
+
+---
+
+## Rendering with providers
+
+Many components need React context providers. The repo has a thin helper at
+`tests/react/renderWithProviders.tsx`:
+
+```tsx
+import { renderWithProviders } from "@/tests/react/renderWithProviders";
+import StatCard from "@/components/Dashboard/StatCard";
+
+it("renders a stat card", () => {
+ renderWithProviders( );
+ expect(screen.getByText("Total Sent")).toBeInTheDocument();
+});
+```
+
+When you only need a single context, wrap inline:
+
+```tsx
+import { ToastProvider } from "@/lib/context/ToastContext";
+
+render(
+
+
+
+);
+```
+
+Prefer `renderWithProviders` for components that depend on multiple contexts (Theme,
+Density, Toast) to avoid verbose nesting.
+
+---
+
+## Querying the DOM
+
+Always prefer **accessible queries** — they match what screen readers and assistive
+technology expose to users:
+
+| Priority | Query | Example |
+| --- | --- | --- |
+| 1st | `getByRole` | `screen.getByRole("button", { name: "Send" })` |
+| 2nd | `getByLabelText` | `screen.getByLabelText("Amount")` |
+| 3rd | `getByPlaceholderText` | `screen.getByPlaceholderText("0.00")` |
+| 4th | `getByText` | `screen.getByText("No results")` |
+| Last resort | `getByTestId` | `screen.getByTestId("stat-card-value")` |
+
+Avoid `getByTestId` unless no semantic query fits — it couples tests to implementation
+details. When you do need one, use the existing `data-testid` conventions from
+[docs/primary-cta-testids.md](primary-cta-testids.md).
+
+**`within` for scoped queries:**
+
+When a page has multiple similar elements, scope your query to the relevant container:
+
+```tsx
+const card = screen.getByRole("article", { name: "Savings Goals" });
+expect(within(card).getByText("$5,000")).toBeInTheDocument();
+```
+
+---
+
+## Simulating user interaction
+
+Always use `@testing-library/user-event` instead of `fireEvent`. It simulates real
+browser behaviour (focus, pointer events, keyboard sequence):
+
+```tsx
+import userEvent from "@testing-library/user-event";
+
+it("opens the dropdown on click", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button", { name: "Wallet" }));
+
+ expect(screen.getByRole("menu")).toBeVisible();
+});
+
+it("closes on Escape", async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button", { name: "Wallet" }));
+ await user.keyboard("{Escape}");
+
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
+});
+```
+
+Create **one `userEvent.setup()` instance per test** — not per action — so the internal
+pointer/keyboard state is consistent across chained interactions.
+
+---
+
+## Testing hooks
+
+Use `renderHook` from `@testing-library/react` for custom hooks:
+
+```tsx
+import { renderHook, act } from "@testing-library/react";
+import { useTitle } from "@/lib/hooks/useTitle";
+
+it("sets document.title", () => {
+ renderHook(() => useTitle("Dashboard"));
+ expect(document.title).toBe("Dashboard");
+});
+
+it("clears the title on unmount", () => {
+ const { unmount } = renderHook(() => useTitle("Dashboard"));
+ unmount();
+ expect(document.title).toBe("");
+});
+```
+
+Use `act` when a hook triggers state updates from outside React (e.g. a timer firing):
+
+```tsx
+it("marks stale after timeout", async () => {
+ vi.useFakeTimers();
+ const { result } = renderHook(() => useStaleFetch({ url: "/api/data", cacheKey: "x" }));
+ act(() => vi.advanceTimersByTime(60_000));
+ expect(result.current.isStale).toBe(true);
+ vi.useRealTimers();
+});
+```
+
+---
+
+## Async behaviour
+
+Use `waitFor` when you need to wait for DOM updates triggered by async operations:
+
+```tsx
+import { waitFor } from "@testing-library/react";
+
+it("shows the error message after a failed fetch", async () => {
+ global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+
+ render( );
+
+ await waitFor(() => {
+ expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
+ });
+});
+```
+
+Prefer `findBy*` queries (which internally use `waitFor`) for simple "element appears"
+assertions:
+
+```tsx
+expect(await screen.findByRole("alert")).toHaveTextContent("Session expired");
+```
+
+---
+
+## i18n in tests
+
+The `useClientTranslator` hook reads from the i18n context. In unit tests, mock it with
+a simple key-pass-through:
+
+```tsx
+vi.mock("@/lib/i18n/client", () => ({
+ useClientTranslator: () => ({
+ t: (key: string) => key, // returns the key as-is
+ }),
+}));
+```
+
+Then assert on the translation key rather than the human-readable string:
+
+```tsx
+expect(screen.getByText("quickRefresh.button")).toBeInTheDocument();
+```
+
+If your test needs the real translated string, supply a dictionary:
+
+```tsx
+const dict: Record = {
+ "quickRefresh.label": "Quick Refresh",
+ "quickRefresh.button": "Refresh",
+};
+
+vi.mock("@/lib/i18n/client", () => ({
+ useClientTranslator: () => ({ t: (key: string) => dict[key] ?? key }),
+}));
+```
+
+---
+
+## Mocking context and external modules
+
+### React context
+
+Wrap the component under test in the real provider with controlled initial state:
+
+```tsx
+import { ToastProvider } from "@/lib/context/ToastContext";
+
+render(
+
+
+
+);
+```
+
+### External modules (React Query, router, etc.)
+
+Use `vi.mock` at the top of the file, before any imports that trigger the module:
+
+```tsx
+const mockRefetchQueries = vi.fn();
+
+vi.mock("@tanstack/react-query", () => ({
+ useQueryClient: () => ({ refetchQueries: mockRefetchQueries }),
+}));
+```
+
+### Next.js router
+
+```tsx
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
+ usePathname: () => "/dashboard",
+ useSearchParams: () => new URLSearchParams(),
+}));
+```
+
+Always call `vi.clearAllMocks()` (or `vi.resetAllMocks()`) in `afterEach` to prevent
+call-count bleed between tests.
+
+---
+
+## Accessibility assertions
+
+Every interactive component should include at least one accessibility assertion.
+
+**Role + accessible name:**
+```tsx
+// Verify the button has an accessible name (visible text or aria-label)
+expect(screen.getByRole("button", { name: "Quick Refresh" })).toBeInTheDocument();
+```
+
+**`aria-label` on icon-only buttons:**
+```tsx
+render( } />);
+expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
+```
+
+**ARIA state after interaction:**
+```tsx
+const trigger = screen.getByRole("button", { name: "Open menu" });
+expect(trigger).toHaveAttribute("aria-expanded", "false");
+await user.click(trigger);
+expect(trigger).toHaveAttribute("aria-expanded", "true");
+```
+
+For deeper automated a11y checks, use `jest-axe` in a dedicated test:
+
+```tsx
+import { axe } from "jest-axe";
+
+it("has no a11y violations", async () => {
+ const { container } = render( );
+ expect(await axe(container)).toHaveNoViolations();
+});
+```
+
+See `components/__tests__/Tooltip.a11y.test.tsx` for a real axe usage example.
+
+---
+
+## What not to test
+
+Avoid testing implementation details — these lead to brittle tests that break on
+refactors without catching real bugs:
+
+| Avoid | Why |
+| --- | --- |
+| CSS class names (`.bg-brand-red`) | Styling is presentational, not behavioural |
+| Internal state variable names | Refactoring state shape should not break tests |
+| Component structure / nesting depth | Test what the user sees, not how it is built |
+| Prop forwarding for its own sake | The rendered output is the contract |
+
+Do test:
+- What text and roles are visible to the user
+- What happens when the user interacts (clicks, types, navigates)
+- What the component does when data changes or errors occur
+- Accessible names, ARIA attributes, and keyboard behaviour
+
+---
+
+## Common mistakes
+
+**1. Forgetting `await` on user interactions**
+
+`userEvent` methods return Promises. Forgetting `await` causes assertions to run before
+the DOM updates.
+
+```tsx
+// ❌ Wrong — assertion may run before click settles
+user.click(button);
+expect(screen.getByRole("menu")).toBeVisible();
+
+// ✅ Correct
+await user.click(button);
+expect(screen.getByRole("menu")).toBeVisible();
+```
+
+**2. Not calling `cleanup()` when managing it manually**
+
+`@testing-library/react` registers an automatic `afterEach` cleanup when imported, but
+if you override `afterEach` you must call `cleanup()` yourself:
+
+```tsx
+afterEach(() => {
+ cleanup(); // ← required when you override afterEach
+ vi.clearAllMocks();
+});
+```
+
+**3. Using `getBy*` for elements that may not be present**
+
+Use `queryBy*` when the element might not exist, so the assertion doesn't throw:
+
+```tsx
+expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+```
+
+**4. Creating `userEvent.setup()` inside a helper that is called per action**
+
+This resets pointer state mid-test. Create the instance once per test:
+
+```tsx
+// ❌ Wrong
+async function click(el: HTMLElement) {
+ await userEvent.setup().click(el); // new instance each call
+}
+
+// ✅ Correct
+const user = userEvent.setup();
+await user.click(buttonA);
+await user.keyboard("{Tab}");
+await user.click(buttonB);
+```
+
+---
+
+## Running component tests
+
+```bash
+# Run all component and unit tests (Vitest)
+npm run test:unit:vitest
+
+# Run a single test file
+node node_modules/vitest/vitest.mjs run --config vitest.config.mjs --configLoader runner \
+ tests/unit/components/QuickRefreshButton.test.tsx
+
+# Run in watch mode during development
+npm run test:watch
+
+# Full suite with coverage report
+npm run test:coverage
+```
+
+See [docs/testing.md](testing.md) for the complete script reference and runner decision matrix.
diff --git a/docs/HOOKS.md b/docs/HOOKS.md
index f3a22735..9f658fca 100644
--- a/docs/HOOKS.md
+++ b/docs/HOOKS.md
@@ -1,5 +1,71 @@
# Hooks
+## `useTitle`
+
+**File:** `lib/hooks/useTitle.ts`
+
+Page + section title stacking hook for dynamically composing `document.title` from nested layout levels. Callers register a title string with an optional `depth` parameter (default `0`). Lower depth = outer layout (page title); higher depth = nested section. Titles at the same depth compose in insertion order.
+
+When a component unmounts, its title entry is removed and `document.title` updates automatically.
+
+**API:**
+
+```tsx
+useTitle(title: string, options?: { depth?: number }): void
+```
+
+**Exported helpers:**
+
+| Export | Type | Description |
+| --- | --- | --- |
+| `composeTitles(titles: string[]): string` | Pure function | Joins non-blank trimmed segments with ` \| `. Returns `""` for empty input. |
+| `titleStack: TitleEntry[]` | Global array | Internal stack (for test inspection only — do not mutate externally). |
+
+**Usage:**
+
+```tsx
+import { useTitle } from "@/lib/hooks/useTitle";
+
+// In a layout wrapper (depth 0 – page-level title)
+export default function DashboardLayout({ children }) {
+ useTitle("Dashboard");
+ return {children}
;
+}
+
+// In a nested section widget (depth 1)
+export default function GoalsWidget() {
+ useTitle("Goals", { depth: 1 });
+ return …
;
+}
+
+// → document.title becomes "Dashboard | Goals"
+```
+
+**Depth behaviour:**
+
+| Depth | Typical use case |
+| --- | --- |
+| `0` (default) | Top-level page or route name (e.g. "Dashboard", "Send Money"). |
+| `1` | Section within a page (e.g. "Transaction History", "Goals"). |
+| `2+` | Nested sub-section (rare; increases specificity further). |
+
+When multiple hooks with the same depth mount, their titles appear in mount order. Depth controls the primary sort; within the same depth, insertion order is preserved.
+
+**Cleanup:**
+
+Every `useTitle` call registers a cleanup function that removes its entry when the component unmounts. The remaining titles recompose automatically.
+
+**SSR safety:**
+
+The hook guards `document.title` writes behind `typeof window === "undefined"`, so it is safe to call in server-rendered components (though it has no effect there).
+
+**Tests:**
+
+- **Unit tests:** `tests/unit/hooks/useTitle.test.tsx`
+- **Property tests:** `tests/property/useTitle.property.test.tsx` (verifies composition invariants with fast-check)
+
+---
+
## `useIntersectionObserver`
**File:** `lib/hooks/useIntersectionObserver.ts`
diff --git a/lib/hooks/useTitle.ts b/lib/hooks/useTitle.ts
index aa0fdef6..b383a566 100644
--- a/lib/hooks/useTitle.ts
+++ b/lib/hooks/useTitle.ts
@@ -1,39 +1,130 @@
-import { useEffect } from "react";
+"use client";
-// Global stack for titles
-// Because React effects run bottom-up, the deepest child pushes its title first.
-// e.g. Child pushes, then Parent pushes.
-// Stack: [Child, Parent].
-// We want "Child | Parent", so we just join in order.
-export const titleStack: string[] = [];
+import { useEffect, useRef } from "react";
+/**
+ * useTitle – page + section title stacking hook.
+ *
+ * Callers declare their title and an optional `depth` (default `0`).
+ * Lower depth = more outer layout level (page title); higher depth = more
+ * nested section. Entries at the same depth keep insertion order.
+ *
+ * ```
+ * // In a layout wrapper (depth 0 – the page-level title)
+ * useTitle("Dashboard");
+ *
+ * // In a nested widget / section (depth 1)
+ * useTitle("Goals", { depth: 1 });
+ *
+ * // → document.title becomes "Dashboard | Goals"
+ * ```
+ *
+ * When a component unmounts the hook removes its entry and updates
+ * `document.title` with whatever is left in the stack.
+ */
+
+// ── Internal stack entry ─────────────────────────────────────────────────────
+
+interface TitleEntry {
+ title: string;
+ depth: number;
+ /** Unique identifier so two components with the same title string can
+ * coexist without interfering during remove. */
+ id: number;
+}
+
+let _nextId = 0;
+
+/**
+ * Exported for direct inspection in tests.
+ * Do NOT mutate externally — use the hook API instead.
+ */
+export const titleStack: TitleEntry[] = [];
+
+// ── Public helpers ───────────────────────────────────────────────────────────
+
+/**
+ * Pure function: given an ordered list of title strings, return the composed
+ * `document.title` string.
+ *
+ * - Trims each segment.
+ * - Drops blank segments.
+ * - Joins remaining segments with ` | `.
+ * - Returns `""` when no valid segment remains.
+ */
export function composeTitles(titles: string[]): string {
- const valid = titles.map(t => t?.trim()).filter(Boolean);
+ const valid = titles.map((t) => t?.trim()).filter(Boolean);
if (valid.length === 0) return "";
return valid.join(" | ");
}
-export function useTitle(title: string) {
+// ── DOM update ───────────────────────────────────────────────────────────────
+
+function updateDomTitle(): void {
+ if (typeof window === "undefined") return;
+
+ // Sort by depth (ascending) then by insertion id (ascending) so that outer
+ // layout titles come first in the composed string.
+ const sorted = [...titleStack].sort(
+ (a, b) => a.depth - b.depth || a.id - b.id
+ );
+
+ const composed = composeTitles(sorted.map((e) => e.title));
+ // Only write when there is something to write; avoids clearing a title that
+ // was set by a different mechanism (e.g. server-side metadata).
+ if (composed) {
+ document.title = composed;
+ } else {
+ document.title = "";
+ }
+}
+
+// ── Hook ─────────────────────────────────────────────────────────────────────
+
+export interface UseTitleOptions {
+ /**
+ * Nesting depth of this call site within the layout tree.
+ * `0` = outermost (page-level). Higher values = deeper sections.
+ * Default: `0`.
+ */
+ depth?: number;
+}
+
+/**
+ * Sets `document.title` to a composed string of all active titles, ordered by
+ * `depth` (ascending) so that the outermost layout title appears first.
+ *
+ * @param title - The title string to register. Whitespace-only strings are
+ * silently ignored.
+ * @param options - Optional configuration (`depth` defaults to `0`).
+ */
+export function useTitle(title: string, options: UseTitleOptions = {}): void {
+ const { depth = 0 } = options;
+
+ // Keep a stable ref to the entry so the cleanup closure always removes the
+ // exact object that was pushed, regardless of re-renders with different title
+ // values.
+ const entryRef = useRef(null);
+
useEffect(() => {
- if (!title) return;
-
- titleStack.push(title);
+ const trimmed = title?.trim();
+ if (!trimmed) return;
+
+ const entry: TitleEntry = { title: trimmed, depth, id: _nextId++ };
+ entryRef.current = entry;
+
+ titleStack.push(entry);
updateDomTitle();
return () => {
- const idx = titleStack.lastIndexOf(title);
+ const idx = titleStack.indexOf(entry);
if (idx > -1) {
titleStack.splice(idx, 1);
}
+ entryRef.current = null;
updateDomTitle();
};
- }, [title]);
-}
-
-function updateDomTitle() {
- if (typeof window === "undefined") return;
- const composed = composeTitles(titleStack);
- if (composed && document.title !== composed) {
- document.title = composed;
- }
+ // Re-run only when the effective trimmed title or depth changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [title?.trim(), depth]);
}
diff --git a/tests/unit/hooks/useTitle.test.tsx b/tests/unit/hooks/useTitle.test.tsx
new file mode 100644
index 00000000..7e40aa65
--- /dev/null
+++ b/tests/unit/hooks/useTitle.test.tsx
@@ -0,0 +1,249 @@
+/**
+ * Unit tests for the useTitle stacking hook.
+ *
+ * Strategy: renderHook from @testing-library/react exercises the hook inside
+ * jsdom so we can assert on document.title after each render/unmount cycle.
+ *
+ * The titleStack is exported for direct inspection when necessary, but most
+ * assertions go through document.title (the observable output).
+ */
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useTitle, titleStack, composeTitles } from "@/lib/hooks/useTitle";
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function resetState() {
+ document.title = "";
+ // Drain the exported stack between tests to avoid state leak.
+ titleStack.splice(0, titleStack.length);
+}
+
+// ── Setup / teardown ─────────────────────────────────────────────────────────
+
+beforeEach(resetState);
+afterEach(resetState);
+
+// ── composeTitles (pure function) ─────────────────────────────────────────────
+
+describe("composeTitles", () => {
+ it("returns an empty string for an empty array", () => {
+ expect(composeTitles([])).toBe("");
+ });
+
+ it("returns an empty string when all segments are blank", () => {
+ expect(composeTitles(["", " ", "\t"])).toBe("");
+ });
+
+ it("joins non-blank segments with ' | '", () => {
+ expect(composeTitles(["Dashboard", "Goals"])).toBe("Dashboard | Goals");
+ });
+
+ it("trims whitespace from each segment", () => {
+ expect(composeTitles([" Dashboard ", " Goals "])).toBe("Dashboard | Goals");
+ });
+
+ it("filters out blank segments in the middle", () => {
+ expect(composeTitles(["Dashboard", "", "Goals"])).toBe("Dashboard | Goals");
+ });
+
+ it("returns a single segment without a separator", () => {
+ expect(composeTitles(["Dashboard"])).toBe("Dashboard");
+ });
+
+ it("handles a large array", () => {
+ expect(composeTitles(["A", "B", "C", "D"])).toBe("A | B | C | D");
+ });
+});
+
+// ── useTitle basic behaviour ──────────────────────────────────────────────────
+
+describe("useTitle", () => {
+ describe("single title", () => {
+ it("sets document.title when a single hook mounts", () => {
+ renderHook(() => useTitle("Dashboard"));
+ expect(document.title).toBe("Dashboard");
+ });
+
+ it("ignores an empty string title", () => {
+ renderHook(() => useTitle(""));
+ expect(document.title).toBe("");
+ });
+
+ it("ignores a whitespace-only title", () => {
+ renderHook(() => useTitle(" "));
+ expect(document.title).toBe("");
+ });
+
+ it("trims whitespace from the title before setting", () => {
+ renderHook(() => useTitle(" Dashboard "));
+ expect(document.title).toBe("Dashboard");
+ });
+
+ it("clears document.title when the hook unmounts", () => {
+ const { unmount } = renderHook(() => useTitle("Dashboard"));
+ expect(document.title).toBe("Dashboard");
+ unmount();
+ expect(document.title).toBe("");
+ });
+ });
+
+ // ── depth-aware stacking ──────────────────────────────────────────────────
+
+ describe("depth-aware stacking", () => {
+ it("orders titles by depth: depth-0 title comes before depth-1", () => {
+ // depth-1 mounts first (simulates a child effect firing before parent)
+ renderHook(() => useTitle("Goals", { depth: 1 }));
+ renderHook(() => useTitle("Dashboard", { depth: 0 }));
+ // Depth 0 (Dashboard) must appear first regardless of mount order
+ expect(document.title).toBe("Dashboard | Goals");
+ });
+
+ it("page title (depth 0) + section title (depth 1) composes correctly", () => {
+ renderHook(() => useTitle("Financial Dashboard", { depth: 0 }));
+ renderHook(() => useTitle("Transaction History", { depth: 1 }));
+ expect(document.title).toBe("Financial Dashboard | Transaction History");
+ });
+
+ it("defaults to depth 0 when no options are provided", () => {
+ renderHook(() => useTitle("Send Money"));
+ renderHook(() => useTitle("Review", { depth: 1 }));
+ expect(document.title).toBe("Send Money | Review");
+ });
+
+ it("two depth-0 titles compose in insertion order", () => {
+ renderHook(() => useTitle("A"));
+ renderHook(() => useTitle("B"));
+ expect(document.title).toBe("A | B");
+ });
+
+ it("three levels: depth 0, 1, 2", () => {
+ renderHook(() => useTitle("RemitWise", { depth: 0 }));
+ renderHook(() => useTitle("Dashboard", { depth: 1 }));
+ renderHook(() => useTitle("Goals", { depth: 2 }));
+ expect(document.title).toBe("RemitWise | Dashboard | Goals");
+ });
+ });
+
+ // ── unmount cleanup ───────────────────────────────────────────────────────
+
+ describe("unmount cleanup", () => {
+ it("restores the parent title when a child section unmounts", () => {
+ renderHook(() => useTitle("Dashboard", { depth: 0 }));
+ const { unmount } = renderHook(() => useTitle("Goals", { depth: 1 }));
+
+ expect(document.title).toBe("Dashboard | Goals");
+ unmount();
+ expect(document.title).toBe("Dashboard");
+ });
+
+ it("restores an empty title when the only mounted hook unmounts", () => {
+ const { unmount } = renderHook(() => useTitle("Dashboard"));
+ unmount();
+ expect(document.title).toBe("");
+ });
+
+ it("does not leak entries in titleStack after unmount", () => {
+ const { unmount } = renderHook(() => useTitle("Dashboard"));
+ expect(titleStack).toHaveLength(1);
+ unmount();
+ expect(titleStack).toHaveLength(0);
+ });
+
+ it("handles multiple unmounts without throwing", () => {
+ const { unmount: u1 } = renderHook(() => useTitle("A", { depth: 0 }));
+ const { unmount: u2 } = renderHook(() => useTitle("B", { depth: 1 }));
+ const { unmount: u3 } = renderHook(() => useTitle("C", { depth: 2 }));
+
+ u2();
+ expect(document.title).toBe("A | C");
+
+ u1();
+ expect(document.title).toBe("C");
+
+ u3();
+ expect(document.title).toBe("");
+ });
+ });
+
+ // ── reactivity (prop changes) ─────────────────────────────────────────────
+
+ describe("prop updates", () => {
+ it("updates document.title when the title string changes", () => {
+ let title = "Dashboard";
+ const { rerender } = renderHook(() => useTitle(title));
+
+ expect(document.title).toBe("Dashboard");
+
+ act(() => {
+ title = "Financial Dashboard";
+ });
+ rerender();
+
+ expect(document.title).toBe("Financial Dashboard");
+ });
+
+ it("updates document.title when depth changes", () => {
+ // Parent stays at depth 0
+ renderHook(() => useTitle("Parent", { depth: 0 }));
+
+ let depth = 1;
+ const { rerender } = renderHook(() => useTitle("Child", { depth }));
+
+ expect(document.title).toBe("Parent | Child");
+
+ act(() => {
+ depth = 2;
+ });
+ rerender();
+
+ // Depth change should not break the composition
+ expect(document.title).toBe("Parent | Child");
+ });
+
+ it("removes the old entry and adds a new one when title changes", () => {
+ let title = "Old Title";
+ const { rerender } = renderHook(() => useTitle(title));
+
+ expect(titleStack).toHaveLength(1);
+ expect(titleStack[0].title).toBe("Old Title");
+
+ act(() => {
+ title = "New Title";
+ });
+ rerender();
+
+ expect(titleStack).toHaveLength(1);
+ expect(titleStack[0].title).toBe("New Title");
+ });
+ });
+
+ // ── SSR safety ────────────────────────────────────────────────────────────
+
+ describe("SSR safety", () => {
+ it("does not throw when window is undefined (server context)", () => {
+ // jsdom has window; we simulate SSR by checking that updateDomTitle
+ // guards against typeof window === "undefined". We can't remove window
+ // in jsdom, but we can verify the hook doesn't explode with empty titles.
+ expect(() => renderHook(() => useTitle(""))).not.toThrow();
+ });
+ });
+
+ // ── two hooks with the same title string ──────────────────────────────────
+
+ describe("duplicate title strings", () => {
+ it("tracks two hooks with the same title independently via internal id", () => {
+ const { unmount: u1 } = renderHook(() => useTitle("Goals", { depth: 0 }));
+ const { unmount: u2 } = renderHook(() => useTitle("Goals", { depth: 1 }));
+
+ expect(document.title).toBe("Goals | Goals");
+
+ u2();
+ // Only the depth-1 entry should be removed
+ expect(document.title).toBe("Goals");
+
+ u1();
+ expect(document.title).toBe("");
+ });
+ });
+});