Fix authentication state hydration mismatch (#433)

## Summary
- Fixed avatar showing anonymous for several seconds on page load
- Eliminated hydration mismatch for authentication state
- Header now shows correct user state immediately

## Root Cause
AccountStateInitializer was running client-side in useEffect AFTER
hydration, causing:
1. Server renders anonymous state
2. Client hydrates with anonymous state
3. useEffect runs and updates state (causing the flash)

## Solution
- Read auth cookies server-side in layout.tsx
- Pass initial auth data as props to AccountStateInitializer
- Initialize Valtio state synchronously before first render
- Client-side cookie reading only as fallback

## Changes
- Added server-side cookie parsing in layout.tsx
- Modified AccountStateInitializer to accept initial auth data props
- Made Header component reactive with useSnapshot from Valtio
- State initialization happens synchronously, preventing the flash

## Test plan
- [x] Avatar renders correctly on first load
- [x] No anonymous avatar flash when logged in
- [x] Login/logout still works properly
- [x] State updates are reactive in the header

🤖 Generated with [Claude Code](https://claude.ai/code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-09-03 17:34:34 -07:00 committed by GitHub
parent 73395efee8
commit 778a1c70bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3191 changed files with 159 additions and 22 deletions

View file

@ -3,6 +3,7 @@ import localFont from 'next/font/local'
import { NextIntlClientProvider } from 'next-intl' import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server' import { getMessages } from 'next-intl/server'
import { Viewport as ToastViewport } from '@radix-ui/react-toast' import { Viewport as ToastViewport } from '@radix-ui/react-toast'
import { cookies } from 'next/headers'
import { locales } from '../../i18n.config' import { locales } from '../../i18n.config'
import '../../styles/globals.scss' import '../../styles/globals.scss'
@ -12,6 +13,7 @@ import Providers from '../components/Providers'
import Header from '../components/Header' import Header from '../components/Header'
import UpdateToastClient from '../components/UpdateToastClient' import UpdateToastClient from '../components/UpdateToastClient'
import VersionHydrator from '../components/VersionHydrator' import VersionHydrator from '../components/VersionHydrator'
import AccountStateInitializer from '~components/AccountStateInitializer'
// Generate static params for all locales // Generate static params for all locales
export function generateStaticParams() { export function generateStaticParams() {
@ -48,6 +50,28 @@ export default async function LocaleLayout({
// Load messages for the locale // Load messages for the locale
const messages = await getMessages() const messages = await getMessages()
// Parse auth cookies on server
const cookieStore = cookies()
const accountCookie = cookieStore.get('account')
const userCookie = cookieStore.get('user')
let initialAuthData = null
if (accountCookie && userCookie) {
try {
const accountData = JSON.parse(accountCookie.value)
const userData = JSON.parse(userCookie.value)
if (accountData && accountData.token) {
initialAuthData = {
account: accountData,
user: userData
}
}
} catch (error) {
console.error('Error parsing auth cookies on server:', error)
}
}
// Fetch version data on the server // Fetch version data on the server
let version = null let version = null
try { try {
@ -67,6 +91,7 @@ export default async function LocaleLayout({
<body className={goalking.className}> <body className={goalking.className}>
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<Providers> <Providers>
<AccountStateInitializer initialAuthData={initialAuthData} />
<Header /> <Header />
<VersionHydrator version={version} /> <VersionHydrator version={version} />
<UpdateToastClient initialVersion={version} /> <UpdateToastClient initialVersion={version} />

View file

@ -0,0 +1,106 @@
'use client'
import { useEffect, useRef } from 'react'
import { getCookie } from 'cookies-next'
import { accountState } from '~utils/accountState'
import { setHeaders } from '~utils/userToken'
interface InitialAuthData {
account: {
token: string
userId: string
username: string
role: number
}
user: {
avatar: {
picture: string
element: string
}
gender: number
language: string
theme: string
bahamut?: boolean
}
}
interface AccountStateInitializerProps {
initialAuthData?: InitialAuthData | null
}
export default function AccountStateInitializer({ initialAuthData }: AccountStateInitializerProps) {
const initialized = useRef(false)
// Initialize synchronously on first render if we have server data
if (initialAuthData && !initialized.current) {
initialized.current = true
const { account: accountData, user: userData } = initialAuthData
console.log(`Logged in as user "${accountData.username}"`)
// Set headers for API calls
setHeaders()
// Update account state
accountState.account.authorized = true
accountState.account.user = {
id: accountData.userId,
username: accountData.username,
role: accountData.role,
granblueId: '',
avatar: {
picture: userData.avatar.picture,
element: userData.avatar.element,
},
gender: userData.gender,
language: userData.language,
theme: userData.theme,
bahamut: userData.bahamut || false,
}
}
useEffect(() => {
// Only run client-side cookie reading if no server data
if (initialized.current) return
const accountCookie = getCookie('account')
const userCookie = getCookie('user')
if (accountCookie && userCookie) {
try {
const accountData = JSON.parse(accountCookie as string)
const userData = JSON.parse(userCookie as string)
if (accountData && accountData.token) {
console.log(`Logged in as user "${accountData.username}"`)
// Set headers for API calls
setHeaders()
// Update account state
accountState.account.authorized = true
accountState.account.user = {
id: accountData.userId,
username: accountData.username,
role: accountData.role,
granblueId: '',
avatar: {
picture: userData.avatar.picture,
element: userData.avatar.element,
},
gender: userData.gender,
language: userData.language,
theme: userData.theme,
bahamut: userData.bahamut || false,
}
initialized.current = true
}
} catch (error) {
console.error('Error parsing account cookies:', error)
}
}
}, [])
return null
}

View file

@ -2,6 +2,8 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { deleteCookie, getCookie } from 'cookies-next' import { deleteCookie, getCookie } from 'cookies-next'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useRouter } from '~/i18n/navigation'
import { useSnapshot } from 'valtio'
import classNames from 'classnames' import classNames from 'classnames'
import clonedeep from 'lodash.clonedeep' import clonedeep from 'lodash.clonedeep'
import Link from 'next/link' import Link from 'next/link'
@ -36,10 +38,14 @@ import styles from './index.module.scss'
const Header = () => { const Header = () => {
// Localization // Localization
const t = useTranslations('common') const t = useTranslations('common')
const router = useRouter()
// Locale // Locale
const locale = (getCookie('NEXT_LOCALE') as string) || 'en' const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
// Subscribe to account state changes
const accountSnap = useSnapshot(accountState)
// State management // State management
const [alertOpen, setAlertOpen] = useState(false) const [alertOpen, setAlertOpen] = useState(false)
const [loginModalOpen, setLoginModalOpen] = useState(false) const [loginModalOpen, setLoginModalOpen] = useState(false)
@ -95,7 +101,7 @@ const Header = () => {
if (key !== 'language') accountState[key] = resetState[key] if (key !== 'language') accountState[key] = resetState[key]
}) })
router.reload() router.refresh()
return false return false
} }
@ -112,8 +118,8 @@ const Header = () => {
// Methods: Rendering // Methods: Rendering
const profileImage = () => { const profileImage = () => {
const user = accountState.account.user const user = accountSnap.account.user
if (accountState.account.authorized && user) { if (accountSnap.account.authorized && user) {
return ( return (
<img <img
alt={user.username} alt={user.username}
@ -126,7 +132,7 @@ const Header = () => {
} else { } else {
return ( return (
<img <img
alt={t('no_user')} alt={t('header.anonymous')}
className={`profile anonymous`} className={`profile anonymous`}
srcSet={`/profile/npc.png, srcSet={`/profile/npc.png,
/profile/npc@2x.png 2x`} /profile/npc@2x.png 2x`}
@ -163,18 +169,18 @@ const Header = () => {
const settingsModal = ( const settingsModal = (
<> <>
{accountState.account.user && ( {accountSnap.account.user && (
<AccountModal <AccountModal
open={settingsModalOpen} open={settingsModalOpen}
username={accountState.account.user.username} username={accountSnap.account.user.username}
picture={accountState.account.user.avatar.picture} picture={accountSnap.account.user.avatar.picture}
gender={accountState.account.user.gender} gender={accountSnap.account.user.gender}
language={accountState.account.user.language} language={accountSnap.account.user.language}
theme={accountState.account.user.theme} theme={accountSnap.account.user.theme}
role={accountState.account.user.role} role={accountSnap.account.user.role}
bahamutMode={ bahamutMode={
accountState.account.user.role === 9 accountSnap.account.user.role === 9
? accountState.account.user.bahamut ? accountSnap.account.user.bahamut
: false : false
} }
onOpenChange={setSettingsModalOpen} onOpenChange={setSettingsModalOpen}
@ -194,12 +200,12 @@ const Header = () => {
// Rendering: Compositing // Rendering: Compositing
const authorizedLeftItems = ( const authorizedLeftItems = (
<> <>
{accountState.account.user && ( {accountSnap.account.user && (
<> <>
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem onClick={closeLeftMenu}> <DropdownMenuItem onClick={closeLeftMenu}>
<Link <Link
href={`/${accountState.account.user.username}` || ''} href={`/${accountSnap.account.user.username}` || ''}
> >
<span>{t('menu.profile')}</span> <span>{t('menu.profile')}</span>
</Link> </Link>
@ -214,8 +220,8 @@ const Header = () => {
) )
const leftMenuItems = ( const leftMenuItems = (
<> <>
{accountState.account.authorized && {accountSnap.account.authorized &&
accountState.account.user && accountSnap.account.user &&
authorizedLeftItems} authorizedLeftItems}
<DropdownMenuGroup> <DropdownMenuGroup>
@ -286,15 +292,15 @@ const Header = () => {
const authorizedRightItems = ( const authorizedRightItems = (
<> <>
{accountState.account.user && ( {accountSnap.account.user && (
<> <>
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel> <DropdownMenuLabel>
{`@${accountState.account.user.username}`} {`@${accountSnap.account.user.username}`}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuItem onClick={closeRightMenu}> <DropdownMenuItem onClick={closeRightMenu}>
<Link <Link
href={`/${accountState.account.user.username}` || ''} href={`/${accountSnap.account.user.username}` || ''}
> >
<span>{t('menu.profile')}</span> <span>{t('menu.profile')}</span>
</Link> </Link>
@ -347,7 +353,7 @@ const Header = () => {
const rightMenuItems = ( const rightMenuItems = (
<> <>
{accountState.account.authorized && accountState.account.user {accountSnap.account.authorized && accountSnap.account.user
? authorizedRightItems ? authorizedRightItems
: unauthorizedRightItems} : unauthorizedRightItems}
</> </>
@ -379,7 +385,7 @@ const Header = () => {
return ( return (
<> <>
{accountState.account.user?.bahamut && ( {accountSnap.account.user?.bahamut && (
<div className={styles.bahamut}> <div className={styles.bahamut}>
<BahamutIcon /> <BahamutIcon />
<p>Bahamut Mode is active</p> <p>Bahamut Mode is active</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Some files were not shown because too many files have changed in this diff Show more