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/components/sentryDocumentTitle.spec.tsx b/static/app/components/sentryDocumentTitle.spec.tsx deleted file mode 100644 index ec7b58422ad62d..00000000000000 --- a/static/app/components/sentryDocumentTitle.spec.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import {render} from 'sentry-test/reactTestingLibrary'; - -import SentryDocumentTitle from './sentryDocumentTitle'; - -describe('SentryDocumentTitle', () => { - it('sets the docuemnt title', () => { - render(); - expect(document.title).toBe('This is a test — Sentry'); - }); - - it('adds a organization slug', () => { - render(); - expect(document.title).toBe('This is a test — org — Sentry'); - }); - - it('adds a project slug', () => { - 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(); - expect(document.title).toBe('This is a test'); - }); - - it('reverts to the parent title', () => { - const {rerender} = render( - - Content - - ); - - expect(document.title).toBe('child title — Sentry'); - - rerender( - new Content - ); - - expect(document.title).toBe('This is a test — Sentry'); - }); -}); 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..62323d87f98eec --- /dev/null +++ b/static/app/components/sentryDocumentTitle/documentTitleManager.tsx @@ -0,0 +1,69 @@ +import React, {createContext, useContext, useEffect, useMemo, useState} from 'react'; + +const DEFAULT_PAGE_TITLE = 'Sentry'; +const SEPARATOR = ' — '; + +interface TitleEntry { + id: string; + noSuffix: boolean; + order: number; + text: string; +} + +interface DocumentTitleManager { + register: (id: string, text: string, order: number, 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 [manager] = useState(() => ({ + 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}]; + }); + }, + unregister: id => { + setEntries(prev => prev.filter(e => e.id !== id)); + }, + })); + + const fullTitle = useMemo(() => { + const entry = entries + .filter(e => e.text.trim() !== '') + .sort((a, b) => b.order - a.order) + .at(0); + + const parts = entry ? [entry.text] : []; + + if (!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.spec.tsx b/static/app/components/sentryDocumentTitle/index.spec.tsx new file mode 100644 index 00000000000000..2b553c8dfc2122 --- /dev/null +++ b/static/app/components/sentryDocumentTitle/index.spec.tsx @@ -0,0 +1,71 @@ +import {render} from 'sentry-test/reactTestingLibrary'; + +import {DocumentTitleManager} from './documentTitleManager'; +import SentryDocumentTitle from '.'; + +describe('SentryDocumentTitle', () => { + it('sets the document title', () => { + render( + + + + ); + expect(document.title).toBe('This is a test — Sentry'); + }); + + it('adds a organization slug', () => { + render( + + + + ); + expect(document.title).toBe('This is a test — org — Sentry'); + }); + + it('adds a project slug', () => { + 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( + + + + ); + expect(document.title).toBe('This is a test'); + }); + + it('reverts to the parent title', () => { + const {rerender} = render( + + + Content + + + ); + + expect(document.title).toBe('child title — Sentry'); + + rerender( + + new Content + + ); + + expect(document.title).toBe('This is a test — Sentry'); + }); +}); diff --git a/static/app/components/sentryDocumentTitle/index.tsx b/static/app/components/sentryDocumentTitle/index.tsx new file mode 100644 index 00000000000000..2debd106ff9e6e --- /dev/null +++ b/static/app/components/sentryDocumentTitle/index.tsx @@ -0,0 +1,69 @@ +import {useEffect, useId, useMemo, useState} 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(); + // compute order once on mount because effects run bottom-up + const [order] = useState(() => performance.now()); + + 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, order, !!noSuffix); + }, [titleManager, id, pageTitle, order, 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/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()} + + ); } 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( - - - + + + + + ); }