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 00000000..8fdb0562 --- /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 b00437ec..8e28d7bb 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 &&