From de362d0bf5751a56f66460459d8483e10e94e425 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 11 Nov 2025 14:12:03 +0100 Subject: [PATCH 1/6] feat(ui): DocumentTitleManager --- static/app/components/sentryDocumentTitle.tsx | 86 ------------------- .../documentTitleManager.tsx | 78 +++++++++++++++++ .../components/sentryDocumentTitle/index.tsx | 67 +++++++++++++++ static/app/main.tsx | 31 ++++--- static/gsAdmin/init.tsx | 9 +- 5 files changed, 168 insertions(+), 103 deletions(-) delete mode 100644 static/app/components/sentryDocumentTitle.tsx create mode 100644 static/app/components/sentryDocumentTitle/documentTitleManager.tsx create mode 100644 static/app/components/sentryDocumentTitle/index.tsx diff --git a/static/app/components/sentryDocumentTitle.tsx b/static/app/components/sentryDocumentTitle.tsx deleted file mode 100644 index 0a20e3da751156..00000000000000 --- a/static/app/components/sentryDocumentTitle.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import {createContext, useContext, useEffect, useMemo} from 'react'; - -type Props = { - children?: React.ReactNode; - /** - * Should the ` - Sentry` suffix be excluded? - */ - noSuffix?: boolean; - /** - * The organization slug to show in the title - */ - orgSlug?: string; - /** - * The project slug to show in the title. - */ - projectSlug?: string; - - /** - * This string will be shown at the very front of the title - */ - title?: string; -}; - -const DEFAULT_PAGE_TITLE = 'Sentry'; - -const DocumentTitleContext = createContext(DEFAULT_PAGE_TITLE); - -/** - * Assigns the document title. The deepest nested version of this title will be - * the one which is assigned. - */ -function SentryDocumentTitle({ - title = '', - orgSlug, - projectSlug, - noSuffix, - children, -}: Props) { - const parentTitle = useContext(DocumentTitleContext); - - const pageTitle = useMemo(() => { - if (orgSlug && projectSlug) { - return `${title} — ${orgSlug} — ${projectSlug}`; - } - - if (orgSlug) { - return `${title} — ${orgSlug}`; - } - - if (projectSlug) { - return `${title} — ${projectSlug}`; - } - - return title; - }, [orgSlug, projectSlug, title]); - - const documentTitle = useMemo(() => { - if (noSuffix) { - return pageTitle; - } - - if (pageTitle !== '') { - return `${pageTitle} — Sentry`; - } - - return DEFAULT_PAGE_TITLE; - }, [noSuffix, pageTitle]); - - // NOTE: We do this OUTSIDE of a use effect so that the update order is - // correct, otherwsie the inner most SentryDocumentTitle will have its - // useEffect called first followed by the parents, which will cause the wrong - // title be set. - if (document.title !== documentTitle) { - document.title = documentTitle; - } - - useEffect(() => { - return () => { - document.title = parentTitle; - }; - }, [parentTitle]); - - return {children}; -} - -export default SentryDocumentTitle; diff --git a/static/app/components/sentryDocumentTitle/documentTitleManager.tsx b/static/app/components/sentryDocumentTitle/documentTitleManager.tsx new file mode 100644 index 00000000000000..9e8ee3c24aa5a7 --- /dev/null +++ b/static/app/components/sentryDocumentTitle/documentTitleManager.tsx @@ -0,0 +1,78 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +const DEFAULT_PAGE_TITLE = 'Sentry'; +const SEPARATOR = ' — '; + +interface TitleEntry { + id: string; + noSuffix: boolean; + // mount order for stable sorting + order: number; + text: string; +} + +interface DocumentTitleManager { + register: (id: string, text: string, noSuffix: boolean) => void; + unregister: (id: string) => void; +} + +const DocumentTitleContext = createContext({ + register: () => {}, + unregister: () => {}, +}); + +export const useDocumentTitleManager = () => useContext(DocumentTitleContext); + +export function DocumentTitleManager({children}: React.PropsWithChildren) { + const [entries, setEntries] = useState([]); + const orderCounter = useRef(0); + + const [manager] = useState(() => ({ + register: (id, text, noSuffix) => { + setEntries(prev => { + // update for same id + if (prev.some(e => e.id === id)) { + return prev.map(e => (e.id === id ? {...e, text, noSuffix} : e)); + } + return [...prev, {id, text, noSuffix, order: orderCounter.current++}]; + }); + }, + unregister: id => { + setEntries(prev => prev.filter(e => e.id !== id)); + }, + })); + + const fullTitle = useMemo(() => { + const parts = entries + .filter(e => e.text.trim() !== '') + .sort((a, b) => a.order - b.order) + .map(e => e.text) + // effects run bottom-up so registration order needs to be reversed + .reverse(); + + if (parts.length === 0 || !entries.some(entry => !entry.noSuffix)) { + parts.push(DEFAULT_PAGE_TITLE); + } + return [...new Set([...parts])].join(SEPARATOR); + }, [entries]); + + // write to the DOM title + useEffect(() => { + if (fullTitle.length > 0) { + document.title = fullTitle; + } + }, [fullTitle]); + + return ( + + {children} + + ); +} diff --git a/static/app/components/sentryDocumentTitle/index.tsx b/static/app/components/sentryDocumentTitle/index.tsx new file mode 100644 index 00000000000000..27af2cf5a6f57b --- /dev/null +++ b/static/app/components/sentryDocumentTitle/index.tsx @@ -0,0 +1,67 @@ +import {useEffect, useId, useMemo} from 'react'; + +import {useDocumentTitleManager} from './documentTitleManager'; + +type Props = { + children?: React.ReactNode; + /** + * Should the ` - Sentry` suffix be excluded? + */ + noSuffix?: boolean; + /** + * The organization slug to show in the title + */ + orgSlug?: string; + /** + * The project slug to show in the title. + */ + projectSlug?: string; + + /** + * This string will be shown at the very front of the title + */ + title?: string; +}; + +function SentryDocumentTitle({ + title = '', + orgSlug, + projectSlug, + noSuffix, + children, +}: Props) { + const titleManager = useDocumentTitleManager(); + const id = useId(); + + const pageTitle = useMemo(() => { + if (orgSlug && projectSlug) { + return `${title} — ${orgSlug} — ${projectSlug}`; + } + + if (orgSlug) { + return `${title} — ${orgSlug}`; + } + + if (projectSlug) { + return `${title} — ${projectSlug}`; + } + + return title; + }, [orgSlug, projectSlug, title]); + + // create or update title entry + useEffect(() => { + titleManager.register(id, pageTitle, !!noSuffix); + }, [titleManager, id, pageTitle, noSuffix]); + + // cleanup on unmount + useEffect(() => { + return () => { + titleManager.unregister(id); + }; + }, [titleManager, id]); + + return children; +} + +export default SentryDocumentTitle; diff --git a/static/app/main.tsx b/static/app/main.tsx index 913300c92e5174..39a1805b545a0b 100644 --- a/static/app/main.tsx +++ b/static/app/main.tsx @@ -7,6 +7,7 @@ import {NuqsAdapter} from 'nuqs/adapters/react-router/v6'; import {AppQueryClientProvider} from 'sentry/appQueryClient'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; import {FrontendVersionProvider} from 'sentry/components/frontendVersionContext'; +import {DocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager'; import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider'; import {SENTRY_RELEASE_VERSION, USE_REACT_QUERY_DEVTOOL} from 'sentry/constants'; import {RouteConfigProvider} from 'sentry/router/routeConfigContext'; @@ -26,20 +27,22 @@ function Main() { return ( - - - - - - - - - - {USE_REACT_QUERY_DEVTOOL && ( - - )} - - + + + + + + + + + + + {USE_REACT_QUERY_DEVTOOL && ( + + )} + + + ); } diff --git a/static/gsAdmin/init.tsx b/static/gsAdmin/init.tsx index 51eb032a7fe506..d0e3c6fd30e09f 100644 --- a/static/gsAdmin/init.tsx +++ b/static/gsAdmin/init.tsx @@ -6,6 +6,7 @@ import {NuqsAdapter} from 'nuqs/adapters/react-router/v6'; import {commonInitialization} from 'sentry/bootstrap/commonInitialization'; import {initializeSdk} from 'sentry/bootstrap/initializeSdk'; +import {DocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager'; import ConfigStore from 'sentry/stores/configStore'; import type {Config} from 'sentry/types/system'; import {DANGEROUS_SET_REACT_ROUTER_6_HISTORY} from 'sentry/utils/browserHistory'; @@ -38,9 +39,11 @@ export function renderApp() { const root = createRoot(rootEl); root.render( - - - + + + + + ); } From a0ba90220f58f799734dd50e8dc051a30eee430d Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 11 Nov 2025 14:13:23 +0100 Subject: [PATCH 2/6] ref: move tests --- .../index.spec.tsx} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename static/app/components/{sentryDocumentTitle.spec.tsx => sentryDocumentTitle/index.spec.tsx} (94%) diff --git a/static/app/components/sentryDocumentTitle.spec.tsx b/static/app/components/sentryDocumentTitle/index.spec.tsx similarity index 94% rename from static/app/components/sentryDocumentTitle.spec.tsx rename to static/app/components/sentryDocumentTitle/index.spec.tsx index ec7b58422ad62d..ccc9c2c890ab34 100644 --- a/static/app/components/sentryDocumentTitle.spec.tsx +++ b/static/app/components/sentryDocumentTitle/index.spec.tsx @@ -1,9 +1,9 @@ import {render} from 'sentry-test/reactTestingLibrary'; -import SentryDocumentTitle from './sentryDocumentTitle'; +import SentryDocumentTitle from '.'; describe('SentryDocumentTitle', () => { - it('sets the docuemnt title', () => { + it('sets the document title', () => { render(); expect(document.title).toBe('This is a test — Sentry'); }); From a1248a9bec63b6e0d871bd150056b32c4f048851 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 11 Nov 2025 14:22:46 +0100 Subject: [PATCH 3/6] tests --- .../documentTitleManager.tsx | 2 +- .../sentryDocumentTitle/index.spec.tsx | 43 ++++++++++++++----- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/static/app/components/sentryDocumentTitle/documentTitleManager.tsx b/static/app/components/sentryDocumentTitle/documentTitleManager.tsx index 9e8ee3c24aa5a7..f1f2ad72fdc3de 100644 --- a/static/app/components/sentryDocumentTitle/documentTitleManager.tsx +++ b/static/app/components/sentryDocumentTitle/documentTitleManager.tsx @@ -57,7 +57,7 @@ export function DocumentTitleManager({children}: React.PropsWithChildren) { // effects run bottom-up so registration order needs to be reversed .reverse(); - if (parts.length === 0 || !entries.some(entry => !entry.noSuffix)) { + if (parts.length === 0 || entries.every(entry => !entry.noSuffix)) { parts.push(DEFAULT_PAGE_TITLE); } return [...new Set([...parts])].join(SEPARATOR); diff --git a/static/app/components/sentryDocumentTitle/index.spec.tsx b/static/app/components/sentryDocumentTitle/index.spec.tsx index ccc9c2c890ab34..a74c3e55068f99 100644 --- a/static/app/components/sentryDocumentTitle/index.spec.tsx +++ b/static/app/components/sentryDocumentTitle/index.spec.tsx @@ -1,46 +1,69 @@ import {render} from 'sentry-test/reactTestingLibrary'; +import {DocumentTitleManager} from './documentTitleManager'; import SentryDocumentTitle from '.'; describe('SentryDocumentTitle', () => { it('sets the document title', () => { - render(); + render( + + + + ); expect(document.title).toBe('This is a test — Sentry'); }); it('adds a organization slug', () => { - render(); + render( + + + + ); expect(document.title).toBe('This is a test — org — Sentry'); }); it('adds a project slug', () => { - render(); + render( + + + + ); expect(document.title).toBe('This is a test — project — Sentry'); }); it('adds a organization and project slug', () => { render( - + + + ); expect(document.title).toBe('This is a test — org — project — Sentry'); }); it('sets the title without suffix', () => { - render(); + render( + + + + ); expect(document.title).toBe('This is a test'); }); it('reverts to the parent title', () => { const {rerender} = render( - - Content - + + + Content + + ); - expect(document.title).toBe('child title — Sentry'); + expect(document.title).toBe('This is a test — child title — Sentry'); rerender( - new Content + + new Content + ); expect(document.title).toBe('This is a test — Sentry'); From 00c56017f3a5cd55739a0edc832536ccf0e9a871 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 11 Nov 2025 15:03:34 +0100 Subject: [PATCH 4/6] fix: switch to calculating order when mounting the SentryDocumentTitle component --- .../documentTitleManager.tsx | 29 +++++++------------ .../components/sentryDocumentTitle/index.tsx | 8 +++-- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/static/app/components/sentryDocumentTitle/documentTitleManager.tsx b/static/app/components/sentryDocumentTitle/documentTitleManager.tsx index f1f2ad72fdc3de..62323d87f98eec 100644 --- a/static/app/components/sentryDocumentTitle/documentTitleManager.tsx +++ b/static/app/components/sentryDocumentTitle/documentTitleManager.tsx @@ -1,11 +1,4 @@ -import React, { - createContext, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, {createContext, useContext, useEffect, useMemo, useState} from 'react'; const DEFAULT_PAGE_TITLE = 'Sentry'; const SEPARATOR = ' — '; @@ -13,13 +6,12 @@ const SEPARATOR = ' — '; interface TitleEntry { id: string; noSuffix: boolean; - // mount order for stable sorting order: number; text: string; } interface DocumentTitleManager { - register: (id: string, text: string, noSuffix: boolean) => void; + register: (id: string, text: string, order: number, noSuffix: boolean) => void; unregister: (id: string) => void; } @@ -32,16 +24,15 @@ export const useDocumentTitleManager = () => useContext(DocumentTitleContext); export function DocumentTitleManager({children}: React.PropsWithChildren) { const [entries, setEntries] = useState([]); - const orderCounter = useRef(0); const [manager] = useState(() => ({ - register: (id, text, noSuffix) => { + register: (id, text, order, noSuffix) => { setEntries(prev => { // update for same id if (prev.some(e => e.id === id)) { return prev.map(e => (e.id === id ? {...e, text, noSuffix} : e)); } - return [...prev, {id, text, noSuffix, order: orderCounter.current++}]; + return [...prev, {id, text, noSuffix, order}]; }); }, unregister: id => { @@ -50,14 +41,14 @@ export function DocumentTitleManager({children}: React.PropsWithChildren) { })); const fullTitle = useMemo(() => { - const parts = entries + const entry = entries .filter(e => e.text.trim() !== '') - .sort((a, b) => a.order - b.order) - .map(e => e.text) - // effects run bottom-up so registration order needs to be reversed - .reverse(); + .sort((a, b) => b.order - a.order) + .at(0); - if (parts.length === 0 || entries.every(entry => !entry.noSuffix)) { + const parts = entry ? [entry.text] : []; + + if (!entry?.noSuffix) { parts.push(DEFAULT_PAGE_TITLE); } return [...new Set([...parts])].join(SEPARATOR); diff --git a/static/app/components/sentryDocumentTitle/index.tsx b/static/app/components/sentryDocumentTitle/index.tsx index 27af2cf5a6f57b..2debd106ff9e6e 100644 --- a/static/app/components/sentryDocumentTitle/index.tsx +++ b/static/app/components/sentryDocumentTitle/index.tsx @@ -1,4 +1,4 @@ -import {useEffect, useId, useMemo} from 'react'; +import {useEffect, useId, useMemo, useState} from 'react'; import {useDocumentTitleManager} from './documentTitleManager'; @@ -32,6 +32,8 @@ function SentryDocumentTitle({ }: Props) { const titleManager = useDocumentTitleManager(); const id = useId(); + // compute order once on mount because effects run bottom-up + const [order] = useState(() => performance.now()); const pageTitle = useMemo(() => { if (orgSlug && projectSlug) { @@ -51,8 +53,8 @@ function SentryDocumentTitle({ // create or update title entry useEffect(() => { - titleManager.register(id, pageTitle, !!noSuffix); - }, [titleManager, id, pageTitle, noSuffix]); + titleManager.register(id, pageTitle, order, !!noSuffix); + }, [titleManager, id, pageTitle, order, noSuffix]); // cleanup on unmount useEffect(() => { From c7449658888a6c03c0a385935b1fecb42e3bf103 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 11 Nov 2025 15:04:10 +0100 Subject: [PATCH 5/6] revert the test changes --- static/app/components/sentryDocumentTitle/index.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/sentryDocumentTitle/index.spec.tsx b/static/app/components/sentryDocumentTitle/index.spec.tsx index a74c3e55068f99..2b553c8dfc2122 100644 --- a/static/app/components/sentryDocumentTitle/index.spec.tsx +++ b/static/app/components/sentryDocumentTitle/index.spec.tsx @@ -58,7 +58,7 @@ describe('SentryDocumentTitle', () => { ); - expect(document.title).toBe('This is a test — child title — Sentry'); + expect(document.title).toBe('child title — Sentry'); rerender( From 2e110f0369698129cae0a2455e523307c008b502 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Wed, 12 Nov 2025 11:22:19 +0100 Subject: [PATCH 6/6] fix: add DocumentTitleManager to processInitQueue and pipelineView --- static/app/bootstrap/processInitQueue.tsx | 25 +++++++++++-------- .../integrationPipeline/pipelineView.tsx | 11 +++++--- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/static/app/bootstrap/processInitQueue.tsx b/static/app/bootstrap/processInitQueue.tsx index c27ad6ff5197b8..13db2dd226fe4b 100644 --- a/static/app/bootstrap/processInitQueue.tsx +++ b/static/app/bootstrap/processInitQueue.tsx @@ -5,6 +5,7 @@ import throttle from 'lodash/throttle'; import {exportedGlobals} from 'sentry/bootstrap/exportGlobals'; import {CommandPaletteProvider} from 'sentry/components/commandPalette/context'; +import {DocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager'; import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider'; import {ScrapsProviders} from 'sentry/scrapsProviders'; import type {OnSentryInitConfiguration} from 'sentry/types/system'; @@ -111,17 +112,19 @@ async function processItem(initConfig: OnSentryInitConfiguration) { * and so we dont know which theme to pick. */ - - - - - - } - /> - - + + + + + + + } + /> + + + ), initConfig.container, diff --git a/static/app/views/integrationPipeline/pipelineView.tsx b/static/app/views/integrationPipeline/pipelineView.tsx index db3a228abaae22..032e08899bea3d 100644 --- a/static/app/views/integrationPipeline/pipelineView.tsx +++ b/static/app/views/integrationPipeline/pipelineView.tsx @@ -4,6 +4,7 @@ import {wrapCreateBrowserRouterV6} from '@sentry/react'; import {fetchOrganizations} from 'sentry/actionCreators/organizations'; import Indicators from 'sentry/components/indicators'; +import {DocumentTitleManager} from 'sentry/components/sentryDocumentTitle/documentTitleManager'; import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider'; import {ScrapsProviders} from 'sentry/scrapsProviders'; import ConfigStore from 'sentry/stores/configStore'; @@ -126,10 +127,12 @@ function PipelineView({pipelineName, ...props}: Props) { return ( - - - {renderOrganizationContextProvider()} - + + + + {renderOrganizationContextProvider()} + + ); }