From 47523b1cbcfc64071b76a36b4e9706b3c0da1884 Mon Sep 17 00:00:00 2001 From: Brijesh Bittu <717550+brijeshb42@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:41:40 +0530 Subject: [PATCH 1/4] [docs] Allow reopening cookie preferences from a URL hash --- .../src/DocsApp/AnalyticsProvider.test.tsx | 111 ++++++++++++++++++ .../src/DocsApp/AnalyticsProvider.tsx | 37 +++++- 2 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx diff --git a/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx new file mode 100644 index 00000000000000..bf9e0d0a6a1b80 --- /dev/null +++ b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, createRenderer, screen, waitFor } from '@mui/internal-test-utils'; +import Router from 'next/router'; +import { AnalyticsProvider } from './AnalyticsProvider'; + +vi.mock('../branding/BrandingCssVarsProvider', () => ({ + BrandingCssThemeProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +describe('AnalyticsProvider', () => { + const { render } = createRenderer(); + + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }); + vi.spyOn(window, 'navigator', 'get').mockReturnValue( + Object.create(navigator, { doNotTrack: { value: '0' } }), + ); + window.history.replaceState(null, '', '/'); + vi.stubGlobal('gtag', vi.fn()); + }); + + afterEach(() => { + window.localStorage.removeItem('docs-cookie-consent'); + window.history.replaceState(null, '', '/'); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each(['analytics', 'essential'])('reopens saved %s consent on page load', async (consent) => { + window.localStorage.setItem('docs-cookie-consent', consent); + window.history.replaceState({ existing: true }, '', '/material-ui/?test=1#cookie-preferences'); + const { user } = render({null}); + + await screen.findByRole('dialog'); + expect(window.localStorage.getItem('docs-cookie-consent')).to.equal(consent); + await user.click(screen.getByRole('button', { name: 'Essential only' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).to.equal(null)); + expect(window.localStorage.getItem('docs-cookie-consent')).to.equal('essential'); + expect(window.location.pathname + window.location.search).to.equal('/material-ui/?test=1'); + expect(window.location.hash).to.equal(''); + expect(window.history.state).to.deep.equal({ existing: true }); + expect(window.gtag).toHaveBeenLastCalledWith('consent', 'update', { + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + analytics_storage: 'denied', + }); + }); + + it('allows reopening repeatedly through a hash link', async () => { + window.localStorage.setItem('docs-cookie-consent', 'essential'); + const { user } = render( + + Cookie settings + , + ); + expect(screen.queryByRole('dialog')).to.equal(null); + + const reopenAndAccept = async () => { + await user.click(screen.getByRole('link', { name: 'Cookie settings' })); + await screen.findByRole('dialog'); + await user.click(screen.getByRole('button', { name: 'Allow analytics' })); + await waitFor(() => expect(screen.queryByRole('dialog')).to.equal(null)); + expect(window.localStorage.getItem('docs-cookie-consent')).to.equal('analytics'); + }; + await reopenAndAccept(); + await reopenAndAccept(); + }); + + it.each(['hashChangeComplete', 'routeChangeComplete'] as const)( + 'opens after Next.js %s navigation', + async (event) => { + window.localStorage.setItem('docs-cookie-consent', 'essential'); + render({null}); + act(() => { + window.history.pushState(null, '', '/#cookie-preferences'); + Router.events.emit(event, '/#cookie-preferences', { shallow: false }); + }); + await screen.findByRole('dialog'); + }, + ); + + it('ignores unrelated hashes', () => { + window.localStorage.setItem('docs-cookie-consent', 'essential'); + window.history.replaceState(null, '', '/#other-section'); + render({null}); + expect(screen.queryByRole('dialog')).to.equal(null); + }); + + it('continues to respect Do Not Track', () => { + vi.spyOn(window, 'navigator', 'get').mockReturnValue( + Object.create(navigator, { doNotTrack: { value: '1' } }), + ); + window.history.replaceState(null, '', '/#cookie-preferences'); + render({null}); + expect(screen.queryByRole('dialog')).to.equal(null); + expect(window.gtag).toHaveBeenLastCalledWith('consent', 'update', { + ad_storage: 'denied', + ad_user_data: 'denied', + ad_personalization: 'denied', + analytics_storage: 'denied', + }); + }); +}); diff --git a/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.tsx b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.tsx index 0d34caf4abfee1..10768359f004d0 100644 --- a/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.tsx +++ b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import Router from 'next/router'; import Button from '@mui/material/Button'; import Fade from '@mui/material/Fade'; import Paper from '@mui/material/Paper'; @@ -13,6 +14,7 @@ import CookieOutlinedIcon from '@mui/icons-material/CookieOutlined'; import { BrandingCssThemeProvider } from '../branding/BrandingCssVarsProvider'; const COOKIE_CONSENT_KEY = 'docs-cookie-consent'; +const COOKIE_PREFERENCES_HASH = '#cookie-preferences'; type ConsentStatus = 'analytics' | 'essential' | null; @@ -183,9 +185,36 @@ function updateGoogleConsent(hasAnalytics: boolean) { export function AnalyticsProvider({ children }: { children: React.ReactNode }) { const [consentStatus, setConsentStatus] = useLocalStorageState(COOKIE_CONSENT_KEY, null); const doNotTrack = useDoNotTrack(); + const [preferencesRequested, setPreferencesRequested] = React.useState(false); + + React.useEffect(() => { + const handleHashChange = () => { + setPreferencesRequested(window.location.hash === COOKIE_PREFERENCES_HASH); + }; + handleHashChange(); + window.addEventListener('hashchange', handleHashChange); + Router.events.on('hashChangeComplete', handleHashChange); + Router.events.on('routeChangeComplete', handleHashChange); + return () => { + window.removeEventListener('hashchange', handleHashChange); + Router.events.off('hashChangeComplete', handleHashChange); + Router.events.off('routeChangeComplete', handleHashChange); + }; + }, []); + + const closePreferences = React.useCallback(() => { + setPreferencesRequested(false); + if (window.location.hash === COOKIE_PREFERENCES_HASH) { + window.history.replaceState( + window.history.state, + '', + window.location.pathname + window.location.search, + ); + } + }, []); // Respect Do Not Track - don't show dialog and treat as essential only - const needsConsent = consentStatus === null && !doNotTrack; + const needsConsent = (consentStatus === null || preferencesRequested) && !doNotTrack; // Update Google consent when status changes or on mount if already set React.useEffect(() => { @@ -199,11 +228,13 @@ export function AnalyticsProvider({ children }: { children: React.ReactNode }) { const setAnalyticsConsent = React.useCallback(() => { setConsentStatus('analytics'); - }, [setConsentStatus]); + closePreferences(); + }, [setConsentStatus, closePreferences]); const setEssentialOnly = React.useCallback(() => { setConsentStatus('essential'); - }, [setConsentStatus]); + closePreferences(); + }, [setConsentStatus, closePreferences]); const contextValue = React.useMemo( () => ({ From fab93beabde8755805781b0794111b0f2c5b53fc Mon Sep 17 00:00:00 2001 From: Brijesh Bittu <717550+brijeshb42@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:55:33 +0530 Subject: [PATCH 2/4] [docs] Show the current cookie preference in the consent dialog --- .../src/DocsApp/AnalyticsProvider.test.tsx | 16 +++++++++++++--- .../core-docs/src/DocsApp/AnalyticsProvider.tsx | 11 +++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx index bf9e0d0a6a1b80..85082690762bf2 100644 --- a/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx +++ b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx @@ -38,6 +38,9 @@ describe('AnalyticsProvider', () => { const { user } = render({null}); await screen.findByRole('dialog'); + expect(screen.getByRole('dialog')).toHaveAccessibleDescription( + `We use cookies to understand site usage and improve our content. This includes third-party analytics. Current preference: ${consent === 'analytics' ? 'Analytics allowed' : 'Essential only'}`, + ); expect(window.localStorage.getItem('docs-cookie-consent')).to.equal(consent); await user.click(screen.getByRole('button', { name: 'Essential only' })); @@ -54,6 +57,12 @@ describe('AnalyticsProvider', () => { }); }); + it('does not show a current preference before the first choice', async () => { + render({null}); + await screen.findByRole('dialog'); + expect(screen.queryByText(/Current preference:/)).to.equal(null); + }); + it('allows reopening repeatedly through a hash link', async () => { window.localStorage.setItem('docs-cookie-consent', 'essential'); const { user } = render( @@ -63,15 +72,16 @@ describe('AnalyticsProvider', () => { ); expect(screen.queryByRole('dialog')).to.equal(null); - const reopenAndAccept = async () => { + const reopenAndAccept = async (currentPreference: string) => { await user.click(screen.getByRole('link', { name: 'Cookie settings' })); await screen.findByRole('dialog'); + expect(screen.getByText(`Current preference: ${currentPreference}`)).not.to.equal(null); await user.click(screen.getByRole('button', { name: 'Allow analytics' })); await waitFor(() => expect(screen.queryByRole('dialog')).to.equal(null)); expect(window.localStorage.getItem('docs-cookie-consent')).to.equal('analytics'); }; - await reopenAndAccept(); - await reopenAndAccept(); + await reopenAndAccept('Essential only'); + await reopenAndAccept('Analytics allowed'); }); it.each(['hashChangeComplete', 'routeChangeComplete'] as const)( diff --git a/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.tsx b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.tsx index 10768359f004d0..fd4da29bfb1d31 100644 --- a/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.tsx +++ b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.tsx @@ -60,7 +60,8 @@ export function useAnalyticsConsent() { } export function CookieConsentDialog() { - const { needsConsent, setAnalyticsConsent, setEssentialOnly } = useAnalyticsConsent(); + const { consentStatus, needsConsent, setAnalyticsConsent, setEssentialOnly } = + useAnalyticsConsent(); const [show, setShow] = React.useState(false); React.useEffect(() => { @@ -144,7 +145,13 @@ export function CookieConsentDialog() { sx={{ textAlign: { xs: 'center', sm: 'start' } }} > We use cookies to understand site usage and improve our content. This includes - third-party analytics. + third-party analytics.{' '} + {consentStatus !== null && ( + + Current preference:{' '} + {consentStatus === 'analytics' ? 'Analytics allowed' : 'Essential only'} + + )} From c298751a35163e79d58b88478582374d0c22e3b2 Mon Sep 17 00:00:00 2001 From: Brijesh Bittu <717550+brijeshb42@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:00:13 +0530 Subject: [PATCH 3/4] [test] Fix cookie consent mocks in browser tests --- .../src/DocsApp/AnalyticsProvider.test.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx index 85082690762bf2..acdf90ad2ddc59 100644 --- a/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx +++ b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx @@ -4,12 +4,14 @@ import { act, createRenderer, screen, waitFor } from '@mui/internal-test-utils'; import Router from 'next/router'; import { AnalyticsProvider } from './AnalyticsProvider'; -vi.mock('../branding/BrandingCssVarsProvider', () => ({ +vi.mock('../branding/BrandingCssVarsProvider', async (importOriginal) => ({ + ...(await importOriginal()), BrandingCssThemeProvider: ({ children }: { children: React.ReactNode }) => children, })); describe('AnalyticsProvider', () => { const { render } = createRenderer(); + const doNotTrackDescriptor = Object.getOwnPropertyDescriptor(navigator, 'doNotTrack'); beforeEach(() => { const storage = new Map(); @@ -18,9 +20,7 @@ describe('AnalyticsProvider', () => { setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key), }); - vi.spyOn(window, 'navigator', 'get').mockReturnValue( - Object.create(navigator, { doNotTrack: { value: '0' } }), - ); + Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: '0' }); window.history.replaceState(null, '', '/'); vi.stubGlobal('gtag', vi.fn()); }); @@ -28,6 +28,11 @@ describe('AnalyticsProvider', () => { afterEach(() => { window.localStorage.removeItem('docs-cookie-consent'); window.history.replaceState(null, '', '/'); + if (doNotTrackDescriptor) { + Object.defineProperty(navigator, 'doNotTrack', doNotTrackDescriptor); + } else { + Reflect.deleteProperty(navigator, 'doNotTrack'); + } vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -105,9 +110,7 @@ describe('AnalyticsProvider', () => { }); it('continues to respect Do Not Track', () => { - vi.spyOn(window, 'navigator', 'get').mockReturnValue( - Object.create(navigator, { doNotTrack: { value: '1' } }), - ); + Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: '1' }); window.history.replaceState(null, '', '/#cookie-preferences'); render({null}); expect(screen.queryByRole('dialog')).to.equal(null); From f9be5509a1a60c3bc8589660535907b8275b60ce Mon Sep 17 00:00:00 2001 From: Brijesh Bittu <717550+brijeshb42@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:05:38 +0530 Subject: [PATCH 4/4] [test] Control cookie dialog animation frames --- .../src/DocsApp/AnalyticsProvider.test.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx index acdf90ad2ddc59..b9af0d62cfd221 100644 --- a/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx +++ b/packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx @@ -13,7 +13,18 @@ describe('AnalyticsProvider', () => { const { render } = createRenderer(); const doNotTrackDescriptor = Object.getOwnPropertyDescriptor(navigator, 'doNotTrack'); + async function findDialog() { + return waitFor(async () => { + await act(async () => { + await vi.advanceTimersByTimeAsync(32); + }); + return screen.getByRole('dialog'); + }); + } + beforeEach(() => { + // Advance the opening animation explicitly, including in background browser tabs. + vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] }); const storage = new Map(); vi.stubGlobal('localStorage', { getItem: (key: string) => storage.get(key) ?? null, @@ -33,6 +44,7 @@ describe('AnalyticsProvider', () => { } else { Reflect.deleteProperty(navigator, 'doNotTrack'); } + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -42,7 +54,7 @@ describe('AnalyticsProvider', () => { window.history.replaceState({ existing: true }, '', '/material-ui/?test=1#cookie-preferences'); const { user } = render({null}); - await screen.findByRole('dialog'); + await findDialog(); expect(screen.getByRole('dialog')).toHaveAccessibleDescription( `We use cookies to understand site usage and improve our content. This includes third-party analytics. Current preference: ${consent === 'analytics' ? 'Analytics allowed' : 'Essential only'}`, ); @@ -64,7 +76,7 @@ describe('AnalyticsProvider', () => { it('does not show a current preference before the first choice', async () => { render({null}); - await screen.findByRole('dialog'); + await findDialog(); expect(screen.queryByText(/Current preference:/)).to.equal(null); }); @@ -79,7 +91,7 @@ describe('AnalyticsProvider', () => { const reopenAndAccept = async (currentPreference: string) => { await user.click(screen.getByRole('link', { name: 'Cookie settings' })); - await screen.findByRole('dialog'); + await findDialog(); expect(screen.getByText(`Current preference: ${currentPreference}`)).not.to.equal(null); await user.click(screen.getByRole('button', { name: 'Allow analytics' })); await waitFor(() => expect(screen.queryByRole('dialog')).to.equal(null)); @@ -98,7 +110,7 @@ describe('AnalyticsProvider', () => { window.history.pushState(null, '', '/#cookie-preferences'); Router.events.emit(event, '/#cookie-preferences', { shallow: false }); }); - await screen.findByRole('dialog'); + await findDialog(); }, );