diff --git a/src/app/splash/hooks/__tests__/use-splash-latest-message.test.tsx b/src/app/splash/hooks/__tests__/use-splash-latest-message.test.tsx new file mode 100644 index 00000000..c0d75b9e --- /dev/null +++ b/src/app/splash/hooks/__tests__/use-splash-latest-message.test.tsx @@ -0,0 +1,68 @@ +/* @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"; + +const mocks = vi.hoisted(() => ({ + loadLatestMessage: vi.fn(() => new Promise(() => undefined)), + cache: { + clear: vi.fn(), + get: vi.fn(), + set: vi.fn(), + }, +})); + +vi.mock("@/lib/chat/splash_latest_message", () => ({ + loadSplashLatestMessagePreview: mocks.loadLatestMessage, +})); + +vi.mock("@/providers/splash-latest-message-provider", () => ({ + useSplashLatestMessageCache: () => mocks.cache, +})); + +import { useSplashLatestMessage } from "../use-splash-latest-message"; + +function LatestMessageHarness() { + const state = useSplashLatestMessage({ + hasInitialized: true, + isAuthLoading: false, + loginStatus: "facebook", + }); + + return state.isLoading ? : Empty; +} + +describe("useSplashLatestMessage hydration", () => { + let root: Root | null = null; + + afterEach(async () => { + if (root) { + await act(async () => root?.unmount()); + root = null; + } + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("keeps the server and hydration snapshots stable before loading", async () => { + const serverHtml = renderToString(); + const container = document.createElement("div"); + const hydrationErrors: unknown[] = []; + container.innerHTML = serverHtml; + document.body.append(container); + + expect(container.textContent).toBe("Empty"); + + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError: (error) => hydrationErrors.push(error), + }); + }); + + expect(hydrationErrors).toEqual([]); + expect(container.textContent).toBe("Loading"); + expect(mocks.loadLatestMessage).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/splash/hooks/use-splash-latest-message.ts b/src/app/splash/hooks/use-splash-latest-message.ts index 51a711da..c5a3889f 100644 --- a/src/app/splash/hooks/use-splash-latest-message.ts +++ b/src/app/splash/hooks/use-splash-latest-message.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import type { LoginStatus } from "@/data/dto/auth"; import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message"; @@ -26,19 +26,31 @@ 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 [state, setState] = useState({ key: "", loaded: false, message: null, }); const shouldLoad = - hasInitialized && !isAuthLoading && loginStatus !== "notLoggedIn"; + hasHydrated && + hasInitialized && + !isAuthLoading && + loginStatus !== "notLoggedIn"; const requestKey = shouldLoad ? loginStatus : ""; useEffect(() => {