fix(splash): prevent latest message hydration mismatch
This commit is contained in:
@@ -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";
|
||||
|
||||
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<SplashLatestMessageState>({
|
||||
key: "",
|
||||
loaded: false,
|
||||
message: null,
|
||||
});
|
||||
const shouldLoad =
|
||||
hasInitialized && !isAuthLoading && loginStatus !== "notLoggedIn";
|
||||
hasHydrated &&
|
||||
hasInitialized &&
|
||||
!isAuthLoading &&
|
||||
loginStatus !== "notLoggedIn";
|
||||
const requestKey = shouldLoad ? loginStatus : "";
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user