From 7945cefcdb2fb283e268b3a33f10345fe956dfde Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 18:18:41 -0300 Subject: [PATCH 01/19] feat: send navbar clicks made before analytics loads through the beacon --- .../LandingNavbar/LandingNavbar.spec.tsx | 81 +++++++++++++++++-- .../LandingNavbar/LandingNavbar.tsx | 24 +++++- 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.spec.tsx b/src/components/LandingNavbar/LandingNavbar.spec.tsx index 2ccdbffb..d3ad9901 100644 --- a/src/components/LandingNavbar/LandingNavbar.spec.tsx +++ b/src/components/LandingNavbar/LandingNavbar.spec.tsx @@ -17,7 +17,10 @@ jest.mock('decentraland-ui2', () => ({ })) jest.mock('decentraland-ui2/dist/components/Notifications/utils', () => ({ NotificationComponentByType: {} })) -jest.mock('@dcl/hooks', () => ({ useAnalytics: jest.fn() })) +jest.mock('@dcl/hooks', () => ({ useAnalytics: jest.fn(), isBotClient: jest.fn() })) +jest.mock('../../modules/analyticsSessionGate', () => ({ isAnalyticsDisabledForSession: jest.fn() })) +jest.mock('../../modules/segmentBeacon', () => ({ postSegmentEvent: jest.fn() })) +jest.mock('../../modules/segmentAnonymousId', () => ({ ensureSegmentAnonymousId: jest.fn() })) jest.mock('../../intl/LocaleContext', () => ({ useLocale: jest.fn() })) jest.mock('../../hooks/adapters/useFormatMessage', () => ({ useFormatMessage: jest.fn() @@ -529,18 +532,86 @@ describe('when the visitor clicks a navbar link', () => { }) describe('and analytics has not finished loading', () => { - beforeEach(async () => { - ;(jest.requireMock('@dcl/hooks').useAnalytics as jest.Mock).mockReturnValue({ isInitialized: false, track }) + let postSegmentEvent: jest.Mock + let isBotClient: jest.Mock + let isAnalyticsDisabledForSession: jest.Mock + + const clickCreatorDocumentation = async () => { renderAt('/events') const createTab = desktopSection(/navbar\.create$/i) await user.hover(createTab.parentElement!) fireEvent.click( preventNavigation(within(createTab.parentElement!).getByRole('link', { name: /creator_documentation/i, hidden: true })) ) + } + + beforeEach(() => { + postSegmentEvent = jest.requireMock('../../modules/segmentBeacon').postSegmentEvent + isBotClient = jest.requireMock('@dcl/hooks').isBotClient + isAnalyticsDisabledForSession = jest.requireMock('../../modules/analyticsSessionGate').isAnalyticsDisabledForSession + ;(jest.requireMock('../../modules/segmentAnonymousId').ensureSegmentAnonymousId as jest.Mock).mockReturnValue('anon-1') + ;(jest.requireMock('@dcl/hooks').useAnalytics as jest.Mock).mockReturnValue({ isInitialized: false, track }) + isBotClient.mockReturnValue(false) + isAnalyticsDisabledForSession.mockReturnValue(false) }) - it('should not send anything', () => { - expect(track).not.toHaveBeenCalled() + describe('and the visitor is a person in a session with analytics on', () => { + beforeEach(async () => { + await clickCreatorDocumentation() + }) + + it('should send the click through the beacon, which survives the page unloading', () => { + expect(track).not.toHaveBeenCalled() + expect(postSegmentEvent).toHaveBeenCalledTimes(1) + expect(postSegmentEvent).toHaveBeenCalledWith( + 'Click', + { + place: 'Landing Navbar', + event: 'click', + action: 'creator_documentation', + section: 'create', + href: 'https://docs.decentraland.org/creator' + }, + 'anon-1' + ) + }) + }) + + describe('and the session started on an exempt page', () => { + beforeEach(async () => { + isAnalyticsDisabledForSession.mockReturnValue(true) + await clickCreatorDocumentation() + }) + + it('should not send anything', () => { + expect(track).not.toHaveBeenCalled() + expect(postSegmentEvent).not.toHaveBeenCalled() + }) + }) + + describe('and the visitor is a bot', () => { + beforeEach(async () => { + isBotClient.mockReturnValue(true) + await clickCreatorDocumentation() + }) + + it('should not send anything', () => { + expect(track).not.toHaveBeenCalled() + expect(postSegmentEvent).not.toHaveBeenCalled() + }) + }) + }) + + describe('and analytics is ready', () => { + beforeEach(() => { + renderAt('/events') + const mobileMenu = screen.getByRole('navigation', { name: 'Mobile navigation' }) + fireEvent.click(preventNavigation(within(mobileMenu).getByRole('link', { name: /navbar\.learn/i, hidden: true }))) + }) + + it('should send the click through analytics and not the beacon', () => { + expect(track).toHaveBeenCalledTimes(1) + expect(jest.requireMock('../../modules/segmentBeacon').postSegmentEvent).not.toHaveBeenCalled() }) }) diff --git a/src/components/LandingNavbar/LandingNavbar.tsx b/src/components/LandingNavbar/LandingNavbar.tsx index 4ae26be7..6544bc3d 100644 --- a/src/components/LandingNavbar/LandingNavbar.tsx +++ b/src/components/LandingNavbar/LandingNavbar.tsx @@ -1,10 +1,13 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' -import { useAnalytics } from '@dcl/hooks' +import { isBotClient, useAnalytics } from '@dcl/hooks' import { useFormatMessage } from '../../hooks/adapters/useFormatMessage' import { MIN_DISPLAY_BALANCE } from '../../hooks/useManaBalances' import { useLocale } from '../../intl/LocaleContext' +import { isAnalyticsDisabledForSession } from '../../modules/analyticsSessionGate' import { SectionViewedTrack, SegmentEvent } from '../../modules/segment' +import { ensureSegmentAnonymousId } from '../../modules/segmentAnonymousId' +import { postSegmentEvent } from '../../modules/segmentBeacon' import { assetUrl } from '../../utils/assetUrl' import { getAvatarBackgroundColor, getDisplayName } from '../../utils/avatarColor' // Module-level cache for notification type→component map from ui2. @@ -255,12 +258,25 @@ const LandingNavbar = memo(function LandingNavbar({ const { isInitialized, track } = useAnalytics() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + // Read through a ref (as useDownloadClick does) so a click sees Segment's current readiness even if it + // finished loading since the last render. + const isInitializedRef = useRef(isInitialized) + isInitializedRef.current = isInitialized + const trackNavbar = useCallback( (action: string, link?: { section: string; href: string }) => { - if (!isInitialized) return - track(SegmentEvent.CLICK, { place: SectionViewedTrack.LANDING_NAVBAR, event: 'click', action, ...link }) + const properties = { place: SectionViewedTrack.LANDING_NAVBAR, event: 'click', action, ...link } + if (isInitializedRef.current) { + track(SegmentEvent.CLICK, properties) + return + } + // Analytics loads once the page is idle and is ready only after Segment fetched its settings. Most navbar + // links leave the page, so a click before that would be lost with it; the beacon survives the unload. It + // follows the same rules that keep the SDK off: sessions that started on an exempt page, and bots. + if (isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent)) return + postSegmentEvent(SegmentEvent.CLICK, properties, ensureSegmentAnonymousId()) }, - [isInitialized, track] + [track] ) const trackNavbarLink = useCallback( From 6bf77844da560bb084d873c5ecddb9bfc3f19159 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 18:32:20 -0300 Subject: [PATCH 02/19] fix: queue navbar clicks that keep the page instead of beaconing them --- .../LandingNavbar.helpers.spec.ts | 29 ++++++++- .../LandingNavbar/LandingNavbar.helpers.ts | 11 +++- .../LandingNavbar/LandingNavbar.spec.tsx | 61 ++++++++++++++----- .../LandingNavbar/LandingNavbar.tsx | 29 ++++++--- 4 files changed, 102 insertions(+), 28 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts b/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts index b854fb76..ada01b75 100644 --- a/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts +++ b/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts @@ -1,4 +1,4 @@ -import { isSectionActive, toNavbarAction, toNotificationLocale } from './LandingNavbar.helpers' +import { isSameTabNavigation, isSectionActive, toNavbarAction, toNotificationLocale } from './LandingNavbar.helpers' describe('when deciding which navbar section owns the current page', () => { it('should light up Discover on the What is On calendar', () => { @@ -67,3 +67,30 @@ describe('when naming the analytics action for a navbar link', () => { expect(toNavbarAction('learn')).toBe('learn') }) }) + +describe('when deciding whether a navbar click replaces the current page', () => { + const linkWith = (target?: string) => { + const link = document.createElement('a') + if (target) link.setAttribute('target', target) + return link + } + const click = (currentTarget: EventTarget, keys: Partial> = {}) => ({ + currentTarget, + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...keys + }) + + it('should treat a plain click on a same-tab link as leaving the page', () => { + expect(isSameTabNavigation(click(linkWith()))).toBe(true) + }) + + it('should keep the page for a link that opens in a new tab', () => { + expect(isSameTabNavigation(click(linkWith('_blank')))).toBe(false) + }) + + it.each(['ctrlKey', 'metaKey', 'shiftKey'] as const)('should keep the page when %s opens the link elsewhere', key => { + expect(isSameTabNavigation(click(linkWith(), { [key]: true }))).toBe(false) + }) +}) diff --git a/src/components/LandingNavbar/LandingNavbar.helpers.ts b/src/components/LandingNavbar/LandingNavbar.helpers.ts index f9116d73..a13b88da 100644 --- a/src/components/LandingNavbar/LandingNavbar.helpers.ts +++ b/src/components/LandingNavbar/LandingNavbar.helpers.ts @@ -49,4 +49,13 @@ function toNavbarAction(labelKey: string): string { return labelKey.slice(labelKey.lastIndexOf('.') + 1) } -export { isSectionActive, toNavbarAction, toNotificationLocale } +/** + * Whether a primary click on a navbar link replaces the current page. A link that opens in a new tab + * (`target="_blank"`, or ctrl/cmd/shift held) leaves this page alive, so its click can wait for analytics. + */ +function isSameTabNavigation(event: { currentTarget: EventTarget; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }): boolean { + const target = event.currentTarget instanceof Element ? event.currentTarget.getAttribute('target') : null + return target !== '_blank' && !event.ctrlKey && !event.metaKey && !event.shiftKey +} + +export { isSameTabNavigation, isSectionActive, toNavbarAction, toNotificationLocale } diff --git a/src/components/LandingNavbar/LandingNavbar.spec.tsx b/src/components/LandingNavbar/LandingNavbar.spec.tsx index d3ad9901..c6f908f5 100644 --- a/src/components/LandingNavbar/LandingNavbar.spec.tsx +++ b/src/components/LandingNavbar/LandingNavbar.spec.tsx @@ -536,13 +536,10 @@ describe('when the visitor clicks a navbar link', () => { let isBotClient: jest.Mock let isAnalyticsDisabledForSession: jest.Mock - const clickCreatorDocumentation = async () => { + const clickLearn = () => { renderAt('/events') - const createTab = desktopSection(/navbar\.create$/i) - await user.hover(createTab.parentElement!) - fireEvent.click( - preventNavigation(within(createTab.parentElement!).getByRole('link', { name: /creator_documentation/i, hidden: true })) - ) + const mobileMenu = screen.getByRole('navigation', { name: 'Mobile navigation' }) + fireEvent.click(preventNavigation(within(mobileMenu).getByRole('link', { name: /navbar\.learn/i, hidden: true }))) } beforeEach(() => { @@ -557,7 +554,7 @@ describe('when the visitor clicks a navbar link', () => { describe('and the visitor is a person in a session with analytics on', () => { beforeEach(async () => { - await clickCreatorDocumentation() + clickLearn() }) it('should send the click through the beacon, which survives the page unloading', () => { @@ -565,22 +562,54 @@ describe('when the visitor clicks a navbar link', () => { expect(postSegmentEvent).toHaveBeenCalledTimes(1) expect(postSegmentEvent).toHaveBeenCalledWith( 'Click', - { - place: 'Landing Navbar', - event: 'click', - action: 'creator_documentation', - section: 'create', - href: 'https://docs.decentraland.org/creator' - }, + { place: 'Landing Navbar', event: 'click', action: 'learn', section: 'learn', href: 'https://decentraland.org/blog/' }, 'anon-1' ) }) }) + describe('and the link opens in a new tab', () => { + let rerender: ReturnType['rerender'] + + beforeEach(async () => { + ;({ rerender } = renderAt('/events')) + const createTab = desktopSection(/navbar\.create$/i) + await user.hover(createTab.parentElement!) + fireEvent.click( + preventNavigation(within(createTab.parentElement!).getByRole('link', { name: /creator_documentation/i, hidden: true })) + ) + }) + + it('should not use the beacon, since the page stays open', () => { + expect(postSegmentEvent).not.toHaveBeenCalled() + expect(track).not.toHaveBeenCalled() + }) + + describe('and analytics finishes loading', () => { + beforeEach(() => { + ;(jest.requireMock('@dcl/hooks').useAnalytics as jest.Mock).mockReturnValue({ isInitialized: true, track }) + // A new prop, since the memoized navbar would otherwise skip the render that reads the new readiness. + rerender( + + + + ) + }) + + it('should send the queued click through analytics', () => { + expect(track).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith( + 'Click', + expect.objectContaining({ action: 'creator_documentation', section: 'create', track_deferred: true }) + ) + }) + }) + }) + describe('and the session started on an exempt page', () => { beforeEach(async () => { isAnalyticsDisabledForSession.mockReturnValue(true) - await clickCreatorDocumentation() + clickLearn() }) it('should not send anything', () => { @@ -592,7 +621,7 @@ describe('when the visitor clicks a navbar link', () => { describe('and the visitor is a bot', () => { beforeEach(async () => { isBotClient.mockReturnValue(true) - await clickCreatorDocumentation() + clickLearn() }) it('should not send anything', () => { diff --git a/src/components/LandingNavbar/LandingNavbar.tsx b/src/components/LandingNavbar/LandingNavbar.tsx index 6544bc3d..f194c117 100644 --- a/src/components/LandingNavbar/LandingNavbar.tsx +++ b/src/components/LandingNavbar/LandingNavbar.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' import { isBotClient, useAnalytics } from '@dcl/hooks' import { useFormatMessage } from '../../hooks/adapters/useFormatMessage' +import { useDeferredTrack } from '../../hooks/useDeferredTrack' import { MIN_DISPLAY_BALANCE } from '../../hooks/useManaBalances' import { useLocale } from '../../intl/LocaleContext' import { isAnalyticsDisabledForSession } from '../../modules/analyticsSessionGate' @@ -32,7 +33,7 @@ import { ShoppingBagIcon, WearableIcon } from './icons' -import { isSectionActive, toNavbarAction, toNotificationLocale } from './LandingNavbar.helpers' +import { isSameTabNavigation, isSectionActive, toNavbarAction, toNotificationLocale } from './LandingNavbar.helpers' import { DROPDOWN_SECTIONS, MENU_CONFIG, USER_MENU_ITEMS } from './navbarConfig' import type { DropdownSection } from './navbarConfig' import { @@ -263,24 +264,32 @@ const LandingNavbar = memo(function LandingNavbar({ const isInitializedRef = useRef(isInitialized) isInitializedRef.current = isInitialized + const deferredTrack = useDeferredTrack() + const trackNavbar = useCallback( - (action: string, link?: { section: string; href: string }) => { + (action: string, link?: { section: string; href: string }, leavesPage = true) => { const properties = { place: SectionViewedTrack.LANDING_NAVBAR, event: 'click', action, ...link } if (isInitializedRef.current) { track(SegmentEvent.CLICK, properties) return } - // Analytics loads once the page is idle and is ready only after Segment fetched its settings. Most navbar - // links leave the page, so a click before that would be lost with it; the beacon survives the unload. It - // follows the same rules that keep the SDK off: sessions that started on an exempt page, and bots. + // Analytics loads once the page is idle and is ready only after Segment fetched its settings. A click + // that stays on the page (new tab, Jump In) waits in the queue with the SDK's full context. + if (!leavesPage) { + deferredTrack(SegmentEvent.CLICK, properties) + return + } + // One that replaces the page would be lost with it; the beacon survives the unload. It follows the same + // rules that keep the SDK off: sessions that started on an exempt page, and bots. if (isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent)) return postSegmentEvent(SegmentEvent.CLICK, properties, ensureSegmentAnonymousId()) }, - [track] + [deferredTrack, track] ) const trackNavbarLink = useCallback( - (section: string, labelKey: string, href: string) => trackNavbar(toNavbarAction(labelKey), { section, href }), + (section: string, labelKey: string, href: string, leavesPage = true) => + trackNavbar(toNavbarAction(labelKey), { section, href }, leavesPage), [trackNavbar] ) @@ -289,10 +298,10 @@ const LandingNavbar = memo(function LandingNavbar({ const navbarLinkHandlers = useCallback( (section: string, labelKey: string, href: string) => ({ onClick: (event: React.MouseEvent) => { - if (event.button === 0) trackNavbarLink(section, labelKey, href) + if (event.button === 0) trackNavbarLink(section, labelKey, href, isSameTabNavigation(event)) }, onAuxClick: (event: React.MouseEvent) => { - if (event.button === 1) trackNavbarLink(section, labelKey, href) + if (event.button === 1) trackNavbarLink(section, labelKey, href, false) } }), [trackNavbarLink] @@ -572,7 +581,7 @@ const LandingNavbar = memo(function LandingNavbar({ {scrolled && onClickJumpIn ? ( { - trackNavbar('jump_in') + trackNavbar('jump_in', undefined, false) onClickJumpIn(e) }} > From bf52e07fa84a79baad708e36005c9f43c716264f Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 18:33:12 -0300 Subject: [PATCH 03/19] feat: flag beacon-sent navbar clicks as deferred --- src/components/LandingNavbar/LandingNavbar.spec.tsx | 9 ++++++++- src/components/LandingNavbar/LandingNavbar.tsx | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.spec.tsx b/src/components/LandingNavbar/LandingNavbar.spec.tsx index c6f908f5..b4399bbb 100644 --- a/src/components/LandingNavbar/LandingNavbar.spec.tsx +++ b/src/components/LandingNavbar/LandingNavbar.spec.tsx @@ -562,7 +562,14 @@ describe('when the visitor clicks a navbar link', () => { expect(postSegmentEvent).toHaveBeenCalledTimes(1) expect(postSegmentEvent).toHaveBeenCalledWith( 'Click', - { place: 'Landing Navbar', event: 'click', action: 'learn', section: 'learn', href: 'https://decentraland.org/blog/' }, + { + place: 'Landing Navbar', + event: 'click', + action: 'learn', + section: 'learn', + href: 'https://decentraland.org/blog/', + track_deferred: true + }, 'anon-1' ) }) diff --git a/src/components/LandingNavbar/LandingNavbar.tsx b/src/components/LandingNavbar/LandingNavbar.tsx index f194c117..689960ed 100644 --- a/src/components/LandingNavbar/LandingNavbar.tsx +++ b/src/components/LandingNavbar/LandingNavbar.tsx @@ -282,7 +282,9 @@ const LandingNavbar = memo(function LandingNavbar({ // One that replaces the page would be lost with it; the beacon survives the unload. It follows the same // rules that keep the SDK off: sessions that started on an exempt page, and bots. if (isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent)) return - postSegmentEvent(SegmentEvent.CLICK, properties, ensureSegmentAnonymousId()) + // Flagged like useDownloadClick's beacon clicks, so beacon-sent and SDK-sent clicks can be told apart. + // eslint-disable-next-line @typescript-eslint/naming-convention + postSegmentEvent(SegmentEvent.CLICK, { ...properties, track_deferred: true }, ensureSegmentAnonymousId()) }, [deferredTrack, track] ) From 78291c4b2380fe0d10a5fa00c7056bef8e1c216f Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 18:46:52 -0300 Subject: [PATCH 04/19] feat: send the deferral timestamps with beacon navbar clicks --- src/components/LandingNavbar/LandingNavbar.spec.tsx | 2 ++ src/components/LandingNavbar/LandingNavbar.tsx | 11 ++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.spec.tsx b/src/components/LandingNavbar/LandingNavbar.spec.tsx index b4399bbb..d55734b4 100644 --- a/src/components/LandingNavbar/LandingNavbar.spec.tsx +++ b/src/components/LandingNavbar/LandingNavbar.spec.tsx @@ -568,6 +568,8 @@ describe('when the visitor clicks a navbar link', () => { action: 'learn', section: 'learn', href: 'https://decentraland.org/blog/', + track_called_at: expect.any(Number), + track_delivered_at: expect.any(Number), track_deferred: true }, 'anon-1' diff --git a/src/components/LandingNavbar/LandingNavbar.tsx b/src/components/LandingNavbar/LandingNavbar.tsx index 689960ed..8d15e93e 100644 --- a/src/components/LandingNavbar/LandingNavbar.tsx +++ b/src/components/LandingNavbar/LandingNavbar.tsx @@ -282,9 +282,14 @@ const LandingNavbar = memo(function LandingNavbar({ // One that replaces the page would be lost with it; the beacon survives the unload. It follows the same // rules that keep the SDK off: sessions that started on an exempt page, and bots. if (isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent)) return - // Flagged like useDownloadClick's beacon clicks, so beacon-sent and SDK-sent clicks can be told apart. - // eslint-disable-next-line @typescript-eslint/naming-convention - postSegmentEvent(SegmentEvent.CLICK, { ...properties, track_deferred: true }, ensureSegmentAnonymousId()) + // Same deferral fields as useDownloadClick's beacon and useDeferredTrack, so every deferred click carries them. + const calledAt = Date.now() + postSegmentEvent( + SegmentEvent.CLICK, + // eslint-disable-next-line @typescript-eslint/naming-convention + { ...properties, track_called_at: calledAt, track_delivered_at: calledAt, track_deferred: true }, + ensureSegmentAnonymousId() + ) }, [deferredTrack, track] ) From 09c759cd1b56ad8e0eb389036275e8691cf709b9 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 19:31:01 -0300 Subject: [PATCH 05/19] fix: treat alt-clicks on navbar links as keeping the page --- .../LandingNavbar/LandingNavbar.helpers.spec.ts | 5 +++-- .../LandingNavbar/LandingNavbar.helpers.ts | 12 +++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts b/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts index ada01b75..2ae2e653 100644 --- a/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts +++ b/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts @@ -74,11 +74,12 @@ describe('when deciding whether a navbar click replaces the current page', () => if (target) link.setAttribute('target', target) return link } - const click = (currentTarget: EventTarget, keys: Partial> = {}) => ({ + const click = (currentTarget: EventTarget, keys: Partial> = {}) => ({ currentTarget, ctrlKey: false, metaKey: false, shiftKey: false, + altKey: false, ...keys }) @@ -90,7 +91,7 @@ describe('when deciding whether a navbar click replaces the current page', () => expect(isSameTabNavigation(click(linkWith('_blank')))).toBe(false) }) - it.each(['ctrlKey', 'metaKey', 'shiftKey'] as const)('should keep the page when %s opens the link elsewhere', key => { + it.each(['ctrlKey', 'metaKey', 'shiftKey', 'altKey'] as const)('should keep the page when %s opens the link elsewhere', key => { expect(isSameTabNavigation(click(linkWith(), { [key]: true }))).toBe(false) }) }) diff --git a/src/components/LandingNavbar/LandingNavbar.helpers.ts b/src/components/LandingNavbar/LandingNavbar.helpers.ts index a13b88da..ac27e17b 100644 --- a/src/components/LandingNavbar/LandingNavbar.helpers.ts +++ b/src/components/LandingNavbar/LandingNavbar.helpers.ts @@ -51,11 +51,17 @@ function toNavbarAction(labelKey: string): string { /** * Whether a primary click on a navbar link replaces the current page. A link that opens in a new tab - * (`target="_blank"`, or ctrl/cmd/shift held) leaves this page alive, so its click can wait for analytics. + * (`target="_blank"`, or ctrl/cmd/shift held) or downloads it (alt/option held) leaves this page alive, so its click can wait for analytics. */ -function isSameTabNavigation(event: { currentTarget: EventTarget; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }): boolean { +function isSameTabNavigation(event: { + currentTarget: EventTarget + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean + altKey: boolean +}): boolean { const target = event.currentTarget instanceof Element ? event.currentTarget.getAttribute('target') : null - return target !== '_blank' && !event.ctrlKey && !event.metaKey && !event.shiftKey + return target !== '_blank' && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey } export { isSameTabNavigation, isSectionActive, toNavbarAction, toNotificationLocale } From b7dc1dc684a5a42a95d07da3821d176cdebdfbf4 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 19:32:09 -0300 Subject: [PATCH 06/19] refactor: share the deferred click beacon between the navbar and download CTAs --- .../LandingNavbar/LandingNavbar.tsx | 12 ++----- src/hooks/useDownloadClick.ts | 18 ++-------- src/modules/deferredClickBeacon.spec.ts | 33 +++++++++++++++++++ src/modules/deferredClickBeacon.ts | 27 +++++++++++++++ 4 files changed, 64 insertions(+), 26 deletions(-) create mode 100644 src/modules/deferredClickBeacon.spec.ts create mode 100644 src/modules/deferredClickBeacon.ts diff --git a/src/components/LandingNavbar/LandingNavbar.tsx b/src/components/LandingNavbar/LandingNavbar.tsx index 8d15e93e..68f139e9 100644 --- a/src/components/LandingNavbar/LandingNavbar.tsx +++ b/src/components/LandingNavbar/LandingNavbar.tsx @@ -6,9 +6,8 @@ import { useDeferredTrack } from '../../hooks/useDeferredTrack' import { MIN_DISPLAY_BALANCE } from '../../hooks/useManaBalances' import { useLocale } from '../../intl/LocaleContext' import { isAnalyticsDisabledForSession } from '../../modules/analyticsSessionGate' +import { postDeferredClick } from '../../modules/deferredClickBeacon' import { SectionViewedTrack, SegmentEvent } from '../../modules/segment' -import { ensureSegmentAnonymousId } from '../../modules/segmentAnonymousId' -import { postSegmentEvent } from '../../modules/segmentBeacon' import { assetUrl } from '../../utils/assetUrl' import { getAvatarBackgroundColor, getDisplayName } from '../../utils/avatarColor' // Module-level cache for notification type→component map from ui2. @@ -282,14 +281,7 @@ const LandingNavbar = memo(function LandingNavbar({ // One that replaces the page would be lost with it; the beacon survives the unload. It follows the same // rules that keep the SDK off: sessions that started on an exempt page, and bots. if (isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent)) return - // Same deferral fields as useDownloadClick's beacon and useDeferredTrack, so every deferred click carries them. - const calledAt = Date.now() - postSegmentEvent( - SegmentEvent.CLICK, - // eslint-disable-next-line @typescript-eslint/naming-convention - { ...properties, track_called_at: calledAt, track_delivered_at: calledAt, track_deferred: true }, - ensureSegmentAnonymousId() - ) + postDeferredClick(properties) }, [deferredTrack, track] ) diff --git a/src/hooks/useDownloadClick.ts b/src/hooks/useDownloadClick.ts index 2c648805..9f033d7f 100644 --- a/src/hooks/useDownloadClick.ts +++ b/src/hooks/useDownloadClick.ts @@ -1,10 +1,9 @@ import { useCallback, useRef } from 'react' import { useAnalytics } from '@dcl/hooks' +import { postDeferredClick } from '../modules/deferredClickBeacon' import { recordDownloadClickCorrelation } from '../modules/downloadClickCorrelation' import { markDownloadCtaClicked } from '../modules/downloadPageExit' import { SegmentEvent } from '../modules/segment' -import { ensureSegmentAnonymousId } from '../modules/segmentAnonymousId' -import { postSegmentEvent } from '../modules/segmentBeacon' import { buildClickPayload } from './adapters/clickPayload.helpers' import { useDeferredTrack } from './useDeferredTrack' @@ -59,20 +58,7 @@ function useDownloadClick(options: UseDownloadClickOptions = {}) { return } - const calledAt = Date.now() - postSegmentEvent( - SegmentEvent.CLICK, - { - ...payload, - // eslint-disable-next-line @typescript-eslint/naming-convention - track_called_at: calledAt, - // eslint-disable-next-line @typescript-eslint/naming-convention - track_delivered_at: Date.now(), - // eslint-disable-next-line @typescript-eslint/naming-convention - track_deferred: true - }, - ensureSegmentAnonymousId() - ) + postDeferredClick(payload) }, [deferredTrack, recordCorrelation] ) diff --git a/src/modules/deferredClickBeacon.spec.ts b/src/modules/deferredClickBeacon.spec.ts new file mode 100644 index 00000000..073ef194 --- /dev/null +++ b/src/modules/deferredClickBeacon.spec.ts @@ -0,0 +1,33 @@ +import { postDeferredClick } from './deferredClickBeacon' +import { ensureSegmentAnonymousId } from './segmentAnonymousId' +import { postSegmentEvent } from './segmentBeacon' + +jest.mock('./segmentBeacon', () => ({ postSegmentEvent: jest.fn() })) +jest.mock('./segmentAnonymousId', () => ({ ensureSegmentAnonymousId: jest.fn() })) + +describe('when a click made before analytics was ready is sent through the beacon', () => { + beforeEach(() => { + ;(ensureSegmentAnonymousId as jest.Mock).mockReturnValue('anon-1') + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + postDeferredClick({ place: 'Landing Navbar', action: 'learn' }) + }) + + afterEach(() => { + jest.restoreAllMocks() + jest.resetAllMocks() + }) + + it('should send a Click with the deferral fields every deferred click carries', () => { + expect(postSegmentEvent).toHaveBeenCalledWith( + 'Click', + { + place: 'Landing Navbar', + action: 'learn', + track_called_at: 1_700_000_000_000, + track_delivered_at: 1_700_000_000_000, + track_deferred: true + }, + 'anon-1' + ) + }) +}) diff --git a/src/modules/deferredClickBeacon.ts b/src/modules/deferredClickBeacon.ts new file mode 100644 index 00000000..92a88de4 --- /dev/null +++ b/src/modules/deferredClickBeacon.ts @@ -0,0 +1,27 @@ +import { SegmentEvent } from './segment' +import { ensureSegmentAnonymousId } from './segmentAnonymousId' +import { postSegmentEvent } from './segmentBeacon' + +/** + * Sends a `Click` made before analytics was ready through the unload-safe beacon, for clicks that leave the page. + * Adds the same deferral fields `useDeferredTrack` gives queued clicks, so every deferred click carries them + * whichever transport sent it. Callers apply their own gates (exempt session, bots) before calling. + */ +function postDeferredClick(properties: Record): void { + const calledAt = Date.now() + postSegmentEvent( + SegmentEvent.CLICK, + { + ...properties, + // eslint-disable-next-line @typescript-eslint/naming-convention + track_called_at: calledAt, + // eslint-disable-next-line @typescript-eslint/naming-convention + track_delivered_at: Date.now(), + // eslint-disable-next-line @typescript-eslint/naming-convention + track_deferred: true + }, + ensureSegmentAnonymousId() + ) +} + +export { postDeferredClick } From 609402eb70c9929f79e78d93e233c8743fc5b293 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 19:32:39 -0300 Subject: [PATCH 07/19] refactor: name the leavesPage option on navbar tracking calls --- src/components/LandingNavbar/LandingNavbar.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.tsx b/src/components/LandingNavbar/LandingNavbar.tsx index 68f139e9..37e0c578 100644 --- a/src/components/LandingNavbar/LandingNavbar.tsx +++ b/src/components/LandingNavbar/LandingNavbar.tsx @@ -266,7 +266,7 @@ const LandingNavbar = memo(function LandingNavbar({ const deferredTrack = useDeferredTrack() const trackNavbar = useCallback( - (action: string, link?: { section: string; href: string }, leavesPage = true) => { + (action: string, link?: { section: string; href: string }, { leavesPage = true }: { leavesPage?: boolean } = {}) => { const properties = { place: SectionViewedTrack.LANDING_NAVBAR, event: 'click', action, ...link } if (isInitializedRef.current) { track(SegmentEvent.CLICK, properties) @@ -287,8 +287,8 @@ const LandingNavbar = memo(function LandingNavbar({ ) const trackNavbarLink = useCallback( - (section: string, labelKey: string, href: string, leavesPage = true) => - trackNavbar(toNavbarAction(labelKey), { section, href }, leavesPage), + (section: string, labelKey: string, href: string, options?: { leavesPage?: boolean }) => + trackNavbar(toNavbarAction(labelKey), { section, href }, options), [trackNavbar] ) @@ -297,10 +297,10 @@ const LandingNavbar = memo(function LandingNavbar({ const navbarLinkHandlers = useCallback( (section: string, labelKey: string, href: string) => ({ onClick: (event: React.MouseEvent) => { - if (event.button === 0) trackNavbarLink(section, labelKey, href, isSameTabNavigation(event)) + if (event.button === 0) trackNavbarLink(section, labelKey, href, { leavesPage: isSameTabNavigation(event) }) }, onAuxClick: (event: React.MouseEvent) => { - if (event.button === 1) trackNavbarLink(section, labelKey, href, false) + if (event.button === 1) trackNavbarLink(section, labelKey, href, { leavesPage: false }) } }), [trackNavbarLink] @@ -580,7 +580,7 @@ const LandingNavbar = memo(function LandingNavbar({ {scrolled && onClickJumpIn ? ( { - trackNavbar('jump_in', undefined, false) + trackNavbar('jump_in', undefined, { leavesPage: false }) onClickJumpIn(e) }} > From a379f0d315265e9a0efc148a28be783bf0d6b8b8 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 19:33:46 -0300 Subject: [PATCH 08/19] test: cover Sign In, section tab and Jump In clicks before analytics loads --- .../LandingNavbar/LandingNavbar.spec.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/components/LandingNavbar/LandingNavbar.spec.tsx b/src/components/LandingNavbar/LandingNavbar.spec.tsx index d55734b4..4d41332e 100644 --- a/src/components/LandingNavbar/LandingNavbar.spec.tsx +++ b/src/components/LandingNavbar/LandingNavbar.spec.tsx @@ -638,6 +638,79 @@ describe('when the visitor clicks a navbar link', () => { expect(postSegmentEvent).not.toHaveBeenCalled() }) }) + + describe('and the visitor clicks Sign In', () => { + beforeEach(async () => { + renderAt('/events') + await user.click(screen.getAllByRole('button', { name: 'component.landing.navbar.sign_in' })[0]) + }) + + it('should send it through the beacon, since signing in redirects away', () => { + expect(postSegmentEvent).toHaveBeenCalledTimes(1) + expect(postSegmentEvent).toHaveBeenCalledWith( + 'Click', + expect.objectContaining({ action: 'sign_in', track_deferred: true }), + 'anon-1' + ) + }) + }) + + describe('and the visitor clicks a section tab', () => { + let open: jest.SpyInstance + + beforeEach(() => { + open = jest.spyOn(window, 'open').mockImplementation(() => null) + renderAt('/events') + fireEvent.click(desktopSection(/navbar\.discover$/i)) + }) + + afterEach(() => { + open.mockRestore() + }) + + it('should send it through the beacon before the tab navigates in the same tab', () => { + expect(postSegmentEvent).toHaveBeenCalledWith( + 'Click', + expect.objectContaining({ action: 'discover', section: 'discover', href: '/events', track_deferred: true }), + 'anon-1' + ) + expect(postSegmentEvent.mock.invocationCallOrder[0]).toBeLessThan(open.mock.invocationCallOrder[0]) + }) + }) + + describe('and the visitor clicks Jump In', () => { + let rerender: ReturnType['rerender'] + let onClickJumpIn: jest.Mock + + beforeEach(async () => { + onClickJumpIn = jest.fn() + Object.defineProperty(window, 'scrollY', { configurable: true, value: 100 }) + ;({ rerender } = renderAt('/', { isLandingPage: true, onClickJumpIn })) + fireEvent.scroll(window) + await user.click(screen.getByRole('button', { name: /jump_in/i })) + }) + + it('should queue it instead of using the beacon, since the page stays open', () => { + expect(postSegmentEvent).not.toHaveBeenCalled() + expect(track).not.toHaveBeenCalled() + expect(onClickJumpIn).toHaveBeenCalledTimes(1) + }) + + describe('and analytics finishes loading', () => { + beforeEach(() => { + ;(jest.requireMock('@dcl/hooks').useAnalytics as jest.Mock).mockReturnValue({ isInitialized: true, track }) + rerender( + + + + ) + }) + + it('should send the queued click through analytics', () => { + expect(track).toHaveBeenCalledWith('Click', expect.objectContaining({ action: 'jump_in', track_deferred: true })) + }) + }) + }) }) describe('and analytics is ready', () => { @@ -651,6 +724,16 @@ describe('when the visitor clicks a navbar link', () => { expect(track).toHaveBeenCalledTimes(1) expect(jest.requireMock('../../modules/segmentBeacon').postSegmentEvent).not.toHaveBeenCalled() }) + + it('should send the plain click payload, without deferral fields', () => { + expect(track).toHaveBeenCalledWith('Click', { + place: 'Landing Navbar', + event: 'click', + action: 'learn', + section: 'learn', + href: 'https://decentraland.org/blog/' + }) + }) }) describe('and it is the logo', () => { From e709356d4d37c012fffded72bfbba2e0c036d54a Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 22:17:28 -0300 Subject: [PATCH 09/19] refactor: type isSameTabNavigation's event as a pick of the React mouse event --- .../LandingNavbar/LandingNavbar.helpers.spec.ts | 2 +- src/components/LandingNavbar/LandingNavbar.helpers.ts | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts b/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts index 2ae2e653..15b46f58 100644 --- a/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts +++ b/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts @@ -74,7 +74,7 @@ describe('when deciding whether a navbar click replaces the current page', () => if (target) link.setAttribute('target', target) return link } - const click = (currentTarget: EventTarget, keys: Partial> = {}) => ({ + const click = (currentTarget: Element, keys: Partial> = {}) => ({ currentTarget, ctrlKey: false, metaKey: false, diff --git a/src/components/LandingNavbar/LandingNavbar.helpers.ts b/src/components/LandingNavbar/LandingNavbar.helpers.ts index ac27e17b..41b9e0ee 100644 --- a/src/components/LandingNavbar/LandingNavbar.helpers.ts +++ b/src/components/LandingNavbar/LandingNavbar.helpers.ts @@ -1,3 +1,4 @@ +import type { MouseEvent } from 'react' import type { NotificationLocale } from 'decentraland-ui2' import { MENU_CONFIG } from './navbarConfig' import type { DropdownSection } from './navbarConfig' @@ -53,14 +54,8 @@ function toNavbarAction(labelKey: string): string { * Whether a primary click on a navbar link replaces the current page. A link that opens in a new tab * (`target="_blank"`, or ctrl/cmd/shift held) or downloads it (alt/option held) leaves this page alive, so its click can wait for analytics. */ -function isSameTabNavigation(event: { - currentTarget: EventTarget - ctrlKey: boolean - metaKey: boolean - shiftKey: boolean - altKey: boolean -}): boolean { - const target = event.currentTarget instanceof Element ? event.currentTarget.getAttribute('target') : null +function isSameTabNavigation(event: Pick): boolean { + const target = event.currentTarget.getAttribute('target') return target !== '_blank' && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey } From d881b17f7e20a9dbd85e7bdd010e0c4195e15c08 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 22:18:00 -0300 Subject: [PATCH 10/19] docs: say the isInitialized ref keeps the click handlers stable --- src/components/LandingNavbar/LandingNavbar.tsx | 4 ++-- src/hooks/useDownloadClick.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.tsx b/src/components/LandingNavbar/LandingNavbar.tsx index 37e0c578..4f02383e 100644 --- a/src/components/LandingNavbar/LandingNavbar.tsx +++ b/src/components/LandingNavbar/LandingNavbar.tsx @@ -258,8 +258,8 @@ const LandingNavbar = memo(function LandingNavbar({ const { isInitialized, track } = useAnalytics() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - // Read through a ref (as useDownloadClick does) so a click sees Segment's current readiness even if it - // finished loading since the last render. + // Read through a ref (as useDownloadClick does) so trackNavbar keeps its identity when analytics becomes + // ready, instead of rebuilding every memoized handler. const isInitializedRef = useRef(isInitialized) isInitializedRef.current = isInitialized diff --git a/src/hooks/useDownloadClick.ts b/src/hooks/useDownloadClick.ts index 9f033d7f..70f6cc43 100644 --- a/src/hooks/useDownloadClick.ts +++ b/src/hooks/useDownloadClick.ts @@ -26,9 +26,7 @@ interface UseDownloadClickOptions { * component-scoped queue because navigation would tear it down. * * `isInitialized` is read through a ref (same pattern as `useDeferredTrack`) so - * the handler sees Segment's current readiness even if it booted since the last - * render — closing the sub-render window where a stale `false` would beacon a - * click that could have gone through analytics-next with full context. + * the returned handler keeps its identity when Segment becomes ready. */ function useDownloadClick(options: UseDownloadClickOptions = {}) { const { recordCorrelation = true } = options From 916d4d47d83a26cff907352988899f20e1fd7432 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 22:18:19 -0300 Subject: [PATCH 11/19] docs: say which gates each postDeferredClick caller applies --- src/modules/deferredClickBeacon.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/deferredClickBeacon.ts b/src/modules/deferredClickBeacon.ts index 92a88de4..7cedca1b 100644 --- a/src/modules/deferredClickBeacon.ts +++ b/src/modules/deferredClickBeacon.ts @@ -5,7 +5,8 @@ import { postSegmentEvent } from './segmentBeacon' /** * Sends a `Click` made before analytics was ready through the unload-safe beacon, for clicks that leave the page. * Adds the same deferral fields `useDeferredTrack` gives queued clicks, so every deferred click carries them - * whichever transport sent it. Callers apply their own gates (exempt session, bots) before calling. + * whichever transport sent it. It applies no gate of its own: the navbar skips exempt sessions and bots before + * calling, while download CTAs skip neither, since clicks on the exempt `/download` page must still count. */ function postDeferredClick(properties: Record): void { const calledAt = Date.now() From ed2c8c3b0fb23f90bc38fa5001e430d9ffb1bd86 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Thu, 8 Oct 2026 22:19:21 -0300 Subject: [PATCH 12/19] test: cover pre-ready middle and modifier clicks and the no-double-send case --- .../LandingNavbar/LandingNavbar.spec.tsx | 96 ++++++++++++++++--- 1 file changed, 84 insertions(+), 12 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.spec.tsx b/src/components/LandingNavbar/LandingNavbar.spec.tsx index 4d41332e..fc3e517b 100644 --- a/src/components/LandingNavbar/LandingNavbar.spec.tsx +++ b/src/components/LandingNavbar/LandingNavbar.spec.tsx @@ -536,10 +536,23 @@ describe('when the visitor clicks a navbar link', () => { let isBotClient: jest.Mock let isAnalyticsDisabledForSession: jest.Mock - const clickLearn = () => { - renderAt('/events') - const mobileMenu = screen.getByRole('navigation', { name: 'Mobile navigation' }) - fireEvent.click(preventNavigation(within(mobileMenu).getByRole('link', { name: /navbar\.learn/i, hidden: true }))) + const learnLink = () => + within(screen.getByRole('navigation', { name: 'Mobile navigation' })).getByRole('link', { name: /navbar\.learn/i, hidden: true }) + + const clickLearn = (init?: MouseEventInit) => { + const rendered = renderAt('/events') + fireEvent.click(preventNavigation(learnLink()), init) + return rendered + } + + // A new prop, since the memoized navbar would otherwise skip the render that reads the new readiness. + const finishLoading = (rerender: ReturnType['rerender']) => { + ;(jest.requireMock('@dcl/hooks').useAnalytics as jest.Mock).mockReturnValue({ isInitialized: true, track }) + rerender( + + + + ) } beforeEach(() => { @@ -553,8 +566,10 @@ describe('when the visitor clicks a navbar link', () => { }) describe('and the visitor is a person in a session with analytics on', () => { + let rerender: ReturnType['rerender'] + beforeEach(async () => { - clickLearn() + ;({ rerender } = clickLearn()) }) it('should send the click through the beacon, which survives the page unloading', () => { @@ -575,6 +590,69 @@ describe('when the visitor clicks a navbar link', () => { 'anon-1' ) }) + + describe('and analytics finishes loading', () => { + beforeEach(() => { + finishLoading(rerender) + }) + + it('should not send the click again through analytics', () => { + expect(track).not.toHaveBeenCalled() + expect(postSegmentEvent).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe.each([ + ['ctrl', { ctrlKey: true }], + ['cmd', { metaKey: true }] + ])('and the visitor %s-clicks a same-tab link to open it in a new tab', (_key, init) => { + let rerender: ReturnType['rerender'] + + beforeEach(() => { + ;({ rerender } = clickLearn(init)) + }) + + it('should queue it instead of using the beacon, since the page stays open', () => { + expect(postSegmentEvent).not.toHaveBeenCalled() + expect(track).not.toHaveBeenCalled() + }) + + describe('and analytics finishes loading', () => { + beforeEach(() => { + finishLoading(rerender) + }) + + it('should send the queued click through analytics', () => { + expect(track).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith('Click', expect.objectContaining({ action: 'learn', track_deferred: true })) + }) + }) + }) + + describe('and the visitor middle-clicks a link', () => { + let rerender: ReturnType['rerender'] + + beforeEach(() => { + ;({ rerender } = renderAt('/events')) + fireEvent(learnLink(), new MouseEvent('auxclick', { bubbles: true, button: 1 })) + }) + + it('should queue it instead of using the beacon, since the page stays open', () => { + expect(postSegmentEvent).not.toHaveBeenCalled() + expect(track).not.toHaveBeenCalled() + }) + + describe('and analytics finishes loading', () => { + beforeEach(() => { + finishLoading(rerender) + }) + + it('should send the queued click through analytics', () => { + expect(track).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith('Click', expect.objectContaining({ action: 'learn', track_deferred: true })) + }) + }) }) describe('and the link opens in a new tab', () => { @@ -596,13 +674,7 @@ describe('when the visitor clicks a navbar link', () => { describe('and analytics finishes loading', () => { beforeEach(() => { - ;(jest.requireMock('@dcl/hooks').useAnalytics as jest.Mock).mockReturnValue({ isInitialized: true, track }) - // A new prop, since the memoized navbar would otherwise skip the render that reads the new readiness. - rerender( - - - - ) + finishLoading(rerender) }) it('should send the queued click through analytics', () => { From 898f3c72edf96d2dacb087284a9ed28d987f464c Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Mon, 12 Oct 2026 17:41:54 -0300 Subject: [PATCH 13/19] fix: skip the deferred queue for exempt sessions and bots too --- .../LandingNavbar/LandingNavbar.spec.tsx | 20 +++++++++++++++++++ .../LandingNavbar/LandingNavbar.tsx | 7 ++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.spec.tsx b/src/components/LandingNavbar/LandingNavbar.spec.tsx index fc3e517b..823b11b3 100644 --- a/src/components/LandingNavbar/LandingNavbar.spec.tsx +++ b/src/components/LandingNavbar/LandingNavbar.spec.tsx @@ -711,6 +711,26 @@ describe('when the visitor clicks a navbar link', () => { }) }) + describe.each([ + ['the session started on an exempt page', () => isAnalyticsDisabledForSession.mockReturnValue(true)], + ['the visitor is a bot', () => isBotClient.mockReturnValue(true)] + ])('and %s and the click keeps the page open', (_case, arrange) => { + let rerender: ReturnType['rerender'] + + beforeEach(() => { + arrange() + ;({ rerender } = renderAt('/events')) + fireEvent(learnLink(), new MouseEvent('auxclick', { bubbles: true, button: 1 })) + finishLoading(rerender) + }) + + // Readiness is forced here only to prove the click was never queued: analytics stays off in these sessions. + it('should not queue it, so nothing is sent even once analytics is ready', () => { + expect(track).not.toHaveBeenCalled() + expect(postSegmentEvent).not.toHaveBeenCalled() + }) + }) + describe('and the visitor clicks Sign In', () => { beforeEach(async () => { renderAt('/events') diff --git a/src/components/LandingNavbar/LandingNavbar.tsx b/src/components/LandingNavbar/LandingNavbar.tsx index 4f02383e..17bac01f 100644 --- a/src/components/LandingNavbar/LandingNavbar.tsx +++ b/src/components/LandingNavbar/LandingNavbar.tsx @@ -272,15 +272,16 @@ const LandingNavbar = memo(function LandingNavbar({ track(SegmentEvent.CLICK, properties) return } + // Analytics never loads for sessions that started on an exempt page or for bots, so neither path may + // send their clicks, and a queued one would wait forever. + if (isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent)) return // Analytics loads once the page is idle and is ready only after Segment fetched its settings. A click // that stays on the page (new tab, Jump In) waits in the queue with the SDK's full context. if (!leavesPage) { deferredTrack(SegmentEvent.CLICK, properties) return } - // One that replaces the page would be lost with it; the beacon survives the unload. It follows the same - // rules that keep the SDK off: sessions that started on an exempt page, and bots. - if (isAnalyticsDisabledForSession() || isBotClient(navigator.userAgent)) return + // One that replaces the page would be lost with it; the beacon survives the unload. postDeferredClick(properties) }, [deferredTrack, track] From 6cb5e96ac826707c90f84453620a19f022a0b819 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Mon, 12 Oct 2026 17:54:44 -0300 Subject: [PATCH 14/19] test: assert the queued Jump In click is sent exactly once --- src/components/LandingNavbar/LandingNavbar.spec.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/LandingNavbar/LandingNavbar.spec.tsx b/src/components/LandingNavbar/LandingNavbar.spec.tsx index 823b11b3..c3fa4da7 100644 --- a/src/components/LandingNavbar/LandingNavbar.spec.tsx +++ b/src/components/LandingNavbar/LandingNavbar.spec.tsx @@ -798,8 +798,10 @@ describe('when the visitor clicks a navbar link', () => { ) }) - it('should send the queued click through analytics', () => { + it('should send the queued click once through analytics', () => { + expect(track).toHaveBeenCalledTimes(1) expect(track).toHaveBeenCalledWith('Click', expect.objectContaining({ action: 'jump_in', track_deferred: true })) + expect(postSegmentEvent).not.toHaveBeenCalled() }) }) }) From 2014d91cfc1d3b7c4a86a9253df17579238bd20a Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Mon, 12 Oct 2026 18:30:27 -0300 Subject: [PATCH 15/19] fix: treat named link targets as opening elsewhere --- .../LandingNavbar/LandingNavbar.helpers.spec.ts | 8 ++++++-- .../LandingNavbar/LandingNavbar.helpers.ts | 12 ++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts b/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts index 15b46f58..c22f7d63 100644 --- a/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts +++ b/src/components/LandingNavbar/LandingNavbar.helpers.spec.ts @@ -87,8 +87,12 @@ describe('when deciding whether a navbar click replaces the current page', () => expect(isSameTabNavigation(click(linkWith()))).toBe(true) }) - it('should keep the page for a link that opens in a new tab', () => { - expect(isSameTabNavigation(click(linkWith('_blank')))).toBe(false) + it.each(['_self', '_parent', '_top', '_SELF'])('should treat a %s link as leaving the page', target => { + expect(isSameTabNavigation(click(linkWith(target)))).toBe(true) + }) + + it.each(['_blank', 'docs'])('should keep the page for a %s link, which opens in another tab or window', target => { + expect(isSameTabNavigation(click(linkWith(target)))).toBe(false) }) it.each(['ctrlKey', 'metaKey', 'shiftKey', 'altKey'] as const)('should keep the page when %s opens the link elsewhere', key => { diff --git a/src/components/LandingNavbar/LandingNavbar.helpers.ts b/src/components/LandingNavbar/LandingNavbar.helpers.ts index 41b9e0ee..d864a6f3 100644 --- a/src/components/LandingNavbar/LandingNavbar.helpers.ts +++ b/src/components/LandingNavbar/LandingNavbar.helpers.ts @@ -50,13 +50,17 @@ function toNavbarAction(labelKey: string): string { return labelKey.slice(labelKey.lastIndexOf('.') + 1) } +// Targets that load the link in this browsing context or one that contains it, so the page is replaced. +const SAME_CONTEXT_TARGETS: ReadonlyArray = [null, '', '_self', '_parent', '_top'] + /** - * Whether a primary click on a navbar link replaces the current page. A link that opens in a new tab - * (`target="_blank"`, or ctrl/cmd/shift held) or downloads it (alt/option held) leaves this page alive, so its click can wait for analytics. + * Whether a primary click on a navbar link replaces the current page. A link that opens elsewhere (`_blank` or a + * named target, or ctrl/cmd/shift held) or downloads it (alt/option held) leaves this page alive, so its click can + * wait for analytics. */ function isSameTabNavigation(event: Pick): boolean { - const target = event.currentTarget.getAttribute('target') - return target !== '_blank' && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey + const target = event.currentTarget.getAttribute('target')?.toLowerCase() ?? null + return SAME_CONTEXT_TARGETS.includes(target) && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey } export { isSameTabNavigation, isSectionActive, toNavbarAction, toNotificationLocale } From d915708ff8a4a4b8d09cdfa3573593e9e86cb6f7 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Mon, 12 Oct 2026 18:31:15 -0300 Subject: [PATCH 16/19] test: restore window.scrollY after each navbar test --- src/components/LandingNavbar/LandingNavbar.spec.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.spec.tsx b/src/components/LandingNavbar/LandingNavbar.spec.tsx index c3fa4da7..b7ad3fcd 100644 --- a/src/components/LandingNavbar/LandingNavbar.spec.tsx +++ b/src/components/LandingNavbar/LandingNavbar.spec.tsx @@ -37,8 +37,14 @@ beforeEach(() => { ;(jest.requireMock('../../hooks/adapters/useFormatMessage').useFormatMessage as jest.Mock).mockReturnValue((key: string) => key) }) +const originalScrollY = Object.getOwnPropertyDescriptor(window, 'scrollY') + +// The navbar shows Jump In only once the page has scrolled. +const scrollPageTo = (value: number) => Object.defineProperty(window, 'scrollY', { configurable: true, value }) + afterEach(() => { jest.resetAllMocks() + if (originalScrollY) Object.defineProperty(window, 'scrollY', originalScrollY) }) const renderAt = (pathname: string, extra: Partial = {}) => @@ -293,7 +299,7 @@ describe('when the landing navbar is minimal', () => { beforeEach(() => { onClickJumpIn = jest.fn() user = userEvent.setup() - Object.defineProperty(window, 'scrollY', { configurable: true, value: 100 }) + scrollPageTo(100) }) it('should invoke jump-in after scrolling', async () => { @@ -776,7 +782,7 @@ describe('when the visitor clicks a navbar link', () => { beforeEach(async () => { onClickJumpIn = jest.fn() - Object.defineProperty(window, 'scrollY', { configurable: true, value: 100 }) + scrollPageTo(100) ;({ rerender } = renderAt('/', { isLandingPage: true, onClickJumpIn })) fireEvent.scroll(window) await user.click(screen.getByRole('button', { name: /jump_in/i })) From f6755a067b2a06cca94b122fa31e7d2954478bef Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Mon, 12 Oct 2026 18:31:59 -0300 Subject: [PATCH 17/19] test: drop async from navbar setup blocks that await nothing --- src/components/LandingNavbar/LandingNavbar.spec.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.spec.tsx b/src/components/LandingNavbar/LandingNavbar.spec.tsx index b7ad3fcd..2674be9b 100644 --- a/src/components/LandingNavbar/LandingNavbar.spec.tsx +++ b/src/components/LandingNavbar/LandingNavbar.spec.tsx @@ -574,7 +574,7 @@ describe('when the visitor clicks a navbar link', () => { describe('and the visitor is a person in a session with analytics on', () => { let rerender: ReturnType['rerender'] - beforeEach(async () => { + beforeEach(() => { ;({ rerender } = clickLearn()) }) @@ -694,7 +694,7 @@ describe('when the visitor clicks a navbar link', () => { }) describe('and the session started on an exempt page', () => { - beforeEach(async () => { + beforeEach(() => { isAnalyticsDisabledForSession.mockReturnValue(true) clickLearn() }) @@ -706,7 +706,7 @@ describe('when the visitor clicks a navbar link', () => { }) describe('and the visitor is a bot', () => { - beforeEach(async () => { + beforeEach(() => { isBotClient.mockReturnValue(true) clickLearn() }) From 5be1ed79375e4c7df885268a2188e56a2d8c1b36 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Mon, 12 Oct 2026 18:33:38 -0300 Subject: [PATCH 18/19] refactor: move the navbar click link and options types to LandingNavbar.types --- src/components/LandingNavbar/LandingNavbar.tsx | 5 +++-- src/components/LandingNavbar/LandingNavbar.types.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 src/components/LandingNavbar/LandingNavbar.types.ts diff --git a/src/components/LandingNavbar/LandingNavbar.tsx b/src/components/LandingNavbar/LandingNavbar.tsx index 17bac01f..8908902e 100644 --- a/src/components/LandingNavbar/LandingNavbar.tsx +++ b/src/components/LandingNavbar/LandingNavbar.tsx @@ -35,6 +35,7 @@ import { import { isSameTabNavigation, isSectionActive, toNavbarAction, toNotificationLocale } from './LandingNavbar.helpers' import { DROPDOWN_SECTIONS, MENU_CONFIG, USER_MENU_ITEMS } from './navbarConfig' import type { DropdownSection } from './navbarConfig' +import type { NavbarClickLink, NavbarClickOptions } from './LandingNavbar.types' import { AvatarButton, AvatarImage, @@ -266,7 +267,7 @@ const LandingNavbar = memo(function LandingNavbar({ const deferredTrack = useDeferredTrack() const trackNavbar = useCallback( - (action: string, link?: { section: string; href: string }, { leavesPage = true }: { leavesPage?: boolean } = {}) => { + (action: string, link?: NavbarClickLink, { leavesPage = true }: NavbarClickOptions = {}) => { const properties = { place: SectionViewedTrack.LANDING_NAVBAR, event: 'click', action, ...link } if (isInitializedRef.current) { track(SegmentEvent.CLICK, properties) @@ -288,7 +289,7 @@ const LandingNavbar = memo(function LandingNavbar({ ) const trackNavbarLink = useCallback( - (section: string, labelKey: string, href: string, options?: { leavesPage?: boolean }) => + (section: string, labelKey: string, href: string, options?: NavbarClickOptions) => trackNavbar(toNavbarAction(labelKey), { section, href }, options), [trackNavbar] ) diff --git a/src/components/LandingNavbar/LandingNavbar.types.ts b/src/components/LandingNavbar/LandingNavbar.types.ts new file mode 100644 index 00000000..a7eae7bc --- /dev/null +++ b/src/components/LandingNavbar/LandingNavbar.types.ts @@ -0,0 +1,11 @@ +type NavbarClickLink = { + section: string + href: string +} + +type NavbarClickOptions = { + // Whether the click replaces the current page. Before analytics is ready, such a click goes out by beacon. + leavesPage?: boolean +} + +export type { NavbarClickLink, NavbarClickOptions } From 05249c7bc96d30835c9fb3023228c60003d153b4 Mon Sep 17 00:00:00 2001 From: Braian Mellor Date: Mon, 12 Oct 2026 18:34:56 -0300 Subject: [PATCH 19/19] test: share the gate cases and the analytics-ready rerender across navbar tests --- .../LandingNavbar/LandingNavbar.spec.tsx | 73 ++++++++----------- 1 file changed, 29 insertions(+), 44 deletions(-) diff --git a/src/components/LandingNavbar/LandingNavbar.spec.tsx b/src/components/LandingNavbar/LandingNavbar.spec.tsx index 2674be9b..387c7309 100644 --- a/src/components/LandingNavbar/LandingNavbar.spec.tsx +++ b/src/components/LandingNavbar/LandingNavbar.spec.tsx @@ -551,12 +551,12 @@ describe('when the visitor clicks a navbar link', () => { return rendered } - // A new prop, since the memoized navbar would otherwise skip the render that reads the new readiness. - const finishLoading = (rerender: ReturnType['rerender']) => { + // A new onClickJumpIn, since the memoized navbar would otherwise skip the render that reads the new readiness. + const finishLoading = (rerender: ReturnType['rerender'], pathname = '/events', extra: Partial = {}) => { ;(jest.requireMock('@dcl/hooks').useAnalytics as jest.Mock).mockReturnValue({ isInitialized: true, track }) rerender( - - + + ) } @@ -693,47 +693,37 @@ describe('when the visitor clicks a navbar link', () => { }) }) - describe('and the session started on an exempt page', () => { - beforeEach(() => { - isAnalyticsDisabledForSession.mockReturnValue(true) - clickLearn() - }) - - it('should not send anything', () => { - expect(track).not.toHaveBeenCalled() - expect(postSegmentEvent).not.toHaveBeenCalled() - }) - }) - - describe('and the visitor is a bot', () => { - beforeEach(() => { - isBotClient.mockReturnValue(true) - clickLearn() - }) - - it('should not send anything', () => { - expect(track).not.toHaveBeenCalled() - expect(postSegmentEvent).not.toHaveBeenCalled() - }) - }) - describe.each([ ['the session started on an exempt page', () => isAnalyticsDisabledForSession.mockReturnValue(true)], ['the visitor is a bot', () => isBotClient.mockReturnValue(true)] - ])('and %s and the click keeps the page open', (_case, arrange) => { - let rerender: ReturnType['rerender'] - + ])('and %s', (_case, arrange) => { beforeEach(() => { arrange() - ;({ rerender } = renderAt('/events')) - fireEvent(learnLink(), new MouseEvent('auxclick', { bubbles: true, button: 1 })) - finishLoading(rerender) }) - // Readiness is forced here only to prove the click was never queued: analytics stays off in these sessions. - it('should not queue it, so nothing is sent even once analytics is ready', () => { - expect(track).not.toHaveBeenCalled() - expect(postSegmentEvent).not.toHaveBeenCalled() + describe('and the click replaces the page', () => { + beforeEach(() => { + clickLearn() + }) + + it('should not send anything', () => { + expect(track).not.toHaveBeenCalled() + expect(postSegmentEvent).not.toHaveBeenCalled() + }) + }) + + describe('and the click keeps the page open', () => { + beforeEach(() => { + const { rerender } = renderAt('/events') + fireEvent(learnLink(), new MouseEvent('auxclick', { bubbles: true, button: 1 })) + finishLoading(rerender) + }) + + // Readiness is forced here only to prove the click was never queued: analytics stays off in these sessions. + it('should not queue it, so nothing is sent even once analytics is ready', () => { + expect(track).not.toHaveBeenCalled() + expect(postSegmentEvent).not.toHaveBeenCalled() + }) }) }) @@ -796,12 +786,7 @@ describe('when the visitor clicks a navbar link', () => { describe('and analytics finishes loading', () => { beforeEach(() => { - ;(jest.requireMock('@dcl/hooks').useAnalytics as jest.Mock).mockReturnValue({ isInitialized: true, track }) - rerender( - - - - ) + finishLoading(rerender, '/', { isLandingPage: true }) }) it('should send the queued click once through analytics', () => {