diff --git a/.github/workflows/perf-guard.yml b/.github/workflows/perf-guard.yml index 1ff1264e4..0c1aba7af 100644 --- a/.github/workflows/perf-guard.yml +++ b/.github/workflows/perf-guard.yml @@ -56,3 +56,12 @@ jobs: env: CI: "true" run: npm run test:a11y-guard + + # Same build: every route's server HTML shows its page skeleton with + # scripts blocked, and the hand-off to the loaded page shifts nothing + # (scripts/boot-skeleton-guard). + - name: Run boot skeleton guard + if: success() || failure() + env: + CI: "true" + run: npm run test:boot-skeleton-guard diff --git a/app/(main)/[locale]/auth/callback/__tests__/callback-skeleton.test.tsx b/app/(main)/[locale]/auth/callback/__tests__/callback-skeleton.test.tsx new file mode 100644 index 000000000..b4a1cd2f7 --- /dev/null +++ b/app/(main)/[locale]/auth/callback/__tests__/callback-skeleton.test.tsx @@ -0,0 +1,24 @@ +import { renderToString } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +// While the sign-in is completed, the callback draws the mail page's +// skeleton: that is where it lands, and the mail page's own gate draws the +// same, so nothing changes when it gets there. + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + useParams: () => ({ locale: 'en' }), + useSearchParams: () => new URLSearchParams('code=abc&state=xyz'), +})); + +const { default: OAuthCallbackPage } = await import('../page'); + +describe('OAuth callback while the sign-in completes', () => { + it('server-renders the mail skeleton with the completing message, not a spinner', () => { + const container = document.createElement('div'); + container.innerHTML = renderToString(); + expect(container.querySelector('[data-boot-skeleton="mail"]')).not.toBeNull(); + expect(container.querySelector('[data-boot-skeleton="mail"] [role="status"]')?.textContent).toBe('oauth_completing'); + expect(container.querySelector('.animate-spin')).toBeNull(); + }); +}); diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index 8a6c3cdbf..97bace353 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -7,18 +7,21 @@ import { useAuthStore } from "@/stores/auth-store"; import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation"; import { buildSettingsPath } from "@/lib/deep-links"; import { LITE_OAUTH_AVAILABLE, getLiteOAuthRedirectUri } from "@/lib/auth/lite-oauth"; -import { Loader2, AlertCircle } from "@/components/icons"; +import { AlertCircle } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useParams } from "next/navigation"; import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff"; import { confirmMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client"; import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; +import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton"; +import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels"; function OAuthCallbackInner() { const router = useRouter(); const params = useParams(); const searchParams = useSearchParams(); const t = useTranslations("login"); + const { labels } = useSkeletonLabels(); const { loginWithOAuth, loginWithServerSso } = useAuthStore(); const [error, setError] = useState(null); // A sign-in started for the mobile app, waiting for the user to confirm @@ -300,27 +303,16 @@ function OAuthCallbackInner() { ); } - return ( -
-
- -

{t("oauth_completing")}

