Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions web/src/__tests__/initial-session-loading.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, createElement, useEffect, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { useInitialSessionLoading } from "../hooks/use-initial-session-loading";

(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;

const win = new Window();
const globals = globalThis as Record<string, unknown>;
if (!globals.window) globals.window = win;
if (!globals.document) globals.document = win.document;
if (!globals.navigator) globals.navigator = win.navigator;

function createLoadingHarness() {
const latest = { value: false };

function Probe({ isPending }: { isPending: boolean }) {
latest.value = useInitialSessionLoading(isPending);
return null;
}

const container = win.document.createElement("div");
const root: Root = createRoot(container as unknown as HTMLElement);

return {
latest,
render(isPending: boolean) {
act(() => root.render(createElement(Probe, { isPending })));
},
unmount() {
act(() => root.unmount());
},
};
}

describe("useInitialSessionLoading", () => {
// 首次 session 请求应阻塞页面,完成后任何后台刷新都不能重新进入全屏 loading
test("仅首次 pending 返回 loading", () => {
const harness = createLoadingHarness();

harness.render(true);
expect(harness.latest.value).toBe(true);

harness.render(false);
expect(harness.latest.value).toBe(false);

harness.render(true);
expect(harness.latest.value).toBe(false);

harness.render(false);
expect(harness.latest.value).toBe(false);
harness.unmount();
});

// 初始已有 session 结果时不应闪现全屏 loading
test("初始非 pending 时直接保持页面可见", () => {
const harness = createLoadingHarness();

harness.render(false);
expect(harness.latest.value).toBe(false);

harness.render(true);
expect(harness.latest.value).toBe(false);
harness.unmount();
});

// 登录页完成首次加载后,聚焦刷新不得卸载子树或清空未提交表单草稿
test("后台 pending 周期保留子组件和本地草稿", () => {
const latest = {
draft: "",
setDraft: null as ((value: string) => void) | null,
mountCount: 0,
};

function DraftProbe() {
const [draft, setDraft] = useState("");
latest.draft = draft;
latest.setDraft = setDraft;

useEffect(() => {
latest.mountCount += 1;
}, []);

return null;
}

function Boundary({ isPending }: { isPending: boolean }) {
const showLoading = useInitialSessionLoading(isPending);
return showLoading ? null : createElement(DraftProbe);
}

const container = win.document.createElement("div");
const root = createRoot(container as unknown as HTMLElement);

act(() => root.render(createElement(Boundary, { isPending: true })));
expect(latest.mountCount).toBe(0);

act(() => root.render(createElement(Boundary, { isPending: false })));
expect(latest.mountCount).toBe(1);

act(() => latest.setDraft?.("admin@fenix.com"));
expect(latest.draft).toBe("admin@fenix.com");

act(() => root.render(createElement(Boundary, { isPending: true })));
expect(latest.mountCount).toBe(1);
expect(latest.draft).toBe("admin@fenix.com");

act(() => root.render(createElement(Boundary, { isPending: false })));
expect(latest.mountCount).toBe(1);
expect(latest.draft).toBe("admin@fenix.com");

act(() => root.unmount());
});
});
16 changes: 16 additions & 0 deletions web/src/hooks/use-initial-session-loading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";

/**
* 仅在 session 首次解析前返回 loading,后台 revalidation 保持现有页面挂载。
*/
export function useInitialSessionLoading(isPending: boolean): boolean {
const [hasSettled, setHasSettled] = useState(() => !isPending);

useEffect(() => {
if (!isPending) {
setHasSettled(true);
}
}, [isPending]);

return isPending && !hasSettled;
}
4 changes: 3 additions & 1 deletion web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Toaster } from "sonner";
import { OrgProvider } from "../contexts/OrgContext";
import { useInitialSessionLoading } from "../hooks/use-initial-session-loading";
import { useSession } from "../lib/auth-client";
import { ThemeProvider } from "../lib/theme";

Expand All @@ -13,6 +14,7 @@ export const Route = createRootRoute({

function RootComponent() {
const { data: session, isPending } = useSession();
const isInitialSessionLoading = useInitialSessionLoading(isPending);
const navigate = useNavigate();
const pathname = useRouterState({ select: (s) => s.location.pathname });
const { t } = useTranslation("common");
Expand All @@ -30,7 +32,7 @@ function RootComponent() {
}
}, [session, isPending, pathname, navigate, isAdminPath]);

if (isPending) {
if (isInitialSessionLoading) {
return (
<ThemeProvider defaultTheme="light">
<div className="flex h-screen flex-col items-center justify-center gap-4">
Expand Down
Loading