From e08de2366f9fdffd4354c8b8846d1c81c9d40f0b Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 4 Oct 2026 23:55:07 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=AA=A9=EB=A1=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=8F=8C=EC=95=84=EC=98=A4=EB=A9=B4=20CursorPager?= =?UTF-8?q?=EC=9D=98=20'=EC=9D=B4=EC=A0=84'=EA=B3=BC=20=EA=B5=AC=EA=B0=84?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EB=90=98=EC=82=B4=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지나온 커서를 컴포넌트 상태에만 쌓아서, 상세 → '목록으로'로 같은 커서 주소에 돌아와도 페이저가 다시 마운트되며 [cursor]부터 시작 → '이전' 버튼과 "21–40 / 전체" 구간 표시가 사라졌음 - list-return에 경로별 커서 기억(rememberTrail·rememberedTrail) 추가. 검색 파라미터 기억과 같은 수명(최대 50경로, forgetSearches가 함께 비움) - CursorPager는 마운트 때 라우터에서 경로를 읽어 기억한 것으로 시작하고, 쌓은 커서가 바뀔 때마다 기록 - 기억한 것이 다른 목록 조건이거나 지금 커서를 지나오지 않았으면 기존 렌더 중 보정이 새로 쌓음(이전 동작) - 라우터 밖에서 단독 렌더하면 경로가 없어 기억하지 않음 → 호출부(11곳)·기존 spec은 그대로 - 회귀 5건: 같은 경로·조건·커서로 다시 마운트하면 '이전'·구간 복원 후 '이전'이 앞 커서로 이동 / 경로·조건이 다르거나 지나오지 않은 커서면 복원 안 함 / forgetSearches 뒤 복원 안 함 - 페이저 변경을 되돌리면 복원 케이스, 경로를 무시하면 경로 케이스, forgetSearches에서 커서를 안 비우면 세션 케이스가 실패하는 것 확인 --- src/shared/lib/list-return.ts | 31 +++++++++++-- src/shared/ui/cursor-pager.spec.tsx | 67 +++++++++++++++++++++++++++++ src/shared/ui/cursor-pager.tsx | 23 ++++++---- 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/shared/lib/list-return.ts b/src/shared/lib/list-return.ts index c8d03f4..534cf53 100644 --- a/src/shared/lib/list-return.ts +++ b/src/shared/lib/list-return.ts @@ -7,20 +7,43 @@ const MAX_PATHS = 50; const normalize = (pathname: string) => pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname; -export function rememberSearch(pathname: string, search: Record) { +/** CursorPager가 지나온 커서. 목록으로 돌아와 다시 마운트돼도 '이전'과 구간 표시를 되살린다 */ +export interface CursorTrail { + /** 커서를 뺀 목록 조건 */ + filterKey: string; + /** 지나온 커서 순서. undefined는 첫 페이지 */ + cursors: (string | undefined)[]; +} +const lastTrail = new Map(); + +function remember(map: Map, pathname: string, value: T) { const key = normalize(pathname); - lastSearch.delete(key); - lastSearch.set(key, search); + map.delete(key); + map.set(key, value); // 상세 경로는 ID마다 쌓이므로 오래된 것부터 버린다 - if (lastSearch.size > MAX_PATHS) lastSearch.delete(lastSearch.keys().next().value!); + if (map.size > MAX_PATHS) map.delete(map.keys().next().value!); +} + +export function rememberSearch(pathname: string, search: Record) { + remember(lastSearch, pathname, search); } export function rememberedSearch(pathname: string): Record | undefined { return lastSearch.get(normalize(pathname)); } +export function rememberTrail(pathname: string, trail: CursorTrail) { + remember(lastTrail, pathname, trail); +} + +export function rememberedTrail(pathname: string): CursorTrail | undefined { + return lastTrail.get(normalize(pathname)); +} + +/** 세션을 잃으면 기억한 검색 파라미터·커서를 모두 비운다 */ export function forgetSearches() { lastSearch.clear(); + lastTrail.clear(); } /** 라우터 이동이 끝날 때마다 위치를 기록한다. 반환값은 구독 해제 함수. */ diff --git a/src/shared/ui/cursor-pager.spec.tsx b/src/shared/ui/cursor-pager.spec.tsx index dcec04c..2052725 100644 --- a/src/shared/ui/cursor-pager.spec.tsx +++ b/src/shared/ui/cursor-pager.spec.tsx @@ -1,7 +1,17 @@ +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router'; import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useEffect, useState } from 'react'; +import { forgetSearches } from '@/shared/lib/list-return'; + import { CursorPager } from './cursor-pager'; const TOTAL = 45; @@ -44,6 +54,23 @@ function Harness({ initial = {} }: { initial?: Search }) { const button = (name: string) => screen.queryByRole('button', { name }); +/** 목록 화면처럼 라우터 아래 경로에 붙여 렌더한다. 검색 파라미터는 Harness가 들고 있다 */ +function renderAt(pathname: string, initial: Search = {}) { + const root = createRootRoute({ component: Outlet }); + const routes = ['/stores', '/orders'].map((path) => + createRoute({ + getParentRoute: () => root, + path, + component: () => , + }), + ); + const router = createRouter({ + routeTree: root.addChildren(routes), + history: createMemoryHistory({ initialEntries: [pathname] }), + }); + return render(); +} + describe('CursorPager', () => { it('다음으로 가며 지나온 커서를 쌓고, 이전으로 한 페이지씩 돌아온다', async () => { render(); @@ -130,6 +157,46 @@ describe('CursorPager', () => { expect(screen.getByText('21–30 / 전체 45건')).toBeInTheDocument(); }); + describe('목록으로 돌아와 다시 마운트될 때', () => { + afterEach(() => forgetSearches()); + + /** 첫 페이지부터 두 번 다음으로 가 41–45 구간에서 화면을 떠난다 */ + async function leaveAtThirdPage() { + const { unmount } = renderAt('/stores'); + await userEvent.click(await screen.findByRole('button', { name: '다음' })); + await userEvent.click(button('다음')!); + expect(screen.getByText('41–45 / 전체 45건')).toBeInTheDocument(); + unmount(); + } + + it('같은 경로·목록 조건이고 그 커서를 지나왔으면 이전과 구간을 되살린다', async () => { + await leaveAtThirdPage(); + renderAt('/stores', { cursor: 'c40' }); + expect(await screen.findByText('41–45 / 전체 45건')).toBeInTheDocument(); + await userEvent.click(button('이전')!); + expect(screen.getByText('21–40 / 전체 45건')).toBeInTheDocument(); + }); + + it.each<[string, string, Search]>([ + ['경로가 다르면', '/orders', { cursor: 'c40' }], + ['목록 조건이 다르면', '/stores', { cursor: 'c40', q: '케이크' }], + ['지나오지 않은 커서면', '/stores', { cursor: 'c30' }], + ])('%s 되살리지 않는다', async (_, pathname, initial) => { + await leaveAtThirdPage(); + renderAt(pathname, initial); + expect(await screen.findByRole('button', { name: '처음' })).toBeInTheDocument(); + expect(button('이전')).not.toBeInTheDocument(); + }); + + it('세션을 비우면(forgetSearches) 되살리지 않는다', async () => { + await leaveAtThirdPage(); + forgetSearches(); + renderAt('/stores', { cursor: 'c40' }); + expect(await screen.findByText('전체 45건 중 5건 표시')).toBeInTheDocument(); + expect(button('이전')).not.toBeInTheDocument(); + }); + }); + it('결과가 없으면 0건으로 보인다', () => { render( ({ filterKey, cursors: [cursor] }); + // 라우터 밖(단독 렌더)이면 경로가 없어 기억하지 않는다. 경로는 목록 화면마다 고정이라 마운트 때 한 번 읽는다 + const router: AnyRouter | null = useRouter({ warn: false }); + const [pathname] = useState(() => router?.state.location.pathname); + // 기억한 것이 다른 조건이거나 지금 커서를 지나오지 않았으면 아래 보정이 새로 쌓는다 + const [trail, setTrail] = useState(() => { + const saved = pathname === undefined ? undefined : rememberedTrail(pathname); + return saved ?? { filterKey, cursors: [cursor] }; + }); + useEffect(() => { + if (pathname !== undefined) rememberTrail(pathname, trail); + }, [pathname, trail]); let current = trail; if (trail.filterKey !== filterKey || !trail.cursors.includes(cursor)) { From e62ea1f0e54334c06f98fa05dbe39ee00d5cc144 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 5 Oct 2026 00:49:35 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20BE=20develop=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- schema/schema.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/schema.graphql b/schema/schema.graphql index 8fb0baa..e2e6c8a 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -1,4 +1,4 @@ -# 생성 파일 — 수정하지 않는다. caquick-be local@77976b55ccf2d2dd32305b66ebf001a5375098cc 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. +# 생성 파일 — 수정하지 않는다. caquick-be local@7bece88a93b583c4c2d27ba42438cea7f01c9a0d 의 src/features/**/*.graphql 64개를 경로순으로 합쳤다. # 갱신: pnpm schema:pull [ref] # ---- src/features/audit-log/audit-log.types.graphql ----