diff --git a/frontend/src/features/schedule/presentation/useScheduleCalendar.ts b/frontend/src/features/schedule/presentation/useScheduleCalendar.ts index 3c572c2b..0ae0bba8 100644 --- a/frontend/src/features/schedule/presentation/useScheduleCalendar.ts +++ b/frontend/src/features/schedule/presentation/useScheduleCalendar.ts @@ -57,6 +57,22 @@ export function useScheduleCalendar( const [locationsLoading, setLocationsLoading] = useState(true); const [occurrencesError, setOccurrencesError] = useState(null); const [locationsError, setLocationsError] = useState(null); + // 首次加载成功后,后续重取(增删日程/语音写日程/轮询)不再把整个 agenda + // 卸载重挂——只在真的还没数据时才盖全屏转圈,之后都是后台悄悄换数据。必须等 + // 两个请求都没报错才算数:首次加载失败时不能锁上,否则点重试会在空/旧数据 + // 上直接显示 agenda,而不是原来的全屏首次加载态。渲染期间同步翻它,不放进 + // useEffect(react-hooks 规则不允许在 effect 里直接 setState 触发级联渲染, + // 也不允许在渲染期间读 ref)。 + const [initialLoadDone, setInitialLoadDone] = useState(false); + if ( + !initialLoadDone && + !occurrencesLoading && + !locationsLoading && + occurrencesError === null && + locationsError === null + ) { + setInitialLoadDone(true); + } const [reloadToken, setReloadToken] = useState(0); const focusKey = focusTarget @@ -215,7 +231,7 @@ export function useScheduleCalendar( occurrencesByDate, selectedOccurrences, locationSchedules, - loading: occurrencesLoading || locationsLoading, + loading: !initialLoadDone && (occurrencesLoading || locationsLoading), error: occurrencesError ?? locationsError, selectDate, changeMonth, diff --git a/frontend/tests/unit/features/schedule/presentation/useScheduleCalendar.test.ts b/frontend/tests/unit/features/schedule/presentation/useScheduleCalendar.test.ts index 58879ca2..984a1bbf 100644 --- a/frontend/tests/unit/features/schedule/presentation/useScheduleCalendar.test.ts +++ b/frontend/tests/unit/features/schedule/presentation/useScheduleCalendar.test.ts @@ -300,4 +300,87 @@ describe('useScheduleCalendar', () => { expect(result.current.locationSchedules).toHaveLength(1); expect(getLocationSchedules).toHaveBeenCalledTimes(1); }); + + it('keeps loading false on a background refetch after the first load completes', async () => { + let resolveSecondFetch: (value: readonly []) => void = () => {}; + const getSchedulesByRange = jest + .fn() + .mockResolvedValueOnce([]) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecondFetch = resolve; + }), + ); + const service = { + getSchedulesByRange, + getSchedulesByDay: jest.fn(), + getLocationSchedules: jest + .fn() + .mockResolvedValue([]), + } as ScheduleCalendarReadService; + + const { result } = renderHook(() => + useScheduleCalendar(service, 'account-a', 'Asia/Shanghai', new Date(2026, 7, 14)), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(getSchedulesByRange).toHaveBeenCalledTimes(1); + + act(() => { + result.current.retry(); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(getSchedulesByRange).toHaveBeenCalledTimes(2); + expect(result.current.loading).toBe(false); + + await act(async () => { + resolveSecondFetch([]); + }); + }); + + it('keeps the full-screen loading state on retry after the initial load fails', async () => { + let resolveRetryFetch: (value: readonly []) => void = () => {}; + const getSchedulesByRange = jest + .fn() + .mockRejectedValueOnce(new Error('network error')) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRetryFetch = resolve; + }), + ); + const service = { + getSchedulesByRange, + getSchedulesByDay: jest.fn(), + getLocationSchedules: jest + .fn() + .mockResolvedValue([]), + } as ScheduleCalendarReadService; + + const { result } = renderHook(() => + useScheduleCalendar(service, 'account-a', 'Asia/Shanghai', new Date(2026, 7, 14)), + ); + + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.loading).toBe(false); + + act(() => { + result.current.retry(); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(getSchedulesByRange).toHaveBeenCalledTimes(2); + expect(result.current.loading).toBe(true); + + await act(async () => { + resolveRetryFetch([]); + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); });