From 96ac1c2652519e3e093d60a0763c3e8c0e3aa22d Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:25:05 +1100 Subject: [PATCH] feat(studio): page titles (core) (#43538) ## What kind of change does this PR introduce? - Resolves FE-1960 - Resolves FE-1983 - Resolves DEPR-207 ## What is the current behavior? Page titles between surfaces are inconsistent and vague. Sometimes they say the product name: ``` My Project | My Org | Supabase ``` ...even when on a specific surface like Database > Tables. Other times they show the entity name but skip over the project or org name : ``` Edge Functions | Supabase ``` ## What is the new behavior? Page titles *mostly* (see below) follow the same format: ``` users | Table Editor | My Project | My Org | Supabase hello-world | Logs | Edge Functions | My Project | My Org | Supabase Backups | Database | My Project | My Org | Supabase Authentication | My Project | My Org | Supabase ``` That format is: entity, section, surface, project, org, brand ## Additional context This is stacked PR 1/5 for page title improvements. Includes the core title utility and ProjectLayout integration/tests. Follow-up stacked PRs are based on this branch: - https://github.com/supabase/supabase/pull/43534 - https://github.com/supabase/supabase/pull/43535 - https://github.com/supabase/supabase/pull/43536 - https://github.com/supabase/supabase/pull/43537 This one should be merged first. The others (listed right above) can _then_ be merged in any order. --- .../layouts/ProjectLayout/index.test.tsx | 195 ++++++++++++++++++ .../layouts/ProjectLayout/index.tsx | 34 +-- apps/studio/lib/page-title.test.ts | 94 +++++++++ apps/studio/lib/page-title.ts | 46 +++++ 4 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 apps/studio/components/layouts/ProjectLayout/index.test.tsx create mode 100644 apps/studio/lib/page-title.test.ts create mode 100644 apps/studio/lib/page-title.ts diff --git a/apps/studio/components/layouts/ProjectLayout/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/index.test.tsx new file mode 100644 index 00000000000..7bec870580d --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/index.test.tsx @@ -0,0 +1,195 @@ +import { render, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { STUDIO_PAGE_TITLE_SEPARATOR } from '@/lib/page-title' + +const { mockRouter, mockSetSelectedDatabaseId, mockSetMobileMenuOpen } = vi.hoisted(() => ({ + mockRouter: { + pathname: '/project/[ref]/observability/query-performance', + asPath: '/project/default/observability/query-performance', + push: vi.fn(), + replace: vi.fn(), + }, + mockSetSelectedDatabaseId: vi.fn(), + mockSetMobileMenuOpen: vi.fn(), +})) + +vi.mock('next/router', () => ({ + useRouter: () => mockRouter, +})) + +vi.mock('next/head', async () => { + const React = await import('react') + + const Head = ({ children }: { children?: ReactNode }) => { + React.useEffect(() => { + const titleElement = React.Children.toArray(children).find( + (child) => React.isValidElement(child) && child.type === 'title' + ) + + if (!React.isValidElement(titleElement)) return + + const titleText = React.Children.toArray(titleElement.props.children).join('') + document.title = titleText + }, [children]) + + return null + } + + return { default: Head } +}) + +vi.mock('common', () => ({ + useParams: () => ({ ref: 'default' }), + mergeRefs: + (..._refs: any[]) => + (_value: unknown) => {}, +})) + +vi.mock('framer-motion', () => ({ + AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}>, + motion: { + div: ({ children, ...props }: any) =>