refactor(frontend): drive document <title> from route handles

Move <title> ownership out of detail-page components into the app
shell. The four detail routes — templates/:templateId,
knowledges/:knowledgeId, settings/providers/:providerId,
flows/:flowId — now expose a titleComponent via react-router
handle. A new <DocumentTitle/> in RootLayout walks useMatches()
deepest-first and renders the matched title component. Each one
subscribes to its resource via Apollo with fetchPolicy:
'cache-only', so it reacts to the destination page's own fetch
without issuing a duplicate request.

Fixes the root cause behind bbf943e's sticky workaround: navigation
between sibling documents (DetailNavigation prev/next) tore down
the page-level state that PageTitle held, flashing a generic
fallback during data fetch. The shell-level <DocumentTitle/>
survives the remount, so the title resolves from cache before the
new page has finished mounting.

- Add src/components/shared/document-title.tsx with DocumentTitle
  plus FlowTitle / KnowledgeTitle / ProviderTitle / TemplateTitle.
- Wire <DocumentTitle/> into RootLayout and attach
  handle={{ titleComponent: ... }} on the four detail routes.
- Remove <PageTitle> calls and now-redundant <> wrappers from the
  four page components.
- Drop the sticky setState-during-render hack from PageTitle —
  listing pages that still use it never flickered, so the simple
  passthrough is enough.

