index.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
  2. import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
  3. import { appSetting } from 'app/lib/util'
  4. import emitter, { EVENTS } from 'app/context/emitter'
  5. import { subscribe } from 'app/ui/atoms/socket'
  6. import {
  7. isSameItemsForUniList,
  8. flattenPagesForUniList,
  9. dedupeUniListItems,
  10. fetchUniListData,
  11. prependItemToUniListQueryCache,
  12. removeItemFromUniListQueryCache,
  13. mergeFirstPageIntoUniListQueryCache,
  14. cursorNextPageParam,
  15. } from './helpers'
  16. /*
  17. * ============================================================================
  18. * Data layer behind UniList: a TanStack infinite query over a UNA browse
  19. * endpoint. `helpers.ts` holds the pure parts (fetch, pagination cursors,
  20. * cache edits); this file is the hook. Import everything from
  21. * `app/lib/browse-query`.
  22. * ============================================================================
  23. */
  24. export {
  25. fetchUniListData,
  26. refetchUniListReducer,
  27. flattenPagesForUniList,
  28. cursorNextPageParam,
  29. startPerPageNextPageParam,
  30. prependItemToUniListQueryCache,
  31. removeItemFromUniListQueryCache,
  32. mergeFirstPageIntoUniListQueryCache,
  33. dedupeUniListItems,
  34. matchesFeedOwnerFilter,
  35. isSameItemsForUniList,
  36. } from './helpers'
  37. /** Batch window for timeline `added` events, plus a random spread so clients don't all hit UNA at once. */
  38. const TIMELINE_BATCH_MS = 1000
  39. const TIMELINE_JITTER_MS = 2000
  40. /** Cap on timeline ids remembered as deleted, so a first page fetched before a deletion cannot bring the row back. */
  41. const TIMELINE_DELETED_MAX = 200
  42. type TimelineSocketEvent = { id?: number | string }
  43. /** Timeline socket payload: a JSON string or an object (`{ id, author_id, peformer_id }`). */
  44. function parseTimelinePayload(payload: unknown): TimelineSocketEvent | null {
  45. if (typeof payload === 'string') {
  46. try {
  47. return JSON.parse(payload)
  48. } catch {
  49. return null
  50. }
  51. }
  52. return payload && typeof payload === 'object' ? (payload as TimelineSocketEvent) : null
  53. }
  54. /** A page without the rows the socket reported deleted since it was requested. */
  55. function withoutDeleted(page: any, deletedIds: Set<string>) {
  56. return deletedIds.size
  57. ? { ...page, data: (page?.data ?? []).filter((it: any) => !deletedIds.has(String(it.id))) }
  58. : page
  59. }
  60. /**
  61. * Shared UniList infinite query: TanStack is the store.
  62. * First page is always fetched (no page-JSON seed). Skeletons until that returns.
  63. */
  64. export function useUniListQuery({
  65. queryKey,
  66. requestUrl,
  67. defaultParams,
  68. enabled = true,
  69. deferNewItems = true,
  70. refetchOnWindowFocus = false,
  71. refetchOnReconnect = true,
  72. gcTime,
  73. getNextPageParam = cursorNextPageParam,
  74. listenPageReload = false,
  75. listenFeed = false,
  76. listenTimeline = false,
  77. matchesNewContent,
  78. canFetchNext,
  79. }: { queryKey: any; requestUrl: string; defaultParams: any; enabled?: boolean; deferNewItems?: any; refetchOnWindowFocus?: any; refetchOnReconnect?: any; gcTime?: number; getNextPageParam?: any; listenPageReload?: boolean; listenFeed?: boolean; listenTimeline?: boolean; matchesNewContent?: any; canFetchNext?: any }) {
  80. const queryClient = useQueryClient()
  81. // Exposed as `skipToastRef` (tail skeleton count). Not read in render here:
  82. // whether a data change is shown or deferred is decided by `requested` below.
  83. const refetchRef = useRef<{ skipToast: boolean; [key: string]: any }>({
  84. skipToast: false,
  85. })
  86. /**
  87. * The next data change was asked for (next page, pull-to-refresh, page
  88. * reload, socket edit), so show it as is. State, not a ref: it is set from
  89. * child callbacks/effects (which run before ours) and consumed in render,
  90. * so it must not depend on effect order.
  91. */
  92. const [requested, setRequested] = useState(false)
  93. const [frozen, setFrozen] = useState<{ key: string; items: any[] } | null>(null)
  94. const requestUrlRef = useRef(requestUrl)
  95. const paramsRef = useRef(defaultParams)
  96. const qKeyRef = useRef(queryKey)
  97. const matchesNewContentRef = useRef(matchesNewContent)
  98. const canFetchNextRef = useRef(canFetchNext)
  99. // "Latest value" refs: written in a layout effect, not during render (React Compiler
  100. // rule). Layout effects run before every passive effect, including TanStack's fetch,
  101. // so `queryFn` and the socket handlers below always see the current values.
  102. useLayoutEffect(() => {
  103. requestUrlRef.current = requestUrl
  104. paramsRef.current = defaultParams
  105. qKeyRef.current = queryKey
  106. matchesNewContentRef.current = matchesNewContent
  107. canFetchNextRef.current = canFetchNext
  108. })
  109. const qKeyId = JSON.stringify(queryKey)
  110. const queryEnabled = Boolean(enabled && requestUrl)
  111. const queryResult = useInfiniteQuery({
  112. queryKey,
  113. queryFn: ({ pageParam }) =>
  114. fetchUniListData({
  115. pageParam,
  116. requestUrl: requestUrlRef.current,
  117. defaultParams: paramsRef.current,
  118. }),
  119. getNextPageParam,
  120. staleTime: appSetting('browse', 'stale_time'),
  121. refetchOnWindowFocus,
  122. refetchOnReconnect,
  123. enabled: queryEnabled,
  124. // TanStack Query v4 name for `gcTime` (v5); undefined keeps the query-client default.
  125. cacheTime: gcTime,
  126. })
  127. const {
  128. status,
  129. data: pagesData,
  130. fetchNextPage,
  131. hasNextPage,
  132. isFetching,
  133. isFetchingNextPage,
  134. refetch,
  135. isRefetching,
  136. } = queryResult
  137. // TODO(react-query v4): `isPending` is v5-only, so this is always undefined here (v4: isLoading).
  138. const isPending = (queryResult as { isPending?: boolean }).isPending
  139. const cacheItems = useMemo(
  140. () => dedupeUniListItems(flattenPagesForUniList(pagesData)),
  141. [pagesData]
  142. )
  143. useEffect(() => {
  144. if (listenPageReload !== true) return undefined
  145. const subscription = emitter.addListener(EVENTS.page, (data: { action?: string }) => {
  146. if (data.action == 'reload') {
  147. refetchRef.current.skipToast = true
  148. setRequested(true)
  149. refetch()
  150. }
  151. })
  152. return () => subscription.remove()
  153. }, [listenPageReload, refetch])
  154. useEffect(() => {
  155. if (listenFeed !== true) return undefined
  156. const subscription = emitter.addListener(EVENTS.feed, (data: any) => {
  157. const cacheKey = qKeyRef.current
  158. if (data.action == 'remove_content') {
  159. refetchRef.current.skipToast = true
  160. setRequested(true)
  161. removeItemFromUniListQueryCache(queryClient, cacheKey, data.id)
  162. setFrozen((f) => f && { ...f, items: f.items.filter((it: any) => it.id != data.id) })
  163. }
  164. if (data.action == 'new_content') {
  165. const item = data.data
  166. const allowed = matchesNewContentRef.current
  167. ? matchesNewContentRef.current(item)
  168. : true
  169. if (!allowed) return
  170. refetchRef.current.skipToast = true
  171. setRequested(true)
  172. prependItemToUniListQueryCache(queryClient, cacheKey, item)
  173. setFrozen((f) => f && { ...f, items: [item, ...f.items] })
  174. }
  175. })
  176. return () => subscription.remove()
  177. }, [listenFeed, queryClient])
  178. /*
  179. * Timeline socket (`bx_timeline_0`): every client gets every post of the
  180. * site, so a full refetch here (all loaded pages, on every client at once)
  181. * loaded UNA on each post. A deleted row is dropped from the cache without
  182. * a request. Added posts are batched: one first-page request after a
  183. * jittered delay, merged on top of the cache; nothing changes when the new
  184. * post is not in this list. Own posts are not skipped: `EVENTS.feed` only
  185. * reaches the tab that posted, and the merge ignores rows already cached.
  186. */
  187. useEffect(() => {
  188. if (listenTimeline !== true) return undefined
  189. let timer: ReturnType<typeof setTimeout> | null = null
  190. let cancelled = false
  191. const deletedIds = new Set<string>()
  192. const loadNewItems = async () => {
  193. timer = null
  194. const cacheKey = qKeyRef.current
  195. // The effect was torn down, or the list moved on to another query, while fetching.
  196. const isStale = () => cancelled || cacheKey !== qKeyRef.current
  197. try {
  198. const freshPage = await fetchUniListData({
  199. pageParam: undefined,
  200. requestUrl: requestUrlRef.current,
  201. defaultParams: paramsRef.current,
  202. })
  203. if (isStale()) return
  204. const page = withoutDeleted(freshPage, deletedIds)
  205. if (mergeFirstPageIntoUniListQueryCache(queryClient, cacheKey, page)) {
  206. refetchRef.current.skipToast = true
  207. setRequested(true)
  208. }
  209. } catch {
  210. // The next event or refetch catches up.
  211. }
  212. }
  213. const offAdded = subscribe('bx_timeline_0', 'added', () => {
  214. if (timer) return
  215. // Jitter only spreads the load; not security-sensitive.
  216. timer = setTimeout(loadNewItems, TIMELINE_BATCH_MS + Math.random() * TIMELINE_JITTER_MS) // NOSONAR
  217. })
  218. const offDeleted = subscribe('bx_timeline_0', 'deleted', (payload: unknown) => {
  219. const id = parseTimelinePayload(payload)?.id
  220. if (id == null) return
  221. deletedIds.add(String(id))
  222. if (deletedIds.size > TIMELINE_DELETED_MAX) deletedIds.delete(deletedIds.values().next().value as string)
  223. const cacheKey = qKeyRef.current
  224. const isShown = flattenPagesForUniList(queryClient.getQueryData(cacheKey)).some((it: any) => it.id == id)
  225. if (isShown) {
  226. refetchRef.current.skipToast = true
  227. setRequested(true)
  228. removeItemFromUniListQueryCache(queryClient, cacheKey, id)
  229. }
  230. setFrozen((f) => f?.items.some((it: any) => it.id == id)
  231. ? { ...f, items: f.items.filter((it: any) => it.id != id) }
  232. : f)
  233. })
  234. return () => {
  235. cancelled = true
  236. if (timer) clearTimeout(timer)
  237. offAdded()
  238. offDeleted()
  239. }
  240. }, [listenTimeline, queryClient])
  241. /*
  242. * Visible rows are derived from the query cache instead of being copied into
  243. * local state by effects (one extra commit per data change, plus a reset
  244. * commit per key change). Local state only holds `frozen`: the rows kept on
  245. * screen while a background refetch waits behind "Show New".
  246. *
  247. * `seen` is the cache snapshot last reconciled. Comparing it in render
  248. * ("adjust state when a prop changes") freezes the old rows before the new
  249. * ones are ever committed.
  250. */
  251. const [seen, setSeen] = useState(() => ({ key: qKeyId, items: cacheItems, hadData: !!pagesData }))
  252. if (seen.key !== qKeyId) {
  253. // Another list: show what the cache has for it, nothing to defer.
  254. setSeen({ key: qKeyId, items: cacheItems, hadData: !!pagesData })
  255. if (frozen) setFrozen(null)
  256. if (requested) setRequested(false)
  257. } else if (seen.items !== cacheItems) {
  258. const defer =
  259. deferNewItems &&
  260. seen.hadData &&
  261. !frozen &&
  262. !requested &&
  263. !isSameItemsForUniList(seen.items, cacheItems)
  264. if (defer) setFrozen({ key: qKeyId, items: seen.items })
  265. setSeen({ key: qKeyId, items: cacheItems, hadData: !!pagesData })
  266. if (requested) setRequested(false)
  267. }
  268. const isFrozen = !!frozen && frozen.key === qKeyId
  269. const listItems = isFrozen ? frozen.items : cacheItems
  270. const listReady = !queryEnabled || !!pagesData || status === 'error'
  271. // skipToastRef only: on for the first page of a key, off once data landed.
  272. useEffect(() => {
  273. refetchRef.current.skipToast = true
  274. }, [qKeyId])
  275. useEffect(() => {
  276. if (pagesData) refetchRef.current.skipToast = false
  277. }, [pagesData])
  278. const handleEndReached = useCallback(
  279. async (lastItemIndex: number | false) => {
  280. if (!requestUrlRef.current) return
  281. if (canFetchNextRef.current && canFetchNextRef.current() === false) return
  282. if (isFetchingNextPage) return
  283. if (hasNextPage === false) return
  284. if (lastItemIndex === false) return
  285. refetchRef.current.skipToast = true
  286. setRequested(true)
  287. fetchNextPage()
  288. },
  289. [isFetchingNextPage, hasNextPage, fetchNextPage]
  290. )
  291. const refetchList = useCallback(() => {
  292. refetchRef.current.skipToast = true
  293. setRequested(true)
  294. return refetch()
  295. }, [refetch])
  296. const acceptNewData = useCallback(() => setFrozen(null), [])
  297. return {
  298. queryClient,
  299. queryKey,
  300. status,
  301. pagesData,
  302. cacheItems,
  303. listItems,
  304. hasNewData: isFrozen,
  305. acceptNewData,
  306. fetchNextPage,
  307. hasNextPage,
  308. isFetching,
  309. isFetchingNextPage,
  310. isPending,
  311. isRefetching,
  312. refetch: refetchList,
  313. handleEndReached,
  314. listReady,
  315. skipToastRef: refetchRef,
  316. }
  317. }