feat: load post cursors infinitely
This commit is contained in:
@@ -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<typeof import('../use-post-feed')>()
|
||||||
|
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(<PostFeed request={undefined} />)
|
||||||
|
|
||||||
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent(
|
||||||
|
'投稿を取得しています…',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a quiet empty state', () => {
|
||||||
|
usePostFeed.mockReturnValue({
|
||||||
|
...queryResult(),
|
||||||
|
data: { pages: [{ tweets: [] }], pageParams: [undefined] },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||||
|
|
||||||
|
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<never>(() => undefined))
|
||||||
|
usePostFeed.mockReturnValue({
|
||||||
|
...queryResult(),
|
||||||
|
data: {
|
||||||
|
pages: [{ tweets: [], nextCursor: 'next' }],
|
||||||
|
pageParams: [undefined],
|
||||||
|
},
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage: true,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||||
|
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(
|
||||||
|
<PostFeed request={{ kind: 'user', target: 'yuta' }} />,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||||
|
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(<PostFeed request={{ kind: 'user', target: 'bad' }} />)
|
||||||
|
|
||||||
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||||
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||||
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||||
|
|
||||||
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
||||||
|
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<never>(() => undefined))
|
||||||
|
usePostFeed.mockReturnValue({
|
||||||
|
...queryResult(),
|
||||||
|
data: {
|
||||||
|
pages: [{ tweets: [post('1')], nextCursor: 'next' }],
|
||||||
|
pageParams: [undefined],
|
||||||
|
},
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage: true,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<PostFeed request={{ kind: 'user', target: 'yuta' }} />,
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<p className="state" role="status">
|
||||||
|
投稿を取得しています…
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (query.isError && !query.isFetchNextPageError && posts.length === 0) {
|
||||||
|
const error =
|
||||||
|
query.error instanceof PostLoadError
|
||||||
|
? query.error.detail
|
||||||
|
: { message: '投稿を取得できませんでした。', retryable: true }
|
||||||
|
return (
|
||||||
|
<div className="state" role="alert">
|
||||||
|
<p>{error.message}</p>
|
||||||
|
{error.retryable ? (
|
||||||
|
<button onClick={() => void query.refetch()} type="button">
|
||||||
|
再試行
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (posts.length === 0 && !query.hasNextPage) {
|
||||||
|
return <p className="state">条件に一致する投稿はありません。</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-live="polite" className="feed">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} />
|
||||||
|
))}
|
||||||
|
<div aria-hidden="true" ref={sentinel} />
|
||||||
|
{query.isFetchingNextPage ? (
|
||||||
|
<div className="loading-rail" role="status">
|
||||||
|
次の投稿を取得しています…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{query.isFetchNextPageError ? (
|
||||||
|
<div className="state" role="alert">
|
||||||
|
<p>続きの投稿を取得できませんでした。</p>
|
||||||
|
<button onClick={() => void query.fetchNextPage()} type="button">
|
||||||
|
再試行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!query.hasNextPage ? (
|
||||||
|
<p className="state">これ以上の投稿はありません。</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<typeof import('@tanstack/react-start')>()
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<LoadResult>
|
||||||
|
search: (options: { data: SearchPageInput }) => Promise<LoadResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<typeof import('../features/posts/use-post-feed')>()
|
||||||
|
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(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { AppShell } from '#/components/app-shell'
|
import { AppShell } from '#/components/app-shell'
|
||||||
|
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||||
import { SearchForm } from '#/features/posts/components/search-form'
|
import { SearchForm } from '#/features/posts/components/search-form'
|
||||||
import { postSearchRouteSchema } from '#/features/posts/inputs'
|
import { postSearchRouteSchema } from '#/features/posts/inputs'
|
||||||
|
|
||||||
@@ -22,6 +23,18 @@ function SearchRoute() {
|
|||||||
key={`${values.q}:${values.product}:${values.following}`}
|
key={`${values.q}:${values.product}:${values.following}`}
|
||||||
onSubmit={(search) => navigate({ search })}
|
onSubmit={(search) => navigate({ search })}
|
||||||
/>
|
/>
|
||||||
|
<PostFeed
|
||||||
|
request={
|
||||||
|
values.q
|
||||||
|
? {
|
||||||
|
kind: 'search',
|
||||||
|
query: values.q,
|
||||||
|
product: values.product,
|
||||||
|
following: values.following,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { AppShell } from '#/components/app-shell'
|
import { AppShell } from '#/components/app-shell'
|
||||||
|
import { PostFeed } from '#/features/posts/components/post-feed'
|
||||||
import { UserForm } from '#/features/posts/components/user-form'
|
import { UserForm } from '#/features/posts/components/user-form'
|
||||||
import { userRouteSearchSchema } from '#/features/posts/inputs'
|
import { userRouteSearchSchema } from '#/features/posts/inputs'
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ function UserRoute() {
|
|||||||
key={target}
|
key={target}
|
||||||
onSubmit={(nextTarget) => navigate({ search: { target: nextTarget } })}
|
onSubmit={(nextTarget) => navigate({ search: { target: nextTarget } })}
|
||||||
/>
|
/>
|
||||||
|
<PostFeed request={target ? { kind: 'user', target } : undefined} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user