@fullstackdatasolutions/articles 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackdatasolutions/articles",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "funding": {
@@ -152,8 +152,16 @@ function renderSection(
152
152
  }
153
153
  }
154
154
 
155
- export function ArticlesPage({ config }: Readonly<{ config: ArticlesConfig }>) {
156
- const state = useArticles()
155
+ export function ArticlesPage({
156
+ config,
157
+ initialArticles,
158
+ initialCategories,
159
+ }: Readonly<{
160
+ config: ArticlesConfig
161
+ initialArticles?: Article[]
162
+ initialCategories?: CategoryInfo[]
163
+ }>) {
164
+ const state = useArticles(initialArticles, initialCategories)
157
165
  const layout = config.layout ?? DEFAULT_LAYOUT
158
166
  const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
159
167
  const categoriesPageSize = config.categoriesPageSize ?? DEFAULT_CATEGORIES_PAGE_SIZE
@@ -273,6 +273,23 @@ describe('ArticlesPage', () => {
273
273
  await waitFor(() => expect(screen.getByText('Browse by Category')).toBeInTheDocument())
274
274
  })
275
275
 
276
+ it('renders real article links synchronously when given server-fetched initialArticles, with no client fetch', () => {
277
+ // Simulates the SSR path: a server component fetches articles via getAllArticles()
278
+ // and passes them down, so Googlebot's first request already contains real hrefs.
279
+ ;(globalThis.fetch as jest.Mock).mockReturnValue(new Promise(() => {}))
280
+ render(
281
+ <ArticlesPage config={{ ...baseConfig, layout: ['latest'] }} initialArticles={mockArticles} />
282
+ )
283
+
284
+ expect(screen.getByText('Latest Articles')).toBeInTheDocument()
285
+ expect(screen.queryByText('Loading articles...')).not.toBeInTheDocument()
286
+ expect(screen.getByRole('link', { name: 'Second Article' })).toHaveAttribute(
287
+ 'href',
288
+ '/articles/article-2'
289
+ )
290
+ expect(globalThis.fetch).not.toHaveBeenCalled()
291
+ })
292
+
276
293
  it('uses pageSize from config', async () => {
277
294
  const manyArticles = Array.from({ length: 10 }, (_, i) => ({
278
295
  ...mockArticles[0],
@@ -190,6 +190,34 @@ describe('useArticles', () => {
190
190
  })
191
191
  })
192
192
 
193
+ describe('with server-seeded initial data', () => {
194
+ it('starts populated and not loading, and skips the mount fetch', () => {
195
+ ;(globalThis.fetch as jest.Mock).mockReturnValue(new Promise(() => {}))
196
+ const mockCategories = [
197
+ { name: 'Campaigns', slug: 'campaigns', count: 2, featuredImage: '/x.jpg' },
198
+ ]
199
+
200
+ const { result } = renderHook(() => useArticles(mockArticles, mockCategories))
201
+
202
+ expect(result.current.loading).toBe(false)
203
+ expect(result.current.articles).toHaveLength(3)
204
+ expect(result.current.categories).toEqual(mockCategories)
205
+ expect(globalThis.fetch).not.toHaveBeenCalled()
206
+ })
207
+
208
+ it('still fetches when a search is performed after seeding', async () => {
209
+ ;(globalThis.fetch as jest.Mock).mockResolvedValue({
210
+ ok: true,
211
+ json: async () => ({ articles: [mockArticles[0]] }),
212
+ })
213
+
214
+ const { result } = renderHook(() => useArticles(mockArticles, []))
215
+ act(() => result.current.handleSearch(''))
216
+
217
+ await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledWith('/api/articles'))
218
+ })
219
+ })
220
+
193
221
  it('refetches all articles when handleSearch is called with empty string', async () => {
194
222
  ;(globalThis.fetch as jest.Mock).mockResolvedValue({
195
223
  ok: true,
@@ -12,12 +12,15 @@ export interface UseArticlesReturn {
12
12
  handleSearch: (query: string) => void
13
13
  }
14
14
 
15
- export function useArticles(): UseArticlesReturn {
15
+ export function useArticles(
16
+ initialArticles?: Article[],
17
+ initialCategories?: CategoryInfo[]
18
+ ): UseArticlesReturn {
16
19
  const [searchQuery, setSearchQuery] = useState('')
17
20
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
18
- const [articles, setArticles] = useState<Article[]>([])
19
- const [categories, setCategories] = useState<CategoryInfo[]>([])
20
- const [loading, setLoading] = useState(true)
21
+ const [articles, setArticles] = useState<Article[]>(initialArticles ?? [])
22
+ const [categories, setCategories] = useState<CategoryInfo[]>(initialCategories ?? [])
23
+ const [loading, setLoading] = useState(initialArticles === undefined)
21
24
  const [error, setError] = useState<string | null>(null)
22
25
 
23
26
  const fetchArticles = useCallback(async (query: string) => {
@@ -66,7 +69,9 @@ export function useArticles(): UseArticlesReturn {
66
69
  }, [])
67
70
 
68
71
  useEffect(() => {
69
- fetchArticles('')
72
+ if (initialArticles === undefined) fetchArticles('')
73
+ // Seeded data came from the server render; skip the redundant client fetch on mount.
74
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
75
  }, [fetchArticles])
71
76
 
72
77
  const handleSearch = useCallback(