fix(auth): stabilize hydration-dependent UI
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user