Skip to content

feat(core): manage browser tab title + favicon from AppShell#324

Open
erickteowarang wants to merge 6 commits into
mainfrom
feat/document-title-sync
Open

feat(core): manage browser tab title + favicon from AppShell#324
erickteowarang wants to merge 6 commits into
mainfrom
feat/document-title-sync

Conversation

@erickteowarang

@erickteowarang erickteowarang commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

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 in public/. This moves both concerns into AppShell so consumers get correct tab behaviour for free.

Title sync

AppShell now keeps document.title in sync with the active route, rendered as "<page> · <title>":

  • <page> is the current breadcrumb leaf, resolved from the same page meta.title app-shell already uses for breadcrumbs — and it honours useOverrideBreadcrumb, 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 existing title prop on <AppShell>.

A single internal DocumentTitle mounted in the root route drives this for every page — no per-page code. When no title is set the tab shows just the page name; 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 like /favicon.ico, or a data URI). It updates the existing link from index.html in 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.tsxDocumentTitle (internal), reads usePathSegments() + breadcrumb overrides + useAppShellConfig().title.
  • components/favicon.tsx + lib/default-favicon.tsFavicon (internal) + embedded Tailor default.
  • routing/router.tsx — mounts <DocumentTitle /> in the root route element (inside the router + breadcrumb/config providers).
  • components/appshell.tsx — new favicon prop, renders <Favicon href={props.favicon} />; title JSDoc 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 a document.title effect.

Testing

  • document-title.test.tsx (4) and favicon.test.tsx (3) — all pass.
  • pnpm run lint clean; full turbo build green (6/6).
  • No new public API beyond the favicon prop; title sync needs zero consumer changes.

Consumer migration (e.g. IMS / Monitor)

Pass title (and optionally favicon) to <AppShell>; delete any local document.title effect. With nothing passed, apps still get the Tailor favicon by default.

🤖 Generated with Claude Code

erickteowarang and others added 2 commits June 19, 2026 14:15
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>
erickteowarang and others added 2 commits June 19, 2026 15:43
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>
@IzumiSy

IzumiSy commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Suggestion (non-blocking)

If we’re okay making React the sole owner of the document title and favicon (i.e. not relying on static <title> / favicon tags in index.html), I wonder if we could lean on React 19’s built-in head tag support here instead of updating the DOM imperatively with useEffect.

In other words:

  • title via <title>{...}</title>
  • favicon via <link rel="icon" href={...} />

That would let us keep this declarative and avoid direct document.title = ... / document.head.querySelector(...) logic.

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 index.html values as a fallback, though, the current approach makes sense too.

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>
@erickteowarang

Copy link
Copy Markdown
Contributor Author

@IzumiSy Ah right not a bad idea, I forgot that React 19 has that now. Might make some changes

@erickteowarang

Copy link
Copy Markdown
Contributor Author

@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 useEffect haha.

@IzumiSy

IzumiSy commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

@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 useEffect haha.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants