diff --git a/src/features/posts/components/post-feed.test.tsx b/src/features/posts/components/post-feed.test.tsx new file mode 100644 index 0000000..a5b6a41 --- /dev/null +++ b/src/features/posts/components/post-feed.test.tsx @@ -0,0 +1,330 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Post } from '../types' +import { PostLoadError } from '../use-post-feed' +import { PostFeed } from './post-feed' + +const usePostFeed = vi.hoisted(() => vi.fn()) + +vi.mock('../use-post-feed', async (importOriginal) => { + const original = await importOriginal() + return { ...original, usePostFeed } +}) + +const observe = vi.fn() +const unobserve = vi.fn() +const disconnect = vi.fn() +let observerCallback: IntersectionObserverCallback = () => {} +let observerOptions: IntersectionObserverInit | undefined + +class FakeIntersectionObserver { + readonly root = null + readonly rootMargin: string + readonly thresholds: readonly number[] = [] + + constructor( + callback: IntersectionObserverCallback, + options?: IntersectionObserverInit, + ) { + observerCallback = callback + observerOptions = options + this.rootMargin = options?.rootMargin ?? '0px' + } + + disconnect = disconnect + observe = observe + takeRecords = () => [] + unobserve = unobserve +} + +const post = (id: string): Post => ({ + id, + text: `post-${id}`, + author: { username: `user-${id}`, name: `User ${id}` }, +}) + +const queryResult = () => ({ + data: undefined, + error: null, + fetchNextPage: vi.fn().mockResolvedValue(undefined), + hasNextPage: false, + isError: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isPending: false, + refetch: vi.fn().mockResolvedValue(undefined), +}) + +describe('PostFeed', () => { + beforeEach(() => { + usePostFeed.mockReset() + usePostFeed.mockReturnValue(queryResult() as never) + observe.mockReset() + unobserve.mockReset() + disconnect.mockReset() + observerCallback = () => {} + observerOptions = undefined + vi.stubGlobal( + 'IntersectionObserver', + FakeIntersectionObserver as unknown as typeof IntersectionObserver, + ) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it('renders no feed state before a deliberate request', () => { + render() + + expect(usePostFeed).toHaveBeenCalledWith(undefined) + expect(screen.queryByRole('article')).not.toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('announces the initial loading state', () => { + usePostFeed.mockReturnValue({ + ...queryResult(), + isPending: true, + } as never) + + render() + + expect(screen.getByRole('status')).toHaveTextContent( + '投稿を取得しています…', + ) + }) + + it('renders a quiet empty state', () => { + usePostFeed.mockReturnValue({ + ...queryResult(), + data: { pages: [{ tweets: [] }], pageParams: [undefined] }, + } as never) + + render() + + expect(screen.getByText('条件に一致する投稿はありません。')).toBeVisible() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('continues from an empty page when a next cursor exists', () => { + const fetchNextPage = vi.fn(() => new Promise(() => undefined)) + usePostFeed.mockReturnValue({ + ...queryResult(), + data: { + pages: [{ tweets: [], nextCursor: 'next' }], + pageParams: [undefined], + }, + fetchNextPage, + hasNextPage: true, + } as never) + + render() + act(() => { + observerCallback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + + expect( + screen.queryByText('条件に一致する投稿はありません。'), + ).not.toBeInTheDocument() + expect(fetchNextPage).toHaveBeenCalledOnce() + }) + + it('deduplicates every page while preserving a quiet end state', () => { + usePostFeed.mockReturnValue({ + ...queryResult(), + data: { + pages: [ + { tweets: [post('1'), post('2')], nextCursor: 'next' }, + { tweets: [post('2'), post('3')] }, + ], + pageParams: [undefined, 'next'], + }, + } as never) + + const { container } = render( + , + ) + + expect(screen.getAllByRole('article')).toHaveLength(3) + expect(container.querySelector('[aria-live="polite"]')).toBeInTheDocument() + expect(screen.getByText('これ以上の投稿はありません。')).toBeVisible() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('retries an initial retryable error with refetch', () => { + const refetch = vi.fn().mockResolvedValue(undefined) + usePostFeed.mockReturnValue({ + ...queryResult(), + error: new PostLoadError({ + code: 'upstream', + message: '最初の取得に失敗しました。', + retryable: true, + }), + isError: true, + refetch, + } as never) + + render() + fireEvent.click(screen.getByRole('button', { name: '再試行' })) + + expect(screen.getByRole('alert')).toHaveTextContent( + '最初の取得に失敗しました。', + ) + expect(refetch).toHaveBeenCalledOnce() + }) + + it('does not offer retry for a non-retryable initial error', () => { + usePostFeed.mockReturnValue({ + ...queryResult(), + error: new PostLoadError({ + code: 'invalid-input', + message: '入力を確認してください。', + retryable: false, + }), + isError: true, + } as never) + + render() + + expect(screen.getByRole('alert')).toHaveTextContent( + '入力を確認してください。', + ) + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('keeps loaded posts while retrying a later page', () => { + const fetchNextPage = vi.fn().mockResolvedValue(undefined) + usePostFeed.mockReturnValue({ + ...queryResult(), + data: { + pages: [{ tweets: [post('kept')], nextCursor: 'next' }], + pageParams: [undefined], + }, + error: new PostLoadError({ + code: 'timeout', + message: '取得がタイムアウトしました。', + retryable: true, + }), + fetchNextPage, + hasNextPage: true, + isError: true, + isFetchNextPageError: true, + } as never) + + render() + fireEvent.click(screen.getByRole('button', { name: '再試行' })) + + expect(screen.getByText('post-kept')).toBeVisible() + expect(screen.getByRole('alert')).toHaveTextContent( + '続きの投稿を取得できませんでした。', + ) + expect(observe).not.toHaveBeenCalled() + expect(fetchNextPage).toHaveBeenCalledOnce() + }) + + it('retries a later page after an empty page with fetchNextPage', () => { + const fetchNextPage = vi.fn().mockResolvedValue(undefined) + const refetch = vi.fn().mockResolvedValue(undefined) + usePostFeed.mockReturnValue({ + ...queryResult(), + data: { + pages: [{ tweets: [], nextCursor: 'next' }], + pageParams: [undefined], + }, + error: new PostLoadError({ + code: 'timeout', + message: '取得がタイムアウトしました。', + retryable: true, + }), + fetchNextPage, + hasNextPage: true, + isError: true, + isFetchNextPageError: true, + refetch, + } as never) + + render() + fireEvent.click(screen.getByRole('button', { name: '再試行' })) + + expect(screen.getByRole('alert')).toHaveTextContent( + '続きの投稿を取得できませんでした。', + ) + expect(fetchNextPage).toHaveBeenCalledOnce() + expect(refetch).not.toHaveBeenCalled() + }) + + it('announces next-page loading', () => { + usePostFeed.mockReturnValue({ + ...queryResult(), + data: { + pages: [{ tweets: [post('1')], nextCursor: 'next' }], + pageParams: [undefined], + }, + hasNextPage: true, + isFetchingNextPage: true, + } as never) + + render() + + expect(screen.getByRole('status')).toHaveTextContent( + '次の投稿を取得しています…', + ) + }) + + it('does not auto-fetch while a next page is already loading', () => { + const fetchNextPage = vi.fn().mockResolvedValue(undefined) + usePostFeed.mockReturnValue({ + ...queryResult(), + data: { + pages: [{ tweets: [post('1')], nextCursor: 'next' }], + pageParams: [undefined], + }, + fetchNextPage, + hasNextPage: true, + isFetchingNextPage: true, + } as never) + + render() + act(() => { + observerCallback( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + + expect(observe).not.toHaveBeenCalled() + expect(fetchNextPage).not.toHaveBeenCalled() + }) + + it('auto-fetches once near the sentinel and disconnects on cleanup', () => { + const fetchNextPage = vi.fn(() => new Promise(() => undefined)) + usePostFeed.mockReturnValue({ + ...queryResult(), + data: { + pages: [{ tweets: [post('1')], nextCursor: 'next' }], + pageParams: [undefined], + }, + fetchNextPage, + hasNextPage: true, + } as never) + + const { unmount } = render( + , + ) + act(() => { + const entries = [{ isIntersecting: true } as IntersectionObserverEntry] + observerCallback(entries, {} as IntersectionObserver) + observerCallback(entries, {} as IntersectionObserver) + }) + + expect(observerOptions).toEqual({ rootMargin: '600px 0px' }) + expect(observe).toHaveBeenCalledOnce() + expect(fetchNextPage).toHaveBeenCalledOnce() + + unmount() + expect(disconnect).toHaveBeenCalledOnce() + }) +}) diff --git a/src/features/posts/components/post-feed.tsx b/src/features/posts/components/post-feed.tsx new file mode 100644 index 0000000..0a8f906 --- /dev/null +++ b/src/features/posts/components/post-feed.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef } from 'react' +import { flattenPostPages } from '../page' +import { type FeedRequest, PostLoadError, usePostFeed } from '../use-post-feed' +import { PostCard } from './post-card' + +export function PostFeed({ request }: { request: FeedRequest | undefined }) { + const query = usePostFeed(request) + const sentinel = useRef(null) + const fetchingNextPage = useRef(query.isFetchingNextPage) + const posts = query.data ? flattenPostPages(query.data.pages) : [] + fetchingNextPage.current = query.isFetchingNextPage + + useEffect(() => { + if ( + !sentinel.current || + !query.hasNextPage || + query.isFetchNextPageError || + query.isFetchingNextPage + ) { + return + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting && !fetchingNextPage.current) { + fetchingNextPage.current = true + void query.fetchNextPage() + } + }, + { rootMargin: '600px 0px' }, + ) + observer.observe(sentinel.current) + return () => observer.disconnect() + }, [ + query.fetchNextPage, + query.hasNextPage, + query.isFetchNextPageError, + query.isFetchingNextPage, + ]) + + if (!request) return null + if (query.isPending) { + return ( +

+ 投稿を取得しています… +

+ ) + } + if (query.isError && !query.isFetchNextPageError && posts.length === 0) { + const error = + query.error instanceof PostLoadError + ? query.error.detail + : { message: '投稿を取得できませんでした。', retryable: true } + return ( +
+

{error.message}

+ {error.retryable ? ( + + ) : null} +
+ ) + } + if (posts.length === 0 && !query.hasNextPage) { + return

条件に一致する投稿はありません。

+ } + + return ( +
+ {posts.map((post) => ( + + ))} +
+ ) +} diff --git a/src/features/posts/use-post-feed.test.ts b/src/features/posts/use-post-feed.test.ts new file mode 100644 index 0000000..0a44e55 --- /dev/null +++ b/src/features/posts/use-post-feed.test.ts @@ -0,0 +1,165 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook } from '@testing-library/react' +import { createElement, type ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createPostFeedOptions, + PostLoadError, + usePostFeed, +} from './use-post-feed' + +const useServerFn = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/react-start', async (importOriginal) => { + const original = + await importOriginal() + return { ...original, useServerFn } +}) + +describe('createPostFeedOptions', () => { + it('forwards one user cursor in the server function payload', async () => { + const loadUser = vi.fn().mockResolvedValue({ + ok: true, + page: { tweets: [], nextCursor: 'next' }, + }) + const options = createPostFeedOptions( + { kind: 'user', target: '@yuta' }, + { loadUser, search: vi.fn() }, + ) + + await options.queryFn({ pageParam: 'cursor' } as never) + + expect(loadUser).toHaveBeenCalledOnce() + expect(loadUser).toHaveBeenCalledWith({ + data: { target: '@yuta', cursor: 'cursor' }, + }) + }) + + it('does not retry requests automatically', () => { + const options = createPostFeedOptions( + { kind: 'user', target: 'yuta' }, + { loadUser: vi.fn(), search: vi.fn() }, + ) + + expect(options.retry).toBe(false) + }) + + it('does not refetch automatically when the window regains focus', () => { + const options = createPostFeedOptions( + { kind: 'user', target: 'yuta' }, + { loadUser: vi.fn(), search: vi.fn() }, + ) + + expect(options.refetchOnWindowFocus).toBe(false) + }) + + it('does not refetch automatically when the connection returns', () => { + const options = createPostFeedOptions( + { kind: 'user', target: 'yuta' }, + { loadUser: vi.fn(), search: vi.fn() }, + ) + + expect(options.refetchOnReconnect).toBe(false) + }) + + it('takes the next cursor only from the resolved page', () => { + const options = createPostFeedOptions( + { kind: 'user', target: 'yuta' }, + { loadUser: vi.fn(), search: vi.fn() }, + ) + + expect( + options.getNextPageParam({ tweets: [], nextCursor: 'page-next' }), + ).toBe('page-next') + expect(options.getNextPageParam({ tweets: [] })).toBeUndefined() + }) + + it('includes every search control in the query key', () => { + const options = createPostFeedOptions( + { + kind: 'search', + query: 'AI', + product: 'Top', + following: true, + }, + { loadUser: vi.fn(), search: vi.fn() }, + ) + + expect(options.queryKey).toEqual([ + 'posts', + { kind: 'search', query: 'AI', product: 'Top', following: true }, + ]) + }) + + it('forwards every search control with the current cursor', async () => { + const search = vi.fn().mockResolvedValue({ + ok: true, + page: { tweets: [], nextCursor: 'search-next' }, + }) + const options = createPostFeedOptions( + { + kind: 'search', + query: 'AI lang:ja', + product: 'Top', + following: true, + }, + { loadUser: vi.fn(), search }, + ) + + await options.queryFn({ pageParam: 'search-cursor' } as never) + + expect(search).toHaveBeenCalledOnce() + expect(search).toHaveBeenCalledWith({ + data: { + query: 'AI lang:ja', + product: 'Top', + following: true, + cursor: 'search-cursor', + }, + }) + }) + + it('preserves a typed load failure for the feed', async () => { + const error = { + code: 'upstream' as const, + message: '取得できませんでした。', + retryable: true, + } + const options = createPostFeedOptions( + { kind: 'user', target: 'yuta' }, + { + loadUser: vi.fn().mockResolvedValue({ ok: false, error }), + search: vi.fn(), + }, + ) + + const request = options.queryFn({ pageParam: undefined } as never) + + await expect(request).rejects.toBeInstanceOf(PostLoadError) + await expect(request).rejects.toMatchObject({ + detail: error, + message: error.message, + }) + }) +}) + +describe('usePostFeed', () => { + beforeEach(() => { + useServerFn.mockReset() + }) + + it('keeps an absent request idle without calling a loader', () => { + const loader = vi.fn() + useServerFn.mockReturnValue(loader) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children) + + const { result } = renderHook(() => usePostFeed(undefined), { wrapper }) + + expect(result.current.fetchStatus).toBe('idle') + expect(loader).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/posts/use-post-feed.ts b/src/features/posts/use-post-feed.ts new file mode 100644 index 0000000..a369b70 --- /dev/null +++ b/src/features/posts/use-post-feed.ts @@ -0,0 +1,78 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { useServerFn } from '@tanstack/react-start' +import type { SearchProduct } from '@yuta/bird' +import { loadUserPosts, searchPosts } from './server-functions' +import type { + LoadError, + LoadResult, + PostPage, + SearchPageInput, + UserPageInput, +} from './types' + +export type FeedRequest = + | { kind: 'user'; target: string } + | { + kind: 'search' + query: string + product: SearchProduct + following: boolean + } + +type Loaders = { + loadUser: (options: { data: UserPageInput }) => Promise + search: (options: { data: SearchPageInput }) => Promise +} + +export class PostLoadError extends Error { + constructor(readonly detail: LoadError) { + super(detail.message) + } +} + +function unwrap(result: LoadResult): PostPage { + if (!result.ok) throw new PostLoadError(result.error) + return result.page +} + +export function createPostFeedOptions(request: FeedRequest, loaders: Loaders) { + return { + queryKey: ['posts', request] as const, + initialPageParam: undefined as string | undefined, + retry: false as const, + refetchOnWindowFocus: false as const, + refetchOnReconnect: false as const, + queryFn: async ({ pageParam }: { pageParam: string | undefined }) => + request.kind === 'user' + ? unwrap( + await loaders.loadUser({ + data: { target: request.target, cursor: pageParam }, + }), + ) + : unwrap( + await loaders.search({ + data: { + query: request.query, + product: request.product, + following: request.following, + cursor: pageParam, + }, + }), + ), + getNextPageParam: (page: PostPage) => page.nextCursor, + } +} + +export function usePostFeed(request: FeedRequest | undefined) { + const loadUser = useServerFn(loadUserPosts) + const search = useServerFn(searchPosts) + const disabled = { + kind: 'user', + target: '', + } satisfies FeedRequest + + return useInfiniteQuery({ + ...createPostFeedOptions(request ?? disabled, { loadUser, search }), + enabled: request !== undefined, + }) +} diff --git a/src/routes/-feed-wiring.test.tsx b/src/routes/-feed-wiring.test.tsx new file mode 100644 index 0000000..a622d51 --- /dev/null +++ b/src/routes/-feed-wiring.test.tsx @@ -0,0 +1,91 @@ +import { QueryClient } from '@tanstack/react-query' +import { + createMemoryHistory, + createRouter, + RouterProvider, +} from '@tanstack/react-router' +import { render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { routeTree } from '../routeTree.gen' + +const usePostFeed = vi.hoisted(() => vi.fn()) + +vi.mock('../features/posts/use-post-feed', async (importOriginal) => { + const original = + await importOriginal() + return { ...original, usePostFeed } +}) + +vi.mock('./__root', async () => { + const { createRootRouteWithContext, Outlet } = await import( + '@tanstack/react-router' + ) + return { + Route: createRootRouteWithContext<{ queryClient: QueryClient }>()({ + component: Outlet, + }), + } +}) + +async function renderRoute(path: string) { + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [path] }), + context: { queryClient: new QueryClient() }, + }) + await router.load() + render() +} + +describe('post feed route wiring', () => { + beforeEach(() => { + vi.stubGlobal('scrollTo', vi.fn()) + usePostFeed.mockReset() + usePostFeed.mockReturnValue({ + data: undefined, + error: null, + fetchNextPage: vi.fn(), + hasNextPage: false, + isError: false, + isFetchNextPageError: false, + isFetchingNextPage: false, + isPending: false, + refetch: vi.fn(), + } as never) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it('keeps an empty user URL disabled', async () => { + await renderRoute('/user?target=') + + expect( + await screen.findByRole('heading', { name: '誰の投稿を見ますか?' }), + ).toBeVisible() + expect(usePostFeed).toHaveBeenCalledWith(undefined) + }) + + it('turns a manually entered user URL into a deliberate request', async () => { + await renderRoute('/user?target=yuta') + + await waitFor(() => + expect(usePostFeed).toHaveBeenCalledWith({ + kind: 'user', + target: 'yuta', + }), + ) + }) + + it('forwards every manually entered search control', async () => { + await renderRoute('/search?q=AI%20lang%3Aja&product=Top&following=true') + + await waitFor(() => + expect(usePostFeed).toHaveBeenCalledWith({ + kind: 'search', + query: 'AI lang:ja', + product: 'Top', + following: true, + }), + ) + }) +}) diff --git a/src/routes/search.tsx b/src/routes/search.tsx index 861f363..ba276e6 100644 --- a/src/routes/search.tsx +++ b/src/routes/search.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { AppShell } from '#/components/app-shell' +import { PostFeed } from '#/features/posts/components/post-feed' import { SearchForm } from '#/features/posts/components/search-form' import { postSearchRouteSchema } from '#/features/posts/inputs' @@ -22,6 +23,18 @@ function SearchRoute() { key={`${values.q}:${values.product}:${values.following}`} onSubmit={(search) => navigate({ search })} /> + ) } diff --git a/src/routes/user.tsx b/src/routes/user.tsx index 3cf08f9..d3656c2 100644 --- a/src/routes/user.tsx +++ b/src/routes/user.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { AppShell } from '#/components/app-shell' +import { PostFeed } from '#/features/posts/components/post-feed' import { UserForm } from '#/features/posts/components/user-form' import { userRouteSearchSchema } from '#/features/posts/inputs' @@ -20,6 +21,7 @@ function UserRoute() { key={target} onSubmit={(nextTarget) => navigate({ search: { target: nextTarget } })} /> + ) }