feat(core): manage browser tab title + favicon from AppShell#324
feat(core): manage browser tab title + favicon from AppShell#324erickteowarang wants to merge 6 commits into
Conversation
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>
Add a `favicon` prop to AppShell that sets the document favicon (any `<link rel="icon">` href — public-path URL or data URI), updating the existing link from index.html in place. Defaults to the bundled Tailor mark (a 32x32 PNG data URI) when omitted, so apps render correct branding without an asset-copy step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ider DocumentTitle renders in the root route element, which router tests mount without the AppShell provider stack. Read the breadcrumb overrides via a non-throwing useBreadcrumbOverrideOptional so the component degrades gracefully (no overrides) instead of crashing the router when the provider is absent. No behaviour change under AppShell, which always mounts BreadcrumbOverrideProvider. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Suggestion (non-blocking)If we’re okay making React the sole owner of the document title and favicon (i.e. not relying on static In other words:
That would let us keep this declarative and avoid direct Rough sketch: import { useAppShellConfig } from "@/contexts/appshell-context";
import { useBreadcrumbOverride } from "@/contexts/breadcrumb-context";
import { usePathSegments } from "@/components/dynamic-breadcrumb";
import { DEFAULT_FAVICON_HREF } from "@/lib/default-favicon";
const SEPARATOR = " · ";
export const DocumentHead = () => {
const { title: appTitle, favicon } = useAppShellConfig();
const { basePath, segments } = usePathSegments();
const { overrides } = useBreadcrumbOverride();
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 nextTitle = [pageTitle, appTitle].filter(Boolean).join(SEPARATOR);
const resolvedFavicon = favicon ?? DEFAULT_FAVICON_HREF;
return (
<>
{nextTitle ? <title>{nextTitle}</title> : null}
<link rel="icon" href={resolvedFavicon} />
</>
);
};Then we could render that once near the router root instead of having separate effect-only components for title/favicon. If the intent is to preserve static |
Adding the document-title/favicon test files shifted vitest's forks
scheduling and surfaced a latent race in default-sidebar's
"excludes componentless resources" test: it waited on page text
("Dashboard" is also the route component's content) then queried the
sidebar before its async loader-driven links rendered. Wait on the
sidebar anchor directly instead. Also add afterEach(cleanup) + state
restoration to the new favicon/document-title tests so they don't leak
DOM/global state across files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@IzumiSy Ah right not a bad idea, I forgot that React 19 has that now. Might make some changes |
|
@IzumiSy So I played around with it and I think the only problem right now is that there's no backward compatibility, i.e we will need to remove the static <title>/ from the index.html files we control in all of our client repos, so app-shell is the sole owner. Otherwise I'm pretty sure it will be overwritten by them. Not a big deal, I do think it's a cleaner way to do it in app-shell and I don't want to constantly use |
Yea, I think so too. Let's go bold. |
Replace the effect-based DocumentTitle + Favicon components with a single declarative DocumentHead that renders <title>/<link rel="icon"> and lets React 19 hoist them into <head>. No more imperative document.title / document.head manipulation; works in client-only apps, streaming SSR, and Server Components. - favicon now flows through AppShellConfigContext (new `favicon` field) so DocumentHead can read it alongside `title`. - favicon still defaults to the bundled Tailor mark; consumers should let AppShell own these tags rather than also declaring static ones in index.html (React only de-dupes stylesheets, not tags it didn't render). Per IzumiSy's review suggestion on the PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tracking issue: tailor-inc/platform-planning#1401
Summary
Today every app-shell consumer ships the same broken browser-tab experience: a hardcoded
<title>that never changes as you navigate, and a<link rel="icon" href="/favicon.ico">that 404s unless the app happens to drop a file inpublic/. This moves both concerns into AppShell so consumers get correct tab behaviour for free.Title sync
AppShell now keeps
document.titlein sync with the active route, rendered as"<page> · <title>":<page>is the current breadcrumb leaf, resolved from the same pagemeta.titleapp-shell already uses for breadcrumbs — and it honoursuseOverrideBreadcrumb, so a detail page that sets a record name (e.g. an order number) gets it in the tab automatically, no extra wiring.<title>is the existingtitleprop on<AppShell>.A single internal
DocumentTitlemounted in the root route drives this for every page — no per-page code. When notitleis set the tab shows just the page name; when nothing resolves, the staticindex.htmltitle is left untouched.Favicon
New
faviconprop on<AppShell>sets the document favicon (any<link rel="icon">href — a public-path URL like/favicon.ico, or a data URI). It updates the existing link fromindex.htmlin place (or creates one). When omitted it defaults to the bundled Tailor mark (a compact 32×32 PNG data URI, ~2 KB), so apps render correct branding out of the box and can override per-app.Implementation
components/document-title.tsx—DocumentTitle(internal), readsusePathSegments()+ breadcrumb overrides +useAppShellConfig().title.components/favicon.tsx+lib/default-favicon.ts—Favicon(internal) + embedded Tailor default.routing/router.tsx— mounts<DocumentTitle />in the root route element (inside the router + breadcrumb/config providers).components/appshell.tsx— newfaviconprop, renders<Favicon href={props.favicon} />;titleJSDoc updated to note tab-title behaviour.Why here (shared location)
The breadcrumb trail is already resolved once per navigation in
usePathSegments/createRootRoute. Driving the tab title from the leaf of that same trail means it tracks every route — and every consumer — from one place, instead of each app re-implementing adocument.titleeffect.Testing
document-title.test.tsx(4) andfavicon.test.tsx(3) — all pass.pnpm run lintclean; fullturbo buildgreen (6/6).faviconprop; title sync needs zero consumer changes.Consumer migration (e.g. IMS / Monitor)
Pass
title(and optionallyfavicon) to<AppShell>; delete any localdocument.titleeffect. With nothing passed, apps still get the Tailor favicon by default.🤖 Generated with Claude Code