diff --git a/src/app/splash/components/__tests__/splash-button-hydration.test.tsx b/src/app/splash/components/__tests__/splash-button-hydration.test.tsx new file mode 100644 index 00000000..edbecc0a --- /dev/null +++ b/src/app/splash/components/__tests__/splash-button-hydration.test.tsx @@ -0,0 +1,41 @@ +/* @vitest-environment jsdom */ + +import { act } from "react"; +import { hydrateRoot, type Root } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { afterEach, describe, expect, it } from "vitest"; + +import { SplashButton } from "../splash-button"; + +describe("SplashButton hydration", () => { + let root: Root | null = null; + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()); + root = null; + } + document.body.innerHTML = ""; + }); + + it("stays enabled with stable text during hydration", async () => { + const tree = undefined} />; + const container = document.createElement("div"); + const hydrationErrors: unknown[] = []; + container.innerHTML = renderToString(tree); + document.body.append(container); + + expect(container.querySelector("button")?.disabled).toBe(false); + expect(container.textContent).toContain("Start Chatting"); + + await act(async () => { + root = hydrateRoot(container, tree, { + onRecoverableError: (error) => hydrationErrors.push(error), + }); + }); + + expect(hydrationErrors).toEqual([]); + expect(container.querySelector("button")?.disabled).toBe(false); + expect(container.textContent).toContain("Start Chatting"); + }); +}); diff --git a/src/app/splash/components/__tests__/tailwind-components.test.tsx b/src/app/splash/components/__tests__/tailwind-components.test.tsx index 0dbffced..095ca0c9 100644 --- a/src/app/splash/components/__tests__/tailwind-components.test.tsx +++ b/src/app/splash/components/__tests__/tailwind-components.test.tsx @@ -1,25 +1,11 @@ import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { SplashBackground } from "../splash-background"; import { SplashButton } from "../splash-button"; import { SplashContent } from "../splash-content"; import { SplashLogo } from "../splash-logo"; -const authStateMock = vi.hoisted(() => ({ - value: { - authPanelMode: "facebook", - isLoading: false, - errorMessage: null, - loginStatus: "notLoggedIn", - hasInitialized: true, - }, -})); - -vi.mock("@/stores/auth/auth-context", () => ({ - useAuthState: () => authStateMock.value, -})); - describe("splash Tailwind components", () => { it("renders SplashLogo with Tailwind wrapper and image classes", () => { const html = renderToStaticMarkup(); @@ -48,33 +34,19 @@ describe("splash Tailwind components", () => { expect(html).toContain("Welcome to my secret hideout"); }); - it("renders SplashButton ready and loading states", () => { - authStateMock.value = { - ...authStateMock.value, - hasInitialized: true, - isLoading: false, - }; - const readyHtml = renderToStaticMarkup( + it("renders SplashButton as an always-ready action", () => { + const html = renderToStaticMarkup( undefined} />, ); - authStateMock.value = { - ...authStateMock.value, - hasInitialized: false, - isLoading: false, - }; - const loadingHtml = renderToStaticMarkup( - undefined} />, - ); - - expect(readyHtml).toContain("z-2"); - expect(readyHtml).toContain("max-w-120"); - expect(readyHtml).toContain("bg-[linear-gradient(to_right"); - expect(readyHtml).toContain("active:enabled:scale-96"); - expect(readyHtml).toContain("active:enabled:brightness-90"); - expect(readyHtml).toContain("touch-manipulation"); - expect(readyHtml).toContain("Start Chatting"); - expect(loadingHtml).toContain("disabled"); - expect(loadingHtml).toContain("animate-spin"); + expect(html).toContain("z-2"); + expect(html).toContain("max-w-120"); + expect(html).toContain("bg-[linear-gradient(to_right"); + expect(html).toContain("active:enabled:scale-96"); + expect(html).toContain("active:enabled:brightness-90"); + expect(html).toContain("touch-manipulation"); + expect(html).toContain("Start Chatting"); + expect(html).not.toContain("disabled=\""); + expect(html).not.toContain("animate-spin"); }); }); diff --git a/src/app/splash/components/splash-button.tsx b/src/app/splash/components/splash-button.tsx index 33fc207c..43658a47 100644 --- a/src/app/splash/components/splash-button.tsx +++ b/src/app/splash/components/splash-button.tsx @@ -2,32 +2,21 @@ /** * Splash 底部按钮组 */ -import { useAuthState } from "@/stores/auth/auth-context"; -import { LoadingIndicator } from "@/app/_components/core/loading-indicator"; - export interface SplashButtonProps { onStartChat: () => void; } export function SplashButton({ onStartChat }: SplashButtonProps) { - const state = useAuthState(); - const isLoading = !state.hasInitialized || state.isLoading; - return (
); diff --git a/src/app/splash/hooks/use-splash-latest-message.ts b/src/app/splash/hooks/use-splash-latest-message.ts index c5a3889f..a1bcc949 100644 --- a/src/app/splash/hooks/use-splash-latest-message.ts +++ b/src/app/splash/hooks/use-splash-latest-message.ts @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useState, useSyncExternalStore } from "react"; +import { useEffect, useState } from "react"; import type { LoginStatus } from "@/data/dto/auth"; +import { useHasHydrated } from "@/hooks/use-has-hydrated"; import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message"; import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider"; import { Logger, Result } from "@/utils"; @@ -26,21 +27,13 @@ interface SplashLatestMessageState { message: string | null; } -const subscribeToHydration = () => () => undefined; -const getClientHydrationSnapshot = () => true; -const getServerHydrationSnapshot = () => false; - export function useSplashLatestMessage({ hasInitialized, isAuthLoading, loginStatus, }: UseSplashLatestMessageInput): UseSplashLatestMessageOutput { const cache = useSplashLatestMessageCache(); - const hasHydrated = useSyncExternalStore( - subscribeToHydration, - getClientHydrationSnapshot, - getServerHydrationSnapshot, - ); + const hasHydrated = useHasHydrated(); const [state, setState] = useState({ key: "", loaded: false, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 722f038a..fa46bad9 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -3,5 +3,6 @@ */ export * from "./use-breakpoint"; +export * from "./use-has-hydrated"; export * from "./use-keyboard-height"; export * from "./use-pull-to-refresh"; diff --git a/src/hooks/use-has-hydrated.ts b/src/hooks/use-has-hydrated.ts new file mode 100644 index 00000000..9b64c92d --- /dev/null +++ b/src/hooks/use-has-hydrated.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +const subscribe = () => () => undefined; +const getClientSnapshot = () => true; +const getServerSnapshot = () => false; + +export function useHasHydrated(): boolean { + return useSyncExternalStore( + subscribe, + getClientSnapshot, + getServerSnapshot, + ); +} diff --git a/src/stores/auth/__tests__/auth-context-hydration.test.tsx b/src/stores/auth/__tests__/auth-context-hydration.test.tsx new file mode 100644 index 00000000..7eb484bc --- /dev/null +++ b/src/stores/auth/__tests__/auth-context-hydration.test.tsx @@ -0,0 +1,87 @@ +/* @vitest-environment jsdom */ + +import { act } from "react"; +import { hydrateRoot, type Root } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AuthPanelMode, LoginStatus } from "@/data/dto/auth"; + +vi.mock("@/stores/auth/auth-machine", async () => { + const { setup } = await import("xstate"); + const initialState = { + authPanelMode: "facebook" as AuthPanelMode, + errorMessage: null as string | null, + loginStatus: "notLoggedIn" as LoginStatus, + hasInitialized: false as boolean, + }; + const authMachine = setup({ + types: { + context: {} as typeof initialState, + events: {} as { type: "AuthNoop" }, + }, + }).createMachine({ + initial: "idle", + context: { + ...initialState, + loginStatus: "facebook", + hasInitialized: true, + }, + states: { idle: {} }, + }); + + return { authMachine, initialState }; +}); + +import { + AuthProvider, + useAuthState, +} from "@/stores/auth/auth-context"; + +function AuthStateProbe() { + const state = useAuthState(); + const isLoading = !state.hasInitialized || state.isLoading; + + return ; +} + +function TestTree() { + return ( + + + + ); +} + +describe("AuthContext hydration", () => { + let root: Root | null = null; + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()); + root = null; + } + document.body.innerHTML = ""; + }); + + it("uses the server auth snapshot until hydration completes", async () => { + const serverHtml = renderToString(); + const container = document.createElement("div"); + const hydrationErrors: unknown[] = []; + container.innerHTML = serverHtml; + document.body.append(container); + + expect(container.querySelector("button")?.disabled).toBe(true); + expect(container.textContent).toBe("Loading"); + + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError: (error) => hydrationErrors.push(error), + }); + }); + + expect(hydrationErrors).toEqual([]); + expect(container.querySelector("button")?.disabled).toBe(false); + expect(container.textContent).toBe("Ready"); + }); +}); diff --git a/src/stores/auth/auth-context.tsx b/src/stores/auth/auth-context.tsx index 765f86bb..8949cd42 100644 --- a/src/stores/auth/auth-context.tsx +++ b/src/stores/auth/auth-context.tsx @@ -6,7 +6,9 @@ import type { Dispatch, ReactNode } from "react"; import { createActorContext, shallowEqual } from "@xstate/react"; import type { SnapshotFrom } from "xstate"; -import { authMachine } from "./auth-machine"; +import { useHasHydrated } from "@/hooks/use-has-hydrated"; + +import { authMachine, initialState } from "./auth-machine"; import type { AuthEvent, AuthState as MachineContext } from "./auth-machine"; /** @@ -26,6 +28,13 @@ type AuthSnapshot = SnapshotFrom; type AuthSelector = (snapshot: AuthSnapshot) => T; const AuthActorContext = createActorContext(authMachine); +const hydrationAuthState: AuthState = { + authPanelMode: initialState.authPanelMode, + isLoading: false, + errorMessage: initialState.errorMessage, + loginStatus: initialState.loginStatus, + hasInitialized: initialState.hasInitialized, +}; export interface AuthProviderProps { children: ReactNode; @@ -36,7 +45,9 @@ export function AuthProvider({ children }: AuthProviderProps) { } export function useAuthState(): AuthState { - return useAuthSelector(selectAuthState, shallowEqual); + const state = useAuthSelector(selectAuthState, shallowEqual); + const hasHydrated = useHasHydrated(); + return hasHydrated ? state : hydrationAuthState; } export function useAuthDispatch(): Dispatch {