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>
|
|
@ -3,6 +3,7 @@ import localFont from 'next/font/local'
|
|||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { getMessages } from 'next-intl/server'
|
||||
import { Viewport as ToastViewport } from '@radix-ui/react-toast'
|
||||
import { cookies } from 'next/headers'
|
||||
import { locales } from '../../i18n.config'
|
||||
|
||||
import '../../styles/globals.scss'
|
||||
|
|
@ -12,6 +13,7 @@ import Providers from '../components/Providers'
|
|||
import Header from '../components/Header'
|
||||
import UpdateToastClient from '../components/UpdateToastClient'
|
||||
import VersionHydrator from '../components/VersionHydrator'
|
||||
import AccountStateInitializer from '~components/AccountStateInitializer'
|
||||
|
||||
// Generate static params for all locales
|
||||
export function generateStaticParams() {
|
||||
|
|
@ -48,6 +50,28 @@ export default async function LocaleLayout({
|
|||
// Load messages for the locale
|
||||
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
|
||||
let version = null
|
||||
try {
|
||||
|
|
@ -67,6 +91,7 @@ export default async function LocaleLayout({
|
|||
<body className={goalking.className}>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Providers>
|
||||
<AccountStateInitializer initialAuthData={initialAuthData} />
|
||||
<Header />
|
||||
<VersionHydrator version={version} />
|
||||
<UpdateToastClient initialVersion={version} />
|
||||
|
|
|
|||
106
components/AccountStateInitializer/index.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
import React, { useState } from 'react'
|
||||
import { deleteCookie, getCookie } from 'cookies-next'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '~/i18n/navigation'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import classNames from 'classnames'
|
||||
import clonedeep from 'lodash.clonedeep'
|
||||
import Link from 'next/link'
|
||||
|
|
@ -36,10 +38,14 @@ import styles from './index.module.scss'
|
|||
const Header = () => {
|
||||
// Localization
|
||||
const t = useTranslations('common')
|
||||
const router = useRouter()
|
||||
|
||||
// Locale
|
||||
const locale = (getCookie('NEXT_LOCALE') as string) || 'en'
|
||||
|
||||
// Subscribe to account state changes
|
||||
const accountSnap = useSnapshot(accountState)
|
||||
|
||||
// State management
|
||||
const [alertOpen, setAlertOpen] = useState(false)
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false)
|
||||
|
|
@ -95,7 +101,7 @@ const Header = () => {
|
|||
if (key !== 'language') accountState[key] = resetState[key]
|
||||
})
|
||||
|
||||
router.reload()
|
||||
router.refresh()
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -112,8 +118,8 @@ const Header = () => {
|
|||
|
||||
// Methods: Rendering
|
||||
const profileImage = () => {
|
||||
const user = accountState.account.user
|
||||
if (accountState.account.authorized && user) {
|
||||
const user = accountSnap.account.user
|
||||
if (accountSnap.account.authorized && user) {
|
||||
return (
|
||||
<img
|
||||
alt={user.username}
|
||||
|
|
@ -126,7 +132,7 @@ const Header = () => {
|
|||
} else {
|
||||
return (
|
||||
<img
|
||||
alt={t('no_user')}
|
||||
alt={t('header.anonymous')}
|
||||
className={`profile anonymous`}
|
||||
srcSet={`/profile/npc.png,
|
||||
/profile/npc@2x.png 2x`}
|
||||
|
|
@ -163,18 +169,18 @@ const Header = () => {
|
|||
|
||||
const settingsModal = (
|
||||
<>
|
||||
{accountState.account.user && (
|
||||
{accountSnap.account.user && (
|
||||
<AccountModal
|
||||
open={settingsModalOpen}
|
||||
username={accountState.account.user.username}
|
||||
picture={accountState.account.user.avatar.picture}
|
||||
gender={accountState.account.user.gender}
|
||||
language={accountState.account.user.language}
|
||||
theme={accountState.account.user.theme}
|
||||
role={accountState.account.user.role}
|
||||
username={accountSnap.account.user.username}
|
||||
picture={accountSnap.account.user.avatar.picture}
|
||||
gender={accountSnap.account.user.gender}
|
||||
language={accountSnap.account.user.language}
|
||||
theme={accountSnap.account.user.theme}
|
||||
role={accountSnap.account.user.role}
|
||||
bahamutMode={
|
||||
accountState.account.user.role === 9
|
||||
? accountState.account.user.bahamut
|
||||
accountSnap.account.user.role === 9
|
||||
? accountSnap.account.user.bahamut
|
||||
: false
|
||||
}
|
||||
onOpenChange={setSettingsModalOpen}
|
||||
|
|
@ -194,12 +200,12 @@ const Header = () => {
|
|||
// Rendering: Compositing
|
||||
const authorizedLeftItems = (
|
||||
<>
|
||||
{accountState.account.user && (
|
||||
{accountSnap.account.user && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={closeLeftMenu}>
|
||||
<Link
|
||||
href={`/${accountState.account.user.username}` || ''}
|
||||
href={`/${accountSnap.account.user.username}` || ''}
|
||||
>
|
||||
<span>{t('menu.profile')}</span>
|
||||
</Link>
|
||||
|
|
@ -214,8 +220,8 @@ const Header = () => {
|
|||
)
|
||||
const leftMenuItems = (
|
||||
<>
|
||||
{accountState.account.authorized &&
|
||||
accountState.account.user &&
|
||||
{accountSnap.account.authorized &&
|
||||
accountSnap.account.user &&
|
||||
authorizedLeftItems}
|
||||
|
||||
<DropdownMenuGroup>
|
||||
|
|
@ -286,15 +292,15 @@ const Header = () => {
|
|||
|
||||
const authorizedRightItems = (
|
||||
<>
|
||||
{accountState.account.user && (
|
||||
{accountSnap.account.user && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{`@${accountState.account.user.username}`}
|
||||
{`@${accountSnap.account.user.username}`}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={closeRightMenu}>
|
||||
<Link
|
||||
href={`/${accountState.account.user.username}` || ''}
|
||||
href={`/${accountSnap.account.user.username}` || ''}
|
||||
>
|
||||
<span>{t('menu.profile')}</span>
|
||||
</Link>
|
||||
|
|
@ -347,7 +353,7 @@ const Header = () => {
|
|||
|
||||
const rightMenuItems = (
|
||||
<>
|
||||
{accountState.account.authorized && accountState.account.user
|
||||
{accountSnap.account.authorized && accountSnap.account.user
|
||||
? authorizedRightItems
|
||||
: unauthorizedRightItems}
|
||||
</>
|
||||
|
|
@ -379,7 +385,7 @@ const Header = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{accountState.account.user?.bahamut && (
|
||||
{accountSnap.account.user?.bahamut && (
|
||||
<div className={styles.bahamut}>
|
||||
<BahamutIcon />
|
||||
<p>Bahamut Mode is active</p>
|
||||
|
|
|
|||
BIN
public/images/previews/01EVWQ.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
public/images/previews/01t0Vs.png
Normal file
|
After Width: | Height: | Size: 563 KiB |
BIN
public/images/previews/02yp3d.png
Normal file
|
After Width: | Height: | Size: 539 KiB |
BIN
public/images/previews/0326wU.png
Normal file
|
After Width: | Height: | Size: 524 KiB |
BIN
public/images/previews/04ME5W.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
public/images/previews/06hrn7.png
Normal file
|
After Width: | Height: | Size: 570 KiB |
BIN
public/images/previews/07A9PE.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/previews/09FxVK.png
Normal file
|
After Width: | Height: | Size: 626 KiB |
BIN
public/images/previews/0BiWM8.png
Normal file
|
After Width: | Height: | Size: 618 KiB |
BIN
public/images/previews/0GuKhK.png
Normal file
|
After Width: | Height: | Size: 450 KiB |
BIN
public/images/previews/0MoFFy.png
Normal file
|
After Width: | Height: | Size: 417 KiB |
BIN
public/images/previews/0N0Xm1.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
public/images/previews/0NkyvT.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/images/previews/0OwKof.png
Normal file
|
After Width: | Height: | Size: 402 KiB |
BIN
public/images/previews/0Rdjf8.png
Normal file
|
After Width: | Height: | Size: 413 KiB |
BIN
public/images/previews/0RuPlO.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
public/images/previews/0S4OKI.png
Normal file
|
After Width: | Height: | Size: 446 KiB |
BIN
public/images/previews/0UfXPc.png
Normal file
|
After Width: | Height: | Size: 494 KiB |
BIN
public/images/previews/0WCBK9.png
Normal file
|
After Width: | Height: | Size: 614 KiB |
BIN
public/images/previews/0Xq7rM.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
public/images/previews/0ZMJ60.png
Normal file
|
After Width: | Height: | Size: 532 KiB |
BIN
public/images/previews/0bTWdc.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
BIN
public/images/previews/0bo50K.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/previews/0dKNFB.png
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
public/images/previews/0eYMha.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
public/images/previews/0esRTi.png
Normal file
|
After Width: | Height: | Size: 658 KiB |
BIN
public/images/previews/0gdkxU.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/images/previews/0mJy7W.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/images/previews/0mZrJu.png
Normal file
|
After Width: | Height: | Size: 497 KiB |
BIN
public/images/previews/0s45bn.png
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
public/images/previews/0s87zu.png
Normal file
|
After Width: | Height: | Size: 672 KiB |
BIN
public/images/previews/0uxwpJ.png
Normal file
|
After Width: | Height: | Size: 781 KiB |
BIN
public/images/previews/0yEA8C.png
Normal file
|
After Width: | Height: | Size: 677 KiB |
BIN
public/images/previews/0z9oUu.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
public/images/previews/0zjppM.png
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
public/images/previews/10GD8g.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
public/images/previews/10wJKn.png
Normal file
|
After Width: | Height: | Size: 444 KiB |
BIN
public/images/previews/11Gvqx.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/images/previews/131yOZ.png
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
public/images/previews/17ppZA.png
Normal file
|
After Width: | Height: | Size: 464 KiB |
BIN
public/images/previews/18sLWV.png
Normal file
|
After Width: | Height: | Size: 477 KiB |
BIN
public/images/previews/18tflX.png
Normal file
|
After Width: | Height: | Size: 652 KiB |
BIN
public/images/previews/1BUdOX.png
Normal file
|
After Width: | Height: | Size: 526 KiB |
BIN
public/images/previews/1C1IpH.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/previews/1CIkd7.png
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
public/images/previews/1CbuAj.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/previews/1D6EJ5.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
public/images/previews/1Gs3IM.png
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
public/images/previews/1Hh0F3.png
Normal file
|
After Width: | Height: | Size: 547 KiB |
BIN
public/images/previews/1HwDeF.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
public/images/previews/1KHO9s.png
Normal file
|
After Width: | Height: | Size: 540 KiB |
BIN
public/images/previews/1LDwKj.png
Normal file
|
After Width: | Height: | Size: 628 KiB |
BIN
public/images/previews/1MFTIw.png
Normal file
|
After Width: | Height: | Size: 556 KiB |
BIN
public/images/previews/1QYsv5.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/images/previews/1TEaI6.png
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
public/images/previews/1Vl6nK.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/images/previews/1WUkJp.png
Normal file
|
After Width: | Height: | Size: 505 KiB |
BIN
public/images/previews/1Whq6N.png
Normal file
|
After Width: | Height: | Size: 513 KiB |
BIN
public/images/previews/1aoPqR.png
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
public/images/previews/1cUuP6.png
Normal file
|
After Width: | Height: | Size: 595 KiB |
BIN
public/images/previews/1cWO3A.png
Normal file
|
After Width: | Height: | Size: 703 KiB |
BIN
public/images/previews/1flRHd.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
public/images/previews/1fpbvc.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
public/images/previews/1fqxmB.png
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
public/images/previews/1gJipk.png
Normal file
|
After Width: | Height: | Size: 527 KiB |
BIN
public/images/previews/1iyWKu.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
public/images/previews/1m9ENM.png
Normal file
|
After Width: | Height: | Size: 613 KiB |
BIN
public/images/previews/1mnjDP.png
Normal file
|
After Width: | Height: | Size: 443 KiB |
BIN
public/images/previews/1tOMr5.png
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
public/images/previews/1tkdp2.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
public/images/previews/1w2hoU.png
Normal file
|
After Width: | Height: | Size: 447 KiB |
BIN
public/images/previews/1wamYs.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/images/previews/1ysxmB.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/images/previews/1zZM96.png
Normal file
|
After Width: | Height: | Size: 748 KiB |
BIN
public/images/previews/20K0pd.png
Normal file
|
After Width: | Height: | Size: 500 KiB |
BIN
public/images/previews/21Q64T.png
Normal file
|
After Width: | Height: | Size: 738 KiB |
BIN
public/images/previews/21mi1o.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
public/images/previews/23eVkv.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
public/images/previews/23ybmd.png
Normal file
|
After Width: | Height: | Size: 479 KiB |
BIN
public/images/previews/247jb8.png
Normal file
|
After Width: | Height: | Size: 556 KiB |
BIN
public/images/previews/24NGoi.png
Normal file
|
After Width: | Height: | Size: 466 KiB |
BIN
public/images/previews/24Q4fh.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/images/previews/26APfW.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
public/images/previews/28xbNt.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
public/images/previews/2Bv6YK.png
Normal file
|
After Width: | Height: | Size: 720 KiB |
BIN
public/images/previews/2FYJbr.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
public/images/previews/2GCOUr.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/previews/2Hq5Hn.png
Normal file
|
After Width: | Height: | Size: 565 KiB |
BIN
public/images/previews/2KYC6c.png
Normal file
|
After Width: | Height: | Size: 526 KiB |
BIN
public/images/previews/2M8cSY.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
public/images/previews/2MSxfD.png
Normal file
|
After Width: | Height: | Size: 600 KiB |
BIN
public/images/previews/2Mma1a.png
Normal file
|
After Width: | Height: | Size: 670 KiB |
BIN
public/images/previews/2NJ3WO.png
Normal file
|
After Width: | Height: | Size: 517 KiB |
BIN
public/images/previews/2NmZRF.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/previews/2O2WwC.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/images/previews/2RCXJo.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/images/previews/2Rkbuw.png
Normal file
|
After Width: | Height: | Size: 521 KiB |