Skip to content
Merged
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
32 changes: 32 additions & 0 deletions .changeset/8637-tab-title-one-writer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@object-ui/layout': patch
'@object-ui/console': patch
---

The console tab title no longer reverts to the bare product name after an in-app
navigation (objectui#8637).

Two effects wrote `document.title` on different keys. The console's `BrandingSync`
was keyed on `useLocation()` and assigned the bare product name on **every route
change**; `useAppShellBranding` assigns the composed `"App label — Product name"`
from an effect keyed on that string, so it fires when the title changes and not on
navigation. Both run on the commit that mounts the shell, and the composed title
wins — which is why the tab looked right and the defect stayed hidden. Navigating
between two pages of the same app moved `location` and not the composed title, so
only the route-keyed writer ran and the tab fell back to the bare product name.
Measured in a real browser, not inferred.

The repair is one writer rather than two careful ones. `useAppShellBranding` now
owns `document.title` for as long as a shell is mounted: it captures whatever the
tab already said, writes `title` over it, and puts the captured string back when the
shell unmounts or `title` changes. That is what let the console's route-keyed writer
drop its title assignment entirely — it had doubled as the reset that took the app
label back off the tab on the way out — and it is now `FaviconSync`, which syncs only
the favicon.

For hosts of `@object-ui/layout`: the forward assignment is unchanged, and a shell
with no `title` still leaves the tab untouched in both directions. What is new is the
restore, so a shell mounted over part of a route tree hands the title back on exit
instead of stranding it. The restore replays the captured string unconditionally, so
a surface that writes the tab title from **inside** a mounted shell has its value
overwritten on unmount; keep such surfaces outside the shell.
26 changes: 4 additions & 22 deletions apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
* with extra `<Route>` children.
*/

import { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from '@object-ui/auth';
import { DevMasterDetail } from './dev/DevMasterDetail';
import { DevLists } from './dev/DevLists';
Expand All @@ -36,12 +36,11 @@ import {
DefaultSettingsPage,
DefaultAcceptInvitationPage,
DefaultAiChatPage,
getProductName,
getFaviconUrl,
RedirectWithSplash,
} from '@object-ui/app-shell';

import { AppContent } from './AppContent';
import { FaviconSync } from './components/FaviconSync';
import { RootLandingRedirect } from './components/RootLandingRedirect';
import { ProtectedRoute } from './components/ProtectedRoute';
import { studioRoutes } from './components/StudioRoute';
Expand Down Expand Up @@ -128,23 +127,6 @@ function HomeRoute() {
);
}

/** Syncs document title + favicon with runtime branding on every route change. */
function BrandingSync() {
const location = useLocation();
useEffect(() => {
document.title = getProductName();
const faviconUrl = getFaviconUrl();
if (faviconUrl) {
const link = document.getElementById('favicon') as HTMLLinkElement | null;
if (link) {
link.href = faviconUrl;
link.type = faviconUrl.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
}
}
}, [location]);
return null;
}

export function App() {
return (
<AuthProvider authUrl={AUTH_URL}>
Expand All @@ -163,7 +145,7 @@ export function App() {
<ConsoleToaster />
<MetadataHmrReloader />
<BrowserRouter basename={BASENAME}>
<BrandingSync />
<FaviconSync />
<ConsoleShell>
<Routes>
{/*
Expand Down
178 changes: 178 additions & 0 deletions apps/console/src/__tests__/tabTitleAfterNavigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The tab title survives an in-app navigation (objectui#8637).
*
* ## The defect this pins shut
*
* Two independent effects wrote `document.title`, keyed on different inputs.
* `apps/console/src/App.tsx` rendered `BrandingSync` — a sibling mounted BEFORE
* the shell, inside `BrowserRouter` — whose effect was keyed on `useLocation()`
* and assigned the BARE product name on every route change. `AppShell`'s
* `useAppShellBranding` assigns the composed `"App label — Product name"` from
* an effect whose dependency list ends in that string, so it fires when the
* title changes and NOT on navigation.
*
* On the commit that first mounts the shell both fire, in tree order, and the
* composed title wins — which is why the tab looks right and the defect hides.
* Navigating between two pages of the SAME app changes `location` and not the
* composed title: only the route-keyed writer runs, and the tab falls back to
* the bare product name.
*
* ## Why this file exists at all
*
* From the card: "the reason this survived is that no test asserts what the tab
* title is after a navigation." Every earlier pin asserted a single write in
* isolation — `app-shell-branding-title-assignment.test.tsx` pins that
* `AppShell` assigns its `title` argument wholesale, and passes identically on
* the defect and on the fix, because it never navigates. So the pin has to
* render BOTH writers together and move the router, which is what the
* `navigate` step below does.
*
* ## The real subjects, not replicas
*
* `FaviconSync` is imported from the console module it actually ships in, and
* `AppShell` from `@object-ui/layout` — so re-adding a `document.title`
* assignment to either one turns this file red. A hand-written replica of the
* route-keyed effect would pin the replica instead, and the defect would walk
* straight back in through the real component.
*
* ⚠️ happy-dom is not where this behaviour was MEASURED — effect ordering
* against real navigation is not something a jsdom-style harness reproduces
* faithfully, and the card's PM note ruled reading the code insufficient too.
* The measurement is a real-Chromium before/after on objectui#8637's pull
* request, driving the same two components under a real `BrowserRouter`. This
* file is the cheap regression guard that runs in CI afterwards; the first test
* below is an environment control so a vacuous green is distinguishable from a
* real one.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { render, cleanup, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter, Routes, Route, Link } from 'react-router-dom';
import { AppShell } from '@object-ui/layout';
import { FaviconSync } from '../components/FaviconSync';

/** What `main.tsx` writes before React mounts: the bare product name. */
const PRODUCT = 'ObjectOS';
/** What `ConsoleLayout` composes and hands to `AppShell.branding.title`. */
const COMPOSED = 'Sales CRM — ObjectOS';

/**
* Navigation is driven by clicking a real `<Link>`, the way the console's own
* sidebar and breadcrumbs move between pages of an app — not by calling the
* router imperatively from outside a component.
*/
function goTo(label: string) {
fireEvent.click(screen.getByText(label));
}

/** Mirrors `App.tsx`: the route-keyed sync is a sibling rendered BEFORE the shell. */
function ConsoleTree({ inApp }: { inApp: boolean }) {
return (
<>
<FaviconSync />
<nav>
<Link to="/apps/crm/a">go to page a</Link>
<Link to="/apps/crm/b">go to page b</Link>
</nav>
{inApp ? (
<AppShell branding={{ title: COMPOSED }}>
<Routes>
<Route path="/apps/crm/a" element={<div>page a</div>} />
<Route path="/apps/crm/b" element={<div>page b</div>} />
</Routes>
</AppShell>
) : (
<div>no shell</div>
)}
</>
);
}

beforeEach(() => {
document.title = PRODUCT;
});

afterEach(() => {
cleanup();
});

describe('environment control', () => {
it('happy-dom lets `document.title` be written and read back', () => {
// Without this every assertion below could be vacuous in a DOM whose
// `title` setter is a no-op. Measured, not assumed.
document.title = 'probe';
expect(document.title).toBe('probe');
document.title = PRODUCT;
expect(document.title).toBe(PRODUCT);
});
});

describe('the tab title after an in-app navigation (objectui#8637)', () => {
it('mounting the shell puts the composed title up — the state the defect started from', () => {
render(
<MemoryRouter initialEntries={['/apps/crm/a']}>
<ConsoleTree inApp />
</MemoryRouter>,
);
expect(document.title).toBe(COMPOSED);
});

it('navigating to another page of the SAME app leaves the composed title alone', () => {
render(
<MemoryRouter initialEntries={['/apps/crm/a']}>
<ConsoleTree inApp />
</MemoryRouter>,
);
expect(document.title).toBe(COMPOSED);

goTo('go to page b');

expect(
document.title,
[
'The tab title reverted after an in-app navigation. A second writer keyed on the',
'ROUTE is assigning `document.title` again — that is objectui#8637. `AppShell`',
'(`useAppShellBranding`) owns the title while a shell is mounted; a route-keyed',
'writer beside it cannot know the app label, so it can only write the bare product',
'name over the specific one. Remove the assignment, do not try to order the two.',
].join('\n'),
).toBe(COMPOSED);
});

it('navigating a third time still leaves it alone — the effect is not merely deferred', () => {
render(
<MemoryRouter initialEntries={['/apps/crm/a']}>
<ConsoleTree inApp />
</MemoryRouter>,
);
goTo('go to page b');
goTo('go to page a');
expect(document.title).toBe(COMPOSED);
});
});

describe('leaving the app hands the tab back (objectui#8637)', () => {
it('unmounting the shell restores the title it found, without a route-keyed reset', () => {
// The route-keyed writer used to double as the reset that took the app
// label back off the tab when the shell went away. Deleting it without a
// replacement would strand `"Sales CRM — ObjectOS"` on `/home`; the
// replacement is `useAppShellBranding`'s own restore-on-unmount, and this
// is the assertion that keeps the two halves of the change together.
const view = render(
<MemoryRouter initialEntries={['/apps/crm/a']}>
<ConsoleTree inApp />
</MemoryRouter>,
);
expect(document.title).toBe(COMPOSED);

view.rerender(
<MemoryRouter initialEntries={['/apps/crm/a']}>
<ConsoleTree inApp={false} />
</MemoryRouter>,
);

expect(document.title).toBe(PRODUCT);
});
});
46 changes: 46 additions & 0 deletions apps/console/src/components/FaviconSync.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { getFaviconUrl } from '@object-ui/app-shell';

/**
* Re-applies the runtime-branded favicon on every route change.
*
* ⛔ **It deliberately does not write `document.title`** (objectui#8637). It
* used to — as `BrandingSync`, it assigned the BARE product name on every
* `useLocation()` change, while `AppShell`'s `useAppShellBranding` assigns the
* composed `"App label — Product name"` from an effect keyed on that string.
* Two writers on one global, keyed on different inputs: navigating between two
* pages of the same app changed `location` but not the composed title, so only
* this one fired and the tab reverted to the bare product name until something
* else changed the composed title. Measured in a real browser, not inferred —
* the reading is on objectui#8637's pull request.
*
* The repair is one writer, not two careful ones: `useAppShellBranding` owns
* `document.title` while a shell is mounted and restores the previous title
* when it unmounts, so leaving an app no longer needs a route-keyed reset here.
* ⛔ Do not re-add a title assignment to this component — that re-creates the
* race, and `apps/console/src/__tests__/tabTitleAfterNavigation.test.tsx` is
* the pin that goes red when it comes back.
*
* Boot-time title writers are a different lifecycle and are left alone: the
* inline script in `apps/console/index.html` and `main.tsx` both set the bare
* product name before React mounts, which is the correct title until a shell
* with an app label is on screen — and now the string `useAppShellBranding`
* captures and restores.
*/
export function FaviconSync() {
const location = useLocation();
useEffect(() => {
const faviconUrl = getFaviconUrl();
if (faviconUrl) {
const link = document.getElementById('favicon') as HTMLLinkElement | null;
if (link) {
link.href = faviconUrl;
link.type = faviconUrl.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
}
}
}, [location]);
return null;
}
25 changes: 24 additions & 1 deletion packages/layout/src/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,22 @@ function foregroundForHex(hex: string): string {
/**
* Apply branding CSS custom properties to the document root.
* This is extracted as a standalone hook so it can be re-used independently.
*
* It is also the ONE writer of `document.title` for as long as a shell is
* mounted (objectui#8637). Ownership is scoped, not permanent: the hook
* captures whatever the tab already said, writes `title` over it, and puts the
* captured string back when the shell unmounts or `title` changes. So a host
* that mounts a shell for part of its route tree gets the specific title while
* it is there and its previous title back when it leaves, without a second
* writer keyed on navigation — which is what the console used to do, and what
* reverted the composed title to the bare product name on every in-app
* navigation.
*
* ⚠️ The capture is a read of the live `document.title`, so a host that lets
* something else write the tab title WHILE a shell is mounted hands this hook a
* value it did not put there, and that value is what comes back on unmount.
* Nesting a second title-writing surface inside a mounted shell is the shape to
* avoid; the console's own auth surfaces sit outside the shell for this reason.
*/
export function useAppShellBranding(branding?: AppShellBranding, title?: string) {
useEffect(() => {
Expand Down Expand Up @@ -210,13 +226,20 @@ export function useAppShellBranding(branding?: AppShellBranding, title?: string)
}
}

// Page title
// Page title. `previousTitle` stays `null` when this hook writes nothing,
// so the no-`title` case restores nothing either — a shell without a title
// leaves the tab entirely alone in both directions.
let previousTitle: string | null = null;
if (title) {
previousTitle = document.title;
document.title = title;
}

return () => {
observer.disconnect();
if (previousTitle !== null) {
document.title = previousTitle;
}
root.style.removeProperty('--brand-primary');
root.style.removeProperty('--brand-primary-hsl');
root.style.removeProperty('--brand-accent');
Expand Down
Loading
Loading