studio/frontend: set per-route document.title (#5660)
* studio/frontend: set per-route document.title The browser tab title was hardcoded to "Unsloth Studio" in index.html and never updated. Users running multiple Studio installs (or browsing several threads in separate tabs) saw the same tab label everywhere, making the OS / browser tab strip useless for switching between them. Map known route prefixes (Chat, Train, Data Recipes, Export, Settings, Login, Onboarding, Change Password) to a "Label - Unsloth Studio" tab title and update document.title from a small effect inside RootLayout. Unknown routes keep the original "Unsloth Studio". Resolves #5659. * studio/frontend: per-route document.title via staticData + useMatches Address review feedback on #5660 (gemini-code-assist): move titles from the centralized ROUTE_TITLES map in __root.tsx into each route's `staticData: { title }` and read the deepest matched route's title via `useMatches`. This co-locates the title with the route definition, so renames or new routes only have to touch one file, and drops the pathname.startsWith(...) string matching. Routes given a title (everything that actually renders chrome): - /chat -> "Chat" - /studio -> "Train" - /data-recipes -> "Data Recipes" - /data-recipes/$recipeId -> "Data Recipes" - /export -> "Export" - /login -> "Login" - /onboarding -> "Onboarding" - /change-password -> "Change Password" /settings and / both redirect on `beforeLoad`, so they never render and don't need a title; they fall through to the default "Unsloth Studio". The previous PR's ROUTE_TITLES + routeTitle() helper are removed from __root.tsx. tsc + vite build clean; bundle confirms every route carries its `staticData:{title:...}` and __root.tsx's useMatches selector walks matches deepest-first. * studio/frontend: type staticData.title via module augmentation + useLayoutEffect - Augment `StaticDataRouteOption` so `createRoute({ staticData: { title } })` is typed at the leaves and the layout reads `match.staticData.title` without the inline cast. - Switch the title-writing effect to `useLayoutEffect` so the tab title updates synchronously and doesn't flash the previous route's title for a frame during in-app navigation. - Use " | " separator (web convention) for the document title. * studio/frontend: Settings dialog drives document.title + revert separator to PR contract 12/12 reviewers flagged that /settings is a modal deep link whose route throws redirect in beforeLoad, so useMatches resolves to the post-auth route (usually /chat). The tab title therefore showed "Chat - Unsloth Studio" while the user was actually looking at the Settings dialog. Fix: - Subscribe to useSettingsDialogStore.open in __root.tsx and prefer "Settings" as the document title while the dialog is visible. - Add staticData.title = "Settings" on /settings for the rare case beforeLoad returns without throwing (future refactor); the live source-of-truth is the dialog store since the redirect means the route never matches. Also revert the document title separator from " | " back to " - " to match the PR description / acceptance contract that the previous round inadvertently broke. * studio/frontend: tighten document-title comments --------- Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
This commit is contained in:
parent
71232f749a
commit
ac8973c493
10 changed files with 49 additions and 1 deletions
|
|
@ -12,12 +12,21 @@ import {
|
|||
Outlet,
|
||||
createRootRoute,
|
||||
redirect,
|
||||
useMatches,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Suspense, useEffect, type ReactNode } from "react";
|
||||
import { Suspense, useEffect, useLayoutEffect, type ReactNode } from "react";
|
||||
import { AppProvider } from "../provider";
|
||||
|
||||
// Type `staticData.title` on every route so the matched-title selector
|
||||
// below stays type-safe without an inline cast.
|
||||
declare module "@tanstack/react-router" {
|
||||
interface StaticDataRouteOption {
|
||||
title?: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback while a lazy route bundle (Train/Recipes/Export) loads.
|
||||
// /chat is synchronous and never hits this.
|
||||
const RouteFallback: ReactNode = (
|
||||
|
|
@ -55,6 +64,9 @@ export const Route = createRootRoute({
|
|||
|
||||
const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"];
|
||||
|
||||
// Fallback when no matched route declares a `staticData.title`.
|
||||
const DEFAULT_DOCUMENT_TITLE = "Unsloth Studio";
|
||||
|
||||
function RootLayout() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname);
|
||||
|
|
@ -63,6 +75,30 @@ function RootLayout() {
|
|||
|
||||
useTrainingUnloadGuard();
|
||||
|
||||
// Walk matches deepest-first; each route declares its own title.
|
||||
const matchedTitle = useMatches({
|
||||
select: (matches) => {
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const title = matches[i].staticData.title;
|
||||
if (title) return title;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
// `/settings` redirects in `beforeLoad`, so its route never stays
|
||||
// matched; surface the modal's title via the store instead.
|
||||
const settingsDialogOpen = useSettingsDialogStore((s) => s.open);
|
||||
const documentTitle = settingsDialogOpen ? "Settings" : matchedTitle;
|
||||
|
||||
// useLayoutEffect updates the tab title before paint, avoiding a
|
||||
// one-frame flash of the previous route's title on navigation.
|
||||
useLayoutEffect(() => {
|
||||
document.title = documentTitle
|
||||
? `${documentTitle} - ${DEFAULT_DOCUMENT_TITLE}`
|
||||
: DEFAULT_DOCUMENT_TITLE;
|
||||
}, [documentTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const ChangePasswordPage = lazy(() =>
|
|||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/change-password",
|
||||
staticData: { title: "Change Password" },
|
||||
beforeLoad: () => requirePasswordChangeFlow(),
|
||||
component: ChangePasswordPage,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type ChatSearch = {
|
|||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/chat",
|
||||
staticData: { title: "Chat" },
|
||||
beforeLoad: () => requireAuth(),
|
||||
validateSearch: (search: Record<string, unknown>): ChatSearch => ({
|
||||
thread: typeof search.thread === "string" ? search.thread : undefined,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const EditRecipePage = lazy(() =>
|
|||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/data-recipes/$recipeId",
|
||||
staticData: { title: "Data Recipes" },
|
||||
beforeLoad: () => requireAuth(),
|
||||
component: DataRecipeEditorRoute,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const DataRecipesPage = lazy(() =>
|
|||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/data-recipes",
|
||||
staticData: { title: "Data Recipes" },
|
||||
beforeLoad: () => requireAuth(),
|
||||
component: DataRecipesPage,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const ExportPage = lazy(() =>
|
|||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/export",
|
||||
staticData: { title: "Export" },
|
||||
beforeLoad: () => requireAuth(),
|
||||
component: ExportPage,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const LoginPage = lazy(() =>
|
|||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/login",
|
||||
staticData: { title: "Login" },
|
||||
beforeLoad: () => requireGuest(),
|
||||
component: LoginPage,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const WizardLayout = lazy(() =>
|
|||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/onboarding",
|
||||
staticData: { title: "Onboarding" },
|
||||
beforeLoad: () => requireAuth(),
|
||||
validateSearch: (search: Record<string, unknown>): OnboardingSearch => ({
|
||||
redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@ import { requireAuth } from "../auth-guards";
|
|||
import { Route as rootRoute } from "./__root";
|
||||
|
||||
// /settings is a deep link to the modal. Open it, then redirect home.
|
||||
// Tab title is driven by useSettingsDialogStore in __root.tsx since the
|
||||
// redirect means /settings never stays matched; staticData is just a
|
||||
// safety net if beforeLoad ever stops throwing.
|
||||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/settings",
|
||||
staticData: { title: "Settings" },
|
||||
beforeLoad: async () => {
|
||||
await requireAuth();
|
||||
useSettingsDialogStore.getState().openDialog();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const StudioPage = lazy(() =>
|
|||
export const Route = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/studio",
|
||||
staticData: { title: "Train" },
|
||||
beforeLoad: () => requireAuth(),
|
||||
component: StudioPage,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue