fix(auth): stabilize hydration-dependent UI
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
/* @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 } from "vitest";
|
||||||
|
|
||||||
|
import { SplashButton } from "../splash-button";
|
||||||
|
|
||||||
|
describe("SplashButton hydration", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (root) {
|
||||||
|
await act(async () => root?.unmount());
|
||||||
|
root = null;
|
||||||
|
}
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays enabled with stable text during hydration", async () => {
|
||||||
|
const tree = <SplashButton onStartChat={() => undefined} />;
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const hydrationErrors: unknown[] = [];
|
||||||
|
container.innerHTML = renderToString(tree);
|
||||||
|
document.body.append(container);
|
||||||
|
|
||||||
|
expect(container.querySelector("button")?.disabled).toBe(false);
|
||||||
|
expect(container.textContent).toContain("Start Chatting");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root = hydrateRoot(container, tree, {
|
||||||
|
onRecoverableError: (error) => hydrationErrors.push(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hydrationErrors).toEqual([]);
|
||||||
|
expect(container.querySelector("button")?.disabled).toBe(false);
|
||||||
|
expect(container.textContent).toContain("Start Chatting");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +1,11 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { SplashBackground } from "../splash-background";
|
import { SplashBackground } from "../splash-background";
|
||||||
import { SplashButton } from "../splash-button";
|
import { SplashButton } from "../splash-button";
|
||||||
import { SplashContent } from "../splash-content";
|
import { SplashContent } from "../splash-content";
|
||||||
import { SplashLogo } from "../splash-logo";
|
import { SplashLogo } from "../splash-logo";
|
||||||
|
|
||||||
const authStateMock = vi.hoisted(() => ({
|
|
||||||
value: {
|
|
||||||
authPanelMode: "facebook",
|
|
||||||
isLoading: false,
|
|
||||||
errorMessage: null,
|
|
||||||
loginStatus: "notLoggedIn",
|
|
||||||
hasInitialized: true,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/stores/auth/auth-context", () => ({
|
|
||||||
useAuthState: () => authStateMock.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("splash Tailwind components", () => {
|
describe("splash Tailwind components", () => {
|
||||||
it("renders SplashLogo with Tailwind wrapper and image classes", () => {
|
it("renders SplashLogo with Tailwind wrapper and image classes", () => {
|
||||||
const html = renderToStaticMarkup(<SplashLogo />);
|
const html = renderToStaticMarkup(<SplashLogo />);
|
||||||
@@ -48,33 +34,19 @@ describe("splash Tailwind components", () => {
|
|||||||
expect(html).toContain("Welcome to my secret hideout");
|
expect(html).toContain("Welcome to my secret hideout");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders SplashButton ready and loading states", () => {
|
it("renders SplashButton as an always-ready action", () => {
|
||||||
authStateMock.value = {
|
const html = renderToStaticMarkup(
|
||||||
...authStateMock.value,
|
|
||||||
hasInitialized: true,
|
|
||||||
isLoading: false,
|
|
||||||
};
|
|
||||||
const readyHtml = renderToStaticMarkup(
|
|
||||||
<SplashButton onStartChat={() => undefined} />,
|
<SplashButton onStartChat={() => undefined} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
authStateMock.value = {
|
expect(html).toContain("z-2");
|
||||||
...authStateMock.value,
|
expect(html).toContain("max-w-120");
|
||||||
hasInitialized: false,
|
expect(html).toContain("bg-[linear-gradient(to_right");
|
||||||
isLoading: false,
|
expect(html).toContain("active:enabled:scale-96");
|
||||||
};
|
expect(html).toContain("active:enabled:brightness-90");
|
||||||
const loadingHtml = renderToStaticMarkup(
|
expect(html).toContain("touch-manipulation");
|
||||||
<SplashButton onStartChat={() => undefined} />,
|
expect(html).toContain("Start Chatting");
|
||||||
);
|
expect(html).not.toContain("disabled=\"");
|
||||||
|
expect(html).not.toContain("animate-spin");
|
||||||
expect(readyHtml).toContain("z-2");
|
|
||||||
expect(readyHtml).toContain("max-w-120");
|
|
||||||
expect(readyHtml).toContain("bg-[linear-gradient(to_right");
|
|
||||||
expect(readyHtml).toContain("active:enabled:scale-96");
|
|
||||||
expect(readyHtml).toContain("active:enabled:brightness-90");
|
|
||||||
expect(readyHtml).toContain("touch-manipulation");
|
|
||||||
expect(readyHtml).toContain("Start Chatting");
|
|
||||||
expect(loadingHtml).toContain("disabled");
|
|
||||||
expect(loadingHtml).toContain("animate-spin");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,32 +2,21 @@
|
|||||||
/**
|
/**
|
||||||
* Splash 底部按钮组
|
* Splash 底部按钮组
|
||||||
*/
|
*/
|
||||||
import { useAuthState } from "@/stores/auth/auth-context";
|
|
||||||
import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
|
|
||||||
|
|
||||||
export interface SplashButtonProps {
|
export interface SplashButtonProps {
|
||||||
onStartChat: () => void;
|
onStartChat: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SplashButton({ onStartChat }: SplashButtonProps) {
|
export function SplashButton({ onStartChat }: SplashButtonProps) {
|
||||||
const state = useAuthState();
|
|
||||||
const isLoading = !state.hasInitialized || state.isLoading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="z-2 flex w-full flex-row items-center justify-center gap-[clamp(var(--spacing-md,12px),4.815vw,var(--spacing-26,26px))] px-[clamp(var(--spacing-sm,8px),2.963vw,var(--spacing-lg,16px))]">
|
<div className="z-2 flex w-full flex-row items-center justify-center gap-[clamp(var(--spacing-md,12px),4.815vw,var(--spacing-26,26px))] px-[clamp(var(--spacing-sm,8px),2.963vw,var(--spacing-lg,16px))]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onStartChat}
|
onClick={onStartChat}
|
||||||
disabled={isLoading}
|
|
||||||
className="inline-flex min-h-(--responsive-control-height,48px) w-full max-w-120 touch-manipulation flex-auto cursor-pointer items-center justify-center rounded-(--radius-full,999px) border-0 bg-[linear-gradient(to_right,var(--color-button-gradient-start),var(--color-button-gradient-end))] px-[clamp(var(--spacing-lg,16px),5.556vw,30px)] font-bold italic text-white shadow-[0_0_10px_rgba(248,89,168,0.3)] transition-[filter,box-shadow,transform] duration-150 ease-out [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-white enabled:hover:-translate-y-px enabled:hover:brightness-105 enabled:hover:shadow-[0_8px_22px_rgba(248,89,168,0.42)] active:enabled:translate-y-0.5 active:enabled:scale-96 active:enabled:brightness-90 active:enabled:shadow-[0_2px_6px_rgba(248,89,168,0.24)] motion-reduce:transition-none motion-reduce:active:enabled:transform-none disabled:cursor-not-allowed disabled:opacity-70"
|
className="inline-flex min-h-(--responsive-control-height,48px) w-full max-w-120 touch-manipulation flex-auto cursor-pointer items-center justify-center rounded-(--radius-full,999px) border-0 bg-[linear-gradient(to_right,var(--color-button-gradient-start),var(--color-button-gradient-end))] px-[clamp(var(--spacing-lg,16px),5.556vw,30px)] font-bold italic text-white shadow-[0_0_10px_rgba(248,89,168,0.3)] transition-[filter,box-shadow,transform] duration-150 ease-out [-webkit-tap-highlight-color:transparent] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-white enabled:hover:-translate-y-px enabled:hover:brightness-105 enabled:hover:shadow-[0_8px_22px_rgba(248,89,168,0.42)] active:enabled:translate-y-0.5 active:enabled:scale-96 active:enabled:brightness-90 active:enabled:shadow-[0_2px_6px_rgba(248,89,168,0.24)] motion-reduce:transition-none motion-reduce:active:enabled:transform-none disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
<span className="whitespace-nowrap text-(length:--responsive-section-title,var(--font-size-xxl)) font-bold text-white">
|
||||||
<LoadingIndicator color="#ffffff" />
|
Start Chatting
|
||||||
) : (
|
</span>
|
||||||
<span className="whitespace-nowrap text-(length:--responsive-section-title,var(--font-size-xxl)) font-bold text-white">
|
|
||||||
Start Chatting
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { LoginStatus } from "@/data/dto/auth";
|
import type { LoginStatus } from "@/data/dto/auth";
|
||||||
|
import { useHasHydrated } from "@/hooks/use-has-hydrated";
|
||||||
import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message";
|
import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message";
|
||||||
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
|
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
|
||||||
import { Logger, Result } from "@/utils";
|
import { Logger, Result } from "@/utils";
|
||||||
@@ -26,21 +27,13 @@ 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(
|
const hasHydrated = useHasHydrated();
|
||||||
subscribeToHydration,
|
|
||||||
getClientHydrationSnapshot,
|
|
||||||
getServerHydrationSnapshot,
|
|
||||||
);
|
|
||||||
const [state, setState] = useState<SplashLatestMessageState>({
|
const [state, setState] = useState<SplashLatestMessageState>({
|
||||||
key: "",
|
key: "",
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./use-breakpoint";
|
export * from "./use-breakpoint";
|
||||||
|
export * from "./use-has-hydrated";
|
||||||
export * from "./use-keyboard-height";
|
export * from "./use-keyboard-height";
|
||||||
export * from "./use-pull-to-refresh";
|
export * from "./use-pull-to-refresh";
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const subscribe = () => () => undefined;
|
||||||
|
const getClientSnapshot = () => true;
|
||||||
|
const getServerSnapshot = () => false;
|
||||||
|
|
||||||
|
export function useHasHydrated(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribe,
|
||||||
|
getClientSnapshot,
|
||||||
|
getServerSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { createActorContext, shallowEqual } from "@xstate/react";
|
||||||
import type { SnapshotFrom } from "xstate";
|
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";
|
import type { AuthEvent, AuthState as MachineContext } from "./auth-machine";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +28,13 @@ type AuthSnapshot = SnapshotFrom<typeof authMachine>;
|
|||||||
type AuthSelector<T> = (snapshot: AuthSnapshot) => T;
|
type AuthSelector<T> = (snapshot: AuthSnapshot) => T;
|
||||||
|
|
||||||
const AuthActorContext = createActorContext(authMachine);
|
const AuthActorContext = createActorContext(authMachine);
|
||||||
|
const hydrationAuthState: AuthState = {
|
||||||
|
authPanelMode: initialState.authPanelMode,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: initialState.errorMessage,
|
||||||
|
loginStatus: initialState.loginStatus,
|
||||||
|
hasInitialized: initialState.hasInitialized,
|
||||||
|
};
|
||||||
|
|
||||||
export interface AuthProviderProps {
|
export interface AuthProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -36,7 +45,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAuthState(): AuthState {
|
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> {
|
export function useAuthDispatch(): Dispatch<AuthEvent> {
|
||||||
|
|||||||
Reference in New Issue
Block a user