Lint + tsc clean, all 475 tests pass. Listing routes (Dashboard,
Flows, Templates, Knowledges, etc.) keep using PageTitle and can
migrate route-by-route without coordination.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-19 09:39:25 +07:00
co-authored by Claude Opus 4.7
parent 765d743b21
commit 3a67daaad8
7 changed files with 189 additions and 88 deletions
+16
View File
@@ -15,6 +15,13 @@ import MainLayout from '@/components/layouts/main-layout';
import SettingsLayout from '@/components/layouts/settings-layout';
import ProtectedRoute from '@/components/routes/protected-route';
import PublicRoute from '@/components/routes/public-route';
import {
DocumentTitle,
FlowTitle,
KnowledgeTitle,
ProviderTitle,
TemplateTitle,
} from '@/components/shared/document-title';
import PageLoader from '@/components/shared/page-loader';
import { Toaster } from '@/components/ui/sonner';
import client from '@/lib/apollo';
@@ -107,6 +114,11 @@ function RootLayout() {
<FavoritesProvider>
<TemplatesProvider>
<ResourcesProvider>
{/* Document <title> driven by route handles — lives in the
shell so it survives navigation between sibling detail
routes (templates/:id → templates/:id') without
flashing a generic fallback during data fetch. */}
<DocumentTitle />
<Suspense fallback={<PageLoader />}>
<Outlet />
</Suspense>
@@ -141,6 +153,7 @@ const router = createBrowserRouter(
/>
<Route
element={<FlowWithProvider />}
handle={{ titleComponent: FlowTitle }}
path="flows/:flowId"
/>
</Route>
@@ -151,6 +164,7 @@ const router = createBrowserRouter(
/>
<Route
element={<Template />}
handle={{ titleComponent: TemplateTitle }}
path="templates/:templateId"
/>
@@ -161,6 +175,7 @@ const router = createBrowserRouter(
/>
<Route
element={<Knowledge />}
handle={{ titleComponent: KnowledgeTitle }}
path="knowledges/:knowledgeId"
/>
</Route>
@@ -191,6 +206,7 @@ const router = createBrowserRouter(
/>
<Route
element={<SettingsProvider />}
handle={{ titleComponent: ProviderTitle }}
path="providers/:providerId"
/>
<Route
@@ -0,0 +1,125 @@
import type { ComponentType } from 'react';
import { useMatches } from 'react-router-dom';
import {
useFlowQuery,
useFlowTemplateQuery,
useKnowledgeDocumentQuery,
useSettingsProvidersQuery,
} from '@/graphql/types';
const APP_NAME = 'PentAGI';
export interface RouteHandleWithTitle {
titleComponent: TitleComponent;
}
type RouteParams = Record<string, string | undefined>;
type TitleComponent = ComponentType<{ params: RouteParams }>;
const hasTitleComponent = (handle: unknown): handle is RouteHandleWithTitle =>
typeof handle === 'object' &&
handle !== null &&
'titleComponent' in handle &&
typeof (handle as RouteHandleWithTitle).titleComponent === 'function';
const renderTitle = (label: null | string) => <title>{label ? `${label}${APP_NAME}` : APP_NAME}</title>;
/**
* Renders the document `<title>` driven by react-router route handles. Walks
* matches deepest-first; the first match exposing `handle.titleComponent`
* wins. Lives in the app shell so it survives navigation between sibling
* detail routes — that's what fixes the previous "Provider — PentAGI"
* flash when DetailNavigation switches between siblings and the destination
* page unmounts/remounts during data fetch.
*
* Routes without a `titleComponent` keep using `<PageTitle>` inside the page
* component — the migration is route-by-route.
*/
export function DocumentTitle() {
const matches = useMatches();
for (let i = matches.length - 1; i >= 0; i--) {
const handle = matches[i].handle;
if (hasTitleComponent(handle)) {
const TitleComponent = handle.titleComponent;
return <TitleComponent params={matches[i].params} />;
}
}
return null;
}
// Per-resource title components read the same Apollo cache the destination
// page is about to populate. `fetchPolicy: 'cache-only'` avoids a duplicate
// HTTP request — the page's own query fills the cache, this subscription
// reacts to it. The variables shape mirrors the page so cache lookups hit.
export function FlowTitle({ params }: { params: RouteParams }) {
const flowId = params.flowId;
const { data } = useFlowQuery({
fetchPolicy: 'cache-only',
skip: !flowId,
variables: flowId ? { id: flowId } : undefined,
});
const flowTitle = data?.flow?.title;
return renderTitle(flowTitle && flowId ? `Flow #${flowId}${flowTitle}` : 'Flow');
}
export function KnowledgeTitle({ params }: { params: RouteParams }) {
const knowledgeId = params.knowledgeId;
const isNew = knowledgeId === 'new';
const { data } = useKnowledgeDocumentQuery({
fetchPolicy: 'cache-only',
skip: isNew || !knowledgeId,
variables: !isNew && knowledgeId ? { id: knowledgeId } : undefined,
});
if (isNew) {
return renderTitle('New knowledge');
}
return renderTitle(data?.knowledgeDocument?.question || 'Knowledge');
}
export function ProviderTitle({ params }: { params: RouteParams }) {
const providerId = params.providerId;
const isNew = providerId === 'new';
const { data } = useSettingsProvidersQuery({
fetchPolicy: 'cache-only',
skip: isNew,
});
if (isNew) {
return renderTitle('New provider');
}
// Provider detail isn't a separate query — `settingsProviders` returns the
// full list and the page filters by id. We do the same here so a single
// cache entry serves both.
const provider = data?.settingsProviders.userDefined?.find((candidate) => candidate.id == providerId);
return renderTitle(provider?.name || 'Provider');
}
export function TemplateTitle({ params }: { params: RouteParams }) {
const templateId = params.templateId;
const isNew = templateId === 'new';
const { data } = useFlowTemplateQuery({
fetchPolicy: 'cache-only',
skip: isNew || !templateId,
variables: templateId && !isNew ? { templateId } : undefined,
});
if (isNew) {
return renderTitle('New template');
}
return renderTitle(data?.flowTemplate?.title || 'Template');
}
+7 -23
View File
@@ -1,5 +1,3 @@
import { useState } from 'react';
const APP_NAME = 'PentAGI';
interface PageTitleProps {
@@ -7,33 +5,19 @@ interface PageTitleProps {
* Page-specific prefix. Skipped when nullish/empty so loading states can
* render `<PageTitle>{flow?.title}</PageTitle>` without temporarily
* setting the tab title to "— PentAGI".
*
* While this component is mounted (i.e. the user stays on the same route),
* the last non-empty value is "sticky" — a brief loading state (e.g. when
* DetailNavigation switches between siblings and the next document is in
* flight) keeps showing the previous title rather than flashing the
* generic "Provider/Knowledge/Template — PentAGI" fallback. State is
* tied to the component instance so it resets cleanly on route change.
*/
children?: null | string;
}
/**
* Renders a `<title>` element that React 19 hoists into <head> automatically.
* Each page-level route component drops one of these at the top of its JSX so
* the browser tab, history, and shareable links reflect the actual page —
* instead of the static "PentAGI" coming from index.html.
*
* Detail routes (templates/:id, knowledges/:id, providers/:id, flows/:id)
* use route handles via `<DocumentTitle/>` instead — that lets the shell
* own the title and survives navigation between sibling documents. This
* helper is for listing/static pages where in-route flicker isn't a
* concern.
*/
export function PageTitle({ children }: PageTitleProps) {
const [sticky, setSticky] = useState<null | string>(children ?? null);
// React 19 supports setState during render for derived state; it triggers
// a synchronous re-render before commit and avoids a one-frame flicker.
if (children && children !== sticky) {
setSticky(children);
}
const effective = children || sticky;
return <title>{effective ? `${effective}${APP_NAME}` : APP_NAME}</title>;
return <title>{children ? `${children}${APP_NAME}` : APP_NAME}</title>;
}
-2
View File
@@ -29,7 +29,6 @@ import {
} from '@/components/shared/detail-navigation';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit';
import { PageTitle } from '@/components/shared/page-title';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
@@ -204,7 +203,6 @@ function Flow() {
return (
<>
<PageTitle>{flowTitle ? `Flow #${flowId}${flowTitle}` : 'Flow'}</PageTitle>
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex w-full items-center justify-between gap-2 px-4">
<div className="flex min-w-0 flex-1 items-center gap-2">
+31 -41
View File
@@ -1,7 +1,6 @@
import { useCallback, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { PageTitle } from '@/components/shared/page-title';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
@@ -68,55 +67,46 @@ function Knowledge() {
if (!isNew && isLoadingKnowledge) {
return (
<>
<PageTitle>{isNew ? 'New knowledge' : (knowledge?.question ?? 'Knowledge')}</PageTitle>
<KnowledgeLayout
isNew={false}
knowledge={knowledge}
>
<div className="flex flex-1 items-center justify-center">
<Spinner variant="circle" />
</div>
</KnowledgeLayout>
</>
<KnowledgeLayout
isNew={false}
knowledge={knowledge}
>
<div className="flex flex-1 items-center justify-center">
<Spinner variant="circle" />
</div>
</KnowledgeLayout>
);
}
if (!isNew && !knowledge) {
return (
<>
<PageTitle>{isNew ? 'New knowledge' : (knowledge?.question ?? 'Knowledge')}</PageTitle>
<KnowledgeLayout
isNew={false}
knowledge={knowledge}
>
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardContent className="flex flex-col items-center gap-4 pt-6 text-center">
<h2 className="text-xl font-semibold">Knowledge not found</h2>
<p className="text-muted-foreground">
The knowledge document you are looking for does not exist.
</p>
<Button onClick={() => navigate('/knowledges')}>Back to Knowledges</Button>
</CardContent>
</Card>
</div>
</KnowledgeLayout>
</>
<KnowledgeLayout
isNew={false}
knowledge={knowledge}
>
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardContent className="flex flex-col items-center gap-4 pt-6 text-center">
<h2 className="text-xl font-semibold">Knowledge not found</h2>
<p className="text-muted-foreground">
The knowledge document you are looking for does not exist.
</p>
<Button onClick={() => navigate('/knowledges')}>Back to Knowledges</Button>
</CardContent>
</Card>
</div>
</KnowledgeLayout>
);
}
return (
<>
<PageTitle>{isNew ? 'New knowledge' : (knowledge?.question ?? 'Knowledge')}</PageTitle>
<KnowledgeForm
initialValues={initialValues}
isNew={isNew}
key={knowledgeId ?? 'new'}
knowledge={knowledge}
onSubmit={handleSubmit}
/>
</>
<KnowledgeForm
initialValues={initialValues}
isNew={isNew}
key={knowledgeId ?? 'new'}
knowledge={knowledge}
onSubmit={handleSubmit}
/>
);
}
@@ -26,7 +26,6 @@ import type {
} from '@/graphql/types';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { PageTitle } from '@/components/shared/page-title';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
@@ -1363,27 +1362,21 @@ function SettingsProvider() {
if (loading) {
return (
<>
<PageTitle>{isNew ? 'New provider' : providerName || 'Provider'}</PageTitle>
<StatusCard
description="Please wait while we fetch provider configuration"
icon={<Loader2 className="text-muted-foreground size-16 animate-spin" />}
title="Loading provider data..."
/>
</>
<StatusCard
description="Please wait while we fetch provider configuration"
icon={<Loader2 className="text-muted-foreground size-16 animate-spin" />}
title="Loading provider data..."
/>
);
}
if (error) {
return (
<>
<PageTitle>{isNew ? 'New provider' : providerName || 'Provider'}</PageTitle>
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Error loading provider data</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
</>
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Error loading provider data</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
);
}
@@ -1395,7 +1388,6 @@ function SettingsProvider() {
return (
<>
<PageTitle>{isNew ? 'New provider' : providerName || 'Provider'}</PageTitle>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h2 className="flex items-center gap-2 text-lg font-semibold">
@@ -26,7 +26,6 @@ import {
DetailNavigationToolbar,
} from '@/components/shared/detail-navigation';
import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit';
import { PageTitle } from '@/components/shared/page-title';
import { Badge } from '@/components/ui/badge';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
@@ -660,7 +659,6 @@ function Template() {
if (!isNew && isLoadingTemplate) {
return (
<>
<PageTitle>{isNew ? 'New template' : (templateName ?? 'Template')}</PageTitle>
{pageHeader}
<div className="flex min-h-[calc(100dvh-3rem)] items-center justify-center">
<Spinner variant="circle" />
@@ -673,7 +671,6 @@ function Template() {
if (!isNew && !isLoadingTemplate && !templateData?.flowTemplate) {
return (
<>
<PageTitle>{isNew ? 'New template' : (templateName ?? 'Template')}</PageTitle>
{pageHeader}
<div className="flex min-h-[calc(100dvh-3rem)] items-center justify-center p-4">
<Card className="w-full max-w-2xl">
@@ -690,7 +687,6 @@ function Template() {
return (
<>
<PageTitle>{isNew ? 'New template' : (templateName ?? 'Template')}</PageTitle>
{pageHeader}
<div className="flex min-h-[calc(100dvh-3rem)]">
<div className="flex min-w-0 flex-1 items-center justify-center p-4">