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(
-
-
-
+
+
+
+
+
);
}