-
-
- ); + // A finished sign-in lands in the mailbox (or the page saved before it), + // so the mail page's skeleton: it stays in place while the app loads there. + return ; } export default function OAuthCallbackPage() { + const t = useTranslations("login"); + const { labels } = useSkeletonLabels(); return ( - -
- -
- - } - > + }> ); diff --git a/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx new file mode 100644 index 000000000..e0c3d6a4b --- /dev/null +++ b/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +// Until /api/config answers (and in the server HTML, where it never has), +// the login page draws its card's skeleton instead of a spinner, the same +// one signed-out visitors of the app pages see first. + +vi.mock('next/navigation', () => ({ + useParams: () => ({ locale: 'en' }), + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', +})); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', + Link: ({ children }: { children: React.ReactNode }) => children, +})); + +const config = vi.hoisted(() => ({ loading: true, oauthOnly: false })); + +vi.mock('@/hooks/use-config', () => ({ + useConfig: () => ({ + appName: 'Webmail', + isLoading: config.loading, + error: null, + jmapServerUrl: 'https://mail.example.org', + jmapServers: [], + loginShowHeading: true, + loginShowSubtitle: true, + loginShowVersion: true, + loginShowTotp: true, + oauthEnabled: config.oauthOnly, + oauthOnly: config.oauthOnly, + }), +})); + +// The provider's metadata never arrives in these tests. +vi.mock('@/lib/browser-navigation', async (importOriginal) => ({ + ...(await importOriginal()), + apiFetch: vi.fn(() => new Promise(() => {})), +})); + +const { default: LoginPage } = await import('../page'); + +describe('login page while the config loads', () => { + it('server-renders the card skeleton, not a spinner', () => { + config.loading = true; + const html = renderToString(); + expect(html).toContain('data-boot-skeleton="login"'); + expect(html).not.toContain('animate-spin'); + }); + + it('keeps the skeleton on the client until the config is there', () => { + config.loading = true; + const { container } = render(); + const region = container.querySelector('[data-boot-skeleton="login"]'); + expect(region).not.toBeNull(); + expect(region!.getAttribute('aria-busy')).toBe('true'); + expect(container.querySelector('[role="status"]')?.textContent).toBe('loading'); + expect(container.querySelector('.animate-spin')).toBeNull(); + }); + + it('draws the sign-in button\'s place while the provider is looked up, not a spinner', () => { + config.loading = false; + config.oauthOnly = true; + try { + const { container } = render(); + expect(container.querySelector('[data-boot-skeleton]')).toBeNull(); + expect(container.querySelector('.animate-spin')).toBeNull(); + expect(container.querySelector('.sk-shape.h-11')).not.toBeNull(); + } finally { + config.oauthOnly = false; + } + }); +}); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index efdf33a1d..1ef9932e0 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -39,6 +39,8 @@ import { buildMobileHandoffUrl, mobileCodeChallenge, mobileHandoffTarget, signed import { requestMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client"; import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; import { HelpLinks } from "@/components/layout/help-links"; +import { LoginSkeleton } from "@/components/boot-skeleton/login-skeleton"; +import { Block } from "@/components/boot-skeleton/kit"; import { consumeSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason"; /** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */ @@ -698,15 +700,10 @@ function LoginPageContent() { setShowThemeMenu(false); }, [setTheme]); + // The server HTML, and every visit until /api/config answers: the card's + // shape, the same one signed-out visitors of the app pages get first. if (configLoading) { - return ( -
-
- - {t("loading")} -
-
- ); + return ; } if (configError) { @@ -1533,9 +1530,8 @@ function LoginPageContent() { ) : ( -
- -
+ // Where the sign-in button goes once the provider answers. + )} ) : ( @@ -1928,8 +1924,9 @@ function LoginPageContent() { // useSearchParams() above must sit under a Suspense boundary for the route to // prerender (the static Lite build); at runtime the boundary never suspends. export default function LoginPage() { + const t = useTranslations("login"); return ( - + }> ); diff --git a/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx new file mode 100644 index 000000000..840c7f39e --- /dev/null +++ b/app/(main)/[locale]/pro/__tests__/pro-gate.test.tsx @@ -0,0 +1,114 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Until the session is up the Pro page draws the same skeleton the server +// sent (the Pro frame), not a spinner and not a blank page, so the first +// paint and the loaded page line up. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => (key === 'common.loading' ? 'Loading…' : key), +})); + +const device = vi.hoisted(() => ({ isMobile: false, isTablet: false, isDesktop: true })); +vi.mock('@/hooks/use-media-query', () => ({ useDeviceDetection: () => device })); + +const auth = vi.hoisted(() => ({ + state: { + isAuthenticated: false, + client: null as unknown, + isLoading: false, + checkAuth: () => new Promise(() => {}), + logout: () => {}, + }, +})); +vi.mock('@/stores/auth-store', () => { + const useAuthStore = Object.assign( + (selector: (s: typeof auth.state) => unknown) => selector(auth.state), + { getState: () => auth.state }, + ); + return { useAuthStore, redirectToLogin: vi.fn() }; +}); + +// The apps the panes host and the shell's dialogs are not under test. +vi.mock('@/components/mail/mail-app', () => ({ MailApp: () =>
})); +vi.mock('@/components/calendar/calendar-app', () => ({ CalendarApp: () => null })); +vi.mock('@/components/contacts/contacts-app', () => ({ ContactsApp: () => null })); +vi.mock('@/components/files/files-app', () => ({ FilesApp: () => null })); +vi.mock('@/components/settings/settings-app', () => ({ SettingsApp: () => null })); +vi.mock('@/components/pro/pro-compose-tab-body', () => ({ ProComposeTabBody: () => null })); +vi.mock('@/components/pro/pro-email-tab-body', () => ({ ProEmailTabBody: () => null })); +vi.mock('@/components/pro/pro-folder-tab-body', () => ({ ProFolderTabBody: () => null })); +vi.mock('@/components/global-search/search-tab-body', () => ({ SearchTabBody: () => null })); +vi.mock('@/components/global-search/global-search-palette', () => ({ GlobalSearchPalette: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () =>