diff --git a/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx index e0c3d6a4b..7009001fd 100644 --- a/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx +++ b/app/(main)/[locale]/login/__tests__/login-page-skeleton.test.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { act } from 'react'; import { render } from '@testing-library/react'; +import { hydrateRoot } from 'react-dom/client'; import { renderToString } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; @@ -63,6 +64,29 @@ describe('login page while the config loads', () => { expect(container.querySelector('.animate-spin')).toBeNull(); }); + it('hydrates the server\'s skeleton when the config arrived first, and then draws the form', async () => { + // The page's Suspense boundary hydrates after the app around it, which fetches the config. + config.loading = true; + const html = renderToString(); + config.loading = false; + const box = document.createElement('div'); + box.innerHTML = html; + document.body.appendChild(box); + const fromServer = box.querySelector('[data-boot-skeleton="login"]'); + expect(fromServer).not.toBeNull(); + const recoverable: unknown[] = []; + const root = hydrateRoot(box, , { onRecoverableError: (error) => { recoverable.push(error); } }); + try { + await act(async () => {}); + expect(recoverable).toEqual([]); + expect(box.querySelector('[data-boot-skeleton="login"]')).toBeNull(); + expect(box.querySelector('#username')).not.toBeNull(); + } finally { + await act(async () => root.unmount()); + box.remove(); + } + }); + it('draws the sign-in button\'s place while the provider is looked up, not a spinner', () => { config.loading = false; config.oauthOnly = true; diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 70b3a7741..135ead1df 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1,7 +1,7 @@ "use client"; import { freshLoginRequired } from "@/lib/app-lock/sign-out"; -import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo } from "react"; +import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo, useSyncExternalStore } from "react"; import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag"; import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge"; import { customServerAllowedByDesktopPolicy, jmapServerAllowedByDesktopPolicy, useDesktopPolicy } from "@/lib/desktop/policy"; @@ -746,8 +746,12 @@ function LoginPageContent() { }, [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) { + // shape, the same one signed-out visitors of the app pages get first. Also + // the pass that hydrates that HTML, however far the config has got: this + // page's Suspense boundary hydrates after the app around it, which may have + // fetched the config by then, and drawing the form would throw the HTML away. + const hydrating = useSyncExternalStore(neverChanges, notHydrating, isHydrating); + if (configLoading || hydrating) { return ; } @@ -2066,6 +2070,10 @@ function LoginPageContent() { ); } +const neverChanges = () => () => {}; +const notHydrating = () => false; +const isHydrating = () => true; + // 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() { diff --git a/components/app-lock/__tests__/app-lock-gate.test.tsx b/components/app-lock/__tests__/app-lock-gate.test.tsx new file mode 100644 index 000000000..0f04098fe --- /dev/null +++ b/components/app-lock/__tests__/app-lock-gate.test.tsx @@ -0,0 +1,227 @@ +import { act, useEffect, type ReactNode } from "react"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToPipeableStream } from "react-dom/server"; +import { hydrateRoot, type Root } from "react-dom/client"; + +/** + * The gate against real React: the server's stream, then hydration. The controller is + * a stand-in, since what's tested here is what the gate does with a hold and a phase + * (lib/app-lock/__tests__/controller.test.ts tests the decisions themselves). + */ +const lock = vi.hoisted(() => { + const listeners = new Set<() => void>(); + const state = { + phase: "open" as string, + hold: null as { promise: Promise; release: () => void } | null, + setPhase(next: string) { + state.phase = next; + for (const listener of [...listeners]) listener(); + }, + takeHold() { + let release = () => {}; + const promise = new Promise((resolve) => { + release = resolve; + }); + state.hold = { promise, release }; + }, + open() { + const held = state.hold; + state.hold = null; + state.setPhase("open"); + held?.release(); + }, + listeners, + }; + return state; +}); + +vi.mock("@/lib/app-lock/controller", () => ({ + appLockHold: () => lock.hold?.promise ?? null, + bootAppLock: () => {}, + getAppLockPhase: () => lock.phase, + recheckAppLockSetup: () => {}, + startAppLock: () => () => {}, + subscribeAppLockPhase: (listener: () => void) => { + lock.listeners.add(listener); + return () => lock.listeners.delete(listener); + }, + takeDraftsSavedNotice: () => false, +})); +vi.mock("../lock-screen", () => ({ + LockScreen: ({ phase }: { phase: string }) =>
{phase}
, +})); + +import { AppLockGate } from "../app-lock-gate"; + +/** Well over the size at which React moves a Suspense boundary out of the shell. */ +const ROWS = Array.from({ length: 600 }, (_, i) => `row ${i} of a page skeleton, as long as a real one`); + +let effects = 0; + +function App() { + useEffect(() => { + effects += 1; + }, []); + return
{ROWS.map((row) =>

{row}

)}
; +} + +const page = (app: ReactNode = ) => {app}; + +/** What the server sends, first byte to last, as the page's real stream does. */ +async function stream(tree: ReactNode): Promise { + vi.stubGlobal("window", undefined); + try { + return await new Promise((resolve, reject) => { + let html = ""; + const out = new PassThrough(); + out.on("data", (chunk) => { html += chunk; }); + out.on("end", () => resolve(html)); + const { pipe } = renderToPipeableStream(tree, { + onShellReady: () => pipe(out), + onError: reject, + }); + }); + } finally { + vi.unstubAllGlobals(); + } +} + +/** The markup to hydrate: the gate alone, since a test can't hydrate a whole document. */ +const serverHtml = () => stream(page()); + +/** + * A whole page, and all, as the root layout renders it. It has to be: React only + * moves a boundary out of a shell that has a document around it. + */ +const serverDocument = () => stream({page()}); + +let root: Root | null = null; +let recoverable: unknown[] = []; + +/** Hydrates the server's markup and lets everything that can finish, finish. */ +async function hydrate(html: string): Promise { + const container = document.createElement("div"); + container.innerHTML = html; + document.body.appendChild(container); + await act(async () => { + root = hydrateRoot(container, page(), { onRecoverableError: (e) => { recoverable.push(e); } }); + }); + return container; +} + +const lockScreen = () => document.querySelector('[data-testid="lock-screen"]'); + +beforeEach(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + lock.phase = "open"; + lock.hold = null; + lock.listeners.clear(); + effects = 0; + recoverable = []; +}); + +afterEach(async () => { + await act(async () => root?.unmount()); + root = null; + document.body.innerHTML = ""; +}); + +describe("what the server sends", () => { + it("has the app in the shell, not in a hidden segment a script reveals later", async () => { + const html = await serverDocument(); + expect(html).toContain("row 0 of a page skeleton"); + expect(html).not.toContain('