diff --git a/knip.config.ts b/knip.config.ts index fc4a961574c343..83dbe4a353731e 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -9,6 +9,7 @@ const productionEntryPoints = [ // dynamic imports _not_ recognized by knip 'static/app/bootstrap/{index,initializeMain}.tsx', 'static/gsApp/initializeBundleMetrics.tsx', + 'static/gsApp/registerHooks.tsx', // defined in webpack.config pipelines 'static/app/utils/statics-setup.tsx', 'static/app/views/integrationPipeline/index.tsx', diff --git a/static/app/bootstrap/initializeApp.tsx b/static/app/bootstrap/initializeApp.tsx index 19206cb659d5c6..56e8357b3064f7 100644 --- a/static/app/bootstrap/initializeApp.tsx +++ b/static/app/bootstrap/initializeApp.tsx @@ -10,7 +10,10 @@ import {processInitQueue} from './processInitQueue'; import {renderMain} from './renderMain'; import {renderOnDomReady} from './renderOnDomReady'; -export function initializeApp(config: Config) { +export function initializeApp( + config: Config, + SentryHooksProvider?: React.ComponentType +) { initializeSdk(config); // Initialize the config store after the SDK, so we can log errors to Sentry during config initialization if needed. N.B. This mutates the config slightly commonInitialization(config); @@ -18,6 +21,6 @@ export function initializeApp(config: Config) { // Used for operational metrics to determine that the application js // bundle was loaded by browser. metric.mark({name: 'sentry-app-init'}); - renderOnDomReady(renderMain); + renderOnDomReady(renderMain(SentryHooksProvider)); processInitQueue(); } diff --git a/static/app/bootstrap/initializeMain.tsx b/static/app/bootstrap/initializeMain.tsx index 673744c3d87365..03eba76b093798 100644 --- a/static/app/bootstrap/initializeMain.tsx +++ b/static/app/bootstrap/initializeMain.tsx @@ -2,7 +2,10 @@ import type {Config} from 'sentry/types/system'; import {initializeLocale} from './initializeLocale'; -export async function initializeMain(config: Config) { +export async function initializeMain( + config: Config, + SentryHooksProvider?: React.ComponentType +) { // This needs to be loaded as early as possible, or else the locale library can // throw an exception and prevent the application from being loaded. // @@ -12,5 +15,5 @@ export async function initializeMain(config: Config) { // This is dynamically imported because we need to make sure locale is configured // before proceeding. const {initializeApp} = await import('./initializeApp'); - initializeApp(config); + initializeApp(config, SentryHooksProvider); } diff --git a/static/app/bootstrap/renderMain.tsx b/static/app/bootstrap/renderMain.tsx index c9d6202e0fbb9a..43f580945eb671 100644 --- a/static/app/bootstrap/renderMain.tsx +++ b/static/app/bootstrap/renderMain.tsx @@ -1,20 +1,26 @@ +import type * as React from 'react'; + import {ROOT_ELEMENT} from 'sentry/constants'; import Main from 'sentry/main'; import {renderDom} from './renderDom'; -export function renderMain() { - try { - renderDom(Main, `#${ROOT_ELEMENT}`, {}); - } catch (err) { - if (err.message === 'URI malformed') { - // eslint-disable-next-line no-console - console.error( - new Error( - 'An unencoded "%" has appeared, it is super effective! (See https://github.com/ReactTraining/history/issues/505)' - ) - ); - window.location.assign(window.location.pathname); +export function renderMain( + SentryHooksProvider?: React.ComponentType +) { + return () => { + try { + renderDom(Main, `#${ROOT_ELEMENT}`, {SentryHooksProvider}); + } catch (err) { + if (err.message === 'URI malformed') { + // eslint-disable-next-line no-console + console.error( + new Error( + 'An unencoded "%" has appeared, it is super effective! (See https://github.com/ReactTraining/history/issues/505)' + ) + ); + window.location.assign(window.location.pathname); + } } - } + }; } diff --git a/static/app/components/core/button/useButtonFunctionality.tsx b/static/app/components/core/button/useButtonFunctionality.tsx index 6de4b4079d9708..e72dab5000f3ef 100644 --- a/static/app/components/core/button/useButtonFunctionality.tsx +++ b/static/app/components/core/button/useButtonFunctionality.tsx @@ -13,16 +13,7 @@ export function useButtonFunctionality(props: ButtonProps | LinkButtonProps) { props['aria-label'] ?? (typeof props.children === 'string' ? props.children : undefined); - const buttonTracking = useButtonTracking({ - analyticsEventName: props.analyticsEventName, - analyticsEventKey: props.analyticsEventKey, - analyticsParams: { - priority: props.priority, - href: 'href' in props ? props.href : undefined, - ...props.analyticsParams, - }, - 'aria-label': accessibleLabel || '', - }); + const buttonTracking = useButtonTracking(); const handleClick = (e: React.MouseEvent) => { // Don't allow clicks when disabled or busy @@ -32,7 +23,16 @@ export function useButtonFunctionality(props: ButtonProps | LinkButtonProps) { return; } - buttonTracking(); + buttonTracking({ + analyticsEventName: props.analyticsEventName, + analyticsEventKey: props.analyticsEventKey, + analyticsParams: { + priority: props.priority, + href: 'href' in props ? props.href : undefined, + ...props.analyticsParams, + }, + 'aria-label': accessibleLabel || '', + }); // @ts-expect-error at this point, we don't know if the button is a button or a link props.onClick?.(e); }; diff --git a/static/app/components/core/trackingContext.tsx b/static/app/components/core/trackingContext.tsx index f6df3f6a1eef5c..b4be55b0ff120e 100644 --- a/static/app/components/core/trackingContext.tsx +++ b/static/app/components/core/trackingContext.tsx @@ -2,9 +2,9 @@ import {createContext, useContext} from 'react'; import type {DO_NOT_USE_ButtonProps as ButtonProps} from './button/types'; -const defaultButtonTracking = (props: ButtonProps) => { +const defaultButtonTracking = () => { const hasAnalyticsDebug = window.localStorage?.getItem('DEBUG_ANALYTICS') === '1'; - return () => { + return (props: ButtonProps) => { const hasCustomAnalytics = props.analyticsEventName || props.analyticsEventKey || props.analyticsParams; if (hasCustomAnalytics && hasAnalyticsDebug) { @@ -21,13 +21,13 @@ const defaultButtonTracking = (props: ButtonProps) => { }; const TrackingContext = createContext<{ - useButtonTracking?: (props: ButtonProps) => () => void; + buttonTracking?: (props: ButtonProps) => void; }>({}); export const TrackingContextProvider = TrackingContext.Provider; -export const useButtonTracking = (props: ButtonProps) => { +export const useButtonTracking = () => { const context = useContext(TrackingContext); - return context.useButtonTracking?.(props) ?? defaultButtonTracking(props); + return context.buttonTracking ?? defaultButtonTracking(); }; diff --git a/static/app/index.tsx b/static/app/index.tsx index 0c615addbd1f3c..b9c482c8c41c8b 100644 --- a/static/app/index.tsx +++ b/static/app/index.tsx @@ -91,11 +91,11 @@ async function app() { // getsentry augments Sentry's application through a 'hook' mechanism. Sentry // provides various hooks into parts of its application. Thus all getsentry // functionality is initialized by registering its hook functions. - const {default: registerHooks} = await registerHooksImport; + const {default: registerHooks, SentryHooksProvider} = await registerHooksImport; registerHooks(); const {initializeMain} = await initalizeMainImport; - initializeMain(config); + initializeMain(config, SentryHooksProvider); const {initializeBundleMetrics} = await initalizeBundleMetricsImport; initializeBundleMetrics(); diff --git a/static/app/main.tsx b/static/app/main.tsx index 9e927c7dccd496..61dfdae1710b1d 100644 --- a/static/app/main.tsx +++ b/static/app/main.tsx @@ -8,29 +8,30 @@ import {OnboardingContextProvider} from 'sentry/components/onboarding/onboarding import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider'; import {USE_REACT_QUERY_DEVTOOL} from 'sentry/constants'; import {routes} from 'sentry/routes'; -import {SentryTrackingProvider} from 'sentry/tracking'; import {DANGEROUS_SET_REACT_ROUTER_6_HISTORY} from 'sentry/utils/browserHistory'; import {buildReactRouter6Routes} from './utils/reactRouter6Compat/router'; -function buildRouter() { +function buildRouter(SentryHooksProvider?: React.ComponentType) { const sentryCreateBrowserRouter = wrapCreateBrowserRouterV6(createBrowserRouter); - const router = sentryCreateBrowserRouter(buildReactRouter6Routes(routes())); + const router = sentryCreateBrowserRouter( + buildReactRouter6Routes(routes(SentryHooksProvider)) + ); DANGEROUS_SET_REACT_ROUTER_6_HISTORY(router); return router; } -function Main() { - const [router] = useState(buildRouter); +function Main(props: { + SentryHooksProvider?: React.ComponentType; +}) { + const [router] = useState(() => buildRouter(props.SentryHooksProvider)); return ( - - - + {USE_REACT_QUERY_DEVTOOL && ( diff --git a/static/app/routes.tsx b/static/app/routes.tsx index 7ecc424b44b828..98d75aa2868453 100644 --- a/static/app/routes.tsx +++ b/static/app/routes.tsx @@ -42,7 +42,9 @@ import {makeLazyloadComponent as make} from './makeLazyloadComponent'; const hook = (name: HookName) => HookStore.get(name).map(cb => cb()); -function buildRoutes() { +function buildRoutes( + SentryHooksProvider: React.ComponentType = Fragment +) { // Read this to understand where to add new routes, how / why the routing // tree is structured the way it is, and how the lazy-loading / // code-splitting works for pages. @@ -2560,18 +2562,24 @@ function buildRoutes() { ); const appRoutes = ( - - - {experimentalSpaRoutes} - - {rootRoutes} - {authV2Routes} - {organizationRoutes} - {legacyRedirectRoutes} - - + { + return ( + + {children} + + ); + }} + > + {experimentalSpaRoutes} + + {rootRoutes} + {authV2Routes} + {organizationRoutes} + {legacyRedirectRoutes} + - + ); return appRoutes; diff --git a/static/app/tracking.tsx b/static/app/tracking.tsx deleted file mode 100644 index c0eb06c98f2da7..00000000000000 --- a/static/app/tracking.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import {useMemo} from 'react'; - -import {TrackingContextProvider} from 'sentry/components/core/trackingContext'; -import HookStore from 'sentry/stores/hookStore'; - -export function SentryTrackingProvider({children}: {children: React.ReactNode}) { - const useButtonTracking = HookStore.get('react-hook:use-button-tracking')[0]; - const trackingContextValue = useMemo(() => ({useButtonTracking}), [useButtonTracking]); - - return ( - - {children} - - ); -} diff --git a/static/app/types/hooks.tsx b/static/app/types/hooks.tsx index a0025d319890eb..0cc7e090423d48 100644 --- a/static/app/types/hooks.tsx +++ b/static/app/types/hooks.tsx @@ -1,6 +1,5 @@ import type {ChildrenRenderFn} from 'sentry/components/acl/feature'; import type {Guide} from 'sentry/components/assistant/types'; -import type {ButtonProps} from 'sentry/components/core/button'; import type {SelectKey} from 'sentry/components/core/compactSelect'; import type {FormPanelProps} from 'sentry/components/forms/formPanel'; import type {JsonFormObject} from 'sentry/components/forms/types'; @@ -315,7 +314,6 @@ type ReactHooks = { 'react-hook:route-activated': ( props: RouteContextInterface ) => React.ContextType; - 'react-hook:use-button-tracking': (props: ButtonProps) => () => void; 'react-hook:use-get-max-retention-days': () => number | undefined; }; diff --git a/static/gsApp/hooks/useButtonTracking.spec.tsx b/static/gsApp/hooks/useButtonTracking.spec.tsx index e91ab64debcccc..7ebf90808c2e69 100644 --- a/static/gsApp/hooks/useButtonTracking.spec.tsx +++ b/static/gsApp/hooks/useButtonTracking.spec.tsx @@ -45,11 +45,10 @@ describe('buttonTracking', function () { it('calls rawTrackAnalyticsEvent with default values', function () { const {result} = renderHook(useButtonTracking, { - initialProps: {'aria-label': 'Create Alert'}, wrapper, }); - result.current(); + result.current({'aria-label': 'Create Alert'}); expect(rawTrackAnalyticsEvent).toHaveBeenCalledWith({ eventName: null, @@ -63,16 +62,15 @@ describe('buttonTracking', function () { it('calls rawTrackAnalyticsEvent with data', function () { const {result} = renderHook(useButtonTracking, { - initialProps: { - 'aria-label': 'Create Alert', - analyticsEventKey: 'settings.create_alert', - analyticsEventName: 'Settings: Create Alert', - analyticsParams: {priority: 'primary', href: 'sentry.io/settings/create_alert'}, - }, wrapper, }); - result.current(); + result.current({ + 'aria-label': 'Create Alert', + analyticsEventKey: 'settings.create_alert', + analyticsEventName: 'Settings: Create Alert', + analyticsParams: {priority: 'primary', href: 'sentry.io/settings/create_alert'}, + }); expect(rawTrackAnalyticsEvent).toHaveBeenCalledWith({ eventName: 'Settings: Create Alert', @@ -88,15 +86,14 @@ describe('buttonTracking', function () { it('calls rawTrackAnalyticsEvent with new event names', function () { const {result} = renderHook(useButtonTracking, { - initialProps: { - 'aria-label': 'Create Alert', - analyticsEventKey: 'settings.create_alert', - analyticsEventName: 'Settings: Create Alert', - }, wrapper, }); - result.current(); + result.current({ + 'aria-label': 'Create Alert', + analyticsEventKey: 'settings.create_alert', + analyticsEventName: 'Settings: Create Alert', + }); expect(rawTrackAnalyticsEvent).toHaveBeenCalledWith({ eventName: 'Settings: Create Alert', diff --git a/static/gsApp/hooks/useButtonTracking.tsx b/static/gsApp/hooks/useButtonTracking.tsx index 5b6287410e94b9..1a5ad5b3b564e5 100644 --- a/static/gsApp/hooks/useButtonTracking.tsx +++ b/static/gsApp/hooks/useButtonTracking.tsx @@ -9,49 +9,45 @@ import {convertToReloadPath, getEventPath} from 'getsentry/utils/routeAnalytics' type Props = ButtonProps; -export default function useButtonTracking({ - analyticsEventName, - analyticsEventKey, - analyticsParams, - 'aria-label': ariaLabel, -}: Props) { +export default function useButtonTracking() { const organization = useOrganization({allowNull: true}); const routes = useRoutes(); - const trackButton = useCallback(() => { - const considerSendingAnalytics = organization && routes; - - if (considerSendingAnalytics) { - const routeString = getEventPath(routes); - const reloadPath = convertToReloadPath(routeString); - - // optional way to override the event name for Reload and Amplitude - // note null means something different than undefined for eventName so - // checking for that explicitly - const eventKey = - analyticsEventKey === undefined - ? `button_click.${reloadPath}` - : analyticsEventKey; - const eventName = analyticsEventName === undefined ? null : analyticsEventName; - - rawTrackAnalyticsEvent({ - eventKey, - eventName, - organization, - // pass in the parameterized path as well - parameterized_path: reloadPath, - text: ariaLabel, - ...analyticsParams, - }); - } - }, [ - analyticsEventKey, - analyticsEventName, - analyticsParams, - ariaLabel, - organization, - routes, - ]); + const trackButton = useCallback( + ({ + analyticsEventName, + analyticsEventKey, + analyticsParams, + 'aria-label': ariaLabel, + }: Props) => { + const considerSendingAnalytics = organization && routes; + + if (considerSendingAnalytics) { + const routeString = getEventPath(routes); + const reloadPath = convertToReloadPath(routeString); + + // optional way to override the event name for Reload and Amplitude + // note null means something different than undefined for eventName so + // checking for that explicitly + const eventKey = + analyticsEventKey === undefined + ? `button_click.${reloadPath}` + : analyticsEventKey; + const eventName = analyticsEventName === undefined ? null : analyticsEventName; + + rawTrackAnalyticsEvent({ + eventKey, + eventName, + organization, + // pass in the parameterized path as well + parameterized_path: reloadPath, + text: ariaLabel, + ...analyticsParams, + }); + } + }, + [organization, routes] + ); return trackButton; } diff --git a/static/gsApp/registerHooks.tsx b/static/gsApp/registerHooks.tsx index c21019650c33f5..0a279347c5ec9c 100644 --- a/static/gsApp/registerHooks.tsx +++ b/static/gsApp/registerHooks.tsx @@ -1,5 +1,6 @@ -import {lazy} from 'react'; +import {lazy, useMemo} from 'react'; +import {TrackingContextProvider} from 'sentry/components/core/trackingContext'; import LazyLoad from 'sentry/components/lazyLoad'; import {IconBusiness} from 'sentry/icons'; import HookStore from 'sentry/stores/hookStore'; @@ -239,7 +240,6 @@ const GETSENTRY_HOOKS: Partial = { 'component:superuser-warning-excluded': shouldExcludeOrg, 'component:crons-list-page-header': () => CronsBillingBanner, 'react-hook:route-activated': useRouteActivatedHook, - 'react-hook:use-button-tracking': useButtonTracking, 'react-hook:use-get-max-retention-days': useGetMaxRetentionDays, 'component:partnership-agreement': p => ( @@ -379,3 +379,14 @@ const registerHooks = () => entries(GETSENTRY_HOOKS).forEach(entry => HookStore.add(...entry)); export default registerHooks; + +export function SentryHooksProvider({children}: {children?: React.ReactNode}) { + const buttonTracking = useButtonTracking(); + const trackingContextValue = useMemo(() => ({buttonTracking}), [buttonTracking]); + + return ( + + {children} + + ); +}