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,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 { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { SplashBackground } from "../splash-background";
import { SplashButton } from "../splash-button";
import { SplashContent } from "../splash-content";
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", () => {
it("renders SplashLogo with Tailwind wrapper and image classes", () => {
const html = renderToStaticMarkup(<SplashLogo />);
@@ -48,33 +34,19 @@ describe("splash Tailwind components", () => {
expect(html).toContain("Welcome to my secret hideout");
});
it("renders SplashButton ready and loading states", () => {
authStateMock.value = {
...authStateMock.value,
hasInitialized: true,
isLoading: false,
};
const readyHtml = renderToStaticMarkup(
it("renders SplashButton as an always-ready action", () => {
const html = renderToStaticMarkup(
<SplashButton onStartChat={() => undefined} />,
);
authStateMock.value = {
...authStateMock.value,
hasInitialized: false,
isLoading: false,
};
const loadingHtml = renderToStaticMarkup(
<SplashButton onStartChat={() => undefined} />,
);
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");
expect(html).toContain("z-2");
expect(html).toContain("max-w-120");
expect(html).toContain("bg-[linear-gradient(to_right");
expect(html).toContain("active:enabled:scale-96");
expect(html).toContain("active:enabled:brightness-90");
expect(html).toContain("touch-manipulation");
expect(html).toContain("Start Chatting");
expect(html).not.toContain("disabled=\"");
expect(html).not.toContain("animate-spin");
});
});
+3 -14
View File
@@ -2,32 +2,21 @@
/**
* Splash 底部按钮组
*/
import { useAuthState } from "@/stores/auth/auth-context";
import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
export interface SplashButtonProps {
onStartChat: () => void;
}
export function SplashButton({ onStartChat }: SplashButtonProps) {
const state = useAuthState();
const isLoading = !state.hasInitialized || state.isLoading;
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))]">
<button
type="button"
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"
>
{isLoading ? (
<LoadingIndicator color="#ffffff" />
) : (
<span className="whitespace-nowrap text-(length:--responsive-section-title,var(--font-size-xxl)) font-bold text-white">
Start Chatting
</span>
)}
<span className="whitespace-nowrap text-(length:--responsive-section-title,var(--font-size-xxl)) font-bold text-white">
Start Chatting
</span>
</button>
</div>
);
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useEffect, useState } from "react";
import type { LoginStatus } from "@/data/dto/auth";
import { useHasHydrated } from "@/hooks/use-has-hydrated";
import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message";
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
import { Logger, Result } from "@/utils";
@@ -26,21 +27,13 @@ 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 hasHydrated = useHasHydrated();
const [state, setState] = useState<SplashLatestMessageState>({
key: "",
loaded: false,