Skip to content

Commit 8affd54

Browse files
feat(core): sync document.title with the active route
AppShell now keeps the browser tab title in sync with the current page, removing per-app boilerplate that every consumer would otherwise need. The tab reads "<page> · <title>", where <page> is the current breadcrumb leaf — including any useOverrideBreadcrumb override, so detail pages get their record name in the tab for free — and <title> is the AppShell `title` prop. A single internal DocumentTitle component mounted in the root route drives this for every page; no per-page wiring. When no `title` is set the tab shows just the page name, and when nothing resolves the static index.html title is left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4cbf84 commit 8affd54

5 files changed

Lines changed: 126 additions & 2 deletions

File tree

.changeset/document-title-sync.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tailor-platform/app-shell": minor
3+
---
4+
5+
Sync the browser tab title (`document.title`) with the active route. AppShell now sets the tab to `"<page> · <title>"`, where `<page>` is the current breadcrumb leaf (including any `useOverrideBreadcrumb` override, so detail pages show their record name automatically) and `<title>` is the `title` prop passed to `<AppShell>`. Works for every page with no per-page wiring; when no `title` is set the tab shows just the page name, and when nothing resolves the static `index.html` title is left untouched.

packages/core/src/components/appshell.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ import type { PageEntry } from "@/fs-routes/types";
3333
*/
3434
type SharedAppShellProps = React.PropsWithChildren<{
3535
/**
36-
* App shell title
36+
* App shell title.
37+
*
38+
* Also used as the suffix of the browser tab title: AppShell keeps
39+
* `document.title` in sync with the active page as `"<page> · <title>"`
40+
* (the page part is the current breadcrumb leaf, including any
41+
* {@link useOverrideBreadcrumb} override). When omitted, the tab shows just
42+
* the page title.
3743
*/
3844
title?: string;
3945

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { render, waitFor } from "@testing-library/react";
2+
import { describe, it, expect, beforeEach } from "vitest";
3+
import { MemoryRouter } from "react-router";
4+
import {
5+
AppShellConfigContext,
6+
type RootConfiguration,
7+
} from "@/contexts/appshell-context";
8+
import { BreadcrumbOverrideProvider } from "@/contexts/breadcrumb-context";
9+
import { useOverrideBreadcrumb } from "@/hooks/use-override-breadcrumb";
10+
import { DEFAULT_LOCALE } from "@/lib/i18n";
11+
import { DocumentTitle } from "./document-title";
12+
13+
const configurations: RootConfiguration = {
14+
modules: [],
15+
settingsResources: [],
16+
locale: DEFAULT_LOCALE,
17+
errorBoundary: null!,
18+
};
19+
20+
// Registers an override for the current route the same way a detail page would.
21+
const Override = ({ title }: { title: string }) => {
22+
useOverrideBreadcrumb(title);
23+
return null;
24+
};
25+
26+
const renderAt = (path: string, title: string | undefined, override?: string) =>
27+
render(
28+
<MemoryRouter initialEntries={[path]}>
29+
<AppShellConfigContext.Provider value={{ title, configurations }}>
30+
<BreadcrumbOverrideProvider>
31+
{override ? <Override title={override} /> : null}
32+
<DocumentTitle />
33+
</BreadcrumbOverrideProvider>
34+
</AppShellConfigContext.Provider>
35+
</MemoryRouter>,
36+
);
37+
38+
describe("DocumentTitle", () => {
39+
beforeEach(() => {
40+
document.title = "initial";
41+
});
42+
43+
it("sets '<page> · <app>' from the leaf segment and app title", async () => {
44+
renderAt("/orders/123", "My App");
45+
// No module mapping, so the leaf title falls back to the decoded segment.
46+
await waitFor(() => expect(document.title).toBe("123 · My App"));
47+
});
48+
49+
it("uses just the page title when no app title is provided", async () => {
50+
renderAt("/orders/123", undefined);
51+
await waitFor(() => expect(document.title).toBe("123"));
52+
});
53+
54+
it("applies a breadcrumb override to the tab title", async () => {
55+
renderAt("/orders/123", "My App", "Order #123");
56+
await waitFor(() => expect(document.title).toBe("Order #123 · My App"));
57+
});
58+
59+
it("leaves the static title untouched when nothing resolves", async () => {
60+
renderAt("/", undefined);
61+
await waitFor(() => expect(document.title).toBe("initial"));
62+
});
63+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useEffect } from "react";
2+
import { useAppShellConfig } from "@/contexts/appshell-context";
3+
import { useBreadcrumbOverride } from "@/contexts/breadcrumb-context";
4+
import { usePathSegments } from "@/components/dynamic-breadcrumb";
5+
6+
const SEPARATOR = " · ";
7+
8+
/**
9+
* Keeps `document.title` in sync with the active route.
10+
*
11+
* The browser tab reads `"<page> · <app>"`, where:
12+
* - `<page>` is the last breadcrumb segment for the current path, with any
13+
* {@link useOverrideBreadcrumb} override applied — so a detail page that sets
14+
* a record name (e.g. an order number) gets it in the tab for free, with no
15+
* extra wiring.
16+
* - `<app>` is the `title` prop passed to `<AppShell>`.
17+
*
18+
* Rendered once inside the router (see `createRootRoute`) so it tracks every
19+
* navigation; consumers never wire titles per page. When neither a page title
20+
* nor an app title resolves, the static `<title>` from `index.html` is left
21+
* untouched.
22+
*
23+
* @internal
24+
*/
25+
export const DocumentTitle = () => {
26+
const { title: appTitle } = useAppShellConfig();
27+
const { basePath, segments } = usePathSegments();
28+
const { overrides } = useBreadcrumbOverride();
29+
30+
const leaf = segments.at(-1);
31+
let pageTitle: string | undefined;
32+
if (leaf) {
33+
const leafFullPath = basePath ? `/${basePath}/${leaf.path}` : `/${leaf.path}`;
34+
pageTitle = overrides.get(leafFullPath) ?? leaf.title;
35+
}
36+
37+
const nextTitle = [pageTitle, appTitle].filter(Boolean).join(SEPARATOR);
38+
39+
useEffect(() => {
40+
if (nextTitle) document.title = nextTitle;
41+
}, [nextTitle]);
42+
43+
return null;
44+
};

packages/core/src/routing/router.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { RouteObject } from "react-router";
44
import { createContentRoutes, wrapErrorBoundary } from "./routes";
55
import { useAppShellConfig, type RootConfiguration } from "@/contexts/appshell-context";
66
import { createNavItemsLoader } from "@/routing/navigation";
7+
import { DocumentTitle } from "@/components/document-title";
78

89
// ============================================================================
910
// Root Route
@@ -41,7 +42,12 @@ const createRootRoute = (params: {
4142
return {
4243
id: loaderID,
4344
loader,
44-
element: children,
45+
element: (
46+
<>
47+
<DocumentTitle />
48+
{children}
49+
</>
50+
),
4551
children: routeChildren,
4652
// Hydration fallback is unused in CSR-only usage of AppShell.
4753
// Return null to silence hydration warnings.

0 commit comments

Comments
 (0)