From aa34692bdd9c21a43a5626587858effaf02b9480 Mon Sep 17 00:00:00 2001 From: Raoni Timo Date: Tue, 28 Apr 2026 11:10:56 -0300 Subject: [PATCH] feat(start): include project name in browser tab title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #177. Browser tabs previously read e.g. "Dashboard | OpenPanel.dev" regardless of which project was open, making it hard to tell tabs apart when multiple projects are pinned in the same window. The existing `createProjectTitle()` helper already accepts an optional projectName argument, but no caller passed it. Threading project name through every route's loader/head would touch ~30 files; instead, this adds a single `useProjectDocumentTitle` hook mounted in the project layout that patches `document.title` on the client whenever it changes. The hook observes mutations on `` so it re-applies the project name after navigation (when TanStack's head() re-renders the title) and when the project query first resolves. SSR title is unchanged — this is purely a browser tab UX improvement. Result: "Dashboard | MyProject | OpenPanel.dev" --- .../src/hooks/use-project-document-title.ts | 44 +++++++++++++++++++ .../_app.$organizationId.$projectId.tsx | 24 +++++++--- 2 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 apps/start/src/hooks/use-project-document-title.ts diff --git a/apps/start/src/hooks/use-project-document-title.ts b/apps/start/src/hooks/use-project-document-title.ts new file mode 100644 index 000000000..8fdb05621 --- /dev/null +++ b/apps/start/src/hooks/use-project-document-title.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef } from 'react'; + +const BASE_SUFFIX = ' | OpenPanel.dev'; + +function inject(title: string, projectName: string): string { + if (!title.endsWith(BASE_SUFFIX)) return title; + if (title.includes(` | ${projectName}${BASE_SUFFIX}`)) return title; + return title.replace(BASE_SUFFIX, ` | ${projectName}${BASE_SUFFIX}`); +} + +// Browser tab titles are set by each route's `head()` (server + client). +// Rather than threading project name through every route's loader/head, +// we patch the title imperatively on the client whenever it changes — +// observed via a MutationObserver on . This keeps the change +// localized to one hook mounted under the project layout. +export function useProjectDocumentTitle(projectName: string | undefined) { + const lastApplied = useRef<string | null>(null); + + useEffect(() => { + if (!projectName) return; + + const apply = () => { + const current = document.title; + if (current === lastApplied.current) return; + const next = inject(current, projectName); + if (next !== current) { + document.title = next; + } + lastApplied.current = document.title; + }; + + apply(); + + // Observe the whole <head> rather than the <title> element directly, + // so we still catch updates if React swaps the title node entirely. + const observer = new MutationObserver(apply); + observer.observe(document.head, { + childList: true, + characterData: true, + subtree: true, + }); + return () => observer.disconnect(); + }, [projectName]); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.tsx index b00437ecf..8e28d7bb9 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.tsx @@ -1,4 +1,5 @@ import BillingPrompt from '@/components/organization/billing-prompt'; +import { useProjectDocumentTitle } from '@/hooks/use-project-document-title'; import { useTRPC } from '@/integrations/trpc/react'; import { PAGE_TITLES, createProjectTitle } from '@/utils/title'; import { FREE_PRODUCT_IDS } from '@openpanel/payments'; @@ -17,22 +18,33 @@ export const Route = createFileRoute('/_app/$organizationId/$projectId')({ }; }, loader: async ({ context, params }) => { - await context.queryClient.prefetchQuery( - context.trpc.organization.get.queryOptions({ - organizationId: params.organizationId, - }), - ); + await Promise.all([ + context.queryClient.prefetchQuery( + context.trpc.organization.get.queryOptions({ + organizationId: params.organizationId, + }), + ), + context.queryClient.prefetchQuery( + context.trpc.project.getProjectWithClients.queryOptions({ + projectId: params.projectId, + }), + ), + ]); }, }); function ProjectDashboard() { - const { organizationId } = Route.useParams(); + const { organizationId, projectId } = Route.useParams(); const trpc = useTRPC(); const { data: organization } = useSuspenseQuery( trpc.organization.get.queryOptions({ organizationId, }), ); + const { data: project } = useSuspenseQuery( + trpc.project.getProjectWithClients.queryOptions({ projectId }), + ); + useProjectDocumentTitle(project?.name); if ( organization.subscriptionProductId &&