diff --git a/components/settings/__tests__/settings-boot-skeleton.test.tsx b/components/settings/__tests__/settings-boot-skeleton.test.tsx index d43c271c9..5417f9871 100644 --- a/components/settings/__tests__/settings-boot-skeleton.test.tsx +++ b/components/settings/__tests__/settings-boot-skeleton.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Until the session is up, Settings draws its page skeleton (the one the @@ -23,12 +23,15 @@ vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stal vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); -vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () =>
appearance pane
})); +vi.mock('@/components/settings/appearance-settings', () => ({ + AppearanceSettings: () =>
appearance pane
, +})); const { SettingsApp } = await import('../settings-app'); const { useAuthStore } = await import('@/stores/auth-store'); const { usePluginStore } = await import('@/stores/plugin-store'); const { useThemeStore } = await import('@/stores/theme-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); const { SETTINGS_NAV_GROUPS } = await import('../settings-tab-icons'); const { BOOT_HINTS_KEY } = await import('@/lib/boot-skeleton/keys'); const { resetRailHintsMemo } = await import('@/lib/boot-skeleton/hints'); @@ -102,3 +105,26 @@ describe('Settings while the session restores', () => { expect(hints?.settings).not.toContain('vacation'); }); }); + +describe('a link to a section while the session restores', () => { + it('scrolls once the page is drawn, without switching Advanced on meanwhile', async () => { + vi.useFakeTimers(); + try { + const scroll = vi.fn(); + Element.prototype.scrollIntoView = scroll; + useSettingsStore.setState({ showAdvancedSettings: false }); + useAuthStore.setState({ isAuthenticated: true, isLoading: true, client: null }); + render(); + await act(async () => { vi.advanceTimersByTime(3000); }); + // The skeleton has no sections; nothing to find is not a hidden section. + expect(useSettingsStore.getState().showAdvancedSettings).toBe(false); + expect(scroll).not.toHaveBeenCalled(); + + act(() => { useAuthStore.setState({ isLoading: false, client: fullClient as never }); }); + await act(async () => { vi.advanceTimersByTime(200); }); + expect(scroll).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index e02596afb..ccda165a0 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -446,8 +446,12 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = // an advanced section stays hidden until the switch is on, so after a // short wait turn it on rather than give up. const updateSetting = useSettingsStore((s) => s.updateSetting); + // The page itself rather than its skeleton (see the gate below). + const pageShown = isAuthenticated && !!client; useEffect(() => { if (!pendingSection || pendingSection.tab !== activeTab) return; + // The skeleton has no sections: wait until the page is drawn. + if (!pageShown) return; if (typeof window === 'undefined') return; let cancelled = false; let timer: ReturnType | undefined; @@ -468,7 +472,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = window.cancelAnimationFrame(raf); if (timer) clearTimeout(timer); }; - }, [pendingSection, activeTab, updateSetting]); + }, [pendingSection, activeTab, updateSetting, pageShown]); // Leaving the settings panel drops any shared-account scope so it never // leaks into the next visit or another session.