fix(auth): stabilize hydration-dependent UI

This commit is contained in:
2026-07-14 13:27:41 +08:00
parent b6551b6a5a
commit 8eaea17156
8 changed files with 175 additions and 66 deletions
@@ -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 <button disabled={isLoading}>{isLoading ? "Loading" : "Ready"}</button>;
}
function TestTree() {
return (
<AuthProvider>
<AuthStateProbe />
</AuthProvider>
);
}
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(<TestTree />);
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, <TestTree />, {
onRecoverableError: (error) => hydrationErrors.push(error),
});
});
expect(hydrationErrors).toEqual([]);
expect(container.querySelector("button")?.disabled).toBe(false);
expect(container.textContent).toBe("Ready");
});
});
+13 -2
View File
@@ -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<typeof authMachine>;
type AuthSelector<T> = (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<AuthEvent> {