fix auth login state race

This commit is contained in:
2026-05-09 01:03:56 +08:00
parent 23ba2703b4
commit 9ca66715a4
11 changed files with 219 additions and 11 deletions

View File

@@ -330,6 +330,42 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('phone login result is not overwritten by an older guest hydrate', async () => {
const user = userEvent.setup();
const onAuthenticated = vi.fn();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
authMocks.getCurrentAuthUser
.mockResolvedValueOnce({
user: null,
availableLoginMethods: ['phone'],
})
.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={onAuthenticated} />
<LogoutStateProbe />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
expect(onAuthenticated).toHaveBeenCalledTimes(1);
expect(screen.getByText('当前用户:测试玩家')).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth gate hides register entry and opens invite modal for new sms account', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?inviteCode=spring-2026');

View File

@@ -120,6 +120,7 @@ export function AuthGate({ children }: AuthGateProps) {
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const autoOpenedInviteCodeRef = useRef<string | null>(null);
const hasRenderedPlatformContentRef = useRef(false);
const authHydrateVersionRef = useRef(0);
const canKeepPlatformContentMounted =
hasRenderedPlatformContentRef.current &&
(status === 'checking' || status === 'recovering');
@@ -134,6 +135,7 @@ export function AuthGate({ children }: AuthGateProps) {
const activateReadyUser = useCallback((nextUser: AuthUser) => {
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
authHydrateVersionRef.current += 1;
setUser(nextUser);
setStatus('ready');
}, []);
@@ -141,6 +143,7 @@ export function AuthGate({ children }: AuthGateProps) {
const clearLocalAuthenticatedState = useCallback(() => {
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
authHydrateVersionRef.current += 1;
pendingProtectedActionRef.current = null;
setUser(null);
setStatus('unauthenticated');
@@ -268,11 +271,13 @@ export function AuthGate({ children }: AuthGateProps) {
useEffect(() => {
let isActive = true;
const hydrate = async () => {
const hydrate = async (hydrateToken: number) => {
const isCurrentHydrate = () =>
isActive && hydrateToken === authHydrateVersionRef.current;
const callbackResult = consumeAuthCallbackResult();
const loadLoginOptions = async () => {
const options = await getAuthLoginOptions();
if (!isActive) {
if (!isCurrentHydrate()) {
return null;
}
@@ -285,14 +290,14 @@ export function AuthGate({ children }: AuthGateProps) {
const resolveGuestFallback = async () => {
try {
await loadLoginOptions();
if (!isActive) {
if (!isCurrentHydrate()) {
return;
}
setUser(null);
setStatus('unauthenticated');
} catch (optionsError) {
if (!isActive) {
if (!isCurrentHydrate()) {
return;
}
@@ -305,7 +310,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
};
if (callbackResult?.error && isActive) {
if (callbackResult?.error && isCurrentHydrate()) {
setError(callbackResult.error);
setShowLoginModal(true);
}
@@ -315,8 +320,11 @@ export function AuthGate({ children }: AuthGateProps) {
// 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期,
// 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。
await refreshStoredAccessToken();
if (!isCurrentHydrate()) {
return;
}
const nextSession = await getCurrentAuthUser();
if (!isActive) {
if (!isCurrentHydrate()) {
return;
}
@@ -339,7 +347,7 @@ export function AuthGate({ children }: AuthGateProps) {
);
setError(callbackResult?.error ?? '');
} catch {
if (!isActive) {
if (!isCurrentHydrate()) {
return;
}
@@ -347,11 +355,11 @@ export function AuthGate({ children }: AuthGateProps) {
}
};
void hydrate();
void hydrate(++authHydrateVersionRef.current);
const handleAuthStateChange = () => {
setStatus('checking');
void hydrate();
void hydrate(++authHydrateVersionRef.current);
};
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);