Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/document-title-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@tailor-platform/app-shell": minor
---

Manage the browser tab from AppShell — title and favicon.

- **Title**: AppShell now keeps `document.title` in sync with the active route as `"<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.
- **Favicon**: new `favicon` prop on `<AppShell>` sets the document favicon (any `<link rel="icon">` href — a public-path URL or a data URI), updating the existing link from `index.html` in place. When omitted it defaults to the bundled Tailor favicon, so apps get correct branding out of the box.
27 changes: 24 additions & 3 deletions packages/core/src/components/appshell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ import type { PageEntry } from "@/fs-routes/types";
*/
type SharedAppShellProps = React.PropsWithChildren<{
/**
* App shell title
* App shell title.
*
* Also used as the suffix of the browser tab title: AppShell keeps
* `document.title` in sync with the active page as `"<page> · <title>"`
* (the page part is the current breadcrumb leaf, including any
* {@link useOverrideBreadcrumb} override). When omitted, the tab shows just
* the page title.
*/
title?: string;

Expand All @@ -42,6 +48,18 @@ type SharedAppShellProps = React.PropsWithChildren<{
*/
icon?: React.ReactNode;

/**
* Browser-tab favicon href. Accepts anything valid on `<link rel="icon">` —
* a public-path URL (e.g. `/favicon.ico`) or a data URI. AppShell renders the
* `<link rel="icon">` for you (React hoists it into `<head>`); when omitted,
* the bundled Tailor favicon is used.
*
* Let AppShell own this tag — don't also declare a static
* `<link rel="icon">` in `index.html`, or the two will coexist (React only
* de-duplicates stylesheets, not tags it didn't render).
*/
favicon?: string;

/**
* Base path for the app shell
*/
Expand Down Expand Up @@ -281,8 +299,11 @@ export const AppShell = (props: AppShellProps) => {

// Memoize context values to prevent unnecessary re-renders
const configValue = useMemo(
() => (configurations ? { title: props.title, icon: props.icon, configurations } : null),
[props.title, props.icon, configurations],
() =>
configurations
? { title: props.title, icon: props.icon, favicon: props.favicon, configurations }
: null,
[props.title, props.icon, props.favicon, configurations],
);

const dataValue = useMemo(
Expand Down
86 changes: 86 additions & 0 deletions packages/core/src/components/document-head.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { render, waitFor, cleanup } from "@testing-library/react";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { MemoryRouter } from "react-router";
import { AppShellConfigContext, type RootConfiguration } from "@/contexts/appshell-context";
import { BreadcrumbOverrideProvider } from "@/contexts/breadcrumb-context";
import { useOverrideBreadcrumb } from "@/hooks/use-override-breadcrumb";
import { DEFAULT_FAVICON_HREF } from "@/lib/default-favicon";
import { DocumentHead } from "./document-head";

const configurations: RootConfiguration = {
modules: [],
settingsResources: [],
locale: "en",
errorBoundary: null!,
};

// Registers a breadcrumb override for the current route, like a detail page.
const Override = ({ title }: { title: string }) => {
useOverrideBreadcrumb(title);
return null;
};

const renderAt = (
path: string,
opts: { title?: string; favicon?: string; override?: string } = {},
) =>
render(
<MemoryRouter initialEntries={[path]}>
<AppShellConfigContext.Provider
value={{ title: opts.title, favicon: opts.favicon, configurations }}
>
<BreadcrumbOverrideProvider>
{opts.override ? <Override title={opts.override} /> : null}
<DocumentHead />
</BreadcrumbOverrideProvider>
</AppShellConfigContext.Provider>
</MemoryRouter>,
);

const iconHref = () =>
document.querySelector<HTMLLinkElement>('link[rel="icon"]')?.getAttribute("href");

describe("DocumentHead", () => {
beforeEach(() => {
document.title = "initial";
document.head.querySelectorAll('link[rel="icon"]').forEach((el) => el.remove());
});

afterEach(() => {
cleanup();
document.head.querySelectorAll('link[rel="icon"]').forEach((el) => el.remove());
});

it("sets '<page> · <app>' from the leaf segment and app title", async () => {
renderAt("/orders/123", { title: "My App" });
// No module mapping, so the leaf title falls back to the decoded segment.
await waitFor(() => expect(document.title).toBe("123 · My App"));
});

it("uses just the page title when no app title is provided", async () => {
renderAt("/orders/123");
await waitFor(() => expect(document.title).toBe("123"));
});

it("applies a breadcrumb override to the tab title", async () => {
renderAt("/orders/123", { title: "My App", override: "Order #123" });
await waitFor(() => expect(document.title).toBe("Order #123 · My App"));
});

it("leaves the document title untouched when nothing resolves", async () => {
renderAt("/");
// Give React a chance to (not) render a <title>.
await waitFor(() => expect(iconHref()).toBe(DEFAULT_FAVICON_HREF));
expect(document.title).toBe("initial");
});

it("renders the bundled Tailor favicon by default", async () => {
renderAt("/orders/123", { title: "My App" });
await waitFor(() => expect(iconHref()).toBe(DEFAULT_FAVICON_HREF));
});

it("renders a consumer-provided favicon", async () => {
renderAt("/orders/123", { title: "My App", favicon: "/custom.ico" });
await waitFor(() => expect(iconHref()).toBe("/custom.ico"));
});
});
51 changes: 51 additions & 0 deletions packages/core/src/components/document-head.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useAppShellConfig } from "@/contexts/appshell-context";
import { useBreadcrumbOverrideOptional } from "@/contexts/breadcrumb-context";
import { usePathSegments } from "@/components/dynamic-breadcrumb";
import { DEFAULT_FAVICON_HREF } from "@/lib/default-favicon";

const SEPARATOR = " · ";

/**
* Declaratively manages the browser tab's title and favicon for the whole app.
*
* - **Title** — `"<page> · <app>"`, where `<page>` is the current breadcrumb
* leaf (including any {@link useOverrideBreadcrumb} override, so detail pages
* show their record name) and `<app>` is the `title` prop passed to
* `<AppShell>`. When neither resolves, no `<title>` is rendered and the
* document keeps whatever title it already had.
* - **Favicon** — the `favicon` prop passed to `<AppShell>`, or the bundled
* Tailor default ({@link DEFAULT_FAVICON_HREF}) when omitted.
*
* Rendered once inside the router (see `createRootRoute`). React 19 hoists the
* `<title>`/`<link>` into `<head>` and updates them on every navigation — no
* imperative `document.title` / head manipulation — and this works in
* client-only apps, streaming SSR, and Server Components.
*
* Consumers should let AppShell own these tags and not *also* declare a static
* `<title>` / `<link rel="icon">` in `index.html`: React only de-duplicates
* stylesheets, so a static tag it did not render would coexist with this one.
*
* @internal
*/
export const DocumentHead = () => {
const { title: appTitle, favicon } = useAppShellConfig();
const { basePath, segments } = usePathSegments();
const overrides = useBreadcrumbOverrideOptional()?.overrides;

const leaf = segments.at(-1);
let pageTitle: string | undefined;
if (leaf) {
const leafFullPath = basePath ? `/${basePath}/${leaf.path}` : `/${leaf.path}`;
pageTitle = overrides?.get(leafFullPath) ?? leaf.title;
}

const title = [pageTitle, appTitle].filter(Boolean).join(SEPARATOR);
const resolvedFavicon = favicon ?? DEFAULT_FAVICON_HREF;

return (
<>
{title ? <title>{title}</title> : null}
<link rel="icon" href={resolvedFavicon} />
</>
);
};
10 changes: 8 additions & 2 deletions packages/core/src/components/sidebar/default-sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,15 @@ describe("DefaultSidebar auto-generation", () => {
</AppShell>,
);

// Wait for auto-generated sidebar items to render
// Wait for the auto-generated sidebar *link* to render. We can't key off
// page text ("Dashboard" is also the route component's content) — the
// sidebar nav items come from the async root loader and land after the
// page, so assert on the sidebar anchors directly.
await waitFor(() => {
expect(screen.getAllByText("Dashboard").length).toBeGreaterThan(0);
const sidebar = document.querySelector('[data-slot="sidebar"]');
assert(sidebar);
const texts = Array.from(sidebar.querySelectorAll("a")).map((link) => link.textContent);
expect(texts).toContain("Dashboard");
});

// Collect all links from the sidebar
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/contexts/appshell-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const buildConfigurations = (options: ConfigurationOptions): RootConfigur
type AppShellConfigContextType = {
title?: string;
icon?: ReactNode;
favicon?: string;
configurations: RootConfiguration;
};

Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/contexts/breadcrumb-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,11 @@ export const useBreadcrumbOverride = () => {
}
return context;
};

/**
* Like {@link useBreadcrumbOverride} but returns `null` instead of throwing when
* no `BreadcrumbOverrideProvider` is mounted. For consumers that should degrade
* gracefully without overrides (e.g. components rendered in the router without
* the full AppShell provider stack).
*/
export const useBreadcrumbOverrideOptional = () => useContext(BreadcrumbOverrideContext);
10 changes: 10 additions & 0 deletions packages/core/src/lib/default-favicon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Default favicon used when a consumer does not pass a `favicon` prop to
* `<AppShell>`. It is the Tailor mark, embedded as a 32×32 PNG data URI so it
* ships in the bundle and needs no asset-copy step in the consuming app.
*
* Consumers override it by passing `favicon` (any href: a public-path URL such
* as `/favicon.ico`, or a data URI).
*/
export const DEFAULT_FAVICON_HREF =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAAFKUlEQVRYCe1WS2xUZRQ+57+3paXEQmOKJYDRgGVDQjRGWOgCDLS6Mu2UMiWupKSF1JYAjsZoE23SaaFtQBC7QBdgSkYSNRYIVkE3BLUEJbgwEA210EGtQKowj3uP35nO0Ll0WmbK1pO5r3/OOd93Hv+DaYbS1SWFcwytEUNvkdAsN59eaGjg4VzdmVwNVP/9Hlk326YjwtTHTE8T03KJUuVMfNm5GB04IEv5Dm0CcL1tUbHjELkukW0TS4wW5eIrpZsVgX0d8oiVT1soQnXGpscUNB4nQvQKTq5DYWThXMppY6PMKS0l09rKt1JjUz3hYmrZt0/mWA5VsFCADT2lyg7AVSwL7w6NkaHjHKf2zS18budOWQBdH4htRl+EQfrltjYeGrfIfJ8yA/u7pdLEqQWRPWcsmqXp1ohNsmsQ9QnDtHvufDo1MEAmsFO2ClE9dJarDvSXxGK0DLC5EfhgrywTh7bBkd+yqUiB9VKnAgSk/2c43VtSRgdrajga2C6rS4rpdYyttgyZlD4ylBdzaG7muCdGPRnYv0dWiksfAbgcT0+d4w6NYKiHHTrUsI2H39ghKwI7JIAMVYBssfZFiqhmChn6CRlUsgnxbby+RF9Ch0ovjY+M3z3TkF1aBebl6sxFtFpnRH0LkRyG07WNTRwc+pNcpPttl+lLlGY93CTAFVT1Yfs7rraYS2uC3XzR57++oqpupFdcOes6Tn/VhqtalrviJcDECq6CdGoU55Gi9Q1NvHHwAl0OBKTFidFX+LsV18MasYoCQ8bQoD3GpXXtHfzm8PDVwuq6kT2Io9+wvYnYlCBbTwBiVUI7efOUIPVHIoVCN2Dw2itNfBJ1fp4s1NlFnQGWDoz3CEifwmzYHWzngcrKvx6q8ofrMUu3M9tLBdPBdfEFZ3qxJUUpHH1OSQA9cHleAZ1tbpZVWHhCtqG5OvdTdVZjZOgMZsKusoXU39TEkZf84TpLYo0AWgkk47oxVbsrjLks4nowPR93Ncdfxmge3S4ooMUASYDrsGYHqf4Vj4OFMXqvtYdvVNUOPVPtH2lGtD628q0EsGjUk0XY9ZTd8zFZHSNaZxRSJTEVXTqdz/RisJPf/XF0uLS6LtxLlt0P4FrQA3gUmkmDhNX0t+ky4LFM9sXf+RY1HRsYvFTlv7aVHd7BnLeYsHC4jgLnLvfPQNJncgX87p0gX3i0fFEts9WDFlqsEQsIzFSyJqAZwG9UgUDmcWTbkinqnAuZrAmoU1QWHBKC1SL7OidtMj5yIpDRwwMO/k/AkwE0WnIneMC85mDuJUCkRyjRjQhksl4jcsDDlNX5NCEeAjGLvsX2ezEWp2uO0Oc+H+li7tGZMM3tjYGrewF+d9ItPc63bOFLOBOsjVv0ZMOr3AEjwZx/4PnGOMnqaoU94pgdNSfSCUxKMw6X19IVsPu5OHgkNqH08WzesR3DDo3lxn9hYzrN7LzQkd6Sm+m2kwik/5l8/0cPKXl5OKI5WZYDoIYtDTqMkh5F6J2ffjz/twy+s2g0iy6gLF9HY1SA7f1DdYKjN+OMkEEAjHRjiY4KOZ+J43R80lc2iOpPWUZPD2TwSMEgXxm9SRUFhfRsZzd76peuj80p8Yk6fw9+9Rz5ZsPRvgU/TAeuBtmUgHp72XO0ERxrUntBorNRa+yKV5D5XRyVvlCo9I90ctO9Z0XgXgfCMkQ45qLJmCQ+6kjkC4uc7tDhhefv1b3f94wI5BcV9UfH/j2O/o6Qbbqs26fPhEI1MzoU/AeEofRp9F8pTwAAAABJRU5ErkJggg==";
8 changes: 7 additions & 1 deletion packages/core/src/routing/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { RouteObject } from "react-router";
import { createContentRoutes, wrapErrorBoundary } from "./routes";
import { useAppShellConfig, type RootConfiguration } from "@/contexts/appshell-context";
import { createNavItemsLoader } from "@/routing/navigation";
import { DocumentHead } from "@/components/document-head";

// ============================================================================
// Root Route
Expand Down Expand Up @@ -41,7 +42,12 @@ const createRootRoute = (params: {
return {
id: loaderID,
loader,
element: children,
element: (
<>
<DocumentHead />
{children}
</>
),
children: routeChildren,
// Hydration fallback is unused in CSR-only usage of AppShell.
// Return null to silence hydration warnings.
Expand Down
Loading