fix(splash): prevent latest message hydration mismatch

This commit is contained in:
2026-07-14 12:34:49 +08:00
parent 538af6d45f
commit b6551b6a5a
2 changed files with 82 additions and 2 deletions
@@ -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<never>(() => 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 ? <button>Loading</button> : <span>Empty</span>;
}
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(<LatestMessageHarness />);
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, <LatestMessageHarness />, {
onRecoverableError: (error) => hydrationErrors.push(error),
});
});
expect(hydrationErrors).toEqual([]);
expect(container.textContent).toBe("Loading");
expect(mocks.loadLatestMessage).toHaveBeenCalledOnce();
});
});
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useSyncExternalStore } from "react";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message"; import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message";
@@ -26,19 +26,31 @@ interface SplashLatestMessageState {
message: string | null; message: string | null;
} }
const subscribeToHydration = () => () => undefined;
const getClientHydrationSnapshot = () => true;
const getServerHydrationSnapshot = () => false;
export function useSplashLatestMessage({ export function useSplashLatestMessage({
hasInitialized, hasInitialized,
isAuthLoading, isAuthLoading,
loginStatus, loginStatus,
}: UseSplashLatestMessageInput): UseSplashLatestMessageOutput { }: UseSplashLatestMessageInput): UseSplashLatestMessageOutput {
const cache = useSplashLatestMessageCache(); const cache = useSplashLatestMessageCache();
const hasHydrated = useSyncExternalStore(
subscribeToHydration,
getClientHydrationSnapshot,
getServerHydrationSnapshot,
);
const [state, setState] = useState<SplashLatestMessageState>({ const [state, setState] = useState<SplashLatestMessageState>({
key: "", key: "",
loaded: false, loaded: false,
message: null, message: null,
}); });
const shouldLoad = const shouldLoad =
hasInitialized && !isAuthLoading && loginStatus !== "notLoggedIn"; hasHydrated &&
hasInitialized &&
!isAuthLoading &&
loginStatus !== "notLoggedIn";
const requestKey = shouldLoad ? loginStatus : ""; const requestKey = shouldLoad ? loginStatus : "";
useEffect(() => { useEffect(() => {