fix auth login state race
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user