Skip to content
Merged
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
24 changes: 23 additions & 1 deletion src/app/(private)/(auth)/hub/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { unstable_rethrow } from 'next/navigation';
import { HubActionCard, HubProfile } from '@/components/auth/hub';
import { apiServer } from '@/lib/apis/server';
import type { ApiResponse } from '@/types/common';
import type { ClubDto } from '@/types/mypage';

type CardVariant = 'create' | 'join' | 'go';

Expand All @@ -20,6 +22,8 @@ export default async function HubPage({

let cardOrder: CardVariant[];
let goHref: string | undefined;
let goClubId: string | undefined;
let goClubName: string | undefined;

const status = await apiServer
.get<MembershipStatusResponse>('/clubs/membership-status')
Expand All @@ -29,7 +33,23 @@ export default async function HubPage({
});

const hasActiveClub = status?.data?.hasActiveClub ?? false;
if (hasActiveClub) goHref = '/club/select';

if (hasActiveClub) {
const clubsRes = await apiServer.get<ApiResponse<ClubDto[]>>('/clubs').catch((err) => {
unstable_rethrow(err);
return null;
});
const clubs = clubsRes?.data ?? [];

if (clubs.length === 1) {
const club = clubs[0];
goHref = `/${club.id}/home`;
goClubId = club.id;
goClubName = club.name;
} else {
goHref = '/club/select';
}
}

if (intent === 'create') {
cardOrder = ['create', 'join', 'go'];
Expand All @@ -55,6 +75,8 @@ export default async function HubPage({
variant={variant}
href={hrefMap[variant]}
isPrimary={index === 0}
clubId={variant === 'go' ? goClubId : undefined}
clubName={variant === 'go' ? goClubName : undefined}
/>
))}
</div>
Expand Down
15 changes: 11 additions & 4 deletions src/hooks/home/useHomeGuard.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
'use client';

import { useEffect, useSyncExternalStore } from 'react';
import { useRouter } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import { isAxiosError } from 'axios';
import { useClubActions, useClubId, useClubStore } from '@/stores/useClubStore';
import { useHomeQuery } from './useHomeQuery';

export function useHomeGuard() {
const router = useRouter();
const { clubId: clubIdParam } = useParams<{ clubId: string }>();
const clubId = useClubId();
const { reset } = useClubActions();
const { reset, syncClubId } = useClubActions();
const { error } = useHomeQuery();

const hydrated = useSyncExternalStore(
Expand All @@ -20,11 +21,17 @@ export function useHomeGuard() {

useEffect(() => {
if (!hydrated) return;
if (!clubId) {

if (clubIdParam && clubId !== clubIdParam) {
syncClubId(clubIdParam);
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!clubIdParam && !clubId) {
reset();
router.replace('/hub');
}
}, [hydrated, clubId, reset, router]);
}, [hydrated, clubId, clubIdParam, reset, router, syncClubId]);

useEffect(() => {
if (isAxiosError(error) && error.response?.status === 404) {
Expand Down
21 changes: 14 additions & 7 deletions src/providers/user-hydrator.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useLayoutEffect, useRef } from 'react';
import { useLayoutEffect } from 'react';

import { useClubStore } from '@/stores/useClubStore';
import { useUserStore } from '@/stores/useUserStore';
Expand All @@ -14,13 +14,20 @@ interface UserHydratorProps {
}

function UserHydrator({ userInfo, clubInfo, children }: UserHydratorProps) {
const hydrated = useRef(false);

useLayoutEffect(() => {
if (hydrated.current) return;
useUserStore.setState(userInfo, false, 'setUser');
useClubStore.setState(clubInfo, false, 'setClub');
hydrated.current = true;
const currentUser = useUserStore.getState();
const currentClub = useClubStore.getState();

if (
currentUser.id !== userInfo.id ||
currentUser.profileImageUrl !== userInfo.profileImageUrl
) {
useUserStore.setState(userInfo, false, 'setUser');
}

if (currentClub.clubId !== clubInfo.clubId || currentClub.clubName !== clubInfo.clubName) {
useClubStore.setState(clubInfo, false, 'setClub');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check ClubIdentifier type definition

rg -n "type ClubIdentifier|interface ClubIdentifier" --type=ts -A10

Repository: Team-Weeth/weeth-client

Length of output: 225


🏁 Script executed:

#!/bin/bash
# View the code context around line 29 in user-hydrator.tsx
cat -n src/providers/user-hydrator.tsx | sed -n '20,40p'

Repository: Team-Weeth/weeth-client

Length of output: 630


🏁 Script executed:

#!/bin/bash
# Find the club store definition and setClub action
rg -n "setClub|export.*createStore" --type=ts -B2 -A8 | head -50

Repository: Team-Weeth/weeth-client

Length of output: 2883


🏁 Script executed:

#!/bin/bash
# Check what type clubInfo has in user-hydrator.tsx
rg -n "clubInfo" src/providers/user-hydrator.tsx -B3 -A3

Repository: Team-Weeth/weeth-client

Length of output: 686


ClubIdentifier 타입에서 clubProfileImageUrl 필드가 누락되어 불완전한 hydration 발생

useClubStore.setState(clubInfo, false, 'setClub')ClubIdentifier 타입의 clubInfo를 직접 전달하는데, 이 타입에는 clubProfileImageUrl이 포함되지 않습니다. store의 setClub action은 clubProfileImageUrl을 지원하도록 설계되었으나, 현재 코드는 이 필드를 제공하지 않아 동기화되지 않습니다.

ClubIdentifier에 clubProfileImageUrl 필드를 추가하거나, 해당 데이터를 별도로 관리해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/providers/user-hydrator.tsx` at line 29, The hydration call
useClubStore.setState(clubInfo, false, 'setClub') is passing a ClubIdentifier
that lacks the clubProfileImageUrl field required by the store's setClub action;
update the code so clubInfo includes clubProfileImageUrl (or map/merge it in
before calling setState) or extend the ClubIdentifier type to include
clubProfileImageUrl, ensuring the setClub action receives the profile URL when
invoking useClubStore.setState (refer to identifiers ClubIdentifier, clubInfo,
clubProfileImageUrl, useClubStore.setState and setClub).

}
}, [clubInfo, userInfo]);

return children;
Expand Down
21 changes: 20 additions & 1 deletion src/stores/useClubStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const useClubStore = create(
combine(initialState, (set) => ({
setClubId: (clubId: string) =>
set({ clubId, clubName: null, clubProfileImageUrl: null }, false, 'setClubId'),
syncClubId: (clubId: string) => set({ clubId }, false, 'syncClubId'),
setClub: (clubId: string, clubName: string, clubProfileImageUrl?: string | null) =>
set(
{ clubId, clubName, clubProfileImageUrl: clubProfileImageUrl ?? null },
Expand All @@ -24,7 +25,24 @@ export const useClubStore = create(
),
reset: () => set(initialState, false, 'reset'),
})),
{ name: 'clubId' },
{
name: 'clubId',
partialize: (state) => ({
...(state.clubId ? { clubId: state.clubId } : {}),
...(state.clubName ? { clubName: state.clubName } : {}),
...(state.clubProfileImageUrl ? { clubProfileImageUrl: state.clubProfileImageUrl } : {}),
}),
merge: (persistedState, currentState) => {
const persisted = (persistedState ?? {}) as Partial<ClubState>;

return {
...currentState,
clubId: persisted.clubId ?? currentState.clubId,
clubName: persisted.clubName ?? currentState.clubName,
clubProfileImageUrl: persisted.clubProfileImageUrl ?? currentState.clubProfileImageUrl,
};
Comment on lines +35 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

merge 로직이 의도적인 null 업데이트를 덮어쓸 수 있습니다.

?? 연산자를 사용하면 persisted 값이 존재할 경우 항상 currentState보다 우선됩니다. 예를 들어:

  • reset() 호출 시 clubIdnull로 설정해도, 기존에 저장된 값이 있으면 다음 hydration 사이클에서 복원됩니다.
  • user-hydrator.tsx에서 clubInfonull 값을 포함할 경우, persisted storage의 이전 값으로 덮어쓰게 됩니다.

의도적인 null 할당을 존중하려면 currentState를 우선하거나, persisted 값을 fallback으로 사용하는 것이 적절합니다.

🔧 currentState 우선 방식으로 변경 제안
         merge: (persistedState, currentState) => {
           const persisted = (persistedState ?? {}) as Partial<ClubState>;

           return {
             ...currentState,
-            clubId: persisted.clubId ?? currentState.clubId,
-            clubName: persisted.clubName ?? currentState.clubName,
-            clubProfileImageUrl:
-              persisted.clubProfileImageUrl ?? currentState.clubProfileImageUrl,
+            clubId: currentState.clubId ?? persisted.clubId,
+            clubName: currentState.clubName ?? persisted.clubName,
+            clubProfileImageUrl:
+              currentState.clubProfileImageUrl ?? persisted.clubProfileImageUrl,
           };
         },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/stores/useClubStore.ts` around lines 36 - 45, The merge implementation
currently favors persistedState via the nullish coalescing operator
(persisted.clubId ?? currentState.clubId), which overwrites intentional nulls
from currentState; change the logic in the merge function to prefer currentState
values when they are present (including explicit null) and only fall back to
persistedState when currentState property is undefined—e.g., for each field like
clubId, clubName, clubProfileImageUrl use a conditional that checks
currentState.<prop> !== undefined ? currentState.<prop> : persisted.<prop> so
reset() or a null clubInfo from user-hydrator.tsx is preserved.

},
},
),
{ name: 'ClubStore' },
),
Expand All @@ -37,6 +55,7 @@ export const useClubActions = () =>
useClubStore(
useShallow((store) => ({
setClubId: store.setClubId,
syncClubId: store.syncClubId,
setClub: store.setClub,
reset: store.reset,
})),
Expand Down
Loading