From ea7293471671da5aaafe16d81078add5a8c7763e Mon Sep 17 00:00:00 2001 From: afterhours247 <85026477+afterhours247@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:22:46 +0800 Subject: [PATCH] perf: cache and prefetch course pages --- lib/hooks/useCourses.ts | 187 +++++++++++++++++++++++++++++++--------- 1 file changed, 145 insertions(+), 42 deletions(-) diff --git a/lib/hooks/useCourses.ts b/lib/hooks/useCourses.ts index 65c678c..f4e4c8c 100644 --- a/lib/hooks/useCourses.ts +++ b/lib/hooks/useCourses.ts @@ -1,14 +1,24 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' + +export interface CourseTag { + id: string + name: string + color?: string | null + _count?: { courseTags: number } +} export interface Course { id: string name: string + displayName?: string | null slug: string thumbnail: string | null description: string | null path: string + favorited?: boolean + tags?: CourseTag[] createdAt: string updatedAt: string _count: { modules: number; lessons: number } @@ -22,7 +32,8 @@ export interface Course { } interface CoursesResponse { - courses: any[] + courses: Course[] + tags: CourseTag[] pagination: { page: number limit: number @@ -42,9 +53,36 @@ interface UseCoursesOptions { initialSortOrder?: string } +interface CacheEntry { + data: CoursesResponse + expiresAt: number +} + +const CACHE_TTL_MS = 20_000 +const MAX_CACHE_ENTRIES = 24 +const responseCache = new Map() + +function readCache(key: string): CoursesResponse | null { + const cached = responseCache.get(key) + if (!cached) return null + if (cached.expiresAt <= Date.now()) { + responseCache.delete(key) + return null + } + return cached.data +} + +function writeCache(key: string, data: CoursesResponse) { + if (responseCache.size >= MAX_CACHE_ENTRIES) { + const oldestKey = responseCache.keys().next().value + if (oldestKey) responseCache.delete(oldestKey) + } + responseCache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS }) +} + export function useCourses(options: UseCoursesOptions = {}) { - const [courses, setCourses] = useState([]) - const [tags, setTags] = useState([]) + const [courses, setCourses] = useState([]) + const [tags, setTags] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [pagination, setPagination] = useState({ @@ -60,19 +98,38 @@ export function useCourses(options: UseCoursesOptions = {}) { const [sortBy, setSortBy] = useState(options.initialSortBy || 'updatedAt') const [sortOrder, setSortOrder] = useState(options.initialSortOrder || 'desc') const [debouncedSearch, setDebouncedSearch] = useState(options.initialSearch || '') + const abortRef = useRef(null) + const requestIdRef = useRef(0) + const hasDataRef = useRef(false) - // Debounce search useEffect(() => { - const timer = setTimeout(() => { + const timer = window.setTimeout(() => { setDebouncedSearch(search) - setPagination(prev => ({ ...prev, page: 1 })) - }, 300) - return () => clearTimeout(timer) + setPagination((previous) => ({ ...previous, page: 1 })) + }, 250) + return () => window.clearTimeout(timer) }, [search]) - const fetchCourses = useCallback(async () => { - setLoading(true) - setError(null) + const applyResponse = useCallback((data: CoursesResponse) => { + hasDataRef.current = true + setCourses(data.courses) + setTags(data.tags || []) + setPagination((previous) => ({ + ...previous, + page: data.pagination.page, + limit: data.pagination.limit, + total: data.pagination.total, + totalPages: data.pagination.totalPages, + hasNext: data.pagination.hasNext, + hasPrev: data.pagination.hasPrev, + })) + }, []) + + const fetchCourses = useCallback(async (force = false) => { + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + const requestId = ++requestIdRef.current const params = new URLSearchParams({ page: pagination.page.toString(), @@ -82,72 +139,118 @@ export function useCourses(options: UseCoursesOptions = {}) { sortBy, sortOrder, }) + const requestKey = params.toString() + const cached = force ? null : readCache(requestKey) + + if (cached) { + applyResponse(cached) + setLoading(false) + setError(null) + return + } + + if (!hasDataRef.current) setLoading(true) + setError(null) try { - const response = await fetch(`/api/courses?${params.toString()}`) + const response = await fetch(`/api/courses?${requestKey}`, { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }) if (!response.ok) { throw new Error('Failed to fetch courses') } - const data = await response.json() - setCourses(data.courses) - setTags(data.tags || []) - setPagination(prev => ({ - ...prev, - total: data.pagination.total, - totalPages: data.pagination.totalPages, - hasNext: data.pagination.hasNext, - hasPrev: data.pagination.hasPrev, - })) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch courses') + + const data = await response.json() as CoursesResponse + if (requestId !== requestIdRef.current) return + + writeCache(requestKey, data) + applyResponse(data) + + if (data.pagination.hasNext) { + const nextParams = new URLSearchParams(params) + nextParams.set('page', String(data.pagination.page + 1)) + const nextKey = nextParams.toString() + + if (!readCache(nextKey)) { + window.setTimeout(() => { + void fetch(`/api/courses?${nextKey}`, { + headers: { Accept: 'application/json' }, + }) + .then((nextResponse) => nextResponse.ok ? nextResponse.json() : null) + .then((nextData: CoursesResponse | null) => { + if (nextData) writeCache(nextKey, nextData) + }) + .catch(() => undefined) + }, 150) + } + } + } catch (caughtError) { + if (caughtError instanceof DOMException && caughtError.name === 'AbortError') return + if (requestId !== requestIdRef.current) return + setError(caughtError instanceof Error ? caughtError.message : 'Failed to fetch courses') } finally { - setLoading(false) + if (requestId === requestIdRef.current) setLoading(false) } - }, [pagination.page, pagination.limit, debouncedSearch, filter, sortBy, sortOrder]) + }, [ + applyResponse, + pagination.page, + pagination.limit, + debouncedSearch, + filter, + sortBy, + sortOrder, + ]) useEffect(() => { - fetchCourses() + void fetchCourses() + return () => abortRef.current?.abort() }, [fetchCourses]) const goToPage = (page: number) => { - setPagination(prev => ({ ...prev, page })) + setPagination((previous) => ({ ...previous, page: Math.max(1, page) })) } const nextPage = () => { if (pagination.hasNext) { - setPagination(prev => ({ ...prev, page: prev.page + 1 })) + setPagination((previous) => ({ ...previous, page: previous.page + 1 })) } } const prevPage = () => { if (pagination.hasPrev) { - setPagination(prev => ({ ...prev, page: prev.page - 1 })) + setPagination((previous) => ({ ...previous, page: Math.max(1, previous.page - 1) })) } } const changeLimit = (limit: number) => { - setPagination(prev => ({ ...prev, limit, page: 1 })) + setPagination((previous) => ({ ...previous, limit, page: 1 })) } - const handleSearch = (value: string) => { - setSearch(value) - } + const handleSearch = (value: string) => setSearch(value) const handleFilter = (value: string) => { setFilter(value) - setPagination(prev => ({ ...prev, page: 1 })) + setPagination((previous) => ({ ...previous, page: 1 })) } const handleSort = (field: string) => { setSortBy(field) - setPagination(prev => ({ ...prev, page: 1 })) + setPagination((previous) => ({ ...previous, page: 1 })) + } + + const handleSortOrder = (value: string) => { + setSortOrder(value === 'asc' ? 'asc' : 'desc') + setPagination((previous) => ({ ...previous, page: 1 })) } const toggleSortOrder = () => { - setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc') - setPagination(prev => ({ ...prev, page: 1 })) + setSortOrder((previous) => previous === 'asc' ? 'desc' : 'asc') + setPagination((previous) => ({ ...previous, page: 1 })) } + const refetch = useCallback(() => fetchCourses(true), [fetchCourses]) + return { courses, tags, @@ -161,12 +264,12 @@ export function useCourses(options: UseCoursesOptions = {}) { sortBy, sortOrder, setSortBy: handleSort, - setSortOrder: toggleSortOrder, + setSortOrder: handleSortOrder, toggleSortOrder, goToPage, nextPage, prevPage, changeLimit, - refetch: fetchCourses, + refetch, } -} \ No newline at end of file +}