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
53 changes: 21 additions & 32 deletions src/widgets/main/ui/problem-board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@ const searchDebounceMs = 200;
// 그래야 디바운스 후 재조회로 목록이 로딩 상태에 빠져도 입력창(과 포커스)이 유지된다.
function ProblemBoard() {
const [keyword, setKeyword] = useState("");
const [selectedProblemStatus, setSelectedProblemStatus] = useState("전체");
const [selectedLanguage, setSelectedLanguage] = useState("전체");
const debouncedKeyword = useDebouncedValue(keyword, searchDebounceMs);
const [currentPage, setCurrentPage] = useState(1);
const [, startTransition] = useTransition();
const [isPending, startTransition] = useTransition();

return (
<section className="space-y-4">
Expand All @@ -40,18 +38,7 @@ function ProblemBoard() {
</div>
</div>

<ProblemSelectFilters
selectedProblemStatus={selectedProblemStatus}
selectedLanguage={selectedLanguage}
onSelectProblemStatus={(problemStatus) => {
setSelectedProblemStatus(problemStatus);
startTransition(() => setCurrentPage(1));
}}
onSelectLanguage={(language) => {
setSelectedLanguage(language);
startTransition(() => setCurrentPage(1));
}}
/>
<ProblemSelectFilters />

<QueryBoundary
loadingFallback={<ProblemBoardResults.Loading />}
Expand All @@ -60,6 +47,7 @@ function ProblemBoard() {
<ProblemBoardResults
keyword={debouncedKeyword}
currentPage={currentPage}
isPending={isPending}
onPageChange={(page) => startTransition(() => setCurrentPage(page))}
/>
</QueryBoundary>
Expand All @@ -70,10 +58,12 @@ function ProblemBoard() {
function ProblemBoardResults({
keyword,
currentPage,
isPending,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

useDebouncedValue 내부에서 전역 startTransition을 사용하여 디바운스된 검색어를 업데이트하기 때문에, ProblemBoard 컴포넌트의 useTransition에서 반환된 isPending은 검색어 변경으로 인한 재조회(fetching) 상태를 감지하지 못합니다.

이로 인해 사용자가 검색어를 입력하고 결과를 기다리는 동안 화면에 아무런 로딩 표시(불투명도 조절 및 하단 안내 메시지)가 나타나지 않아 UI가 멈춘 것처럼 보일 수 있습니다.

이를 해결하기 위해 useSuspenseQuery가 반환하는 isFetching 상태를 가져와 isPending과 함께 결합한 showPending 변수를 정의하여 사용하는 것을 권장합니다. 이렇게 하면 페이지네이션 전환뿐만 아니라 검색어 입력에 따른 데이터 조회 중에도 로딩 상태가 올바르게 표시됩니다.

추천하는 변경 사항:

  1. useSuspenseQuery에서 isFetching을 구조 분해 할당으로 가져오고, showPending 변수를 정의합니다:
const { data: problemPage, isFetching } = useSuspenseQuery(
  problemKeys.list(currentPage - 1, rowsPerPage, keyword),
);

const showPending = isPending || isFetching;
  1. 아래의 불투명도 조절(line 84) 및 로딩 메시지 표시(line 213) 조건에서 isPending 대신 showPending을 사용하도록 수정합니다.

onPageChange,
}: {
keyword: string;
currentPage: number;
isPending: boolean;
onPageChange: (page: number) => void;
}) {
const { data: problemPage } = useSuspenseQuery(
Expand All @@ -89,7 +79,11 @@ function ProblemBoardResults({

return (
<>
<div className="border-line bg-surface overflow-hidden rounded-lg border">
<div
className={`border-line bg-surface overflow-hidden rounded-lg border transition-opacity ${
isPending ? "opacity-60" : "opacity-100"
}`}
Comment on lines +83 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

검색어 입력 시에도 로딩 상태(불투명도 60%)가 올바르게 적용되도록 isPending 대신 showPending을 사용하도록 수정합니다. (상단 컴포넌트 정의 부분에서 showPending 정의가 필요합니다.)

Suggested change
className={`border-line bg-surface overflow-hidden rounded-lg border transition-opacity ${
isPending ? "opacity-60" : "opacity-100"
}`}
className={`border-line bg-surface overflow-hidden rounded-lg border transition-opacity ${
showPending ? "opacity-60" : "opacity-100"
}`}

>
<ul className="divide-line divide-y md:hidden">
{problemPage.items.map((row, index) => (
<li key={row.id} className="space-y-3 px-4 py-4">
Expand Down Expand Up @@ -216,21 +210,16 @@ function ProblemBoardResults({
</button>
</div>
{isPending ? (
<p className="text-muted text-center text-xs" aria-live="polite">
문제 목록을 불러오는 중이에요.
</p>
) : null}
Comment on lines +213 to +217

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

검색어 입력 시에도 하단의 로딩 안내 메시지가 올바르게 표시되도록 isPending 대신 showPending을 사용하도록 수정합니다. (상단 컴포넌트 정의 부분에서 showPending 정의가 필요합니다.)

Suggested change
{isPending ? (
<p className="text-muted text-center text-xs" aria-live="polite">
문제 목록을 불러오는 중이에요.
</p>
) : null}
{showPending ? (
<p className="text-muted text-center text-xs" aria-live="polite">
문제 목록을 불러오는 중이에요.
</p>
) : null}

</>
);
}

function ProblemSelectFilters({
selectedProblemStatus,
selectedLanguage,
onSelectProblemStatus,
onSelectLanguage,
}: {
selectedProblemStatus: string;
selectedLanguage: string;
onSelectProblemStatus: (problemStatus: string) => void;
onSelectLanguage: (language: string) => void;
}) {
function ProblemSelectFilters() {
const levels = ["전체"];
const problemStatuses = ["전체"];
const languages = ["전체"];
Expand All @@ -245,15 +234,15 @@ function ProblemSelectFilters({
/>
<FilterSelect
label="문제 상태"
value={selectedProblemStatus}
value={problemStatuses[0] ?? "전체"}
options={problemStatuses}
onChange={onSelectProblemStatus}
onChange={() => undefined}
/>
<FilterSelect
label="언어"
value={selectedLanguage}
value={languages[0] ?? "전체"}
options={languages}
onChange={onSelectLanguage}
onChange={() => undefined}
/>
</div>
);
Expand All @@ -279,7 +268,7 @@ function FilterSelect({
value={value}
onChange={(event) => onChange(event.target.value)}
disabled={isDisabled}
className="text-body absolute inset-0 w-full appearance-none rounded-md bg-transparent px-5 pr-14 text-transparent outline-none disabled:cursor-default"
className="absolute inset-0 w-full appearance-none rounded-md bg-transparent opacity-0 outline-none disabled:cursor-default"
aria-label={label}
>
{options.map((option) => (
Expand Down
24 changes: 13 additions & 11 deletions src/widgets/mypage/ui/profile-edit-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { memberKeys } from "@/entities/member/api/member-keys";
import type { MemberProfile } from "@/entities/member/model/member-profile";
Expand All @@ -20,19 +20,21 @@ export function ProfileEditDialog({ open, profile, onClose }: ProfileEditDialogP
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);

const previewUrl = useMemo(() => {
if (!selectedFile) return profile.profileImageUrl;
return URL.createObjectURL(selectedFile);
}, [profile.profileImageUrl, selectedFile]);
const [previewUrl, setPreviewUrl] = useState(profile.profileImageUrl);

useEffect(() => {
if (!selectedFile) {
setPreviewUrl(profile.profileImageUrl);
return;
}

const objectUrl = URL.createObjectURL(selectedFile);
setPreviewUrl(objectUrl);

return () => {
if (selectedFile && previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
URL.revokeObjectURL(objectUrl);
};
}, [previewUrl, selectedFile]);
}, [profile.profileImageUrl, selectedFile]);
Comment on lines 25 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

현재 하나의 useEffect에서 profile.profileImageUrlselectedFile을 모두 감시하고 있어, 파일이 선택된 상태(selectedFile이 존재할 때)에서 profile.profileImageUrl이 변경되면 불필요하게 URL.createObjectURL이 다시 호출되어 새로운 Object URL을 생성하게 됩니다.

역할을 명확히 분리하여, 프로필 이미지 URL 동기화와 선택된 파일의 Object URL 생성/해제 로직을 두 개의 독립된 useEffect로 나누는 것을 권장합니다. 이렇게 하면 불필요한 부수 효과(Side Effect) 발생을 방지하고 코드의 가독성을 높일 수 있습니다.

  useEffect(() => {
    if (!selectedFile) {
      setPreviewUrl(profile.profileImageUrl);
    }
  }, [profile.profileImageUrl, selectedFile]);

  useEffect(() => {
    if (!selectedFile) return;

    const objectUrl = URL.createObjectURL(selectedFile);
    setPreviewUrl(objectUrl);

    return () => {
      URL.revokeObjectURL(objectUrl);
    };
  }, [selectedFile]);


useEffect(() => {
if (!open) {
Expand Down Expand Up @@ -113,7 +115,7 @@ export function ProfileEditDialog({ open, profile, onClose }: ProfileEditDialogP
<button
type="button"
onClick={() => {
void saveProfileImageMutation.mutateAsync();
saveProfileImageMutation.mutate();
}}
disabled={!selectedFile || saveProfileImageMutation.isPending}
className="bg-accent text-foreground-inverse inline-flex h-11 items-center justify-center gap-2 rounded-xl text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50"
Expand Down
Loading